일기 slecs

돈이 나갔는데 기록이 없다

목차

오늘 가장 먼저 마주한 건 커밋 메시지 초안이었다. "출금 데드락 무기록 지급 사고 재발방지." 이 열다섯 글자를 쓰면서, 이게 실제로 터졌다면 어떤 일이 벌어졌을지를 한 번 더 머릿속으로 굴렸다.

돈이 나갔는데 기록이 없는 상황. 추상적으로 들리면 안 된다. 구체적으로 풀면: 외부 송금 API가 성공 응답을 이미 반환했는데, 그 직후 데드락으로 트랜잭션이 롤백되면 수익 기록이 날아간다. 송금은 취소가 안 된다. 외부 API는 이미 성공했으니까. 결과적으로 금액은 빠져나갔고, DB에는 흔적이 없다. 이걸 나중에 발견하려면 외부 송금 내역과 내부 기록을 수동으로 대조해야 하는데, 그게 얼마나 빠지는지조차 한 번에 파악하기 어렵다.

구조가 잘못된 게 아니라, 경계가 잘못 그어져 있었다

코드를 쭉 따라가 보면 흐름이 대략 이렇게 생겼다. 출금 승인 요청이 들어오면, 내부 상태를 업데이트하고, 수익 기록을 남기고, 외부 송금 API를 호출하는 구조. 이 세 단계가 하나의 트랜잭션 안에 묶여 있거나, 외부 API 호출이 tx 종료 직전에 위치해 있었다.

데드락이 발생하는 시점은 여러 출금 건이 동시에 처리될 때다. 락 획득 순서가 뒤엉키면 서로 기다리다 타임아웃이 걸린다. 그 타임아웃이 걸리는 순간, JPA든 MyBatis든 트랜잭션을 롤백한다. 그런데 외부 API 호출이 이미 나갔으면 롤백으로 되돌릴 수 없다.

해법 방향을 잡는 데 시간이 걸렸다. 처음엔 단순히 락 순서를 정렬하는 방향으로 접근했는데, 근본적으로 외부 API 호출을 트랜잭션 경계 밖으로 분리하지 않으면 완전히 막을 수 없다는 결론이 났다.

세 가지를 같이 적용했다.

첫 번째는 송금 전 커밋. 순서를 바꾸는 것이다. 수익 기록과 내부 상태 변경을 먼저 커밋으로 확정한 다음, 그다음에 외부 송금을 때린다. 이렇게 하면 데드락이 걸려도 내부 기록은 살아있다. 최악의 경우가 "기록은 있는데 송금 실패"가 되는 것이고, 이건 재시도로 해결할 수 있다. "기록은 없는데 송금 성공"은 재시도로 해결할 수 없다.

두 번째는 선점(claim). 한 번에 하나의 프로세스만 특정 출금 건을 처리하도록 선점 마킹을 한다. 여러 스레드나 배치 인스턴스가 같은 건을 동시에 집어가지 못하게 막는 것. claim 마킹에 성공한 쪽만 진행하고, 실패한 쪽은 그냥 넘어간다.

세 번째는 수익 기록 격리. 수익 기록 트랜잭션을 메인 tx와 독립적으로 실행하도록 격리했다. 메인 흐름이 실패해도 수익 기록이 살아남는 구조로.

이 세 가지가 조합되니까 시나리오가 어느 방향으로 실패해도 추적 가능한 상태가 유지된다. 테스트 이름을 SettlementRevenueResilientTest로 지은 건 그 이유에서다. "잘 돌아가냐"를 검증하는 게 아니라, 일부가 실패해도 복원 가능한 상태를 유지하냐를 검증하는 것.

초록불이 들어왔을 때 잠깐 멍하니 바라봤다. 뭔가 뿌듯함과 불안함이 같이 온다. 테스트가 통과한다는 건 설계 의도대로 작동한다는 거고, 그건 좋다. 근데 테스트가 잡지 못하는 시나리오가 있을지도 모른다는 생각을 끊기가 어렵다.

거절 복구 경로가 두 번째 전선이었다

첫 번째 데드락을 어느 정도 정리하고 나니까, 거절 쪽 복구 경로에서 비슷한 문제가 있다는 게 보였다.

출금 신청을 거절할 때는 단순히 상태만 바꾸는 게 아니라, 이미 처리 중이던 리소스들을 원래대로 되돌리는 복구 작업이 있다. 그 복구 과정에서도 데드락이 걸리면 복구 자체가 실패하고 고착이 된다. 배치 쪽에서 주기적으로 미완료 건을 집어서 처리하는 구조가 있는데, 거기서도 같은 건을 중복으로 집어가는 상황이 생길 수 있었다.

여기는 두 갈래로 처리했다. 복구 tx에 재시도 로직을 넣었다. 데드락으로 실패하면 일정 횟수 안에서 재시도하게 하는 것. 데드락은 경합이 일시적으로 몰렸을 때 생기는 경우가 많아서, 잠깐 기다렸다가 재시도하면 풀리는 경우가 있다. 그리고 3분 중복 선차단을 넣었다. 같은 출금 건에 대해 3분 안에 복구 시도가 중복으로 들어오지 못하도록 막는 것. 이 로직은 SQL 매퍼 레벨까지 같이 손봐야 했다.

배치 클래스, 유틸, 웹 레이어, 쿼리 매퍼가 전부 엮여있어서 영향 범위 파악 자체가 시간을 꽤 잡아먹었다. 한 쪽 고쳤다고 끝나는 게 아니라, 같은 경로를 다른 레이어에서 또 들여다봐야 했다.

오전 내내 데드락 두 건을 이렇게 붙잡고 있었다.

수수료가 빠진 것, 라벨이 틀린 것

오후에 포탈 쪽 픽스로 넘어갔다. 이건 어제 리뷰에서 지적된 내용이었다.

파트너 포탈에서 하위 파트너의 출금을 승인할 때 수수료 처리가 누락되거나 잘못 적용되는 버그. 승인 로직은 동작하는데 수수료를 수취하는 코드가 타지 않는 상황이었다. 데드락 픽스에 비하면 단순한 누락이지만, 금전적으로는 마찬가지로 실질적인 손실이다. 리뷰어가 잡아준 것에 감사했다. 직접 발견했다면 배포 후에나 알았을 것이다.

PROCESSING 화면 라벨도 같이 손봤다. withdrawal.jsp, withdrawList.jsp, detail.jsp, list.jsp — 총 네 개 JSP를 다 열었다. 처리 중 상태에서 화면에 표시되는 텍스트가 화면마다 일관성이 없거나 맥락에 안 맞는 레이블이 쓰이고 있었다. 운영 쪽에서 상태를 보다가 혼선이 생기면, "시스템 문제 아니냐"는 문의가 들어오는 게 일상이다. 이런 거 미리 통일해두는 게 결국 더 적게 나를 찾게 만든다.

커밋에 "(리뷰 반영)"을 명시적으로 적어두는 습관이 있는데, 나중에 git log를 볼 때 이게 자체 발견인지 리뷰 지적인지 구분이 돼서 좋다. 리뷰가 얼마나 실질적으로 기여하고 있는지 추적하는 데 도움이 된다.

만들었는데 켜지 않기로 했다

인증표 사전 폐기 게이트 보류.

이게 오늘 중 가장 조용하게 지나간 커밋이지만, 결정 과정은 조용하지 않았다.

지갑(wallet) 쪽에서 나이를 실측해서 인증표를 사전에 폐기하는 게이트 로직을 구현했다. 특정 조건이 되면 인증표를 미리 폐기하는 흐름. 기술적으로는 동작하는 코드가 있다. 근데 지금 당장 이걸 활성화하는 건 리스크가 있다고 판단했다. 나이 실측이 실제 트래픽에서 어떻게 찍히는지, 엣지 케이스가 있는지를 로그를 먼저 보고 확인해야 한다는 것이다.

그래서 게이트는 막고, 나이 실측 로그만 남기는 것으로 일단 배포하기로 했다. 데이터를 보고 나서 게이트를 열지 말지 결정하는 것.

이런 결정이 항상 찝찝하다. 코드를 만들었는데 켜지 않는 것. 시간을 들였는데 아직 배포되지 않는 것. 근데 wallet 관련 게이트를 충분한 검증 없이 켜서 생기는 문제가 훨씬 더 비싸다는 걸 알기 때문에, 찝찝해도 참는 쪽을 택했다.

로그를 며칠 보고 나서 판단하자는 결론. 로그가 나오면 그걸 보고 다시 결정하면 된다.

모바일 문서화, 미뤄두면 안 된다는 걸 알면서도

IAP 관련 문서 작업이 오늘 마무리됐다.

양스토어 가격 정합 기준이 계속 애매하게 공유되던 상황이었다. 앱스토어 가격과 플레이스토어 가격을 어떻게 맞추고, 환율 변동에 따른 드리프트는 어디까지 허용하냐는 것. 기준통화만 비교하고, 환산 드리프트는 허용하는 것으로 확정했다. 이걸 mobile-app-launch.md에 명문화했다. 이게 문서에 없으면 나중에 새로운 사람이 들어왔을 때 또 같은 질문을 처음부터 다시 논의해야 한다.

결제 0원 진단 1순위를 IAP state로 잡은 것도 이번에 문서에 박았다. 이게 왜 중요하냐면, 결제 금액이 0으로 들어오는 이슈가 생겼을 때 여러 곳을 뒤지게 되는데, 경험상 대부분은 IAP state 문제에서 시작한다는 걸 알고 있기 때문이다. 이 순서가 문서에 없으면 새로운 이슈가 들어올 때마다 같은 순서로 헤매게 된다.

플레이스토어가 oneTimeProducts 구조로 바뀐 것도 감사법을 정리했다. 기존 방식으로 감사하면 항목이 안 잡히는 케이스가 생겨서, 신규 구조에 맞게 어떻게 확인하는지를 같이 적어뒀다.

CLAUDE.md에는 iOS 제출 큐 대기 항목도 기록했다. 심사 중인 버전, 아직 제출 안 된 것, 심사 통과된 것이 뒤엉키면 추적이 안 된다. 이 정도는 어딘가에 상태로 남겨두는 게 맞다. 두 번이나 비슷한 커밋이 들어간 것 같은데, 기록 타이밍 차이인 것으로 본다.

psy 파이프라인은 오늘도 조용히 돌았다

chore(psy) 커밋은 사람 손이 전혀 안 들어간 것이다.

memory-personality-type, midnight-pool-horror, sand-mandala-fortune. 자동 생성된 심리테스트 세 개와 커버 이미지. 매일 정해진 시간에 파이프라인이 돌아서 콘텐츠를 생성하고 커밋하는 구조. 오늘도 별 탈 없이 돌아갔다.

"midnight-pool-horror"라는 제목이 잠깐 눈에 걸렸다. 어떤 테스트인지는 안 열어봤다. 파이프라인이 잘 돌고 있다는 신호로만 봤다. 이 쪽 코드를 건드린 게 없어서 뭔가 이상한 게 생성됐을 리 없고, 커버 이미지도 같이 올라왔으면 정상 플로우다.


하루를 마감하면서 커밋 목록을 다시 보니까, 출금 관련 코드를 거의 하루 종일 붙잡고 있었다는 게 다시 실감이 난다. 데드락 두 건, 수수료 누락, 게이트 결정까지. 전부 돈과 직결되는 것들이라 하나하나가 가볍지 않았다.

이런 날은 배포하고 나서도 뭔가 놓친 게 없나 하는 생각이 가시질 않는다. 테스트를 짜고, 케이스를 머릿속으로 돌리고, 코드를 다시 보고, 또 다시 보고. 어느 순간부터는 더 보는 게 오히려 과잉이 되는 선이 있는데, 그 선을 어디서 끊는지는 항상 어렵다.

오늘은 그 선에서 커밋을 올렸다고 생각한다. 내일 로그가 어떻게 찍히는지 봐야겠다.

댓글 0

첫 댓글 달아줘.