-
Notifications
You must be signed in to change notification settings - Fork 0
초기 결제는 아래와 같이 동기적으로 구현되었습니다.
DB 접근을 위한 커넥션 풀은 한정적인 자원입니다. 또한, 많은 사용자가 해당 커넥션 풀을 이용합니다.
때문에, 해당 커넥션 내부에서 서버에서 통제할 수 없는 외부 API 호출을 하지 말아야 한다고 생각하였습니다.
이로, Facade 패턴을 이용하여, 3가지의 결제 파이프라인을 구성합니다.
1번 작업은 승인 요청을 검증하는 작업이었습니다.
이 작업에서는 첫번째 트랜잭션으로 클라이언트에서 받은 PaymentKey를 DB에 저장하고, 결제 상태를 READY에서 IN_PROGRESS로 바꿉니다.
상태를 통해 외부 API 중복 호출을 막을 수 있었습니다.
2번 작업은 토스 결제 API를 호출하는 작업이었습니다.
이 작업에서는 토스 API를 호출합니다. 외부 API와 서버의 경계였기 때문에, 다양한 예외가 발생할 수 있는 취약 경계였습니다.
아래는 이 작업에서 발생할 수 있는 문제입니다.
- API 호출 요청을 네트워크 문제로 보낼 수 없음
- 토스 내부에서 예외 발생(재시도 가능 vs 재시도 불가능)
- 실 결제(승인) 완료 후 응답 유실
1번 문제와 2번 중 재시도 가능한 문제, 3번 문제 이 세가지는 back-off를 적용한 재시도 전략으로 해결을 시도하였습니다.
나머지 2번 중 재시도 불가능 예외는 예외를 사용자에게 바로 던졌습니다.
위에서 볼 수 있듯 토스의 PaymentKey를 통해 중복 결제를 막을 수 있었고, 이로 재시도를 수월하게 수행할 수 있었습니다.
3번 작업은 외부 API 호출이 성공 후 해당 결과를 DB에 저장하는 작업이었습니다.
2번째 트랜잭션을 통해 IN_PROGRESS에서 DONE 상태로 업데이트합니다.
V1은 3개의 작업이 존재했습니다.
- 승인 요청 검증
- 승인 API 호출
- 승인 결과 DB 저장
이 구조에서는 단계 사이에서 부분 실패가 발생할 수 있습니다.
- 1번 실패 : 검증부터 실패한 경우
- 1번 성공-2번 실패: 검증 성공 IN_PROGRESS 상태에서 실 결제 실패한 경우
- 1번 성공-2번 성공-3번 실패: 검증, 실 결제 성공 후 DONE 상태로 바꾸지 못한 경우
초기 의도한 상태 전이는 다음과 같았습니다.
- 모두 성공 : DONE
- 부분 실패 : FAILED
따라서 부분 실패를 FAILED로 수렴시키기 위해 각 단계마다 별도 예외 분기와 보상 트랜잭션이 필요했습니다.
특히 실패 지점이 1번, 2번, 3번으로 분산되어 try-catch와 상태 업데이트 로직이 곳곳에 퍼졌고, 결과적으로 결제 승인 플로우의 구현 복잡도와 변경 비용이 함께 증가했습니다.
동기식 결제였기 때문에, 결제 승인 API의 Latency가 우려되었습니다.
결제는 Payment 테이블 단일 레코드 조회/수정이었기 때문에, Payment 인스턴스 조회는 인덱스를 통해 쉽게 해결할 수 있을 것이라 생각했습니다.
하지만, 토스 API 호출은 서버에서 통제할 수 없었기에 약 100건의 결제를 진행해보았습니다.
100건의 호출 Latency 평균 1104ms(소수점 1에서 반올림)가 나왔기 때문에 성능 우려가 되었습니다.
때문에, 1초를 Sleep하는 목 서버를 구현하여 Artillery를 사용한 100건의 성능 테스트를 진행해보았습니다.
아래는 초당 10개의 결제 요청을 10초 발생 시킨 성능 그래프입니다.
당연한 결과로, 1초~1.5초의 Latency를 볼 수 있습니다.
결제라는 특수한 경우에서 1초 가량의 Latency는 문제가 될 부분이 없었습니다.
하지만, 진짜 문제는 예외를 통한 재시도가 되는 상황에서였습니다.
위는 한 번의 Timeout 예외가 발생하는 상황입니다. 1~1.5의 Latency가 2까지 증가한 것을 볼 수 있습니다.
위는 두 번의 Timeout 예외가 발생하는 상황입니다. 거의 4의 Latency를 갖는 것을 볼 수 있습니다.
# application.yml
server:
tomcat:
threads:
max: 200
min-spare: 10
max-connections: 8192
accept-count: 100
connection-timeout: 20000
port: 8080
Default 스프링 부트의 Worker Thread Pool의 설정값입니다.
즉, 타임 아웃 시 2개의 요청이 2개의 쓰레드를 차지하고 있어, 총 8개의 쓰레드만이 요청을 처리했음을 알 수 있습니다.
다시 정리해보자면,
- 실패 시 보상 트랜잭션을 통한 실패로 업데이트 - 복잡한 실패 처리
- 토스 API의 Latency 1초 - 기본적으로 높은 Latency
- 동기적인 재시도 - 동기적 재시도 시 쓰레드 점유로 인한 폭등하는 Latency
위와 같은 3가지의 문제로 인하여,
- 실패 시 자연스러운 실패 상태 업데이트
- Worker 쓰레드 비중 줄이기
- 비동기적인 재시도
위의 3가지의 해결책이 필요했습니다.
Outbox 패턴을 적용한 결제 플로우는 위와 같습니다.
동기식 결제 프로세스는 실패 로직에 대한 아래와 같은 문제점이 있었습니다.