결제 도메인 변경 이력 적재 기능 추가
목차
결제 도메인에서 변경 이력을 남기는 건 "나중에 필요하면 추가하자"로 미룰 수 있는 기능이 아니다. 분쟁이 생겼을 때, QA가 "이 값이 왜 바뀌었냐"고 물었을 때, 운영팀이 "분명히 이렇게 설정했는데 왜 다르냐"고 올라올 때 - 이력이 없으면 할 말이 없다. 로그가 없으면 없었던 일이 된다. 이번 작업은 그 기반을 까는 작업이었다.
무엇을, 어떻게 설계했나
history 영역에 tb_system_history / tb_common_history 적재 로직을 추가했다. 변경된 파일은 내부 클래스 4개, SQL 매퍼 2개다.
두 테이블을 분리한 데는 이유가 있다. 시스템 레벨 이력(설정 변경, 권한 조정 등)과 공통 도메인 이력(결제 상태 전이, 주문 정보 수정 등)을 같은 테이블에 몰아넣으면 조회 조건이 어중간해지고, 보관 정책(retention policy)이나 인덱스 전략을 각자 다르게 가져가기도 어려워진다. 이력 테이블은 처음부터 관심사 분리가 되어 있어야 한다.
핵심 적재 필드는 세 가지로 정리했다.
| 항목 | 설명 |
|---|---|
| before / after | 변경 전후 값 - 변경된 필드 단위로 직렬화 |
| actor | 누가 바꿨는지 (사용자 ID 또는 관리자 ID) |
| changed_at | 서버 타임스탬프 기준 변경 시각 |
before/after 값을 어떤 형태로 저장할지가 설계의 핵심이었다. 칼럼 하나당 로우를 분리하는 EAV 스타일은 쿼리가 복잡해지고 칼럼 수가 많아질수록 INSERT 건수가 폭발적으로 늘어난다. JSON 통으로 때려넣는 방식은 저장은 단순하지만 특정 필드로 검색할 때 인덱스를 걸기가 어렵다. 이번엔 변경된 필드 단위로 직렬화해서 한 로우로 저장하는 방향을 선택했다. 조회 시나리오가 대부분 "이 엔티티의 전체 이력을 보여줘"이기 때문에 이쪽이 맞다고 판단했다.
구현할 때 실제로 신경 쓴 것들
이력 테이블 INSERT 자체는 어렵지 않다. 진짜 문제는 어느 레이어에서 처리하느냐다.
비즈니스 로직 레이어에서 서비스 메서드마다 직접 INSERT를 호출하면 빠뜨리는 순간 그냥 이력이 없는 채로 넘어간다. 조용하게 누락된다. 그래서 수정/삭제 흐름에서 before 값 스냅샷을 진입부에 찍고, 트랜잭션 커밋 직전에 after 값과 함께 이력을 쌓는 구조로 잡았다.
// 변경 전 스냅샷 - 수정 로직 실행 전에 찍어야 함
SomeEntity before = repository.findById(id).orElseThrow();
// 도메인 로직 실행
entity.update(request);
repository.save(entity);
// 이력 적재 - 메인 로직과 동일 트랜잭션 내부
historyService.record(actor, before, entity);
트랜잭션을 분리하면 메인 로직은 성공했는데 이력만 실패하는 케이스가 생긴다. 이 경우 이력 누락인지 실제 변경 실패인지 구분이 안 돼서 나중에 감사 추적 자체가 불가능해진다. 이력 적재를 같은 트랜잭션 안에 묶어두면 둘 다 커밋되거나 둘 다 롤백된다. 결제 도메인에서는 이 방향이 맞다.
NULL 처리도 빠뜨리면 안 됐다. 최초 생성 건은 before가 없고, 일부 필드는 옵셔널이라 NULL이 정상값일 수 있다. NULL을 그냥 직렬화하면 "값이 없었음"과 "값이 null이었음"을 나중에 구분 못한다. 직렬화 단계에서 absent와 null을 명시적으로 다르게 처리하는 쪽으로 방어했다.
SQL 매퍼에서는 변경된 필드만 골라서 적재하는 동적 INSERT를 작성했다. 전체 칼럼을 무조건 다 때려넣으면 before/after 차이를 파악하기 어렵고 이력 로우가 불필요하게 무거워진다. MyBatis의 동적 SQL 블록으로 처리했는데, 이 부분이 매퍼 2개가 건드려진 이유다.
쿼리 실행 계획도 사전에 확인했다. 이력 테이블은 쓰기 빈도가 높다. 결제 상태가 바뀔 때마다, 관리자가 설정을 건드릴 때마다 INSERT가 발생한다. 초반엔 느낌이 없지만 데이터가 쌓이면 조회 병목이 된다. 관리자 화면의 이력 조회는 대부분 "특정 엔티티 ID + 날짜 범위" 조건이라 이 두 칼럼 기준 복합 인덱스를 먼저 확인하고 반영했다. 기존 테이블 대상이라 온라인 DDL 가능 여부도 사전에 체크했다.
이 작업에서 다시 확인한 것
금융/결제 도메인에서 기능 하나를 추가한다는 건 레이어 전체를 관통한다는 뜻이다. SQL 적재, 서비스 로직, 트랜잭션 범위, NULL 방어, 화면 조회까지 어느 하나만 빠뜨려도 이력이 누락되거나 관리자 화면에서 숫자가 맞지 않는다. 그리고 이력 기능은 문제가 생기기 전까지 아무 티가 안 나다가, 정작 필요한 순간에 없으면 치명적이다.
구현 후에는 직접 화면에서 시나리오를 돌렸다. before → 변경 → after 값이 이력 테이블에 맞게 쌓이는지, 관리자 화면 조회 결과와 실제 현재 값이 일치하는지 cross-check했다. 기존 데이터가 깨지지 않았는지도 같이 확인했다. 수치 하나가 다른 화면과 다르게 보이면 그게 버그인지 원래 그런 건지 판단하기 어렵기 때문에, 작업 전에 현재 상태를 메모해두고 같은 케이스로 비교하는 게 습관이 됐다.
커밋도 "이력 기능 추가" 한 방에 밀어넣지 않았다. 테이블 설계 확인, 매퍼, 서비스 레이어, 화면 연동을 논리적으로 독립된 단위로 쪼갰다. 나중에 어느 변경에서 뭔가 깨졌는지 git log로 좁혀야 할 때 이렇게 해둔 게 진짜 힘을 발휘한다. 커밋 메시지도 "무엇을 했다"보다 "왜 이렇게 했다"를 담으려고 신경 쓴다. 한 달 뒤에 자기 커밋 보면 "무엇"은 diff 보면 되고, "왜"는 그때 기억이 안 난다.
댓글 0
첫 댓글 달아줘.