김코딩

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

TIL

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

김코딩딩 2025. 8. 7. 16:41

결제 시스템에서 동기식 처리를 비동기식으로 전환했을 때 정말로 장애 격리가 달성되는지 실제 테스트해보려고 합니다.

포인트 서비스에 의도적으로 장애를 발생시켜서 직접 확인해보겠습니다. :

  • 동기식에서는 어떤 문제가 발생하는지
  • 비동기식에서는 어떻게 장애가 격리되는지
  • 재시도 메커니즘이 실제로 동작하는지

무엇을 테스트할 것인가?

가설

"비동기 이벤트 처리를 통해 포인트 서비스 장애가 결제 성공에 영향을 주지 않는다"

검증 방법

  1. 포인트 서비스에 6초 지연 발생 (타임아웃 5초보다 길게)
  2. 동기식과 비동기식 결제 처리 비교
  3. 재시도 로직의 실제 동작 확인

성공 기준

  • 결제는 성공하지만 포인트는 실패하는 상황 재현
  • 사용자는 결제 성공을 즉시 확인
  • 백그라운드에서 포인트 재시도 동작

테스트 환경 설정

장애 상황 시뮬레이션

포인트 서비스 API에 의도적 지연 추가:

@PostMapping("/points/{userId}/charge")
    public ResponseEntity<Void> chargePoint(@PathVariable Long userId, @RequestBody PointChargeRequest request) {

        // 네트워크 병목 시뮬레이션 (포인트 서비스가 느려진 상황)
        try {
            Thread.sleep(6000); // 6초 지연 (결제 서비스의 포인트 타임아웃 5초보다 길게)
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // 실제 포인트 충전 로직 실행
        pointFacade.charge(userId, PointPresentationMapper.toPointChargeCommand(request));

        return ResponseEntity.ok().build();
    }

RestClient 타임아웃 설정

포인트 클라이언트에 5초 타임아웃 적용:

@Component
@RequiredArgsConstructor
public class PointClientImpl implements PointClient {

    private final RestClient restClient; // 타임아웃이 설정된 빈 주입

    @Override
    public void chargePoint(Long userId, int amount, String reason, String orderId) {

        PointChargeRequest pointChargeRequest = new PointChargeRequest(amount, reason, orderId);

        restClient.post()
            .uri("/internal/points/{userId}/charge", userId)
            .contentType(MediaType.APPLICATION_JSON)
            .body(pointChargeRequest)
            .retrieve()
            .toBodilessEntity();
    }
}
@Configuration
public class RestClientConfig {

    @Value("${external.base-url}")
    private String baseUrl;

    @Bean
    @Primary
    public RestClient restClient() {

        return RestClient.builder()
            .baseUrl(baseUrl)
            .requestFactory(createPointRequestFactory())
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }

    // 포인트 전용 타임아웃 설정
    private ClientHttpRequestFactory createPointRequestFactory() {
        SimpleClientHttpRequestFactory factory =
            new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(3));   // 연결: 3초
        factory.setReadTimeout(Duration.ofSeconds(5));     // 응답: 5초
        return factory;
    }
}

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);

            // 포인트 충전(실패 시, 3번 재시도)
            pointClient.chargePoint(
                userId,
                command.getAmount(),
                "CHARGE",
                tossConfirmResult.getOrderId()
            );

            return PaymentApplicationMapper.toPaymentConfirmResult(payment);

        } catch (RestClientResponseException e) {
            ...
    }

PointClient에 직접 재시도 로직 도입

@Component
@RequiredArgsConstructor
@Slf4j
public class PointClientImpl implements PointClient {

    private final RestClient restClient; // 타임아웃이 지정된 빈 주입

    @Override
    @Retryable(
        retryFor = {Exception.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    public void chargePoint(Long userId, int amount, String reason, String orderId) {
        log.info("포인트 충전 시도 - userId: {}, orderId: {}, amount: {}", userId, orderId, amount);

        PointChargeRequest pointChargeRequest = new PointChargeRequest(amount, reason, orderId);

        try {
            restClient.post()
                .uri("/internal/points/{userId}/charge", userId)
                .contentType(MediaType.APPLICATION_JSON)
                .body(pointChargeRequest)
                .retrieve()
                .toBodilessEntity();

            log.info("포인트 충전 성공 - userId: {}, orderId: {}", userId, orderId);

        } catch (Exception e) {
            log.warn("포인트 충전 실패 - userId: {}, orderId: {}, error: {}",
                userId, orderId, e.getMessage());
            throw e; // 재시도를 위해 예외를 다시 던짐
        }
    }
}

토스 결제 시도

 

토스 결제 성공(이때, 사용자의 돈은 차감이 된다.)

포인트 서버의 네트워크 지연으로 인한 재시도 로그 확인

 

포인트 충전 재시도 3번 후에, 그래도 포인트 충전에 실패하면 결제 실패 반환 및 토스 결제 취소 api 호출

결제 실패 응답

 

동기식 테스트 결과

진행 과정
1. 사용자가 포인트 결제 시도
2. 토스 결제 승인 완료 
3. 포인트 서버 네트워크 지연 발생 
4. 포인트 충전 3회 재시도 (각 5초씩 대기)
5. 모든 재시도 실패 후 토스 결제 취소
6. 사용자에게 "결제 실패" 응답

문제점 
- 사용자 대기시간: 20284ms(약 20초)
- 최종 결과: 결제 실패
- 사용자 경험: 최악 (돈 빠졌다가 다시 들어옴)


After(비동기식 처리)

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);

        // 포인트 충전 이벤트 발행
        eventPublisher.publishEvent(new PointChargeRequested(
            payment.getUserId(),
            payment.getAmount(),
            "CHARGE",
            payment.getOrderId()
        ));

        return PaymentApplicationMapper.toPaymentConfirmResult(payment);

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

EventListener 에 포인트 재시도 로직 도입

@Component
@RequiredArgsConstructor
@Slf4j
public class PointChargeEventHandler {

    private final PointClient pointClient;

    @EventListener
    @Async
    @Retryable(
        retryFor = {Exception.class}, // 모든 예외에 대하여 재시도
        noRetryFor = {IllegalStateException.class}, // 파라미터 오류는 재시도 해도 똑같이 오류가 나니까 재시도에서 제외
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000) // 1초 기다리고 재시도
    )
    public void handlePointCharge(PointChargeRequested 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, PointChargeRequested event) {
        log.error("포인트 충전 최종 실패 - 수동 처리 필요: orderId = {}", event.getOrderId(), e);
    }
}

 


토스 결제 시도

토스 결제 성공(이때, 사용자의 돈은 차감이 된다.)

포인트 서버의 네트워크 지연으로 인한 재시도 로그 확인

포인트 재시도 3번이 모두 실패해도 사용자에게는 결제 성공 응답을 보내준다. (포인트는 백그라운드 내부에서 처리)

 

비동기식 테스트 결과

진행 과정

  1. 사용자가 포인트 결제 시도
  2. 토스 결제 승인 완료 (사용자 카드에서 돈 차감)
  3. Payment 객체 저장 완료
  4. 포인트 충전 이벤트 발행 후 즉시 "결제 성공" 응답
  5. 백그라운드에서 포인트 서버 네트워크 지연 발생
  6. 백그라운드에서 포인트 충전 3회 재시도 (각 5초씩)
  7. 백그라운드에서 모든 재시도 실패 → 수동 처리 알림

개선 효과

  • 사용자 대기시간: 665ms (1초 이내)
  • 최종 결과: 결제 성공
  • 사용자 경험: 최고 (즉시 결제 완료 확인)
  • 포인트 처리: 백그라운드에서 안전하게 재시도

동기식 vs 비동기식 극적 비교

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

핵심 차이점

동기식 (장애 전파)

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

 

비동기식 (장애 격리)

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

생각 정리

Q. 동기식에서 포인트 충전 실패 시 환불해주는 것이 비즈니스적으로 수익 이탈이라고 본다면, 차라리 환불 없이 내부적으로 처리하면 되는 것 아닌가요?

A. 네, 그렇게 처리할 수도 있습니다.
하지만 동기식 방식에서는 사용자가 포인트 충전 실패를 확인하기까지 약 20초(20,000ms) 가량 기다리게 됩니다. 이 긴 대기 시간은 단순한 기술적 실패를 넘어, 사용자의 서비스 이용 이탈로 이어질 수 있는 심각한 UX 저하 요소입니다.

따라서 결제가 완료된 시점에 포인트 충전은 비동기로 전환하여 서버에서 백그라운드로 처리하고, 사용자에게는 빠르게 결제 완료를 안내하는 것이 더 나은 사용자 경험과 수익 보호를 동시에 달성하는 방향이라 판단됩니다.


Q. 그렇다면 현재 포인트 충전 로직에서 재시도가 모두 실패한다고 하였는데, 내부적으로 처리는 어떻게 해주실건가요?

 

A. 현재까지는 모든 재시도 실패 시 사용자에게 별도의 안내 없이 수동 처리를 해달라는 로그만 보여주고 종료되고 있습니다.
하지만 향후에는 아래와 같은 방식으로 내부 보완 로직을 설계 및 구현할 계획입니다:

  1. 장애 로그 저장 및 알림 전송
    모든 재시도 실패 시, 해당 결제 건에 대해 에러 로그를 상세히 남기고, 슬랙/이메일 등의 채널을 통해 운영팀에게 실시간 알림을 보냅니다.
  2. 보상 트랜잭션 큐 등록 (Dead Letter Queue 등)
    실패한 케이스는 별도의 보상 큐(예: DLQ, retry queue)에 등록되어 후속 재처리 대상이 되며, 운영자가 개입해 수동 처리하거나, 자동화된 재처리 시스템이 주기적으로 시도할 수 있도록 설계합니다.