감사 로그와 변경 이력 설계
거래처의 결제 조건이 「월말 마감 30일」에서 「월말 마감 60일」로 바뀌어 있습니다. 거래처 테이블에는 updatedAt 이 있어서 어제 오후 3시에 바뀌었다는 것은 압니다.
누가 바꿨는지, 원래 값이 무엇이었는지, 그때 결제 조건 말고 다른 칸도 바뀌었는지는 모릅니다. 어제 오후 3시 이후에 담당자가 주소를 한 번 더 고쳤다면, 그 시각마저 덮여 있습니다.
updatedAt 이 있으니 이력은 있다는 생각
생성 시각과 수정 시각 칸은 대부분의 테이블에 기본으로 들어갑니다. 그래서 「언제 바뀌었는지는 기록된다」고 여기기 쉽습니다.
updatedAt 이 기록하는 것은 마지막으로 바뀐 시각 하나입니다. 그 전의 변경은 새 시각에 덮입니다. 무엇이 바뀌었는지, 누가 바꿨는지, 이전 값이 무엇이었는지는 처음부터 담겨 있지 않습니다. updatedAt 은 이력이 아니라 「최근에 손댄 행」을 찾거나 캐시를 무효화하는 데 쓰는 칸입니다.
이력이 답해야 하는 네 가지 질문
업무 시스템에서 이력이 필요해지는 순간은 대개 사고가 난 뒤입니다. 그때 묻는 질문은 거의 정해져 있습니다.
| 질문 | 필요한 기록 |
|---|---|
| 누가 | 사용자 id (그리고 그때의 이름) |
| 언제 | 변경 시각 |
| 무엇을 | 대상 테이블과 행, 동작 종류(생성, 수정, 삭제) |
| 무엇에서 무엇으로 | 바뀐 칸의 이전 값과 새 값 |
마지막 질문이 가장 자주 빠집니다. 「홍길동이 어제 3시에 거래처 A 를 수정함」까지는 남기면서, 무엇을 어떻게 바꿨는지는 남기지 않는 경우입니다. 그 기록으로는 되돌릴 수도, 잘못인지 판단할 수도 없습니다.
활동 기록과 변경 이력의 차이
이력이라는 말에는 목적이 다른 두 가지가 섞여 있습니다.
활동 기록은 사람이 읽는 기록입니다. 「수주 SO-2026-0012 출하 SH-2026-0031 · 40개 · 매출 1,200,000원」 같은 문장입니다. 대시보드의 최근 활동, 문서 상세 화면의 타임라인에 보입니다. 업무 동작 단위로 남기고, 문장은 앱이 만듭니다.
변경 이력은 데이터를 복원하고 추적하기 위한 기록입니다. 어느 행의 어느 칸이 어떤 값에서 어떤 값으로 바뀌었는지를 기계가 읽을 수 있는 형태로 남깁니다. 출하 하나를 등록하면 출하, 재고 이동, 수주 줄, 전표가 바뀌므로 변경 이력은 여러 줄이 됩니다.
둘은 대체되지 않는다고 봅니다. 활동 기록만 있으면 무엇이 바뀌었는지 되짚을 수 없고, 변경 이력만 있으면 사람이 「그때 무슨 일이 있었나」를 읽기 어렵습니다. 작은 시스템이라면 활동 기록에 바뀐 칸의 이전 값과 새 값을 JSON 으로 함께 넣어 하나로 시작해도 충분합니다.
앱에서 남기기와 데이터베이스에서 남기기
변경 이력을 남기는 자리는 두 곳입니다.
앱에서 남기면 저장하는 코드 옆에서 이력 행을 같이 씁니다. 누가 했는지를 앱은 알고 있으니 사용자 정보를 넣기 쉽고, 「출하 등록」 같은 업무 동작의 이름도 붙일 수 있습니다. 대신 이력을 남기지 않는 저장 경로가 하나라도 있으면 그 변경은 기록되지 않습니다. 관리용 스크립트나 데이터베이스에 직접 접속해서 고친 것도 빠집니다.
데이터베이스에서 남기면 트리거를 씁니다. PostgreSQL 문서에는 테이블의 모든 삽입, 수정, 삭제를 감사 테이블에 기록하는 트리거 예제가 있습니다. 어떤 경로로 바꾸든 빠지지 않는다는 것이 장점입니다. 대신 트리거는 데이터베이스 사용자는 알아도 앱의 로그인 사용자가 누구인지는 모릅니다. 웹 앱은 보통 하나의 데이터베이스 계정으로 접속하기 때문입니다. 앱 사용자를 기록하려면 트랜잭션마다 그 값을 세션 설정으로 넘겨 트리거가 읽게 하는 추가 작업이 필요합니다.
저라면 업무 시스템에서는 앱에서 남기는 쪽으로 시작하겠습니다. 누가 했는지와 업무 동작의 이름이 가장 자주 필요한 정보이기 때문입니다. 저장 경로를 한 곳으로 모아 두면 빠지는 경로를 줄일 수 있습니다. 규제 때문에 빠짐없는 기록이 요구되는 테이블이 있다면 그 테이블만 트리거를 더합니다.
이력을 트랜잭션 안에 넣는 이유
이력 행은 원래 변경과 같은 트랜잭션에서 써야 합니다. 원래 변경이 커밋되고 이력 쓰기가 실패하면 기록 없는 변경이 생깁니다. 반대로 이력만 커밋되고 원래 변경이 롤백되면 일어나지 않은 일이 기록에 남습니다.
활동 기록을 「저장이 끝난 뒤 한 줄 더 남기는 것」으로 짜면 이 문제가 생깁니다. 이력이 원래 변경과 운명을 같이하게 하려면 같은 트랜잭션의 마지막 단계로 넣습니다.
이름은 복사해서 남기기
이력에 사용자 id 만 남기면, 그 사용자가 퇴사해서 계정이 지워지거나 이름이 바뀐 뒤에는 「누가」를 읽을 수 없습니다. 거래처 이름도 마찬가지입니다. 이력은 과거의 사실을 적는 곳이라서, 사람이 읽어야 하는 이름은 그때의 값을 복사해 둡니다. id 는 연결을 위해, 이름은 읽기 위해 둘 다 남깁니다.
같은 이유로 이력 테이블은 원래 테이블에 외래 키로 강하게 묶지 않는 편이 맞다고 봅니다. 원래 행이 지워져도 이력은 남아야 합니다. 삭제의 이력이 가장 중요한 이력인 경우가 많습니다.
이력이 커질 때의 선택
변경 이력은 쌓이기만 합니다. 모든 칸의 이전 값과 새 값을 남기면 원래 테이블보다 빠르게 커집니다.
처음부터 모든 테이블의 모든 칸을 남길 필요는 없다고 생각합니다. 돈, 재고, 권한, 거래 조건처럼 틀렸을 때 비용이 큰 칸부터 남기고, 화면 정렬 순서나 메모처럼 추적할 이유가 적은 칸은 뺍니다. 오래된 이력은 날짜별로 테이블을 나눠 두면 나중에 보관하거나 지우기 쉽습니다.
여기까지가 확실한 부분
감사 트리거는 PostgreSQL 문서의 예제로 확인할 수 있는 방법입니다. 앱 사용자를 트리거에 넘기는 구체적인 방법과 성능 영향은 이 글에서 다루지 않았습니다. 어떤 기록을 얼마나 보관해야 하는지는 업종과 법령에 따라 달라서, 개인정보가 담긴 칸의 이력은 보관 기간을 따로 확인해야 합니다.
함께 읽기
- 금액·수량 자료형 선택: 정수, numeric, 부가세 끝수 처리견적서의 품목 세 줄에 부가세를 줄마다 계산해서 더한다고 해 보겠습니다. 같은 견적을 합계에서 부가세를 한 번 계산한 화면과 나란히 놓으면 부가세가 1원 다릅니다.
- 소프트 삭제(deletedAt)의 비용과 대안품목 코드 RM-001 을 잘못 만들어서 지웠다고 해 보겠습니다. 소프트 삭제라 행은 남고 deletedAt 에 시각이 찍힙니다. 이제 같은 코드로 품목을 다시 만들려고 하면 오류가 납니다.
- 복합 인덱스 칸 순서 정하는 법여러 회사가 함께 쓰는 업무 시스템에 전표 테이블이 있고, 인덱스가 이렇게 걸려 있다고 해 보겠습니다.
- 트랜잭션 경계는 어디서 나눠야 할까?출하 등록 버튼 하나가 서버에서 하는 일을 적어 보면 이렇습니다.
- 동시 수정 처리: 트랜잭션 격리 수준과 낙관적 잠금두 사람이 같은 거래처 화면을 열었다고 해 보겠습니다. 한 사람은 전화번호를 고치고 저장합니다. 1분 뒤 다른 사람이 주소를 고치고 저장합니다. 두 번째 저장이 끝나면 전화번호는 옛 번호로 돌아가 있습니다.