개발 slecs

가상계좌 웹훅 데드락을 멱등성과 잠금 순서 통일로 해결

목차

가상계좌 충전 웹훅에서 간헐적으로 터지던 데드락을 잠금 순서 통일과 멱등성 키 도입으로 잡았다. "간헐적"이라는 말이 붙으면 재현이 타이밍에 달려 있다는 뜻이고, 로컬에서는 좀처럼 안 나타나다가 운영에서만 드문드문 터지는 패턴이다. 결제대행사 쪽에서 응답을 못 받으면 웹훅을 재전송하는데, 그 재전송이 경쟁을 심화시켜서 교착 상태를 더 자주 만들어 냈다.

금융/결제 도메인에서 입금이 반영되지 않으면 단순 오류가 아니라 잔액 불일치로 이어진다. 사용자 입장에선 돈을 넣었는데 잔액이 안 늘어난 상황이고, 신뢰가 무너지는 건 순식간이다. 그래서 "나중에 보자"로 넘길 수 없었음.

데드락이 생기는 구조

가상계좌 입금 알림이 거의 동시에 두 건 이상 들어오면 각 트랜잭션이 서로 다른 순서로 테이블 락을 잡으려 했음. 단순화하면 이렇다.

트랜잭션 A (웹훅 요청 1):
  충전 테이블 락 획득 → 잔액 테이블 락 대기 중

트랜잭션 B (웹훅 요청 2):
  잔액 테이블 락 획득 → 충전 테이블 락 대기 중

→ 서로 상대방의 락을 기다리며 교착 상태

데드락이 발생하면 DB가 결국 한쪽 트랜잭션을 희생자(victim)로 골라 롤백시킨다. 롤백된 쪽이 그냥 오류로 처리되고 끝나면 그 입금은 반영되지 않는다. 애플리케이션이 재시도 로직을 갖고 있으면 다시 시도하겠지만, 재전송과 재시도가 중첩되면 경쟁이 또 생긴다.

근본 원인은 코드 경로마다 테이블을 잠그는 순서가 달랐기 때문이다. 기능을 추가할 때 각자 편한 순서로 쿼리를 짰고, 서로 참조하는 테이블 조합이 역전되는 지점이 생겼음. 이런 문제는 코드를 한 줄씩 읽어선 잘 안 보인다. 두 코드 경로를 나란히 놓고 잠금 흐름을 따라가 봐야 "여기서 순서가 다르네" 하는 게 보인다.

멱등성과 잠금 순서로 풀기

해결책은 세 갈래였음.

잠금 순서 통일. 어떤 코드 경로든 충전 테이블 락 먼저, 잔액 테이블 락 다음으로 고정했다. 순서가 일관되면 교착은 구조적으로 생기지 않는다. 한 가지 주의할 점은, 이 규칙을 코드 어딘가에 명시해 두지 않으면 다음 기능을 추가할 때 또 뒤집힌다는 거다. 주석이든 아키텍처 문서든 "항상 이 순서로 획득한다"는 한 줄이 있어야 코드 리뷰 때 검사 포인트가 생긴다.

멱등성 키 도입. 결제대행사 웹훅에는 거래를 식별할 수 있는 고유 키가 있다. 그 키를 유니크 제약 컬럼으로 박아 두면, 같은 웹훅이 두 번 오더라도 두 번째는 중복 키 오류로 조용히 무시할 수 있음.

-- 같은 webhook_id는 한 번만 처리되도록 보장
ALTER TABLE charge_events
  ADD CONSTRAINT uq_webhook_id UNIQUE (webhook_id);

애플리케이션 레벨에서 "이미 처리됐는지 조회 후 처리"를 하면 조회와 처리 사이 간격에 경쟁 조건이 생기지만, 유니크 제약은 DB가 원자적으로 처리해 주므로 그 틈이 없다. ON CONFLICT DO NOTHING이나 예외 핸들링으로 중복을 깔끔하게 넘길 수 있음.

재시도 로직 제거. 역설적이지만, 애플리케이션 레벨에서 스스로 재시도하던 로직이 경쟁을 심화시키고 있었음. 결제대행사가 이미 재전송을 보장하는데 우리도 재시도하면 트래픽이 중첩되어 데드락 확률이 올라간다. 외부에서 재시도를 보장해 주는 환경이라면 내부 재시도는 없애거나 최소화하는 편이 낫다.

방법 효과 전제 조건
잠금 순서 통일 교착 상태 구조적 제거 모든 코드 경로 점검 필요
멱등성 키 중복 처리 원천 차단 외부 키의 고유성 전제
내부 재시도 제거 경쟁 완화 외부 재시도 보장 전제

세 가지를 같이 적용한 이유는 각각이 다른 실패 모드를 막아주기 때문이다. 잠금 순서만 맞춰도 데드락은 안 생기지만, 웹훅이 두 번 오면 중복 처리가 생긴다. 멱등성 키만 있으면 중복은 막지만 동시성 문제가 남는다. 조합해야 방어선이 겹쳐진다.

개발 습관과 코드 리뷰

이번 작업에서도 몇 가지 루틴을 그대로 유지했다.

  • 변경 전에 현재 동작을 수치나 로그로 메모
  • 수정 후 같은 케이스로 재확인, 관련 화면 있으면 숫자 cross-check
  • 커밋 메시지는 "무엇을"보다 "왜"를 담으려고 노력

작은 커밋을 자주 하는 이유는 나중에 문제가 생겼을 때 어느 변경에서 깨졌는지 찾기 훨씬 쉬워서다. 잠금 순서 변경, 멱등성 키 추가, 재시도 제거를 하나의 커밋으로 묶으면 롤백 단위도 커지고 리뷰도 어렵다. 논리적으로 독립된 단위로 쪼개 두면 git bisect나 revert가 훨씬 편하다.

코드 리뷰 때 잠금 순서를 명시적으로 검사하자는 방향으로 팀 내 합의를 만들어 가고 있음. "이 코드가 어떤 순서로 락을 잡는가" 한 가지 질문만 습관적으로 물어봐도, 새 코드 경로가 기존 순서를 역전시키는 경우를 미리 잡아낼 수 있다. 데드락은 두 번째 사람이 순서를 바꿔서 생기는 경우가 많다. 첫 번째 사람이 규칙을 잘 지켜도, 그게 문서화가 안 되어 있으면 세 번째 사람이 모르고 뒤집는다.

사내 서비스를 만들다 보면 기능 하나가 단순히 화면에 버튼 하나 추가하는 것으로 끝나지 않는다는 걸 계속 체감한다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 모두 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 사용자에게 이상한 화면이 나타남. 특히 금융 도메인은 "대충 맞는 것 같다"로 넘어가면 반드시 다시 돌아온다. 이번 데드락도 그랬다.

댓글 0

첫 댓글 달아줘.