개발 slecs

거래명세서 정산 자동 발송과 충전·결제·수수료 통합 이력 추가

목차

settlement-statement 영역에 기능을 꽤 많이 붙였다. 거래명세서 메일 본문에 충전·결제·수수료 3분류를 통합하고, 자동 발송 배치를 달고, 발송 이력 화면까지. 변경된 파일은 내부 클래스 5개에 SQL 1개다. 숫자만 보면 작아 보이지만 작업 범위는 생각보다 넓었다.

왜 이걸 건드리게 됐나

기존 거래명세서는 '결제' 데이터만 담고 있었다. 충전과 수수료는 별도 화면에서 봐야 했고, 메일로 나가는 명세서와 관리 화면의 숫자를 일치시키려면 사람이 직접 대조해야 하는 구조였다. 자동화도 없어서 발송 타이밍이 불규칙했고, 언제 누가 받았는지 추적하는 이력도 없었다.

금융/결제 도메인에서 이런 구조는 시간이 지날수록 부채가 쌓인다. 충전·결제·수수료는 각각 다른 테이블·집계 로직을 갖고 있는데 이걸 명세서 하나에 맞게 통합하려면 쿼리 레벨부터 설계를 다시 해야 했다. 단순히 화면에 탭 하나 더 추가하는 수준이 아니었다.

구현 내용

정산 흐름에 신규 상태/단계를 추가하고 PENDING → CONFIRMED 전환 로직을 붙였다. 취소·환불 발생 시 정산 동기화, 감사(audit) 이력 테이블 연동, 중복 처리 방어를 위한 멱등성 키 적용까지 포함이다.

메일 본문 통합 집계 쿼리는 대략 이런 구조로 짰다.

SELECT
    t.type,                         -- 'CHARGE' | 'PAYMENT' | 'FEE'
    SUM(t.amount)      AS total_amount,
    COUNT(*)           AS tx_count,
    MIN(t.created_at)  AS period_start,
    MAX(t.created_at)  AS period_end
FROM transactions t
WHERE t.settled_at BETWEEN :from AND :to
  AND t.status = 'CONFIRMED'
GROUP BY t.type
ORDER BY t.type;

한 쿼리에서 3분류를 한 번에 뽑아서 애플리케이션 레이어에서 타입별로 분기하는 방식을 택했다. 쿼리를 3개 나눠 쓰는 것보다 실행 계획 상 유리하고, 숫자 불일치 가능성도 낮다. settled_at 인덱스가 없었으면 full scan을 탈 구간이었기 때문에 실행 계획을 미리 떼어봤다.

멱등성 키는 배치에서 특히 중요하다. 발송 잡이 retry되거나 중복 트리거됐을 때 메일이 두 번 나가는 걸 막아야 했다. 구현 방식은 (settlement_id, send_type, period) 조합을 유니크 키로 잡고, 발송 전 존재 여부를 먼저 체크하는 형태다.

String idempotencyKey = String.format("%s_%s_%s",
    settlementId, sendType, period.toString());

if (sendHistoryRepository.existsByIdempotencyKey(idempotencyKey)) {
    log.info("이미 발송된 명세서, skip. key={}", idempotencyKey);
    return;
}
// 실제 발송 + 이력 저장

DB 유니크 제약도 같이 걸었다. 코드 레벨 체크만 있으면 레이스 컨디션에서 뚫릴 수 있다.

발송 이력 화면은 audit 테이블을 기반으로 했다. 언제, 누구에게, 어떤 기간 명세서가 발송됐는지 조회할 수 있다. 운영 도중 "저 메일 받았어요?" 류의 질문이 들어올 때 화면 하나로 답이 나와야 했다.

설계 시 트레이드오프

데이터 갱신 방식은 실시간이 아닌 배치로 결정했다. 명세서 특성상 실시간 갱신보다는 정해진 주기에 정확한 숫자가 필요한 경우다. 폴링이나 이벤트 스트림을 붙이면 복잡도가 올라가는 데 비해 실질적인 이득이 없다고 판단했다.

취소·환불 동기화는 정산 상태와 분리해서 별도 이벤트로 처리했다. 취소가 발생했을 때 이미 CONFIRMED된 정산을 어디까지 되돌릴지 경계를 명확히 해야 했고, 그 경계를 코드보다 상태 정의 쪽에서 문서화해뒀다.

예외 케이스는 세 가지를 기본으로 잡았다.

  • 집계 대상 데이터가 없는 기간 (빈 명세서 발송 여부 정책)
  • 권한 없는 접근 (수신자 필터링 로직)
  • NULL 집계값 처리 (금액 합산 시 COALESCE 처리)

이 중 빈 명세서는 발송하지 않는 쪽으로 정책을 잡았다. 받는 사람 입장에서 빈 명세서가 오면 혼란스럽다.

검증과 작업 방식

구현 후 직접 화면에서 동작 확인. 기존 데이터가 깨지지 않았는지, 관련 화면의 숫자가 일치하는지 cross-check했다. 변경 전에 스크린샷이나 수치를 메모해두고 수정 후 같은 케이스로 다시 확인하는 게 습관이 됐다.

커밋은 논리적으로 독립된 단위로 쪼갰다. SQL 추가, 상태 머신 변경, 배치 로직, 이력 화면을 각각 커밋으로 나눴다. 나중에 특정 시점에서 숫자가 틀렸다는 리포트가 들어올 때 어느 변경에서 깨졌는지 추적하는 게 훨씬 빠르다. 커밋 메시지도 "무엇을" 보다 "왜"를 담으려고 했다.

사내 서비스를 계속 만들다 보면 기능 하나가 버튼 하나 추가로 끝나는 경우가 드물다는 걸 다시 체감했다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 다 엮여 있어서 하나만 빠뜨려도 숫자가 안 맞거나 특정 사용자에게 이상한 화면이 나온다. 금융/결제 도메인은 특히 "대충 맞는 것 같다"로 넘기면 반드시 다시 돌아오기 때문에 꼼꼼함이 기본값이어야 한다.

댓글 0

첫 댓글 달아줘.