자동화 slecs

파트너 정산에 잔액 스냅샷 배치와 상태 전환 흐름 추가

목차

partner-settlement 영역에 두 가지 기능을 붙였다. 파트너 일 마감 잔액 스냅샷 배치와 포털 연동 흐름. 변경 파일은 내부 클래스 3개, 설정·문서 1개, SQL 매퍼 1개, 뷰/스타일 1개다. 숫자만 보면 조촐해 보이지만 실제로는 쿼리 레벨부터 화면 렌더링까지 수직으로 다 건드린 작업이었다.

기존 API와 화면에서 제공하지 않던 데이터와 동작이 필요해진 게 발단이었다. 화면에 컬럼 하나 추가하는 게 아니라, 어느 시점 기준 잔액을 "확정된 값"으로 찍어놓는 스냅샷이 없으면 집계 화면의 숫자가 조회 시점마다 달라질 수 있었다. 거기서부터 설계 방향이 잡혔다. 단순 UI 추가가 아니라 쿼리 레벨부터 설계해서 정합성을 맞춰야 했던 이유다.

구현: 상태 전환과 스냅샷 연결

정산 흐름에 새 상태와 단계를 추가했다. 핵심은 PENDING → CONFIRMED 전환이고, 여기에 취소·환불 발생 시 정산 동기화 로직이 붙는다.

이벤트 이전 상태 이후 상태 부가 처리
정산 배치 실행 - PENDING 잔액 스냅샷 생성
포털 확인 완료 PENDING CONFIRMED audit 이력 기록
취소/환불 발생 CONFIRMED ADJUSTED 정산 금액 재동기화

상태 머신을 코드로 표현할 때 허용되지 않은 전환을 명시적으로 막는 게 핵심이다. CONFIRMED 상태에서 PENDING으로 돌아가는 게 불가능하다면 코드에서도 그 경로를 아예 열어두지 않아야 한다. 열려있으면 언젠가 예상치 못한 호출 경로에서 잘못된 방향으로 전환된다.

이번에 가장 신경 쓴 건 중복 처리 방어였다. 배치가 어떤 이유로든 두 번 돌거나 이벤트가 재처리될 때 스냅샷이 두 번 찍히면 안 된다. 멱등성 키로 막았다.

String idempotencyKey = buildKey(partnerId, settlementDate);
if (snapshotRepository.existsByIdempotencyKey(idempotencyKey)) {
    log.info("이미 처리된 스냅샷, 건너뜀: {}", idempotencyKey);
    return;
}
snapshotRepository.save(buildSnapshot(partnerId, settlementDate, idempotencyKey));

이 방어 없이 배치를 재시도하면 같은 날짜 잔액이 두 배로 찍힐 수 있다. 금융 도메인에서 "일단 저장하고 중복은 나중에 걸러내자"는 접근은 위험하다. 나중에 걸러낼 시점에는 이미 화면에 노출됐거나 다른 연산의 인풋으로 쓰였을 수 있기 때문이다. 쓰기 시점에 막는 게 맞다.

감사 이력 테이블 연동도 함께 넣었다. 정산 상태가 바뀔 때마다 누가, 언제, 어떤 값에서 어떤 값으로 변경됐는지를 남긴다. 당장 쓸 일이 없어 보여도 "왜 이 숫자가 됐냐"는 질문이 오면 로그를 뒤지는 것보다 훨씬 빠르게 답할 수 있다. audit 테이블 한 줄이 나중에 한두 시간을 아껴줄 때가 있다.

SQL 매퍼도 짚어두자면, 스냅샷 테이블과 기존 정산 테이블을 조인하는 쿼리가 새로 생겼고 집계 방향이 달라진 쿼리도 있었다. 조인 쿼리에서 resultMap 설계를 허술하게 하면 컬럼 이름이 충돌해서 값이 무음으로 덮어씌워지는 경우가 생긴다. 이번에는 alias를 전부 명시적으로 잡아줬다.

설계에서 실제로 고민한 것들

화면에 보여줄 숫자가 어떤 SQL로 나오는지를 먼저 확정하고 나서 서비스 클래스 구조를 잡는 순서로 했다. 반대로 서비스 레이어부터 설계하면 나중에 쿼리 결과와 계산값이 미묘하게 안 맞는 케이스가 튀어나온다. 이미 그 순서를 거꾸로 했다가 고생한 기억이 있어서 이번엔 처음부터 SQL 먼저 확정했다.

구체적으로 선택지가 있었던 포인트들:

  • 실시간 갱신 vs 배치: 잔액 스냅샷은 일 마감 기준이라 실시간으로 계산할 이유가 없었다. 폴링이나 이벤트 드리븐 대신 배치를 택했다. 대신 배치 완료 시점과 포털 반영 시점 사이의 gap을 문서에 명시해뒀다. 사용자가 "왜 아직 안 반영됐냐"고 물었을 때 답할 수 있어야 한다.
  • NULL 처리 정책: 아직 정산 데이터가 없는 파트너에게 0을 보여줄지, 행 자체를 숨길지. 이 도메인에서 0과 "데이터 없음"은 다른 의미다. COALESCE로 뭉개버리면 안 되는 케이스였다.
  • 쿼리 실행 계획 확인: 조인 쿼리가 새로 생겼으니 인덱스를 짚었다. 지금 규모에서는 문제없지만 풀스캔이 날 수 있는 구조는 미리 손봐뒀다.
  • 권한 경계: 포털에서 파트너 A의 정산 데이터를 파트너 B가 볼 수 없어야 한다. 기존 API에는 파트너 범위 필터가 이미 걸려있었지만 새로 추가된 엔드포인트에도 같은 기준이 명시적으로 적용되는지 확인했다. "기존이랑 비슷한 구조니까 괜찮겠지"가 구멍이 되는 전형적인 패턴이다.

데이터 정합성은 금융/결제 도메인에서 기본값이어야 한다. 기존 데이터가 깨지지 않아야 하고, 다른 화면에서 보이는 숫자와 일치해야 한다. 새 기능을 추가할 때도 그 전제가 유지되는지 확인하는 게 설계의 절반이다.

검증 루틴과 커밋 단위

구현 후에 화면에서 직접 동작을 확인했다. 기존 데이터가 깨지지 않았는지, 관련 화면 숫자가 일치하는지 cross-check하는 게 루틴이다. 순서는 이렇다:

  • 변경 전 화면 수치나 동작 상태를 메모하거나 스크린샷으로 남기기
  • 수정 후 같은 케이스를 그대로 재확인
  • 관련 화면이 여러 개면 같은 파트너 ID 기준으로 숫자를 나란히 비교

번거로워 보여도 이 과정이 없으면 "대충 맞는 것 같다"로 넘어가게 되고, 그게 나중에 반드시 돌아온다. 특히 금융 도메인에서는. 숫자 하나가 틀리면 신뢰가 무너진다는 게 과장이 아니다.

커밋은 논리적으로 독립된 단위로 쪼갰다. 스냅샷 배치 로직, 상태 전환 클래스, SQL 매퍼 수정, 뷰 변경을 각각 따로 커밋했다. 단위가 작을수록 문제가 생겼을 때 어느 변경에서 깨졌는지 빠르게 좁힐 수 있다. 커밋 메시지도 "무엇을" 보다 "왜"를 담으려 했다. 몇 달 뒤에 이 코드를 보는 사람이 자기 자신일 수도 있으니까.

사내 서비스를 계속 만들다 보면 기능 하나가 버튼 하나 추가로 끝나지 않는다는 걸 매번 실감한다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 전부 얽혀 있어서 어느 하나만 빠뜨려도 특정 조건에서 숫자가 안 맞거나 이상한 화면이 나온다. 이번 작업도 그 구조를 처음부터 끝까지 다시 한번 따라가는 과정이었다.

댓글 0

첫 댓글 달아줘.