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

정산을 떼어 낼 근거 하나를 기각했다: 그 실험이 대신 포인트 적립의 동시성 결함을 찾았다

목차

정산 모듈을 별도 서비스로 떼어 낼 근거로 세운 가설 셋 가운데 “정산 배치가 결제를 느리게 한다”는 기각했습니다. 결제 부하를 깔고 정산 배치를 7번 연달아 돌렸는데 결제 p95는 오히려 64.3ms에서 54.1ms로 낮게 나왔습니다. 커넥션 풀 20개 중 최대 15개를 썼고 대기는 0이었습니다. 그 실험의 1차 실행에서는 다른 것이 무너졌습니다. 결제 승인의 70%가 500을 냈습니다. 원인은 결제가 아니라 포인트 적립의 낙관적 락 충돌이었습니다. 적립을 DB에서 바로 더하는 방식으로 바꿔 같은 최악 조건의 승인 성공률을 39.6%에서 100%로 올렸습니다.

분리하기 전에 세운 세 가설

모놀리스를 쪼개자는 말은 흔히 “쪼개면 좋다”에서 시작합니다. 이 저장소는 분리하기 전에 지금 구조가 어디서 부딪히는지를 가설로 세우고 부하 실험으로 하나씩 확인했습니다. 세 가설은 이렇습니다.

가설결과분리 근거로 쓰는가
1. 정산 배치가 결제 API 지연을 악화시킨다미재현쓰지 않는다
2. 수평 확장하면 정산 스케줄러가 중복 실행된다재현쓴다
3. 무관한 모듈의 배포가 결제를 멈춘다재현쓴다

재현된 두 결합은 서비스를 떼지 않고 배포 단위만 나눠도 0이 됐습니다(인스턴스 2개의 스케줄러 중복 0건, 워커 재배포 중 결제 실패 270건에서 0건). 이 결과는 정산 배치의 실행 주체 나누기와 문서와 테스트가 맞아도 배포를 믿을 수 없는 이유에 있습니다. 이 글은 기각한 가설 1과, 그 실험이 대신 찾은 포인트 적립 결함을 다룹니다.

측정은 로컬 단일 장비(MySQL 8.4, Redis 7.4)에서 했습니다.

결론부터

  • 정산 배치 7회 동안 결제 p95는 64.3ms에서 54.1ms로 오히려 낮아졌고, 커넥션 대기도 0이어서 “배치가 결제를 늦춘다”는 가설은 기각했습니다.
  • 같은 실험의 1차 실행에서는 포인트 적립의 낙관적 락 충돌로 승인 성공률이 39.6%까지 떨어졌습니다.
  • 포인트를 DB 원자 증가로 바꾸자 같은 최악 조건에서 승인 성공률이 100%가 됐습니다.

서비스 분리의 근거는 구조 설명이 아니라, 분리 전과 후에 무엇이 실제로 달라지는지 보여 주는 실험이어야 합니다.

분리 근거로 세운 가설 하나를 기각했고 그 실험이 다른 결함을 드러냈다. 배치 7연타에도 결제 p95가 변하지 않았고, 1차 실행에서는 포인트 적립의 낙관적 락 충돌로 승인 70%가 500이었다. 적립을 원자 증가로 바꿔 39.6%에서 100%가 됐다

가설 1은 왜 그럴듯했나

정산 배치와 결제 API는 같은 JVM에서 같은 HikariCP 커넥션 풀을 씁니다. 배치는 정산 항목 2만 건을 읽고 전부 갱신한 뒤 한 트랜잭션으로 커밋하며 한 번에 약 10초가 걸립니다. 그 10초 동안 커넥션을 하나 붙잡고 있으니 결제가 커넥션을 기다리게 될 것이라고 봤습니다. 이 가설이 맞으면 “배치를 다른 프로세스로 떼야 한다”는 근거가 됩니다.

재 봤더니 흔들리지 않았다

정산 항목 10개 날짜 × 2만 건을 준비하고 주문 생성에서 결제 승인까지 도는 체크아웃 부하 30VU를 4분 동안 고정으로 깔았습니다. 처음 90초는 배치 없는 기준선이고 90초부터 관리자 API로 배치를 12초 간격으로 연속 실행했습니다. 부하가 변하지 않아야 90초 이후의 변화를 배치 탓으로 돌릴 수 있으므로 VU 수는 고정했습니다.

구간결제 p95결제 p99
기준선(배치 없음)64.3ms91.1ms
경합 구간(배치 7회)54.1ms68.0ms

배치는 7회를 끝까지 돌았고 결제 지연은 흔들리지 않았습니다. 5초 간격 타임라인에서도 배치 시점에 튀는 구간이 없었습니다. 기준선이 오히려 높은 것은 초반 워밍업(JIT 컴파일, 커넥션 풀 채움) 때문입니다.

커넥션 풀 지표가 이유를 보여 줬습니다. 경합 구간에서 풀 20개 중 최대 사용이 15개였고 대기(pending)는 0이었습니다. 배치가 커넥션 하나를 10초씩 잡아도 풀에 여유가 남아 대기열 자체가 생기지 않았습니다. 초당 약 26건의 체크아웃과 커넥션 20개 조합에서는 경합 조건이 성립하지 않았습니다.

그래서 가설 1은 분리 근거에서 뺐습니다. 재현될 만한 조건도 적어 두었습니다. 파드마다 자원을 제한해 풀이 작아질 때, 항목이 수십만 건을 넘을 때, 배치를 병렬로 돌릴 때입니다. 이 조건이 생기면 같은 실험을 같은 방법으로 다시 돌리면 됩니다.

1차 실행에서 무너진 것은 결제가 아니었다

위 표는 2차 실행의 결과입니다. 1차 실행은 데모 계정 하나로 30VU를 돌렸고 결제 승인의 70%가 500으로 실패했습니다. 로그에 찍힌 예외는 결제가 아니라 포인트 계좌였습니다.

ObjectOptimisticLockingFailureException:
Row was updated or deleted by another transaction ... [PointAccount#1]

결제가 끝나면 실결제액의 1%를 포인트로 적립합니다. 적립은 결제 트랜잭션 안에서 “계좌를 읽고, 잔액에 더하고, 저장”하는 순서로 돌고 계좌에는 @Version 낙관적 락이 걸려 있습니다. 같은 사용자의 결제가 동시에 들어오면 같은 계좌 행을 두고 충돌합니다.

운영 구성에서는 이 충돌이 거의 드러나지 않습니다. 사용자별 요청 제한(초당 5건)이 같은 사용자의 동시 결제를 문 앞에서 막기 때문입니다. 요청 제한이 동시성 결함을 가리는 완충재 역할을 하고 있었습니다. 그래도 결함은 결함입니다. 요청 제한 설정이 바뀌거나 배치가 일괄 적립을 하거나 적립 경로가 늘면(배치에는 요청 제한이 없습니다) 같은 충돌이 납니다. 그리고 실패의 반경이 나쁩니다. 부가 기능인 적립의 충돌이 결제 승인 자체를 500으로 만들었습니다.

2차 실행에서는 가입 API로 VU마다 다른 회원을 만들어 부하 모델을 실제 트래픽에 가깝게 고쳤습니다. 적립 결함은 따로 다뤘습니다.

포인트는 돈이라 선택지가 줄어든다

조회수 카운터였다면 성능만 보면 됩니다. 포인트 잔액은 원장 이력과 1원 단위로 맞아야 하므로 근사치도 유실도 허용되지 않습니다. 이 제약이 선택지를 바꿨습니다. 먼저 요청 제한을 끄고 계정 하나로 30VU가 주문과 승인을 반복하는 최악 조건을 만들어 쟀습니다. 승인 성공률은 39.6%(7,625건 중 3,022건)였고 실패는 전부 낙관적 락 충돌이었습니다.

선택지판단
낙관적 락 재시도코드 몇 줄이면 되지만 경합 자체는 그대로다. 부하가 오르면 재시도도 바닥나고 그동안 결제 트랜잭션이 길어진다. 증상 완화라 버렸다
DB 원자 증가적립은 더하기이고 더하기는 순서가 무관하다. 순서가 무관한 연산을 읽어서 순서를 만들 이유가 없다. 채택
적립을 이벤트로 분리적립 실패가 결제를 못 죽이게 되지만 취소가 적립보다 먼저 도착하면 회수할 적립이 0으로 계산돼 취소된 주문의 적립이 영구히 남는다. 지금은 같은 트랜잭션이라 이 순서 역전이 구조적으로 불가능하다. 보류
Redis로 세고 주기적으로 반영조회수라면 정답 후보다. 포인트에서는 반영 전 장애가 곧 돈의 유실이라 버렸다

원자 증가는 재고 차감에서 락 전략 셋을 비교해 조건부 UPDATE를 고른 것과 같은 처방입니다. 차이는 재고에는 quantity >= n 같은 조건이 필요하고 적립에는 필요 없다는 것뿐입니다. 계좌가 아직 없는 첫 적립의 생성 경합은 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 흡수했습니다.

함정이 하나 있었습니다. DB에서 바로 더하는 쿼리는 @Version 검사를 우회합니다. 버전을 올리지 않으면 같은 계좌를 엔티티로 읽어 둔 다른 경로(포인트 사용, 환불)가 낡은 잔액으로 덮어써도 버전 검사를 통과해 적립분이 소리 없이 사라집니다. 그래서 version = version + 1도 함께 올려, 그 경로들이 지금처럼 충돌을 감지하게 했습니다.

같은 조건으로 다시 쟀다

낙관적 락원자 증가
승인 성공률39.6% (3,022/7,625)100.00% (6,761/6,761)
실패 원인전부 @Version 충돌없음
승인 p95측정 안 함134ms

유실이 없었는지 원장과 대조했습니다. 계좌 잔액 978,400원과 원장의 적립 합계에서 회수 합계를 뺀 값 978,400원이 같았고(적립 9,784건), 불일치는 0이었습니다.

이 결정이 말하지 않는 것

  • 가설 1의 기각은 이 규모(초당 약 26건, 풀 20개, 항목 2만 건)에서의 결론입니다. 조건이 바뀌면 다시 재야 합니다.
  • 적립은 여전히 결제 트랜잭션 안에서 동기로 돕니다. 적립에 외부 조회가 붙거나 적립 경로가 늘면 취소 순서 보장과 함께 비동기 분리를 다시 설계합니다.
  • 최악 조건은 요청 제한을 끄고 계정 하나로 만든 것입니다. 실제 트래픽에서 같은 빈도로 충돌한다는 뜻이 아닙니다.

참고

프로필 사진
작성자 @범수

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

댓글

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