BE-commerce 프로젝트
약 6분 분량 결제·정산동시성·분산

API 서버를 늘렸더니 정산 배치가 두 번 돌았다: 실행권과 배포 단위를 분리한 이유

목차

결제 API를 확장하려고 서버 인스턴스를 두 대로 늘렸습니다. HTTP 요청은 분산됐지만 같은 애플리케이션 안의 @Scheduled 정산 배치도 두 개가 됐습니다.

전일 정산 2만 건을 시드하고 인스턴스 둘(8081·8082)을 동시에 기동하니 첫 틱에서 재현됐습니다. 양쪽 다 “정산 없음”을 보고 같은 2만 건을 집계했습니다. 돈이 두 번 나가지는 않았습니다. 늦게 커밋한 쪽을 DB 유니크 제약이 막았기 때문입니다.

8081 ── 정산 없음 확인 ── 20,000건 집계 ── COMMIT 성공
8082 ── 정산 없음 확인 ── 20,000건 집계 ── UNIQUE 위반

정상적인 동작은 아니었습니다. 유니크 제약은 같은 결과가 두 번 저장되는 것을 막지만, 누가 이 정산을 실행할지는 정해 주지 못합니다.

반대로 서버를 한 대만 유지하면 다른 문제가 생겼습니다. 결제 요청에 부하를 걸어둔 채 애플리케이션을 재배포했더니 9초 동안 9,870건 중 270건이 connection refused로 실패했습니다.

이 문제는 “서버를 늘릴 것인가”가 아니라 요청을 처리하는 실행 단위와 정산을 실행하는 단위를 왜 함께 복제하고 있는가의 문제였습니다.

결국 선택지 둘 다 안전하지 않았습니다.

  • 서버를 늘리면 스케줄러도 함께 복제됩니다
  • 서버를 늘리지 않으면 배포할 때 결제 API도 함께 멈춥니다

그래서 저장소나 서비스를 바로 분리하는 대신 API와 배치의 실행 단위만 나눴습니다. 이 글의 결론은 다음과 같습니다.

  • 유니크 제약은 중복 결과를 막을 뿐, 누가 배치를 실행할지는 정하지 못합니다.
  • API를 한 대만 두는 방식은 중복은 피하지만 배포 중 결제 중단을 감수하게 합니다.
  • 같은 jar에 worker 프로파일을 추가해 API와 워커의 배포 단위를 나누면, 이 규모에서는 MSA 없이 두 문제를 함께 줄일 수 있습니다.

이 과정에서 세 가지를 확인했습니다.

  • 왜 DB 유니크 제약만으로 배치 중복 실행을 해결할 수 없었는가
  • API와 워커의 배포 단위만 나눠도 어디까지 문제가 사라지는가
  • 요청이 없는 배치가 조용히 멈췄을 때 어떻게 알아챌 것인가

주기적으로 반복되는 작업 여덟 개는 그 전에 이미 배치로 옮겨 둔 상태였습니다. 미확정 결제 복구, 끊긴 체크아웃 복구, 보상 재시도, 정산 집계, 에스크로 자동 해제, 주문·가상계좌 만료, 정기결제 재청구, 아웃박스·멱등키 정리입니다. 전부 @ConditionalOnProperty 게이트라 꺼져 있으면 빈으로도 등록되지 않습니다.

1. 가드는 동시 진입에 무력했다

진입점에 “이미 정산했으면 건너뛴다”를 걸어 뒀는데 인스턴스 둘이 동시에 통과했습니다.

인스턴스 둘이 같은 정산을 두 번 집계하는 타임라인. 8081과 8082가 동시에 진입해 둘 다 "정산 없음"을 보고 전일 2만 건을 중복 집계하고, 늦은 쪽이 uk_settlement_date 제약에 막힌다

돈은 두 번 나가지 않았지만 가드가 일한 것이 아니라 제약이 일했습니다. 유니크 제약은 중복 결과의 커밋만 막고, 실행권(이 배치를 지금 돌릴 권한)은 정하지 못합니다. 멱등키는 일을 하기 전에 PROCESSING을 선점하는 것과 반대 방향입니다. 인스턴스 수만큼 무의미한 집계가 돌고 로그는 장애처럼 보입니다.

실행권을 떼는 방법은 여럿이고, 첫 줄은 이 저장소에서 당장 됩니다.

방법이 저장소에서
배치 전용 프로파일로 한 대만 실행지금 됩니다. 스케줄러가 전부 게이트라 API 쪽은 끄고 배치 한 대만 켜면 됩니다
집계 전에 (정산일, PROCESSING) 선점코드 한 겹. 대신 실행 중 죽은 건의 만료·재시도 정책이 따라붙습니다
ShedLock·DB advisory lock의존을 하나 들입니다
리더 선출·외부 스케줄러(K8s CronJob)운영 기반이 먼저 필요합니다

2. 그렇다고 서버를 한 대만 둘 수도 없었다

30 VU 부하 중 워커를 재시작한 결과입니다.

항목모놀리스배포 단위 분리
실패 요청270 / 9,870건 (2.7%)0 / 10,174건 (0.00%)
다운타임9초결제 API는 안 멈춤
5xx0건 (전부 connection refused)0건

20초 드레이닝이 인플라이트 결제를 지켰습니다. 늘리면 중복, 안 늘리면 배포마다 전면 중단이라 둘 다 안 됩니다.

3. 그래서 MSA로 쪼개지 않고 실행 단위만 나눴다

같은 jar에 worker 프로파일을 하나 더했습니다. 워커로 뜨면 스케줄러가 켜지고, 아무 프로파일 없이 뜨면(API) 스케줄러가 전부 꺼집니다.

API × 2 HTTP API, Scheduler OFF
Worker × 1 HTTP API 없음, Scheduler ON

API 인스턴스 2개(8080·8082)와 워커 1개(8081)를 띄운 결과입니다. API를 둘로 늘려도 정산 스케줄러는 복제되지 않아 같은 날짜 중복 집계 0건, 유니크 위반 0건이었습니다. 재배포 중 결제 중단도 270/9,870건에서 0/10,174건으로 사라졌고, 재기동한 워커는 첫 틱에서 같은 날짜를 다시 집계하려다 멱등하게 건너뜁니다.

늘린다 (배포 단위 분리)안 늘린다 (같은 실행 단위)
확장API와 워커를 따로 늘림. 스케줄러는 한 대API를 늘리면 스케줄러가 함께 복제돼 같은 정산을 중복 집계
배포무관 모듈 수정도 워커만 재배포. 결제 무중단전체 재시작이라 배포마다 결제가 멈춤
대가배포 단위가 둘로 늘고, 워커는 아직 단일 실행 주체(다중화하면 중복 재발)독립 확장·무중단 배포를 못 함

실측이 입증한 것은 “같은 실행 단위로 복제하면 중복과 낭비가 난다”까지입니다. “저장소까지 분리해야만 풀린다”는 아닙니다. 두 결합(스케줄러 중복·배포 결합)은 배포 단위를 나누는 것만으로 0이 됩니다. 분리를 검토한 이유는 실행 주기·확장 단위·배포 이유가 결제 API와 달랐기 때문입니다.

4. 그런데 처음 의심했던 문제는 재현되지 않았다

“배치가 결제 지연을 오염시킨다”는 가설을 먼저 세워 뒀는데, 재현되지 않았습니다. 배치 7연타 중에도 결제 p95는 64.3ms → 54.1ms로 흔들리지 않았고(오히려 베이스라인이 초반 워밍업으로 높았습니다) 커넥션 풀 pending은 0이었습니다. 이 규모에서는 근거가 아니므로 분리 근거로 쓰지 않았습니다.

5. API가 죽으면 보이는데, 배치가 죽으면 아무 일도 일어나지 않았다

API 장애는 5xx와 응답시간이 올라갑니다. 배치는 아무 요청도 받지 않으니 죽어도 둘 다 멀쩡합니다. 요청이 없으니 에러율도 응답시간도 안 움직입니다. 그래서 멈춤을 지표로 만들었습니다.

복구 배치가 멈춘 뒤의 지표 곡선. 에러율과 응답시간은 평평한데 payment.unknown.oldest.age만 치솟아 임계 600초를 넘고 알림이 뜬다

멈춤도 적체도 가장 오래된 것의 나이로 봅니다.

요청이 없는 작업은 개수가 그대로여도 나이는 계속 올라갑니다. 사람이 손으로 비우는 대사 큐는 처음에 건수로 걸었습니다. 한 건 이상이 15분 이어지면 울리는 식이었는데 운영자가 제때 종결해도 새 불일치가 이어서 들어오면 큐가 한 번도 비지 않아 알림이 울리는 구조였습니다. 방치된 건이 있을 때만 자라는 값은 가장 오래된 건의 나이라서 대사 큐도 같은 잣대로 옮겼습니다. 계속 들어오지만 제때 비워지는 시계열을 promtool에 넣으면 옛 식은 울리고 지금 식은 조용합니다.

지표무엇을 말하나
payment.unknown.oldest.age가장 오래된 미확정 결제가 몇 초째 떠 있나
recon.pending.oldest.age.seconds사람 확인이 필요한 대사 중 가장 오래된 건이 몇 초째 남아 있나
outbox.pending.oldest.age발행 못 한 이벤트가 몇 초째 남아 있나

복구 배치가 죽으면 결제는 정상으로 보이는데 unknown.oldest.age만 올라갑니다. 여기에 임계를 걸어 알림을 붙였습니다. 대표적인 셋만 적으면 이렇습니다.

지표·알림임계잡는 자리
미확정 결제 방치600초 초과 5분미확정 복구 배치
대사 미해결가장 오래된 건 900초 초과 5분대사 확정 큐
승인 성공률95% 미만 5분결제 승인 경로

승인 성공률은 95% 하나로 끝내지 않았습니다. 새벽처럼 분모가 작으면 한 건 실패가 곧 75%가 되므로 초당 0.1건 이상일 때만 95%를 적용하고 그 아래에서는 50% 미만일 때만 울립니다. 나머지 알림도 같은 방식으로 붙였습니다.

사람이 보는 다른 큐에도 같은 잣대를 댔습니다. 알림이 결제·대사 쪽에만 몰려 있었습니다. 심사가 밀리면 사기 의심 결제가 그대로 정산으로 넘어갑니다. 이상거래 심사 큐도 같은 모양으로 붙여 가장 오래된 건이 1시간을 넘으면 울립니다. 열 건이 방금 들어온 것과 한 건이 이틀 묵은 것은 위험이 다릅니다.

AI 자리는 “조용히 멈춤”이 더 위험합니다. 가드에 다 걸려 제안이 0건이 되면 기능이 꺼진 것과 같은데 에러는 안 납니다. 모델은 불리고 가드도 정상으로 걸러내는데 아무것도 안 나갑니다. 그래서 모델은 불리는데 통과 제안이 30분째 0건이면 알립니다.

남은 구멍

워커는 아직 단일 실행 주체라 다중화하면 중복 집계가 재발합니다. 선점이나 리더 선출은 붙이지 않은 상태입니다.

대사 큐의 나이는 판정 시각을 기준으로 잽니다. 같은 거래일 파일을 다시 올리면 판정을 지우고 새로 쓰므로 나이도 0부터 다시 시작합니다. 재업로드가 알림을 늦출 수 있다는 뜻이고 아직 고치지 않았습니다.

참고

근거: 배포 결합과 중복 집계 실측은 ADR-029에 있습니다.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.