Skip to Content
← 블로그
개발기

개발망에서는 "동작"했다 — 통합거래내역 적재를 다시 뜯어본 기록

만화로 보는 요약 — 먼저 읽어보세요
만화로 보는 요약 — 먼저 읽어보세요

TL;DR — 개발망에서 잘 돌던 적재 경로에 요구사항 하나를 얹으려다 취소·환불이 처음부터 한 건도 적재된 적 없다는 것을 발견했습니다. 발행 지점이 여섯 곳에 흩어져 있어 "빠뜨려도 아무도 모르는" 구조였고, 증상 스무 개가 그 하나에서 나왔습니다. 운영 배포 전이라 실사용자 피해는 없었습니다.

새 요구사항은 평범했습니다. 해외 지갑 결제사 A의 모든 거래를 통합거래내역에 실시간으로 넣어달라, 취소는 02·환불은 03으로 구분해달라, 가맹점 국가코드는 세 자리로 바꿔달라. 필드 몇 개 손보는 일로 보였습니다.

기존 적재 경로를 열었습니다. 그 자리에서 일이 커졌습니다.

취소·환불은 처음부터 한 건도 적재된 적이 없었습니다. 요구사항 2번은 "코드를 02/03으로 고쳐주세요"가 아니라 "없는 기능을 만들어주세요"였습니다.

먼저 분명히 해둘 것이 있습니다. 이 기능은 아직 운영에 배포되지 않았습니다. 개발망에서만 돌고 있었고, 이번이 첫 배포입니다. 그래서 이 글에는 장애 리포트도, 고객 피해 건수도 없습니다. 배포 전에 열어봤다는 것 — 그게 이 글에서 유일하게 다행인 부분입니다.

무엇을 적재하는가

결제 승인이 끝나면 "통합거래내역"이라는 표준 테이블에 거래를 한 줄 남깁니다. 컬럼이 55개인 스펙 테이블이고, 분쟁 시스템과 정산 시스템이 이걸 참조합니다. 즉 결제 시스템 입장에서는 다운스트림 리포트입니다. 결제가 이 테이블을 읽을 일은 없어야 합니다 — 이 문장이 나중에 이 글의 중심이 됩니다.

적재 경로는 이렇게 생겼습니다.

발행 지점이 여섯 곳에 흩어져 있습니다. 결제사나 결제 방식이 하나 늘 때마다, 개발자가 "여기에도 발행 코드를 심어야 한다"는 사실을 기억해야 합니다. 기억에 의존하는 규칙은 언젠가 깨집니다. 위 그림에서 점선이 그 결과입니다.

1️⃣ 빠진 연결, 틀린 필드, 오염된 금액

정의만 되고 호출되지 않은 발행 코드

환불 서비스에는 취소·환불 완료 시 이벤트를 발행하는 메서드가 있습니다. 주석에는 "횡단 관심사"라고까지 적혀 있습니다. 판정 헬퍼도 같이 만들어져 있습니다. 둘 다 어디서도 호출되지 않습니다.

커밋 이력을 보니 의도는 분명했습니다. "CPM·D-MPM에 이미 적용된 통합거래 이벤트를 취소/환불에도 확장 적용"이 목적이었습니다. 발행 메서드를 만들었고, 판정 조건을 만들었고, 호출부를 넣지 않았습니다. 컴파일은 통과합니다. 사용되지 않는 private 메서드에 대해 컴파일러는 아무 말도 하지 않고, 테스트도 존재하지 않는 호출을 검증하지 못합니다.

죽은 코드는 조용합니다. 그리고 이 경우 죽은 코드는 "미구현"보다 나쁩니다. 구현된 것처럼 보이기 때문입니다. 코드 리뷰에서 "취소도 발행하나요?"라고 물으면 누구든 이 메서드를 가리켰을 겁니다.

환불이 "승인"으로 기록되는 유형 코드

표준 스펙에서 매출거래유형코드는 01=승인, 02=취소, 03=환불입니다. 판정 로직은 이렇게 생겼습니다.

// 취소일자 하나만 보고 판정한다
if (cancelDate == null)             return "01";  // 승인
if (originDate.equals(cancelDate))  return "02";  // 취소
return "03";                                       // 환불

문제는 이 시스템에서 환불이 새로운 결제 원장으로 처리된다는 점입니다. 새 거래번호가 발급되고, 새 행이 생깁니다. 그 새 행의 취소일자 필드는 당연히 null입니다. 아직 취소된 적 없는 신규 원장이니까요.

그래서 첫 줄에서 걸립니다. 모든 환불 거래가 01(승인)으로 기록됩니다. 정산 관점에서 이건 취소분이 매출로 잡히는 것과 같습니다.

아이러니한 건 그다음입니다. 환불 원장을 만드는 코드는 이미 정확히 판정하고 있었습니다.

// 원장 생성 시점 — 여기서는 맞게 구분했다
paymentType = originDate.equals(refundDate) ? CANCEL : REFUND;

정답이 바로 옆 필드에 이미 들어 있었는데, 적재 로직은 다른 필드를 보고 있었습니다. 두 코드는 다른 모듈에 있고, 서로를 모릅니다.

부호가 아니라 금액이 오염된다

환불 원장의 금액은 원거래 금액 × -1로 만들어집니다. 망취소 원장도 .negate()를 씁니다. 원장 안에서는 합리적입니다 — 합산하면 0이 되니까요.

통합거래내역 스펙은 다릅니다. 취소·환불도 양수로 적재하고, 거래 유형 코드로 구분합니다. 그런데 적재 코드는 금액을 그대로 복사합니다.

더 나쁜 건 원금 계산입니다. 원금은 합계 - 부가세 - 봉사료로 구합니다. 그리고 합계만 음수고 부가세·봉사료는 양수로 복사됩니다.

항목
합계-11,000
부가세1,000
원금 (계산 결과)-12,000

부호가 뒤집힌 게 아닙니다. 절댓값을 씌워도 복구되지 않습니다. 금액 자체가 틀렸습니다.

왜 이렇게 됐나

이 셋은 성격이 달라 보이지만 발생 조건이 같습니다. 55개 컬럼을 채우는 코드에는 "덜 채워도 통과"와 "잘못 채워도 통과"가 동시에 성립합니다. 필드 하나를 안 넣으면 null이 들어가고, 잘못 넣으면 그럴듯한 값이 들어갑니다. 어느 쪽도 컴파일러나 테스트가 잡아주지 않습니다. 그리고 이 테이블을 읽는 쪽은 다른 팀입니다. 틀렸다는 신호가 돌아오는 데 몇 주가 걸립니다.

2️⃣ 리포트 테이블이 결제 전문의 의존 대상이 되어 있었다

이게 가장 오래 들여다본 부분입니다.

해외 지갑 결제사 A는 원화와 함께 USD 환산 금액을 승인 전문에 실어 보냅니다. 정산을 USD로 하는 해외 발급사라 환산이 필요합니다. 환율은 별도의 환율 고시 테이블에서 매매기준율을 가져옵니다. 여기까지는 정상입니다.

문제는 취소 전문입니다. 취소할 때도 USD 금액을 실어야 하는데, 이 값을 어디서 가져오는지 따라가 봤더니 통합거래내역 테이블이었습니다. 거래번호로 조회해서 USD 합계 컬럼을 읽습니다.

// 취소 전문 생성 중
BigDecimal usd = integratedTxRepository.findUsdAmount(txId);  // 리포트 테이블 조회

여기서 두 가지가 겹칩니다.

  • 결제사 A는 애초에 발행 지점이 없습니다. 승인 처리 코드에는 // TODO: 통합 거래 내역 전송 주석만 남아 있습니다. 행이 생긴 적이 없으니 조회 결과는 null입니다.
  • 설령 행이 생기더라도, 적재 코드는 USD 관련 금액을 전부 BigDecimal.ZERO로 하드코딩하고 있습니다. 그러면 0입니다.

즉 취소 전문의 USD 금액은 null이거나 0으로 나갑니다. 어느 쪽도 맞지 않습니다.

하지만 이건 표면입니다. 값을 채워 넣으면 사라질 문제가 아닙니다.

의존 방향이 거꾸로입니다. 통합거래내역은 이벤트 → Kafka → 컨슈머를 거쳐 채워지는 비동기 산출물인데, 결제 취소라는 동기 경로가 그 결과를 기다리지 않고 읽습니다. 승인 직후 곧바로 취소하면 행이 아직 없을 수 있습니다. 컨슈머가 밀리면 없을 수 있습니다. 컨슈머가 죽어 있어도 없습니다.

리포트가 늦으면 결제가 틀리는 구조입니다. 리포트는 늦어도 되는 것이어야 합니다. 그게 리포트를 비동기로 뺀 이유니까요. 그 전제가 조용히 무너져 있었습니다.

왜 이렇게 됐는지는 추측할 수 있습니다. 취소 시점에 USD 금액이 필요했고, "그 값이 어딘가 저장돼 있나?"를 찾았고, 통합거래내역에 USD 합계 컬럼이 있었습니다. 필요한 값이 마침 그 테이블에 있었다는 이유만으로 의존이 생겼습니다. 그 테이블이 어떤 경로로 채워지는지는 조회하는 쪽에서 보이지 않습니다. 리포지토리 메서드 하나는 동기든 비동기든 똑같이 생겼습니다.

3️⃣ 실패가 세 겹으로 조용하다

위의 문제들은 "잘못 적재된다"였습니다. 이 블록은 "적재가 실패해도 아무도 모른다"입니다.

핸들러. 이벤트 핸들러는 모든 예외를 잡아 log.error만 남깁니다. 예외를 다시 던지지 않으니 호출부는 성공으로 인식합니다.

프로듀서. Kafka 전송 실패 콜백도 log.error뿐입니다. 그리고 코드에 이 주석이 그대로 남아 있습니다.

// TODO: 추후 전송 실패할 케이스를 대비해서 아웃박스 패턴을 도입하는게 좋을 듯?

같은 클래스에 전송 실패 폴백 서비스가 주입돼 있습니다. 그런데 그 클래스를 열어보면 본문이 TODO 주석 한 줄뿐인 빈 클래스입니다. 의존성 주입만 되어 있고 호출도 없습니다. 1️⃣의 죽은 발행 메서드와 정확히 같은 패턴입니다 — 자리는 만들어졌고, 연결은 없습니다.

컨슈머. 에러 핸들러가 FixedBackOff(0L, 0L)입니다. 재시도 0회. DB 락 대기 한 번, 순간적인 커넥션 부족 한 번에 곧바로 DLT로 갑니다.

그리고 이 토픽의 DLT를 읽는 리스너가 없습니다. 다른 토픽들은 DLT 리스너가 있습니다. 이 토픽만 없습니다.

정리하면 실패는 로그 한 줄을 남기고 사라지거나, 아무도 구독하지 않는 토픽에 쌓입니다. 모니터링에는 아무것도 잡히지 않습니다. 이 시스템은 자신이 실패하고 있다는 사실을 말할 방법이 없었습니다. 누군가 코드를 열어보는 것 말고는요.

여기에 하나가 더 겹칩니다. 테이블에는 거래번호 UNIQUE 제약이 걸려 있는데, 컨슈머에 중복 체크가 없습니다. Kafka는 at-least-once입니다. 재전달은 장애가 아니라 정상 동작입니다. 그런데 재전달이 오면 UNIQUE 위반 예외가 나고, 재시도는 0회고, DLT로 갑니다. 정상적인 중복 전달이 장애로 처리됩니다. 발행 시 파티션 키도 지정하지 않으므로, 같은 거래의 승인과 취소가 다른 파티션에 들어가 순서가 뒤집힐 여지도 남아 있습니다.

4️⃣ 스펙 위반과, 페이로드에 실린 열쇠

나머지는 개별적으로는 작지만 성격이 같아 묶습니다.

  • 국가 코드 자릿수. 가맹점 소재국 코드의 원본은 alpha-2("KR")인데 표준 스펙은 alpha-3("KOR")입니다. 변환 없이 그대로 넣고 있었습니다.
  • 하드코딩된 거래 유형. FX거래유형코드가 값 하나로 고정돼 있습니다. 국내 거래도, 해외 가맹점 거래도 전부 같은 코드로 적재됩니다. 가맹점 유형(직영/대리점)도 마찬가지로 한 값에 고정돼 있습니다.
  • 복사·붙여넣기 흔적. USD 금액 계열 필드에서 같은 setter가 두 번 호출되고, 대신 다른 필드 하나가 통째로 누락됐습니다.
  • 하드코딩된 null. 원거래일자 필드에 null이 그대로 들어갑니다. 취소·환불에서 원거래를 잇는 유일한 단서입니다.

그리고 보안 쪽. Kafka로 나가는 페이로드 DTO에 결제 암호화에 쓰이는 비밀키와 IV가 들어 있습니다. 통합거래내역은 이 두 값을 전혀 사용하지 않습니다. 적재에 필요 없는 값이 브로커를 통과하고 있습니다. 게다가 이 DTO에는 @ToString이 붙어 있어, 어딘가에서 로그로 찍히면 그대로 남습니다.

왜 들어갔을까요. 아마 결제 도메인 객체를 그대로 매핑했기 때문일 겁니다. 필드를 골라내는 것보다 다 복사하는 게 빠르고, 안 쓰면 무해해 보입니다. 경계를 넘는 페이로드에서 "안 쓰니까 무해하다"는 성립하지 않습니다. 넘어간 순간 이미 노출입니다.

스무 개가 아니라 하나였다

처음에는 목록을 만들며 "버그가 많네"라고 생각했습니다. 정리하고 나니 그렇지 않았습니다. 하나의 구조적 선택이 만든 여러 증상이었습니다.

"거래 완료 후처리"를 각 결제사 플로우가 각자 알아서 하게 뒀다는 것. 여기서 전부 나옵니다.

증상구조적 원인
취소·환불 미적재, 결제사 A 미적재빠뜨려도 아무도 모른다 — 발행은 각 플로우의 자율이다
유형 코드·금액·스펙 위반잘못 채워도 컴파일된다 — 55개 필드에 검증 지점이 없다
유실·중복·순서실패해도 조용하다 — 실패 경로에 주인이 없다
결제 취소가 리포트를 읽음표준 통로가 없으니 필요한 값을 있는 곳에서 가져온다

발행 지점이 여섯 곳이라는 건 "거래가 끝났다"를 판정하는 주체가 여섯 명이라는 뜻입니다. 여섯 명은 서로 합의하지 않습니다. 한 명이 빠져도 나머지 다섯은 정상 동작하므로, 빠진 사실이 드러나지 않습니다.

그리고 결정적으로 — 개발망에서는 "동작"했습니다. 승인 건은 적재됐고, 조회하면 나왔고, 화면에 표시됐습니다. 안 되는 것들은 틀린 값으로 나온 게 아니라 아예 없었습니다. 없는 것은 눈에 띄지 않습니다. 테스트도 있는 것만 검사합니다.

"동작한다"와 "맞다"는 다릅니다. 그리고 그 간극은 없는 것 쪽에 숨습니다.

운영 배포 전이라는 것의 의미

이 글에는 장애 서사가 없습니다. 그게 이 진단의 성격을 결정합니다.

같은 결함을 운영에서 발견했다면 글의 재료가 훨씬 풍부했을 겁니다. 유실 건수, 정산 차액, 대사 기록. 대신 그 재료는 누군가 실제로 손해를 본 뒤에만 생깁니다. 여기서 확인한 것들은 전부 "이렇게 되면 이렇게 될 것이다"이지 "이렇게 됐다"가 아닙니다.

그래서 이 진단의 강도는 약합니다. 발생 확률을 모르고, 영향 범위도 추정입니다. 다만 방향은 분명합니다 — 배포 후에 알았을 것을 배포 전에 알았습니다. 하루 승인 5,000건, 요청 1만 건 규모입니다. 취소·환불이 한 건도 적재되지 않은 채로 배포됐다면, 그 사실은 분쟁이 접수되고 조회가 실패한 뒤에야 드러났을 겁니다.

이 진단이 틀릴 수 있는 지점

진단 단계라서, 정직하게 남겨둘 것들이 있습니다.

  • 실제 유실 건수는 0입니다 — 다만 그건 잘 돌아서가 아니라 배포된 적이 없어서입니다. "취소·환불이 한 건도 없다"는 개발망 기준 확인이고, 운영 데이터는 애초에 존재하지 않습니다.
  • DLT에 무엇이 쌓여 있는지 확인하지 못했습니다. 리스너가 없다는 건 코드로 확인했지만, 토픽 자체를 열어보지는 않았습니다. 실제 실패율은 미지수입니다.
  • 금액 오염의 실제 영향 범위가 불확실합니다. 정산 시스템이 이 컬럼을 그대로 쓰는지, 자체적으로 재계산하는지 확인하지 못했습니다. 다운스트림에서 보정하고 있다면 영향은 제가 추정한 것보다 작습니다.
  • 의존 역전이 실제로 문제를 일으킨 사례를 찾지 못했습니다. "승인 직후 즉시 취소하면 행이 없을 수 있다"는 구조상 가능한 시나리오이지, 관측된 장애가 아닙니다. 애초에 결제사 A는 행이 생긴 적이 없어서 이 레이스가 발현될 조건조차 없었습니다. 역설적이지만, 더 근본적인 결함이 덜 근본적인 결함을 가리고 있었습니다.
  • 커밋 이력에서 추론한 "의도"는 추측입니다. 메시지와 코드 상태로 역산한 것이고, 당시 작성자에게 확인하지 않았습니다.

이 목록을 만들면서 한 가지가 분명해졌습니다. 진단의 절반은 코드에서 읽어낸 것이고 나머지 절반은 운영 데이터로만 확인 가능한 것입니다. 후자는 배포 후에야 채워집니다. 그때까지 이 글은 가설의 모음입니다.

정정 기록

이 글의 초판에 두 가지 사실 오류가 있어 바로잡았습니다. 지운 흔적을 남기는 편이 낫다고 봤습니다.

(1) 진단 계기를 지어냈습니다. 초판은 "분쟁 시스템 담당자가 취소 건이 안 보인다고 했다"로 시작했습니다. 그런 일은 없었습니다. 실제 계기는 새 요구사항을 받아 기존 코드를 열어본 것이고, 이 기능은 운영에 배포된 적이 없어 그런 문의가 들어올 경로 자체가 없습니다. 글의 훅을 만들려다 없는 장면을 넣었습니다. 진단 글에서 계기를 각색하면 나머지 사실의 신뢰도까지 같이 떨어집니다.

(2) 없는 결함을 있다고 적었습니다. 초판은 FX거래유형 enum에 코드 중복("03")이 있어 한 값이 영영 조회되지 않는다고 적었습니다. 커밋 이력을 다시 확인하니 그 값은 처음 추가될 때부터 "04" 였고, 중복이었던 적이 없습니다. 설계 문서에 그렇게 적혀 있어서 코드를 확인하지 않고 옮겼습니다.

두 번째가 더 뼈아픕니다. 이 글은 "문서와 코드가 어긋나 있었다"를 주제로 쓴 글인데, 정작 저는 문서를 읽고 코드를 확인하지 않았습니다. 실제 코드로 대조한 건 구현 단계에 들어가서였고, 그때는 이미 글이 나간 뒤였습니다.

이 두 건은 다음 편의 주제와도 이어집니다. 다음 편은 "추상 수준을 한 칸 내릴 때마다 설계가 무너졌다"는 이야기인데, 여기에 한 칸이 더 있었던 셈입니다 — 문서에서 코드로 내려가는 칸.


다음 편에서는 이 진단을 어떻게 설계로 옮겼는지 씁니다. 핵심 질문은 "증상을 하나씩 고칠 것인가, 발행 지점이 여섯 곳이라는 사실 자체를 바꿀 것인가"였습니다. 개별 수정은 빠르고 확실하지만 일곱 번째 결제사가 붙는 순간 같은 일이 반복됩니다. 통로를 하나로 모으는 쪽은 옳아 보이지만 여섯 개 플로우를 전부 건드려야 하고, 그중 넷은 지금 멀쩡히 동작 중입니다 — 첫 번째 함정이 여기 있었습니다.