자동화 slecs

결제 영수증 자동생성 누락 경로 보강과 과거 데이터 백필

목차

pay/receipt 영역 작업 중에서도 이번 건은 좀 묵직했다. 충전·결제 영수증 자동생성 로직에 누락된 경로가 있었고, 그걸 보강하는 김에 과거 데이터 백필까지 함께 처리했다. 변경된 내부 클래스가 6개인데, 숫자만 보면 별 거 아닌 것 같지만 각 클래스가 쿼리·상태 전이·이력 테이블·권한 체크에 다 걸려 있어서 실제 작업량은 훨씬 컸다.

"영수증 자동생성 누락"이라는 표현은 좀 애매하게 들릴 수 있다. 정확히는 충전과 결제 각각의 상태 전이 중 일부 경로에서 발급 트리거가 빠져 있었다는 거다. 정상 완료 케이스는 이미 발급되고 있었지만, 특정 조건에서 이력 테이블에 레코드가 남지 않는 경우가 있었다. 즉각적인 장애로 터진 건 아니었고, 사용자가 "내 영수증 어디 있어요?"라고 문의가 들어오면서 확인된 케이스다. 금융·결제 도메인에서 이런 게 쌓이면 정산·세무 이슈로 불거질 수 있어서, 발견 즉시 뿌리까지 건드리기로 했다.

설계 - 발급 트리거를 어디에 둘 것인가

가장 먼저 고민한 건 "영수증 발급을 어느 시점에, 어느 계층에서 처리할 것인가"였다. 선택지는 크게 세 가지였다.

방식 장점 단점
결제 완료 이벤트 훅 즉시 발급, 느슨한 결합 이벤트 유실 시 누락 재발
서비스 레이어 인라인 처리 트랜잭션 내 보장 결제 로직과 영수증 로직이 강결합
이력 테이블 주기적 재처리 멱등성 보장, 재시도 쉬움 즉시성 포기, 배치 복잡도 추가

결국 서비스 레이어 인라인 + 발급 이력 테이블을 조합하는 방식으로 갔다. 트랜잭션 커밋 직전에 발급 레코드를 함께 INSERT하되, 중복 발급 방지를 위해 (payment_id, receipt_type) 유니크 인덱스를 걸었다. 동일 건에 대해 발급 시도가 두 번 들어와도 DB 레벨에서 차단되니 애플리케이션 레이어 가드보다 훨씬 확실하다.

실시간 갱신이 필요한지 여부도 별도로 판단했다. 폴링 vs 이벤트 드리븐을 검토했는데, 영수증은 발급 시점이 명확하고 사용자가 실시간으로 상태 변화를 지켜보는 UI가 아니라서 단순 조회 쿼리로 가져오는 방향으로 정리했다. 사용자 접근 동선은 상세 페이지에서 영수증 링크를 노출하는 구조로, UI보다 그 링크가 가리키는 쿼리가 인덱스를 제대로 타는지를 먼저 확인했다.

예외 케이스 방어도 설계 단계에서 목록을 미리 뽑았다. 빈 데이터(발급 이력이 아직 없는 신규 유저), 권한 없는 접근(타 유저 영수증 조회 시도), NULL 처리(금액 컬럼이 nullable한 레거시 컬럼), 이 세 가지를 꼭 짚고 지나갔다. 특히 NULL 처리는 집계 쿼리에서 의도치 않게 0으로 처리되거나 아예 행이 빠지는 경우가 있어서 COALESCE를 쓰는 위치를 명시적으로 정했다.

백필 - 과거 데이터를 안전하게 채우는 방법

누락 경로를 보강해도 이미 구멍이 뚫린 과거 건들은 그대로 남는다. 백필은 선택이 아니라 필수였다.

백필 SQL을 작성할 때 지켜야 했던 원칙을 정리하면:

  • 멱등성: 여러 번 돌려도 결과가 같아야 한다. INSERT IGNORE 또는 유니크 인덱스를 활용해 중복 레코드 생성을 방지했다.
  • 범위 제한: 전체 히스토리를 한 번에 처리하지 않고 날짜 범위를 끊어서 배치로 돌렸다. 운영 DB 락과 부하를 최소화하기 위해서다.
  • 검증 쿼리 선 작성: 백필 전에 "대상이 몇 건인지" 카운트 쿼리를 먼저 만들고, 백필 후 같은 조건으로 다시 세서 0이 나오는지 확인했다.
-- 발급 이력 없는 완료 결제 건 조회 (백필 대상 확인용)
SELECT p.id, p.payment_type, p.completed_at
FROM payments p
LEFT JOIN receipt_issues ri
    ON ri.payment_id = p.id
   AND ri.receipt_type = p.payment_type
WHERE p.status = 'COMPLETED'
  AND ri.id IS NULL
  AND p.completed_at >= '2025-01-01'
ORDER BY p.completed_at;

실제 INSERT는 위 SELECT를 서브쿼리로 감싸는 형태로 작성해서 로컬에서 충분히 검증한 뒤 운영에 적용했다. row 수가 많으면 LIMIT을 걸어 청크 단위로 나눠 돌리는 게 안전하다. 한 번에 다 때리면 테이블 락이 길어질 수 있고, 중간에 문제가 생겼을 때 어디까지 됐는지 파악하기도 어렵다. 청크로 나누면 각 배치 전후로 카운트 비교가 가능하다.

한 가지 더 - 백필 SQL은 커밋 히스토리에 남겨두는 게 좋다. "운영에 직접 때린 SQL"은 나중에 감사 추적이 안 된다. 마이그레이션 파일이나 별도 스크립트 파일로 커밋해두면, 나중에 "그때 왜 이 데이터가 여기 있어요?"라는 질문에 git blame으로 답할 수 있다.

구현 후 검증과 cross-check

구현이 끝났다고 바로 배포하지 않았다. 변경 전에 메모해둔 케이스들을 기준으로 화면에서 직접 확인하는 게 먼저다. 영수증 번호, 발급 일시, 금액이 실제 결제 데이터와 일치하는지 세 화면을 나란히 띄워놓고 숫자를 맞췄다.

금융·결제 도메인에서 "대충 비슷해 보인다"는 검증 기준이 될 수 없다. 원 단위까지 같아야 하고, 어느 한 화면에서 NULL이 뜨면 그게 곧 버그다. 상세 페이지 금액, 발급된 영수증 금액, 이력 테이블 레코드 - 세 곳이 전부 같은 값을 가리킬 때 비로소 완료다.

클래스 6개가 바뀌었지만 커밋은 논리 단위로 쪼갰다. 발급 로직 추가, 이력 테이블 연동, 백필 SQL, 동선 변경을 각각 분리해서 커밋했다. 나중에 어느 변경에서 이상이 생겼는지 추적할 때 이 습관이 빛을 발한다. "이 커밋 하나만 revert하면 된다"는 상황이 생기면, 커밋을 덩어리째 넣은 걸 후회하기엔 이미 늦다.

커밋 메시지는 "무엇을"보다 "왜"를 담으려고 노력하는 편이다. 영수증 발급 로직 추가보다 충전 취소 후 재결제 경로에서 영수증 누락 - 발급 트리거 추가 및 이력 테이블 보강이 나중에 훨씬 유용하다.

결제 영역 작업은 기능 하나를 추가해도 생각보다 많은 계층을 건드린다. 쿼리, 상태 전이, 이력 기록, 권한 체크, 화면 렌더링 - 어느 하나라도 구멍이 생기면 숫자가 안 맞거나 특정 케이스에서만 이상하게 동작한다. "대충 맞는 것 같다"로 넘어간 것들은 반드시 다시 돌아온다. 처음부터 꼼꼼하게 치고 나가는 게 나중에 고치는 비용보다 훨씬 싸다는 걸 이번에도 다시 확인했다.

댓글 0

첫 댓글 달아줘.