장애 대응 시스템 구축하기

2025. 8. 22. 23:41·Study/Architecture

이번 주에는 시뮬레이터지만 PG(Payment Gateway) 모듈을 결제 로직과 연동하는 작업을 진행했다.

해당 시뮬레이터 중 결제요청 API의 스펙은 다음과 같다.

  • 요청 성공 확률: 60%
  • 요청 지연: 100ms ~ 500ms
  • 처리 지연: 1s ~ 5s
  • 처리 결과:
  • 성공 : 70%
  • 한도 초과 : 20%
  • 잘못된 카드 : 10%

기존에는 사용자가 포인트를 충전하고 그 포인트로만 결제하는 구조였는데, 이제는 결제 시 포인트를 일부 사용하고 남은 금액은 PG를 통해 결제하는 방식으로 바꾸게 됐다.

PG는 외부 시스템인만큼 통제할 수 없는 상황이 훨씬 많아져 고민할 지점이 너무 많아졌다. 단순히 호출만 성공하면 끝나는 게 아니라, 네트워크 지연, 응답 누락, 외부 장애 같은 수많은 변수를 생각해야 했다. 가장 중요한 건 PG가 실패한다고 해서 내 시스템까지 실패하도록 만들어서는 안된다는 점이었다. 그래서 이번 작업은 외부 시스템은 언제든 실패할 수 있다는 것을 염두에 두고 내 시스템을 안정적으로 버틸 수 있게 설계해보는 작업이 되었다.

 

 

주문과 결제 로직 분리

처음 시스템을 설계했을 때는 단순하게 주문 요청 시 결제까지 한번에 처리하는 방식으로 구현했었다.

  • 주문 트랜잭션:
    • 주문 생성 → 쿠폰 적용 → 포인트 사용 → 재고 차감 → 결제 생성 → 결제상태 외부 전송 → 주문 상태 변경

주문 트랜잭션 내에서 결제 로직을 같이 처리하고 주문 1건에 결제 1건이 매핑되는 구조였다. 당장 로컬에서 테스트 등이 문제없이 돌아가니까 괜찮아 보였지만 PG를 붙이는 등 시스템이 확장되면서 여러 문제가 발생했다.

  • PG 호출 중 타임아웃 발생 시 주문까지 롤백
    • 사용자가 주문 버튼을 눌렀는데 PG가 늦게 응답하거나 끊기면 주문 자체가 사라져버림
    • 사용자 입장에서는 주문 버튼을 눌러서 처리가 됐는데, 주문 목록에서는 노출되지 않게 될 수 있음
  • 재시도 시 중복 주문 발생 가능성
    • 클라이언트나 서버에서 재시도를 걸면 같은 결제를 여러 번 요청하게 됨
    • 주문이 중복으로 생성되거나 주문 상태와 결제 상태가 일치하지 않을 수도 있음
  • 여러 가지 실패 케이스에 대한 로깅이 어려움
    • 단순히 롤백되기 때문에 어느 단계에서 실패했는지 로깅이 어려움
    • 주문 조건이 맞지 않아 실패한 주문과 PG 단계에서 실패한 주문이 모두 같은 취소로 간주되고 있었음

 

따라서 다시 처음으로 돌아가 트랜잭션 단위를 다시 정의하고 주문과 결제를 아예 분리하기로 했다.

  • 주문 트랜잭션:
    • 주문 생성 → 주문 검증(쿠폰 적용, 포인트 사용, 재고차감)
  • 결제 트랜잭션:
    • 주문상태 확인 (INIT) → 주문상태 변경 (WAITING_PAYMENT) → 결제 생성 → PG 결제 요청

이렇게 역할을 분리하자 구조나 책임이 더 명확해졌다. 주문 검증 단계에서 실패하면 주문은 FAILED 상태로 남아 왜 주문이 실패했는지 추적할 수 있게 됐다. 또한 결제 실패 시에도 주문은 그대로 남고 결제만 FAIL로 기록되어 원복 처리 하기 용이해졌다. 또한 한 주문에 여러 건의 결제가 연결될 수 있어 재시도 로직 처리도 더 안전해졌다.

 

하지만 그래도 여전히 남은 문제들이 있었다.

  • 응답이 늦을 경우 얼마나 기다릴 것인가
  • 실패했을 경우 어디까지 재시도할 것인가
  • 계속해서 실패하는 경우 어떻게 막을 것인가
  • 콜백이 누락되거나 너무 늦을 경우 어떻게 보완할 것인가

각 문제들을 해결하기 위해 하나씩 처리해 보기로 했다.

 

 

Timeout

응답이 늦을 경우 얼마나 기다릴 것인가

내부 서비스라면 보통은 짧은 응답시간이 보장되지만, PG처럼 외부 시스템은 네트워크 지연이나 상대 서버 부하로 인해 예상치 못하게 늘어날 수 있다.

만약 아무 설정 없이 무한정 기다린다면 어떻게 될까?

결제 API가 지연될 때마다 내 스레드도 무한정 지연될 것이다. 트래픽이 몰리면 스레드풀이 금방 고갈되고 다른 요청 처리도 계속 지연될 것이다. 결국 한 번의 지연이 전체 시스템으로 전파되어 시스템 장애로 이어지는 것이다.

타임아웃을 너무 짧게 잡으면 실패가 너무 잦아질 수 있고, 너무 길게 잡으면 지연은 줄어도 시스템 전체가 느려질 수 있어서 적정값을 정하는 게 중요했다.

시뮬레이터의 요청 지연 범위는 100ms ~ 500ms 이기 때문에 1초 이상이 걸린다면 장애로 판단하도록 했다. 처리 지연 범위는 1s ~ 5s 지만 너무 길게 잡게 되면 위에서 말한 문제가 발생하기 때문에 절충안으로 중간값인 3초를 선택했다. 3초를 넘으면 느린 응답으로 판단해 실패처리 후 별도로 처리해야겠다고 판단했다.

여기서 실제로 프로젝트에서는 아래와 같이 설정을 추가했다.

spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connectTimeout: 1000     # TCP 연결 수립까지 최대 1초
            readTimeout: 3000        # 연결 후 응답을 기다리는 최대 3초

 

 

Retry

실패했을 경우 어디까지 재시도할 것인가

Timeout 설정으로 응답이 너무 늦어지면 끊고 실패 처리까지는 할 수 있게 되었다.

하지만 이 말은 응답이 늦으면 바로 실패로 처리하게 된다는 것이다. PG는 외부 시스템이라 일시적인 네트워크 지연이 흔히 발생할 수 있다. 실제로는 정상처리가 가능한 상황인데도 한 번만에 바로 실패로 끝내게 된다면 이것 또한 사용자 경험에 썩 좋은 영향을 주지는 못할 것 같았다.

그래서 다음으로 고려한 게 실패한 전략에 대해 재시도하는 Retry 전략이었다.

 

재시도 전략은 다음과 같이 설정했다. 구현에는 Resilience4j 라이브러리를 활용해 간단하게 설정할 수 있었다.

resilience4j:
  retry:
    instances:
      pgRetry:
        max-attempts: 3               # 최대 재시도 횟수
        wait-duration: 1s             # 재시도 간 대기 시간
        retry-exceptions:             # 재시도 할 예외 클래스
          - feign.RetryableException
          - java.net.SocketTimeoutException
          - java.io.IOException
        fail-after-max-attempts: true # 최대 재시도 횟수 초과 시 명시적으로 예외 던짐
  • max-attempts: 3
    • 단순 네트워크 지연이라면 보통 한두 번 안에 정상화될 것이라 생각해 3회로 설정함
  • wait-duration: 1s
    • 바로 연속으로 재요청하면 PG 서버에 부하가 너무 커질 수 있기 때문에 1초 간격을 두고 요청하게 함
  • retry-exceptions
    • 모든 실패에 대해 재시도하는 것이 아닌 네트워크 레벨 예외에 대해서만 재시도하도록 설정함
  • fail-after-max-attempts: true
    • 설정된 횟수를 넘으면 진짜 실패하도록 예외를 던지도록 함

 

여기서 중요한 점은 회복 가능성이 있는 실패에 대해서만 재시도하도록 하는 것이었다. 따라서 비즈니스 로직을 제외한 네트워크 레벨의 예외에만 재시도를 설정해 주었다.

 

 

Circuit Breaker

계속해서 실패하는 경우 어떻게 막을 것인가

Retry 설정을 통해 실패 요청에 대해 재시도할 수 있도록 설정해 주었다.

하지만 만약 PG 시스템이 실제로 장애를 겪고 있는 상황이라면 어떻게 될까? Retry는 결국 같은 요청을 반복하니까 똑같은 실패를 3번 반복한 뒤에야 예외를 던진다. 이 경우 서버 리소스만 더 사용하고 PG 서버에는 불필요한 부하가 더 가해져 복구가 더뎌질 수 있다. 이렇듯 지속적인 장애에 대해 별도의 대책이 필요했고 Circuit Breaker를 고려하게 되었다.

서킷 브레이커는 주식을 해 본 사람이라면 익숙한 단어일 것이다. 주가가 급변할 때 매매를 중단시킬 때도 이 용어를 사용한다. 동일한 메커니즘으로 실패가 지속될 때 회로를 끊어버려서 요청을 막는 역할이다.

 

서킷 브레이커는 아래처럼 설정을 넣어주었다. 여기서 서킷 브레이커가 발동된 상황을 open으로 표기하는데, 회로는 열려있는 상태가 끊긴 상태이기 때문에 그런 것 같았다.

resilience4j:
  circuitbreaker:
    instances:
      pgCircuit:
        sliding-window-size: 10          # 최근 n회 호출 결과 기준으로 실패율 계산
        failure-rate-threshold: 50       # 실패율이 50% 넘으면 Open
        wait-duration-in-open-state: 10s # Open 상태 유지 시간
        permitted-number-of-calls-in-half-open-state: 2
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 50
  • sliding-window-size: 10
    • 최근 요청 중 실패율을 계산하는 요청 개수 단위인데 10회 정도면 의미 있는 수치로 볼 수 있을 것 같았음
  • failure-rate-threshold: 50
    • 절반 이상이 실패하면 정상이라고 보기 어렵기 때문에 50% 설정함
  • wait-duration-in-open-state: 10s
    • 회로가 끊긴 상태를 유지하는 시간으로 10초 정도면 복구되지 않았을까 생각해 설정
  • permitted-number-of-calls-in-half-open-state: 3
    • 10초 뒤 바로 정상으로 간주하지 않고 일부 요청만 보내보고 성공률을 보고 다시 회로를 닫음

 

실제 코드는 아래처럼 구현했다.

@CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback")
@Retry(name = "pgRetry", fallbackMethod = "fallback")
public PaymentResponse process(PaymentRequest request) {
    Payment payment = paymentService.getDetail(request.getPaymentId());

    // 결제 API 호출
    PaymentResponse response = paymentGateway.requestPayment(request, callbackUrl);
    if (response.getStatus().equals("SUCCESS")) {
        payment.setPaymentPending(response.getTransactionKey());
    }

    return response;
}

이렇게 Circuit Breaker를 설정하니 PG 서버가 다운된 상황에서도 불필요한 재시도를 막을 수 있었다. 우리 시스템 입장에서는 이미 장애인걸 알고 있으니까 괜히 시도하지 않고 빠르게 실패를 반환해 사용자 경험도 개선할 수 있을 것 같았다. 서킷 브레이커는 단순 PG 서버만 보호하는 게 아니라 결국은 우리 시스템도 보호하는 장치인 것이다.

 

 

Scheduler

콜백이 누락되거나 너무 늦을 경우 어떻게 보완할 것인가

Circuit Breaker까지 적용하니까 PG 장애에 대해 비교적 단단한 서버가 되었다.

  • Timeout으로 지연 차단
  • Retry로 일시적 실패에 대해 재시도
  • Circuit Breaker로 장애 확산 방지

 

그래도 여전히 남은 구멍이 있었다.

PG 시스템은 콜백을 통해 결제 결과를 전달해 주는 비동기 결제 방식을 사용하기 때문에, 콜백이 정상적으로 오지 않는 상황도 가정해야 했다. 이런 상황을 방치한다면 우리 시스템과 PG 시스템 간 데이터 정합성 문제가 발생할 수 있었다.

 

이 문제를 해결하기 위해 주기적으로 결제 상태를 점검하는 Scheduler를 추가했다.

일정 주기마다 내 우리 시스템에서 아직 WAITING_PAYMENT 상태인 결제를 조회하고, PG API를 통해 실제 결제 상태를 다시 확인한다. 만약 성공/실패이라면 SUCCESS/FAIL 갱신, 처리 중이라면 다음 주기에 다시 확인하는 식으로 작성했다.

이 방식을 통해 외부 콜백에만 의존하지 않아 콜백 누락 건에 대해 보완할 수 있게 되었고, 서킷이 열려있을 때 들어와 실패했던 결제요청을 정상화 이후에 다시 확인할 수 있게 되었다. PG와 우리 시스템 간 데이터 정합성을 더 지킬 수 있게 되었다.

 

실제 코드는 아래처럼 구현했다.

@Scheduled(fixedDelay = 60000) // 1분마다 실행
public void reconcilePayments() {
    List<Payment> waitingPayments = paymentRepository.findAllByStatus(PaymentStatus.WAITING);

    for (Payment payment : waitingPayments) {
        PgApiResponse response = pgClient.getTransactionsByOrder(payment.getOrderNo());
        payment.updateStatus(response.toPaymentStatus());
    }
}

10분마다 PENDING 상태인 결제를 점검하고, 생성 후 30분 이상된 지연 건은 강제로 실패처리한다. 실패처리 시 결제 데이터에 대한 원복도 함께 진행하도록 했다. 콜백 누락 시 계속해서 방치되는 것이 아니라 주기적으로 복구될 수 있게 되었다.

 

 

마무리

이번 작업을 통해서 외부 API는 언제든 실패할 수 있기 때문에 실패 시에도 시스템이 안전하게 동작하도록 대비하는 과정을 배울 수 있었다.

  • 주문이 롤백돼서 기록이 사라지면? 원인 추적조차 못한다.
  • 타임아웃을 안 걸면? 우리 쪽 스레드 풀이 다 막혀서 서비스 전체가 멈출 수 있다.
  • 재시도가 없으면? 잠깐 끊긴 요청도 전부 실패 처리된다.
  • 서킷 브레이커가 없으면? 외부 장애가 그대로 내부 장애로 번진다.
  • 스케줄러가 없으면? 콜백 누락 하나로 주문이 대기상태에 머물러버린다.

실패를 피할 수 없다면 그에 대한 보완책을 설정해야 한다는 고민을 하면서 Timeout → Retry → Circuit Breaker → Scheduler 로 이어지는 대책을 세울 수 있게 되었다.

단순히 PG 연동하기가 아니라 외부 연동을 어떻게 안정적으로 운영할지에 대한 감각을 조금은 익히게 된 것 같다.

다만 이번에는 외부 API 호출 로직에 대해 트랜잭션까지 분리하지는 못했다. 앞으로 이 부분을 이벤트 처리를 통해 아예 트랜잭션 분리까지 가져갈 수 있게 해서 좀 더 안정적인 서비스를 만드는 과정을 고민해보고자 한다.

반응형

'Study > Architecture' 카테고리의 다른 글

내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로  (0) 2025.09.05
주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?  (2) 2025.08.29
캐시 구조 개선  (0) 2025.08.22
읽기 성능 개선 보고서  (4) 2025.08.15
멱등성을 고려한 좋아요 기능 설계  (5) 2025.08.01
'Study/Architecture' 카테고리의 다른 글
  • 내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로
  • 주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?
  • 캐시 구조 개선
  • 읽기 성능 개선 보고서
haylee
haylee
개발하면서 보고 듣고 느낀 것들을 정리하는중
  • haylee
    haylee
    haylee
    • 홈
    • GitHub
    • LinkedId
    • 분류 전체보기 (19)
      • Project (6)
        • 회고 (6)
      • Study (13)
        • Database (3)
        • Test (1)
        • Architecture (9)
  • hELLO· Designed By정상우.v4.10.1
haylee
장애 대응 시스템 구축하기
상단으로

티스토리툴바