개발 slecs

결제 정산 감사 로직의 멱등성

목차

system-ledger의 결제 정산 감사 로직 버그를 수정했다. 증상은 특정 조건에서 집계 값이 틀리거나 예외가 터지는 것. 원인을 파고들어 보니 백필 시드(backfill seed) 설정이 모호하게 되어 있었고, audit 로직이 멱등하지 않은 상태로 동작하고 있었다. 수정 범위는 내부 클래스 3개, SQL 매퍼 1개, SQL 파일 1개다.

감사 로직에서 멱등성이 왜 까다로운가

결제·정산 시스템에서 감사(audit) 로직은 단순 조회 API와 성격이 다르다. 조회 API야 언제 몇 번을 불러도 같은 값을 돌려주면 그만이지만, 감사 로직은 "이 이벤트가 이미 처리됐는가"를 판단해서 레코드를 쓰거나 집계를 업데이트하는 일까지 한다. 같은 감사 이벤트가 두 번 처리되어도 결과는 하나여야 한다는 제약이 붙는 것. 이게 멱등성이고, 특히 백필이 끼어들면 더 까다로워진다.

백필은 새로운 감사 요구사항이 생겼을 때 기존 데이터에 소급 적용하는 작업이다. 이때 시드, 즉 어느 기준점부터 채울 건지가 명확하지 않으면 같은 건이 여러 번 처리되거나 반대로 누락될 수 있다. 이번 버그의 핵심이 여기 있었다. 시드 기준이 불분명한 상태에서 배치가 돌면, 날짜 경계나 상태 전이 직후 같은 엣지 케이스에서 집계가 이중으로 잡히거나 빠져버렸다.

화면에서는 그냥 숫자가 나오니까 처음엔 안 보인다. 특정 조건이 겹칠 때만 터지기 때문에 재현 조건을 특정하는 것 자체도 쉽지 않았다.

멱등성을 보장하는 방향은 크게 세 가지가 있다.

방법 설명 트레이드오프
DB 유니크 제약 자연 키로 중복 삽입 자체를 DB가 차단 단순하지만 집계 로직엔 그대로 적용 안 됨
처리 여부 컬럼 완료 마킹 컬럼을 두고 쿼리 조건에 포함 조건이 늘어나지만 의도가 명시적
upsert 있으면 업데이트, 없으면 삽입 항상 최신 상태로 수렴, 히스토리 추적은 어려움

이번엔 두 번째 방식을 주로 썼다. SQL 조건에 처리 여부 컬럼을 추가하고, 처리 완료 시 타임스탬프를 기록해 다음 배치에서 이미 처리된 건을 걸러내는 방식이다.

백필 시드 명확화와 SQL 수정

백필 시드 문제는 처음 보면 단순하게 느껴진다. "어디서부터 채울 건지 정하는 거 아니냐"고. 근데 감사 로직에 붙으면 이 기준점이 여러 해석을 허용하면 안 된다.

예를 들어 "정산 완료 시각"을 기준으로 쓰는데, 이게 DB에 최초로 기록된 시각인지 실제 처리가 끝난 시각인지 정의가 느슨하면 배치가 돌 때마다 범위가 조금씩 달라진다. 자정 경계나 배치 실행이 겹치는 구간에서 같은 건이 두 번 집계되는 일이 생기는 것. 이번 케이스가 정확히 이 패턴이었다.

수정 방향은 시드를 명시적으로 고정하는 것. "완료 상태인 것"처럼 해석이 열려있는 조건이 아니라, 처리 완료 여부 컬럼을 조건에 직접 박는 방식으로 바꿨다.

-- 수정 전: 범위 조건만 있어서 재실행 시 중복 처리 가능
WHERE status = 'SETTLED'
  AND settled_at BETWEEN :fromDate AND :toDate

-- 수정 후: 처리 여부 컬럼으로 멱등 보장
WHERE status = 'SETTLED'
  AND settled_at BETWEEN :fromDate AND :toDate
  AND audit_processed_at IS NULL

배치 처리 완료 후에는 해당 건에 audit_processed_at 타임스탬프를 업데이트한다. 다음 배치가 돌 때 이미 처리된 건은 조건에서 걸러진다. SQL 파일과 매퍼 양쪽을 수정한 이유가 여기 있다. 매퍼에서 파라미터 바인딩이 달라졌고, 실제 쿼리 조건도 함께 손봐야 했으니까.

클래스 수정은 이 상태 업데이트 로직을 트랜잭션 안에 묶는 작업이었다. 집계 → 감사 레코드 생성 → 처리 완료 마킹, 이 세 단계가 원자적으로 돌아야 하는데 기존에는 연결이 느슨했다. 중간에 실패하면 처리 완료 마킹이 안 된 채로 감사 레코드만 있거나, 반대로 마킹만 되고 레코드는 없는 상태가 생길 수 있었다. 어느 쪽이든 다음 배치에서 이상한 동작으로 이어진다.

수정 후 검증과 재발 방지

버그 수정 후 확인은 증상 재현부터 했다. 버그가 터지던 조건을 그대로 만들어서 정상 응답이 나오는지 먼저 봤고, 관련 정산 화면에서 숫자를 cross-check했다. 수정 전 수치를 미리 메모해뒀다가 수정 후와 비교하는 방식으로 회귀가 없는지도 확인했다.

비슷한 패턴이 다른 경로에도 있는지 탐색하는 작업도 했다. 감사 로직이 이 하나뿐이면 다행인데, 동일한 집계 패턴이 다른 조건으로 또 있으면 거기도 같은 함정이 있을 수 있다. 이번엔 위험해 보이는 케이스 하나를 함께 수정했다.

확인 절차를 정리하면:

  • 버그 발생 조건을 재현해서 수정 후 결과 검증
  • 정상 케이스(엣지 아닌 평범한 케이스)도 수정 전과 동일하게 동작하는지 확인
  • 숫자가 나오는 다른 화면과 비교해서 정합성 점검
  • 비슷한 SQL/로직이 다른 쿼리나 클래스에도 있는지 탐색
  • 커밋 메시지에 "왜 이 조건이 필요한가" 담기

금융/결제 도메인에서 감사 숫자가 틀리면 단순 UI 오류랑 무게가 다르다. 운영 데이터가 꼬이거나 정산이 어긋나면 수습 비용이 훨씬 크다. 엣지 케이스를 꼼꼼히 따지는 게 느려 보여도, 같은 버그로 다시 돌아오는 것보다는 빠르다.

커밋도 단계별로 쪼갰다. 백필 시드 수정, SQL 집계 조건 수정, 클래스 로직 수정을 각각 독립된 커밋으로 분리한 것. 나중에 문제가 생겼을 때 어느 변경에서 깨졌는지 추적하기 훨씬 쉬워지고, 리뷰할 때도 의도를 파악하기 좋다.

사내 서비스를 만들다 보면 기능 하나가 화면에 버튼 추가하는 것으로 끝나지 않는다는 걸 계속 체감한다. SQL 집계, 상태 머신, 예외 처리, 권한 체크가 다 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 케이스에서 이상한 동작이 나온다. 멱등성 같은 개념이 처음엔 원론적으로 들리는데, 이번처럼 실제로 터지고 나면 왜 그걸 신경 써야 하는지 체감이 확실히 된다.

댓글 0

첫 댓글 달아줘.