김코딩

결제 시스템 성능 개선: 동기식에서 비동기식 처리로의 전환 본문

개발팁

결제 시스템 성능 개선: 동기식에서 비동기식 처리로의 전환

김코딩딩 2025. 8. 6. 16:30

 

개선 목적

프론트엔드에서 토스 결제 완료 후, 서버에서 수행하는 포인트 충전 로직의 성능 개선을 통해 전체 결제 프로세스의 응답시간 단축과 안정성 향상을 달성한다.

 

참고 : https://ddokyun.tistory.com/66

 

토스 결제 승인부터 포인트 충전까지의 전체 플로우

도입 배경기존에는 사용자가 원하는 포인트, 결제 수단을 Mock 방식을 이용해서 요청 body에 넣어주는 방식으로 포인트 충전을 진행하였다. 하지만 이번에 토스페이먼츠를 도입하면서 결제 요청

ddokyun.tistory.com


성능상 문제점

1. 긴 응답시간

현재 결제 API는 동기 방식으로 모든 작업을 순차 처리하여 긴 응답시간이 발생합니다.

처리 플로우:

유저 조회 → 토스 결제 승인 → Payment 성공 처리 → 포인트 충전 → 응답
   (동기)                (동기)                    (동기)                      (동기)

 

각 단계별 타임아웃:

  • 유저 조회 API: 5초 (빠른 실패 응답)
  • 토스 결제 승인 API: 30초 (토스 페이에서 권장하는 타임아웃이 30초 ~ 60초인데, 빠른 응답을 위해 30초로 설정)
  • 포인트 충전 API: 5초 (빠른 실패 응답)

최악의 경우: 총 40 소요 가능


2. 장애 전파 문제

포인트 서비스 장애 시 전체 결제가 실패하는 구조적 문제가 있습니다.

 

문제 시나리오:

  1. 토스페이 결제 진행 → 사용자 카드에서 돈 차감 완료
  2. 포인트 충전 시도 → 포인트 서버 오류로 실패
  3. 전체 API 실패 응답 → 사용자는 "결제 실패"로 인식

상세 장애 시나리오 분석

더보기

1. 토스 결제 승인 실패 (토스 서버 문제, 네트워크 응답문제, 타임아웃 문제 등)

 토스 결제 승인에 실패하면 사용자의 카드에서 돈이 빠지지 않으므로 별도의 취소 처리가 불필요하다. 사용자에게 "결제 실패" 메시지를 보여주면 된다.

 

2. 토스 결제 승인 성공 후 후속 처리(Payment객체 성공 처리, 포인트 충전) 실패

 토스 결제 승인에 성공하면 이미 사용자 카드에서 돈이 빠진 상태이다. 이후 단계에서 실패할 경우:

2-1. 금액 검증 실패

  • 프론트에서 요청한 금액과 토스에서 실제 결제된 금액이 불일치
  • 이미 결제된 상태이므로 토스 취소 API 호출 필요
  • 취소 실패 시 수동 처리 필요

2-2. Payment 객체 성공 처리 실패

  • DB 연결 오류, 제약조건 위반 등으로 Payment 성공 처리 실패
  • 이미 결제된 상태이므로 토스 취소 API 호출 필요
  • 취소 실패 시 수동 처리 필요

2-3. 포인트 충전 실패

  • 포인트 서비스 장애, 네트워크 지연, 타임아웃 등으로 포인트 충전 실패
  • 핵심 문제: 결제는 완벽하게 성공했는데 포인트 때문에 전체를 실패 처리
  • 토스 취소 API 호출하여 성공한 결제를 억지로 취소
  • 사용자는 "결제 실패" 경험 → 이탈률 증가

3. 토스 취소 처리 실패 (극한 상황)

 토스 결제는 성공했지만 후속 처리 실패로 취소 시도 시:

  • 토스 취소 API도 동시에 장애 발생
  • 사용자 카드에서는 돈이 빠진 상태로 남음
  • 시스템에서는 "결제 실패"로 처리
  • 결과: 고객은 돈을 잃고 상품/포인트도 받지 못함
  • 수동 환불 처리 및 고객 불만 증가

2-1. 토스페이에서 결제를 진행

2-2. 토스페이 결제 완료 (이때, 사용자의 계좌에서 돈이 빠져나감)

2-3. Point 충전 중 오류가 발생하였을 때, 사용자에게 실패 응답

 

핵심 문제:

  • 결제는 이미 완료된 상태인데 부가 기능(포인트) 때문에 전체 실패 처리
  • 사용자 혼란: "돈은 빠졌는데 왜 실패?"
  • 부분 장애가 전체 시스템을 마비시키는 구조
  • 결과적으로 사용자 이탈률 증가고객 불만 야기

3. 동기식에서 포인트 충전에 재시도 로직 도입 시, 사용자 대기시간이 더욱 증가

  • 포인트 충전의 안정성을 위해 재시도 로직을 도입할 경우, 동기 방식에서는 사용자 대기시간이 기하급수적으로 증가합니다.
    • 1차 시도: 포인트 충전 실패 (5초 타임아웃)
    • 1초 대기 후 2차 시도: 포인트 충전 실패 (5초 타임아웃)
    • 1초 대기 후 3차 시도: 포인트 충전 실패 (5초 타임아웃)
    총 소요시간: 5 + 1 + 5 + 1 + 5 = 18초딜레마 상황:
    • 재시도 로직 없음 → 포인트 유실 위험
    • 재시도 로직 있음 → 사용자 경험 악화
  • 이는 포인트 충전만으로도 18초가 걸리며, 전체 결제 프로세스는 최악의 경우 50초 이상 소요될 수 있습니다.

해결 방안: 비동기 도입

핵심 아이디어

결제 승인(핵심 기능)과 포인트 충전(부가 기능)을 분리하여, 사용자는 결제 완료를 즉시 확인하고 포인트 충전은 백그라운드에서 안정적으로 처리하도록 개선했습니다.

 

Before(동기 처리)

public PaymentConfirmResult confirmAndChargePoint(Long userId, PaymentConfirmCommand command) {

        // 유저 검증
        userClient.getUserById(userId);

        Payment payment = null;
        TossConfirmResult tossConfirmResult = null;
        TossCancelResult tossCancelResult = null;

        try {
            // 토스 페이먼츠의 /payments/confirm API 호출 (결제 승인)
            tossConfirmResult = tossClient.confirmPayment(
                command.getPaymentKey(),
                command.getOrderId(),
                command.getAmount()
            );

            // 프론트에서 결제 요청한 금액과, 토스에서 실제로 결제한 금액이 일치하는지 확인
            // 일치하지 않는다면 예외를 터트려서 토스 결제 취소
            paymentService.validatePaymentAmount(tossConfirmResult.getTotalAmount(), command.getAmount());

            // 토스 결제를 완료하고, 금액 검증을 통과하였으면 토스에서 응답받은 값을 이용하여 payment 객체 생성
            payment = paymentService.createPaymentFromConfirm(userId, tossConfirmResult);

            // 포인트 충전(동기식 처리)
            pointClient.chargePoint(
                userId,
                command.getAmount(),
                "CHARGE",
                tossConfirmResult.getOrderId()
            );

            return PaymentApplicationMapper.toPaymentConfirmResult(payment);

        } catch (RestClientResponseException e) {
            ...
        }
    }

After(비동기 처리)

/**
     * 결제 승인 및 포인트 충전 요청 처리
     * 1. 사용자 검증 -> 2. 토스 결제 승인 -> 3. Payment 객체를 DONE 으로 변경 -> 4. 포인트 충전, 알림 이벤트 발행
     */
    public PaymentConfirmResult confirmAndChargePoint(Long userId, PaymentConfirmCommand command) {

        boolean userVerified = false;
        boolean tossPaymentSucceeded = false;
        TossConfirmResult tossConfirmResult = null;
        Payment payment;

        try {
            // 유저 검증
            userClient.getUserById(userId);
            userVerified = true;

            // 토스 페이먼츠의 /payments/confirm API 호출 (결제 승인)
            tossConfirmResult = tossClient.confirmPayment(
                command.getPaymentKey(),
                command.getOrderId(),
                command.getAmount()
            );
            tossPaymentSucceeded = true;

            // 프론트에서 요청한 결제 금액과 토스에서 실제로 결제한 금액이 일치하는지 확인
            // 일치하지 않는다면 예외를 터트린다.
            // 일치한다면, 토스에서 응답받은 값을 이용하여 payment 객체 생성
            payment = paymentService.markAsSuccess(
                tossConfirmResult,
                command.getAmount()
            );

            // 포인트 충전 및 알림 전송 이벤트 발행
            publishPaymentCompletedEvent(payment);

            return PaymentApplicationMapper.toPaymentConfirmResult(payment);

        } catch (RestClientResponseException e) {
            // RestClient 에서 오류가 발생하였을 때,

            // 1. 토스 결제 요청 하기 전 실행된 prepare API 에서 저장된 Payment 객체를 fail 상태로 바꾼다.
            paymentService.markAsFailed(
                command.getOrderId(),
                command.getPaymentKey()
            );

            // 2. userClient 오류인지, tossClient 오류인지 확인 후에, 적절한 오류를 던져준다.
            handleRestClientError(e, userVerified, tossPaymentSucceeded);

            // 실행되지 않음, 컴파일 에러 방지용
            return null;
        } catch (Exception e) {
            // 어쩔 수 없이 발생하는 모든 예외 상황

            // 1. Payment 객체를 fail 상태로 수정
            paymentService.markAsFailed(
                command.getOrderId(),
                command.getPaymentKey()
            );

            // 2. 토스 결제 승인을 한 상태로 예외가 발생하면, 토스 결제를 취소해줘야한다.
            if (tossConfirmResult != null) {
                cancelToss(tossConfirmResult);
            }

            throw new PaymentException(PaymentErrorCode.PAYMENT_PROCESSING_FAILED, e);
        }
    }

이벤트 핸들러

@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentCompletedEventHandler {

    private final PointClient pointClient;

    @EventListener
    @Async
    @Retryable(
        retryFor = {Exception.class}, // 모든 예외에 대하여 재시도
        noRetryFor = {IllegalStateException.class}, // 파라미터 오류는 재시도 해도 똑같이 오류가 나니까 재시도에서 제외
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000) // 1초 기다리고 재시도
    )
    public void handlePointCharge(PaymentCompletedEvent event) {
        log.info("포인트 충전 시도: orderId = {}", event.getOrderId());
        pointClient.chargePoint(
            event.getUserId(),
            event.getAmount(),
            event.getReason(),
            event.getOrderId()
        );
        log.info("포인트 충전 성공: orderId = {}", event.getOrderId());
    }

    @Recover
    public void recover(Exception e, PaymentCompletedEvent event) {
        log.error("포인트 충전 최종 실패 - 수동 처리 필요: orderId = {}", event.getOrderId(), e);
    }
}

 


개선 결과

1. 성능 개선 (실측 데이터)

동기식
항목 비고
총 샘플 수 30개  
평균 응답시간 738.3ms  
최소 응답시간 590ms 최고 성능
최대 응답시간 1,008ms 유일한 1초 초과 사례
표준편차 약 90ms 비교적 안정적

 

Before: https://ddokyun.tistory.com/62

 

동기식 결제 승인 API의 응답속도 테스트 및 분석

테스트 대상 API 경로 및 메서드: POST /payments/confirm설명:프론트엔드에서 토스 결제가 완료된 후, 결제 승인과 동시에 사용자 포인트를 충전하는 API내부적으로 paymentFacade.confirmAndChargePoint를 호출해

ddokyun.tistory.com

 

비동기식
항목 비고
총 샘플 수 30개  
평균 응답시간 621.6ms  
최소 응답시간 541ms 최고 성능
최대 응답시간 786ms 상대적으로 느린 구간
표준편차 약 70.7ms 비교적 안정적

 

After: https://ddokyun.tistory.com/64

 

비동기식 결제 승인 API의 응답속도 테스트 및 분석

테스트 대상 API@PostMapping("/payments/confirm")public ResponseEntity> confirmAndChargePoint( @RequestBody PaymentConfirmRequest request, @AuthenticationPrincipal Auth auth) { long startTime = System.currentTimeMillis(); PaymentConfirmResult paymentCo

ddokyun.tistory.com

구분 Before (동기식) After (비동기식) 개선 효과
평균 응답시간 738.3ms 621.6ms 116.7ms 단축 (15.8% 개선)
최소 응답시간 590ms 541ms 49ms 단축
최대 응답시간 1,008ms 786ms 222ms 단축 (22% 개선)
표준편차 약 90ms 약 70.7ms 안정성 21% 향상

2. 장애 격리 달성

https://ddokyun.tistory.com/65

 

동기식 처리에서 비동기식 처리로 장애 격리 달성 테스트

결제 시스템에서 동기식 처리를 비동기식으로 전환했을 때 정말로 장애 격리가 달성되는지 실제 테스트해보려고 합니다.포인트 서비스에 의도적으로 장애를 발생시켜서 직접 확인해보겠습니

ddokyun.tistory.com

 

  • 결제 서비스 독립성: 포인트 서비스 장애가 결제에 영향 주지 않음
  • 부분 장애 대응: 결제는 성공하고 포인트만 나중에 처리하는 구조

동기식 vs 비동기식 비교

구분 동기식 비동기식 개선 효과
사용자 대기시간 20284ms 665ms 19,619ms 단축(약 29배 개선)
포인트 실패 시 결제 전체 실패 결제 성공 매출 보호
사용자 경험 최악 최고 천지차이
재시도 처리 사용자 대기 백그라운드 처리 격리 달성

동기식 (장애 전파)

토스 결제 성공 → 포인트 실패 → 전체 실패 → 사용자 "20초 대기 후 실패"

 

비동기식 (장애 격리)

토스 결제 성공 → 즉시 성공 응답 → 백그라운드 포인트 처리 → 사용자 "1초 만에 성공"

동기식 vs 비동기식 전환 효과 종합

측면 동기식 (Before) 비동기식 (After) 개선 효과
성능 평균 738.3ms 평균 621.6ms 15.8% 개선
장애 대응 포인트 실패 → 전체 실패 포인트 실패 → 결제 성공 장애 격리
사용자 경험 20초 대기 후 실패 1초 만에 성공 29배 개선
재시도 처리 사용자 대기 필요 백그라운드 처리 투명한 처리
매출 보호 포인트 오류로 매출 손실 결제 성공으로 매출 보호 비즈니스 가치

결론

Spring Event를 활용한 비동기 처리 전환을 통해 결제 시스템의 성능과 안정성을 동시에 향상시켰습니다.

핵심 성과:

  • 성능: 평균 응답시간 15.8% 개선 (738.3ms → 621.6ms)
  • 안정성: 장애 격리로 포인트 서비스 장애가 결제에 미치는 영향 차단
  • 사용자 경험: 결제 완료 즉시 확인 가능 (20초 → 1초)
  • 비즈니스 가치: 부분 장애로 인한 매출 손실 방지