본문 바로가기

카테고리 없음

분산 트랜잭션 환경에서 데이터 일관성 어떻게 보장할까(2)

개요

이전까지는 분산 환경에서 데이터 일관성을 지키기 위해 CAP 이론과 2PC, Saga 패턴에 대해서 정리하였다.

특히 Saga 패턴은 MSA 환경에서 데이터 일관성을 유지하기 위한 효과적인 방법이지만,
보상 트랜잭션의 한계, 네트워크 오류, 멱등성 처리와 같은 문제는 여전히 남아있다.

따라서 이러한 문제를 완화하고 서비스 간 통신 안정성을 높이기 위한 다양한 기술들을 추가로 학습하고자 한다.


서킷브레이커(Circuit Breaker) - Resilience4j

 

서킷 브레이커가 필요한 이유

현재 영화 티켓 예매 서비스에서 Saga 패턴 중 오케스트레이션을 사용하여 서비스 간 트랜잭션을 관리하고 있다고 가정한다.

이때 주문 생성 과정에서 오케스트레이터가 좌석 서비스로 요청을 전송하였지만,

몇 초가 지나도 응답이 넘어오지 않는 상황이 발생할 수 있다.
이 경우 호출한 주문 서비스는 설정된 타임아웃 시간에 도달할 때까지 응답을 기다리게 된다.

 

만약 이러한 요청이 반복적으로 발생하고 동시에 주문 요청이 증가한다면, 각 요청이 응답을 기다리는 동안 스레드가 계속 점유되게 된다.
결과적으로 스레드 풀이 모두 소진되고 이후 들어오는 요청들은 대기 큐에 쌓이게 되어 결국 새로운 요청을 처리할 수 없는 서비스 장애로 이어질 수 있다.

 

이러한 상황을 해결하기 위해 서킷 브레이커를 활용한다.


서킷브레이커란?

  • MSA 환경에서 서비스 간의 호출 실패를 감지하고 시스템의 전체적인 안정성을 유지하는 패턴이다.
  • 서비스 호출 실패율이 일정 임계값을 초과할 경우, 해당 서비스를 일시적으로 차단하여 빠르게 실패(Fail Fast)하도록 유도함으로써 시스템의 다른 부분에 장애가 전파되지 않도록 예방한다.
  • 결과적으로 불필요한 대기 시간을 줄이고, 시스템 자원 고갈(Thread Pool Exhaustion)을 방지할 수 있다.

 

[서킷브레이커 상태]

서킷브레이커는 3개의 일반 상태와 2개의 특수 상태로 분류할 수 있다.

 

일반 상태

  • CLOSED
    • 정상적으로 호출되고 요청을 처리할 수 있는 상태이다.
    • 이 상태에서 호출이 실패하면 실패 카운터가 증가하고, 실패율이 설정된 임계값을 초과하면 OPEN 상태로 전환된다.
  • OPEN
    • 장애 상황(잦은 요청 실패, 타임 아웃 등)으로 간주하여 이후 모든 요청에 대해 거부하는 상태로 요청이 실패하지 않고 즉시 에러 응답을 반환한다.
    • 설정된 대기 시간이 지난 후, 서킷 브레이커는 HALF_OPEN 상태로 전환된다.
  • HALF_OPEN
    • 연동된 서비스의 장애 여부를 확인하기 위해 제한적으로 호출을 허용하고 복구 결과에 따라 상태를 전환합니다.
    • 요청이 성공하면 CLOSED 상태로 다시 실패하면 OPEN 상태로 전환된다.

특수 상태

  • DISABLED: Circuit Breaker가 비활성화된 상태로 항상 호출을 허용한다.
  • FORCED_OPEN: 특정 조건에서 강제로 OPEN 상태로 변경되어 요청에 대해 처리할 수 없는 상태이다.

 


Resilience4j란?

  • Resilience4j 는 자바를 위해 만들어진 회복 탄력성을 보장하는 라이브러리다.
  • Circuit Breaker, Retry, TimeLimiter 등 다양한 장애 대응 기능을 제공하며, 장애 격리 및 빠른 실패를 통해 복원력을 높인다.
  • Resilience4j Dashboard를 통해 서킷 브레이커 상태를 모니터링하고 관리할 수 있다.

 

Resilience4j 적용 전략

서비스 간 동기 호출 환경에서 네트워크 지연과 일시적인 장애에 대응하기 위해
다음과 같은 Resilience4j 기능을 조합하여 안정적인 서비스 간 통신을 구현할 수 있다.

 

 

① 타임아웃 설정 (TimeLimiter)

  • 지정된 시간 내 응답이 없을 경우, 요청을 강제로 종료하여 스레드 점유 시간을 제한한다.
# application.yml
feign:
  client:
    config:
      default:
        connectTimeout: 3000
        readTimeout: 5000

resilience4j:
  timelimiter:
    configs:
      default:
        timeoutDuration: 5s

 

 

재시도 (Retry)

  • 일시적인 네트워크 오류 발생 시, 일정 횟수만큼 재시도를 수행한다.
@Retry(
    name = "seatService", fallbackMethod = "retryFallback")
public void decreaseSeat(
        Long seatId, int qty, String idempotencyKey) {

    seatClient.decreaseSeat(
            seatId,
            new SeatDecreaseRequest(qty, idempotencyKey),
            idempotencyKey
    );
}

// 모든 Retry 실패 후 실행
public void retryFallback(
        Long seatId, int qty, String idempotencyKey, Exception e) {

    log.error("좌석 서비스 재시도 3회 실패 - Saga 보상 시작", e);

    throw new SagaRollbackException("좌석 차감 최종 실패", e);
}
# application.yml - Retry 설정
resilience4j.retry:
  instances:
    seatService:
      maxAttempts: 3
      waitDuration: 1s
      enableExponentialBackoff: true    # 1초 → 2초 → 4초
      exponentialBackoffMultiplier: 2
      retryExceptions:                 # 이 예외들만 재시도
        - feign.RetryableException
        - java.net.SocketTimeoutException
      ignoreExceptions:                # 이 예외들은 재시도 안 함 (바로 실패)
        - feign.FeignException.BadRequest      # 400
        - feign.FeignException.NotFound        # 404
        - com.example.order.domain.exception.BusinessException

 

 

[②-1] 재시도해야 하는 에러 vs 하면 안 되는 에러

모든 에러에 대해 재시도를 수행하는 것은 옳지 않다.
재시도는 일시적인 장애에만 의미가 있으며, 요청 자체가 잘못되었거나 비즈니스 로직상 실패한 경우에는 재시도해도 동일하게 실패할 뿐이다.

따라서 어떤 예외를 재시도 대상으로 볼 것인지 명확히 구분해야 한다.

[재시도해야 하는 에러 — 일시적 장애]
503 Service Unavailable → 서버가 잠깐 바쁨, 재시도하면 될 수 있음
408 Request Timeout    → 타임아웃, 재시도하면 될 수 있음
429 Too Many Requests  → 요청 너무 많음, 잠깐 쉬고 재시도

[재시도하면 안 되는 에러 — 비즈니스/요청 에러]
400 Bad Request        → 요청 자체가 잘못됨 (재시도해도 계속 실패)
404 Not Found          → 상품이 없음 (재시도해도 계속 실패)
409 Conflict           → 이미 처리됨 (멱등성으로 처리)
422 Unprocessable      → 재고 부족 등 비즈니스 에러 (재시도 의미 없음)

 

 

 

서킷 브레이커 (Circuit Breaker)

@CircuitBreaker(
    name = "seatService", fallbackMethod = "decreaseSeatFallback")
public void decreaseSeat(
        Long seatId, int qty, String idempotencyKey) {
    seatClient.decreaseSeat(
            seatId,
            new SeatDecreaseRequest(qty, idempotencyKey),
            idempotencyKey
    );
}

// Circuit Open 또는 호출 실패 시 fallback
public void decreaseSeatFallback(
        Long seatId, int qty, String idempotencyKey, Exception e) {
        
    if (e instanceof CallNotPermittedException) {
        log.warn("좌석 서비스 Circuit Open - 호출 차단");
    }

    log.error("좌석 서비스 호출 실패", e);

    // Saga 롤백 트리거
    throw new SagaRollbackException("좌석 서비스 호출 불가", e);
}
# application.yml - CircuitBreaker 설정
resilience4j.circuitbreaker:
  instances:
    productService:
      slidingWindowSize: 10
      failureRateThreshold: 60        # 60% 이상 실패하면 서킷 오픈
      waitDurationInOpenState: 30s     # 30초 동안 호출 차단
      permittedNumberOfCallsInHalfOpenState: 3

 

 

[ - 1] 서킷 브레이커 적용 시 예외 처리 전략

Resilience4j 는 AOP 기반으로 동작하기 때문에, 예외가 외부로 전달되어야 Circuit Breaker가 실패로 인식할 수 있다.

 

예를 들어 다음과 같이 좌석 서비스 호출 중 예외가 발생하여 catch 되었지만, new SeatResponse("FAILED");와 같이 정상 객체를 return 한다면 예외가 외부로 전달되지 않았기 때문에 서킷 브레이커는 해당 호출을 성공으로 인식하게 된다.

@CircuitBreaker(name = "seatService")
public SeatResponse callSeat() {

    try {
        return seatClient.reserveSeat();

    } catch (Exception e) {
        // throw e; 처럼 예외를 던지지 않고 잡음
        return new SeatResponse("FAILED");
    }
}

 

 

따라서 내부에서 예외를 단순히 catch하고 처리할 경우, Circuit Breaker가 실패를 감지하지 못할 수 있으므로 어떤 예외를 실패로 기록할 것인지 명확히 정의해야 한다.

@CircuitBreaker(
    name = "seatService",
    recordExceptions = { TimeoutException.class }, // 실패 기록
    ignoreExceptions = { BusinessException.class } // 무시
)

 

 

 

Fallback 설정

  • 외부 서비스 호출이 실패했을 때, 실패 대체 로직을 제공하여 장애가 발생해도 사용자에게 일정한 응답을 제공할 수 있다.
@Service
public class SeatServiceCaller {

    @CircuitBreaker(name = "seatService", fallbackMethod = "seatFallback")
    public SeatResponse reserveSeat(Long seatId) {
        return seatClient.reserveSeat(seatId);
    }

    public SeatResponse seatFallback(Long seatId, Throwable t) {
        log.error("좌석 서비스 호출 실패 - fallback 실행", t);

        // 사용자에게 임시 응답 제공
        return SeatResponse.failed("좌석 서비스 지연 중입니다.");
    }
}

 

 

전체 흐름 정리


결론

Resilience4j를 도입하면 Retry를 통해 일시적인 오류를 자동으로 복구할 수 있으며,
Circuit Breaker를 통해 반복적인 실패가 발생하는 서비스 호출을 조기에 차단함으로써
스레드 고갈과 같은 자원 소모 문제를 예방할 수 있다.

또한 Fallback 전략을 적용하면 외부 서비스 장애 상황에서도
시스템이 완전히 중단되지 않도록 하고, 사용자에게 일정한 응답을 제공할 수 있도록 설계할 수 있다.

 

결과적으로 Resilience4j의 다양한 기능을 적절히 조합하여 서비스 간 통신에 회복 탄력성을 적용하면,
일시적인 네트워크 장애나 외부 서비스 지연이 발생하더라도
분산 환경에서 안정적으로 서비스를 운영할 수 있는 구조를 구축할 수 있다는 것을 배울 수 있었다.