결제 성공 콜백의 동시성 처리 안정화
- 개발
- 2026. 3. 20. 21:16
분산락을 적용한 뒤 부하 테스트를 진행하는 과정에서 5XX 응답이 과도하게 발생하는 현상을 확인했습니다. 이번 작업의 목적은 동일한 결제 성공 콜백이 동시에 들어오는 상황에서 분산락이 의도한 대로 동작하는지 검증하고, 그 결과를 해석 가능하게 만드는 것이었습니다. 문제는 5XX가 발생했을 때 그것이 분산락 경합에 의한 결과인지, 아니면 락 외부의 예외인지 구분하기 어려웠다는 점이었습니다. 따라서 이번 작업에서는 결제 기능 전체의 안정성 개선보다, 분산락 동작을 방해하는 노이즈를 줄이고, 락 경쟁 결과를 명확히 관측할 수 있도록 만드는 것에 초점을 맞췄습니다.


먼저 200UV를 기준으로 잡고 k6를 돌려보겠습니다.
30분간 200UV를 돌린결과 5XX에러가 19950번이 발생이 되었다는 것을 알 수 있습니다.
하지만 정확히 어떤 문제에서 터졌는지 알 수 없다는 문제가 있었습니다.

이제 본격적으로 진행해보겠습니다.
사전 작업
제가 테스트하려고 하는 부분은 결제이후에 토스 api를 통해 구독을 처리하는 부분입니다.
테스트 하기전에 두 가지 문제가 발생하였습니다. 테스트를 하기 위해서는 로그인이 필수불가하게 필요하다.
두 번째, 토스 API로 직접 부하테스트를 진행할 수 없다.
첫 번째

강제로 특정 프로파일인 경우 헤더에 EMAIL표기가 되어있다면, 로그인 절차를 거치지 않고 세션에 헤더에서 읽어온 이메일을 반영해주는걸로 변경하였습니다. 그래서 특정 이메일로 회원가입을 하고, 그것으로 로그인을 하는 절차를 지웠습니다.
두 번째
직접적으로 토스API를 테스트하는 것이 아닌 Fake혹은 Mock객체를 만들어서 진행하였습니다.

물론, 실제 토스 API를 사용하는 곳에서는 @Profile("!dev")로 설정해놓았습니다.
어째서 프로파일이 test나 local이 아닌 이유는 저는 스프링 부트를 직접적으로 돌리는 것이 아닌 도커로 띄워서 돌리는 것을 선택하였습니다. 도커로 띄우는 경우에는 프로파일을 dev로 설정하여서 분산락 테스트는 dev로 하기로 결정하였습니다.
저는 도커 앱을 3개를 띄우고 시작하였습니다.

하지만 이렇게 되면 시작 포인트가 8083,8084,8085 이렇게 3개로 고정이 되어집니다. 사실 우리가 웹을 들어가게 되면
8083으로도 들어갈수도 있고, 8084로도 들어갈수도 있고, 8085로 들어가게 설정하지는 않습니다.
단 하나의 시작 포인트로 들어가게 됩니다. 그렇다고 해서 3개중 하나가 리더가 되는 형태는 전혀아닙니다.
하나의 시작 포인트에서 들어가기 위해서는 로드벨런서라는것이 필요합니다.
이는 부하를 고르게 전달해주는 장치라고 생각하시면 됩니다. 저는 nginx로 롤링배포를 사용해서 8083,8084,8085를 사용하게 해두었습니다.


그리고 30분간 200UVS로 잡고 테스트를 진행하였습니다.
부하 테스트를 진행하던 과정에서 5XX 응답이 과도하게 발생하는 문제를 확인했습니다. 물론 부하 상황에서 일부 5XX가 발생하는 것은 가능하지만, 문제는 해당 오류가 분산락충돌 이후 중복 처리 과정에서 발생한 것인지, 혹은 락과 무관한 다른 원인 때문인지 즉시 구분하기 어려웠다는 점이었습니다. 그래서 동일한 orderId에 대한 동시 confirm 요청이 들어오더라도, 분산락 경합 결과가 예측 가능한 응답으로 처리되도록 로직을 보완했습니다.
1. 동일한 orderId에 대한 동시 confirm 요청이 들어오더라도, 분산락 경합 결과가 409 Conflict 등 예측 가능한 응답으로 처리되도록 한다.
2. 5XX 응답 발생 시, 분산락 경합에 의한 문제인지 락 외부 문제인지 구분 가능하도록 한다.
3. 30분 baseline 테스트에서 분산락 관련 메트릭과 응답 패턴을 안정적으로 검증할 수 있도록 한다.
동일한 orderId에 대한 동시 confirm 요청이 들어오더라도, 분산락 경합 결과가 409 Conflict 등 예측 가능한 응답으로 처리되도록 한다.
토스 결제시스템은 다음과 같은 절차를 가집니다.

토스 UI에서 success API로 리다이렉트될 때 paymentKey가 전달됩니다. 이 값을 이용해 토스 승인 API를 호출하고, 결제가 정상적으로 완료됐는지 확인할 수 있습니다.
paymentKey는 토스 결제를 식별하는 고유값이므로 중복되면 안 됩니다.하지만 현재는 이에 대한 제약이 없습니다. 우리 시스템은 토스 UI에서 바로 결제 요청을 보내는 구조가 아니라, 먼저 결제 API를 호출해 결제 데이터를 READY(대기) 상태로 저장합니다.
따라서 결제 전에는 paymentKey가 null일 수밖에 없습니다. 이후 토스 성공 리다이렉트 시점에 전달받은 paymentKey를 저장하게 되는데, 이때 동일한 paymentKey가 중복 저장되면 동일 결제가 여러 번 처리될 위험이 있습니다. 그래서 paymentKey에는 유니크 제약이 반드시 필요합니다.

이렇게 하면 기존 payment가 등록이 되어있다면, 409예외를 터드리도록 하였습니다.
하지만 이럼에도 불구하고 DB측에서 먼저 데이터가 저장이 될 수 있습니다.

DB측에도 paymentKey를 unique로 등록시켜두었습니다. 하지만 이렇게 설정하다보면 동일한 payment가 저장이 되어진다면, 409가 아님 5XX에러가 발생이 되어집니다. 이를 방지하고자

결제 완료 처리 시 paymentKey를 저장합니다. paymentKey는 토스 결제를 식별하는 고유값이므로 중복되면 안 됩니다. 따라서 저장 과정에서 중복으로 인해 DataIntegrityViolationException이 발생하면, 서버 내부 오류(5XX)로 두지 않고 409 Conflict로 변환해 이미 처리된 결제 요청임을 명확하게 반환합니다.
그러면 어째서 saveAndFlush를 했냐라고 한다면?
paymentKey 중복 여부는 애플리케이션 메모리에서 확인되는 것이 아니라 DB의 유니크 제약으로 최종 보장됩니다. 따라서 단순히 엔티티 값만 변경하는 것으로는 부족하고, saveAndFlush()를 통해 DB 반영 시점을 앞당겨야 중복 충돌을 현재 로직 안에서 감지할 수 있습니다.
-> saveAndFlush()를 쓴 이유는 중복 예외를 트랜잭션 커밋 시점이 아니라 현재 confirm 처리 흐름 안에서 잡기 위해서입니다.
이 작업을 한다고 해서 5XX예외가 4XX예외로 바뀌지는 않습니다. 하지만 조금더 동일한 orderId에 대한 동시 confirm 요청이 들어와도, 분산락 경합 결과를 예측 가능한 응답으로 처리하도록 개선하는것이 목적입니다.
간단하게 10분정도만 테스트를 진행해보겠습니다. 이걸 한다고 해서 전에꺼랑 비교는 어렵지만 말이지만 말입니다.

역시 예상한거와 달리 아직도 예상치 못한 오류가 발생이 되어지는군요.

시간이 달라 얼마나 더 좋아졌는지는 알수 없습니다. 아무튼 추후 얼마나 좋아졌는지 확인해봅시다.
5XX 응답 발생 시, 분산락 경합에 의한 문제인지 락 외부 문제인지 구분 가능하도록 한다.
기존에는 분산락 처리 과정에서 발생한 오류와 락 외부에서 발생한 오류가 모두 5XX로 동일하게 응답되어, 원인 구분이 어려웠습니다. 그 결과, 장애가 분산락 경합/처리 문제인지 아니면 다른 내부 오류인지 파악하기 어려운 경우가 많았습니다. 이번에는 이 둘을 구분 가능한 응답으로 분리해, 문제 원인을 더 명확하게 식별할 수 있도록 개선하려고 합니다.

이제 분산락을 얻는 도중 문제가 발생이 되어진다면, LockException이 발생하게 됩니다.
이제 분산락에서 발생한 예외랑 그렇지 않는 예외를 구분을 지을 수 있게 되었습니다. 이번에도 10분간 테스트를 진행해보았습니다.
놀랍게도 일시적인 오류는 발생하지 않았습니다.

30분 baseline 테스트에서 분산락 관련 메트릭과 응답 패턴을 안정적으로 검증할 수 있도록 한다
이제 다시 30분동안 재 테스트하면서 얼마나 변화하였는지 살펴봅시다.

추가적인 5XX에러가 노출되지 않았습니다. 그렇다고 해서 100%없어진것은 아니네요.

예상치못하게 예외가 발생하는 경우를 8.23%에서 0.02%로 감소 시켰습니다.

로드벨런싱도 잘되고 있네요.
그렇다면 레디스 트랜잭션으로 변경하면 어떨까요?
레디스 트랜잭션은 여러 Redis 명령을 하나의 작업 단위처럼 묶어 실행하는 기능입니다.
주로 MULTI / EXEC 를 사용합니다.
- MULTI : 트랜잭션 시작
- 이후 명령들: 바로 실행되지 않고 큐에 쌓임
- EXEC : 쌓인 명령을 순서대로 실행


생각한거보다 성능이 너무 처참하네요.

실행 시간은 나쁘지는 않은거 같은데 22:30분 전후로 p99가 좀 많이 우뚝 서있는거 말고는 괜찮아보입니다.
하지만

루아로 작업했을때는 보이지 않은 락해제 실패가 보이네요..
그렇다면, 레디슨은 어떨까?
레디슨(Redisson)은 Java에서 Redis를 더 쉽게 쓰게 해주는 고수준 클라이언트 라이브러리입니다.

다행히 실패율은 발생하지 않았네요..

나쁘지는 않지만 예상치못한 예외가 발생하는 경우가 0.18%로 루아만 사용했을때보다 높게나왔네요.
마무리
원래는 분산락 자체를 더 근본적으로 조정하는 방향을 생각했지만, 이번에는 Lua 스크립트 기반 락 처리만으로도 충분히 의미 있는 개선을 확인할 수 있었습니다. 초반에는 예상치 못한 오류가 발생했는데, 원인을 확인해보니 DB의 unique 제약 예외가 그대로 500 응답으로 노출되고 있었습니다. 즉, 실제 문제는 분산락 자체라기보다 동시 처리 과정에서 발생한 DB 중복 예외가 적절히 변환되지 않았던 점에 있었습니다. 이후 DB 중복 예외 발생 시 이를 409 Conflict로 처리하도록 보완한 결과, 비정상 응답 비율을 8.23%에서 0.02%로 감소시킬 수 있었습니다.
'개발' 카테고리의 다른 글
| Stateless하게 개발하기 (0) | 2026.03.19 |
|---|---|
| 스프링 이벤트 리뉴얼(1) - Publisher (0) | 2026.03.18 |
| 라이브락 vs 데드락 (0) | 2026.03.16 |
| 문제 발견: LazyConnectionDataSourceProxy 톺아보기 (1) | 2026.03.13 |
| Actuator 메트릭 생성 하기 (0) | 2026.03.11 |