외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증
Payment Platform Project
- 1 결제 정보 검증을 통한 안전한 결제 연동 시스템 구현
- 2 트랜잭션 범위 최소화를 통한 성능 및 안정성 향상
- 3 결제 상태 전환 관리와 재시도 로직을 통한 결제 복구 시스템 구축
- 4 외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증 OPEN
- 5 Logger 성능 저하 방지와 구조화된 로깅 설계
- 6 결제 이력 추적 및 핵심 지표 모니터링 시스템 구현
- 7 보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계
- 8 전략 패턴을 통한 PG 독립성 확보 및 확장 가능한 결제 시스템 설계
- 9 Checkout API 멱등성 보장 — Caffeine 캐시와 TOCTOU 경쟁 조건 해결
- 10 비동기 결제 처리 플로우 구현 — Outbox 패턴부터 LinkedBlockingQueue Worker까지
- 11 결제 복구 상태 모델 설계 — 장애 내성을 갖춘 상태 전이
실행 환경: Java 21, Spring Boot 3.3.3, JUnit 5
결제 시스템은 서비스 신뢰성과 직결되므로, 다양한 시나리오를 검증하는 테스트 코드가 필수적이다.
- 단위 테스트: 개별 도메인 및 서비스 로직을 대상으로 세부 로직 검증
- 통합 테스트: 실제 결제 흐름을 따라 다양한 예외 상황에 대응하는 테스트 작성
단위 테스트와 통합 테스트로 구분해 작성했으며, 토스 결제 시스템의 외부 의존성 제어가 핵심 과제였다.
참고: 토스 실제 결제 승인은 클라이언트 SDK를 통해 결제 요청을 먼저 수행해야 하며, 그 후에 결제 승인을 진행해야 한다.
결제 승인 과정에서 발생할 수 있는 다양한 시나리오에 대응할 수 있고, 신뢰성 높은 결제 프로세스를 보장하기 위해 여러 테스트 전략을 적용해보았다.
테스트 코드 작성 목표
섹션 제목: “테스트 코드 작성 목표”테스트를 통해 보장하고자 한 주요 목표는 다음과 같다.
- 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보
- 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성
- 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증
1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보
섹션 제목: “1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보”결제 승인 과정은 복잡한 절차적 흐름과 다양한 예외 상황을 포함하므로, 파라미터화된 테스트로 여러 시나리오를 한번에 검증했다.
-
단일 케이스를 넘어 여러 파라미터를 조합한 테스트를 통해 다양한 시나리오 및 상태값 검증
-
서비스 로직에서 발생 가능한 에러에 대한 처리 확인
-
단순 성공 케이스 뿐만 아니라, 예외 상황이나 여러 상태 값에 대한 다양한 케이스를 고려하여 테스트 코드 작성
class PaymentEventTest {
@ParameterizedTest @CsvSource({ "1, validPaymentKey, 15000, order123, INVALID_TOTAL_AMOUNT", "2, invalidPaymentKey, 15000, order123, INVALID_PAYMENT_KEY", "1, validPaymentKey, 14000, wrongOrderId, INVALID_ORDER_ID" }) @DisplayName("다양한 조건에서 검증 실패 시 PaymentValidException 예외가 발생한다.") void validate_InvalidCases( Long userId, String paymentKey, int amount, String orderId ) { PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy( () -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo)) .isInstanceOf(PaymentValidException.class); }
@ParameterizedTest @EnumSource(value = TossPaymentStatus.class, names = { "CANCELED", "EXPIRED", "PARTIAL_CANCELED", "ABORTED" }) @DisplayName("결제 상태가 유효하지 않은 경우 PaymentStatusException 예외가 발생한다.") void validate_InvalidPaymentStatus(TossPaymentStatus tossPaymentStatus) { PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy( () -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo)) .isInstanceOf(PaymentStatusException.class); }}서비스 로직에서 Toss API 통신 중 발생할 수 있는 모든 에러 코드에 대한 처리도 함께 테스트했다.
class PaymentGatewayServiceImplErrorCaseTest extends IntegrationTest {
// ...
@ParameterizedTest(name = "{index}: Test with TossPaymentErrorCode={0}") @EnumSource(TossPaymentErrorCode.class) @DisplayName("TossPaymentErrorCode에 따라 Header를 설정하고 결제 확인 결과를 검증한다.") void confirmPayment_withTossPaymentErrorCode(TossPaymentErrorCode errorCode) { // given String uuid = new SystemUUIDProvider().generateUUID();
ReflectionTestUtils.invokeMethod(httpOperator, "addHeader", "TossPayments-Test-Code", errorCode.name());
// ...
// when TossPaymentInfo tossPaymentInfo = paymentGatewayService.confirmPayment(tossConfirmCommand, uuid); PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// then Assertions.assertThat(paymentConfirmResultStatus) .isEqualTo(getExpectedResultStatus(errorCode)); }
// ...
@TestConfiguration static class TestConfig {
@Bean public HttpOperator httpOperator() { return new AdditionalHeaderHttpOperator(); } }}헤더에 특정 에러 코드를 추가하면 토스 측이 에러를 반환하는 테스트 API를 활용해 에러 상황을 시뮬레이션하여, 예외 상황에 대한 처리와 에러별 재요청 가능/불가능 여부를 검증했다.
2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성
섹션 제목: “2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성”토스 API 결제 승인은 클라이언트 SDK 선행 요청이 필요해 통합 테스트 진행이 어려웠는데, 페이크 객체로 외부 의존성을 제거해 이 문제를 해결했다.
public class FakeTossHttpOperator implements HttpOperator {
// ...
// Reflection을 통해 호출되는 메서드
@SuppressWarnings("unused") public void setDelayRange(int minDelayMillis, int maxDelayMillis) { this.minDelayMillis = minDelayMillis; this.maxDelayMillis = maxDelayMillis; }
@SuppressWarnings("unused") public void addErrorInPostRequest(String code, String message) { this.code = code; this.message = message; this.isErrorInPostRequest = true; }
@SuppressWarnings("unused") public void clearErrorInPostRequest() { this.isErrorInPostRequest = false; }
@SuppressWarnings("java:S2925") private void simulateNetworkDelay() { long delay = minDelayMillis + (long) (Math.random() * (maxDelayMillis - minDelayMillis)); try { TimeUnit.MILLISECONDS.sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
private void throwError() { // ... }
// ...
@Override public <T, E> E requestPost(String url, Map<String, String> httpHeaderMap, T body, Class<E> responseType) { // 네트워크 지연 시뮬레이션 simulateNetworkDelay();
// 에러 발생 시뮬레이션 if (isErrorInPostRequest) { throwError(); }
// ...
// 실제 API 호출 없이 응답 반환 return responseType.cast(tossPaymentApiResponse); }}위 구현체는 기존 실제 요청을 보내는 구현체의 인터페이스인 HttpOperator를 구현했지만 실제 네트워크 요청 대신, 결제 승인을 위한 객체로 만들었다.
- 실제 API 호출 X: 외부 API 호출 없이 승인 결과 반환
- 네트워크 지연 시뮬레이션: 실제 환경과 유사하게 네트워크 지연 시간 설정 가능
- 에러 발생 시뮬레이션: 특정한 오류 코드와 메시지를 지정한 에러 발생 가능
페이크 객체는 @TestConfiguration으로 테스트 환경에서만 사용되도록 설정하고, Reflection으로 테스트 코드에서 호출한다.
class PaymentControllerTest extends IntegrationTest {
@Test @DisplayName("Payment Confirm 요청이 성공하면 결제가 승인되고 DONE / SUCCESS 상태로 변경되면서 재고가 감소한다.") void confirmPayment_Success() throws Exception { // ...
ReflectionTestUtils.invokeMethod(httpOperator, "clearErrorInPostRequest"); // 에러 없음 => 성공
// ... }
@Test @DisplayName("Payment Confirm 요청 중 재시도 가능 오류가 발생하면 결제는 실패하고 UNKNOWN / UNKNOWN 상태로 변경되면서 재고는 감소된 상태로 유지된다.") void confirmPayment_Failure_RetryableError() throws Exception { // ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest", TossPaymentErrorCode.PROVIDER_ERROR.name(), // 재시도 가능한 오류 코드 TossPaymentErrorCode.PROVIDER_ERROR.getDescription() );
// ... }
@Test @DisplayName("Payment Confirm 요청 중 재시도 불가능 오류가 발생하면 결제는 실패하고 FAILED / FAIL 상태로 변경되면서 재고는 다시 복구된다.") void confirmPayment_Failure_NonRetryableError() throws Exception { // ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest", TossPaymentErrorCode.INVALID_STOPPED_CARD.name(), // 재시도 불가능한 오류 코드 TossPaymentErrorCode.INVALID_STOPPED_CARD.getDescription() );
// ... }
@TestConfiguration static class TestConfig {
@Bean public HttpOperator httpOperator() { return new FakeTossHttpOperator(); } }}페이크 객체를 활용해 외부 API 의존성과 절차적 선행 조건을 제거했고, 다양한 결제 시나리오를 간결하게 테스트했다.
3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증
섹션 제목: “3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증”여러 사용자가 동일한 상품을 동시에 주문하거나 외부 API 타임아웃이 발생할 수 있기 때문에 이러한 상황에 대한 검증이 필요하다.
동시성 이슈 검증
섹션 제목: “동시성 이슈 검증”동시성 문제는 여러 요청이 동시에 동일한 리소스를 수정할 때 발생하며, 멀티 스레드 테스트를 통해 상황을 시뮬레이션하여 주문 수량과 재고가 정확히 관리되는지 확인했다.
@Tag("TooLongIntegrationTest")class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest @CsvSource({ "1000, 1000, 1000, 0, 0", // 재고와 주문 수량이 일치 "1000, 999, 999, 0, 1", // 주문 수량이 재고보다 적음 "1000, 1001, 1000, 1, 0", // 주문 수량이 재고보다 많음 "1000, 1050, 1000, 50, 0", // 재고 초과 주문 (50개 초과 주문 실패) "1200, 1000, 1000, 0, 200", // 재고가 1200개, 주문 수량은 1000개 (200개 남음) }) @DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.") void concurrentConfirmPayment_withStock( int stock, int orderCount, int expectedSuccess, int expectedFail, int expectedStock ) { // ...
executeConcurrentActions(orderIndex -> { try {
// ...
MvcResult mvcResult = mockMvc.perform( post("/api/v1/payments/confirm") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(confirmRequest)) ).andReturn(); if (mvcResult.getResponse().getStatus() == 200) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } catch (Exception e) { throw new RuntimeException(e); } }, orderCount);
// then Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain(); assertThat(successCount.get()).isEqualTo(expectedSuccess); assertThat(failCount.get()).isEqualTo(expectedFail); assertThat(updatedProduct.getStock()).isEqualTo(expectedStock); }
@TestConfiguration static class TestConfig {
@Bean public HttpOperator httpOperator() { return new FakeTossHttpOperator(); } }}멀티스레드 수행 중엔 FakeTossHttpOperator로 외부 API 호출 없이 테스트를 구성했다.(실제 테스트 키로 부하 테스트를 수행하면 토스 측에서 일정 시간 동안 요청을 제한한다.)
타임아웃 시나리오 검증
섹션 제목: “타임아웃 시나리오 검증”외부 네트워크 지연으로 인한 타임아웃 문제를 검증하기 위해 결제 승인 요청에 대해 일정 지연을 설정하여 해당 상황에서도 모든 결제 요청이 적절하게 처리되는지 검증하는 테스트를 작성했다.
@Tag("TooLongIntegrationTest")class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest @CsvSource({ "500, 1000", "1000, 3000", "3000, 7000", }) @DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.") void confirmPayment_withTimeout( int minDelayMills, int maxDelayMills ) { // ...
// 네트워크 지연 설정 ReflectionTestUtils.invokeMethod(httpOperator, "setDelayRange", minDelayMills, maxDelayMills);
executeConcurrentActions(orderIndex -> { try {
// ...
MvcResult mvcResult = mockMvc.perform( post("/api/v1/payments/confirm") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(confirmRequest)) ).andReturn(); if (mvcResult.getResponse().getStatus() == 200) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } catch (Exception e) { throw new RuntimeException(e); } }, orderCount);
// then Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain(); assertThat(successCount.get()).isEqualTo(expectedSuccess); assertThat(failCount.get()).isEqualTo(expectedFail); assertThat(updatedProduct.getStock()).isEqualTo(expectedStock); }
@TestConfiguration static class TestConfig {
@Bean public HttpOperator httpOperator() { return new FakeTossHttpOperator(); } }}결제 시스템은 안정성이 최우선이기 때문에, 발생 가능한 다양한 시나리오와 문제를 테스트하는 것이 다른 도메인보다 더 중요하다고 생각한다.

단위 테스트와 통합 테스트로 로직과 전체 흐름을 검증했고, 멀티 스레드 테스트로 동시성 및 타임아웃 처리까지 확인했다.
- 결제 승인 과정 검증: 파라미터화된 테스트로 다양한 상황별 예외 처리를 검증
- 효율적인 테스트 환경:
FakeTossHttpOperator로 외부 API 호출을 대체해 유연한 테스트 환경 구성 - 동시성·타임아웃 처리: 멀티 스레드 테스트로 실서비스 환경에서 발생할 수 있는 문제 사전 검증
단, 이번 테스트는 기본적인 동시성·타임아웃 수준의 검증이며, 정밀한 부하 테스트는 전용 툴을 통해 추가로 진행해야 한다.