실패를 넘어서: Resilience4j와 고급 Fallback 전략으로 만드는 탁월한 사용자 경험

Jayson·2025년 9월 19일
0
post-thumbnail

I. 서론: 실패의 필연성과 우아한 성능 저하

2024년 7월쯤에 마이크로소프트(MS) 에저 서비스가 문제를 일으켜 전세계 곳곳에 문제를 발생시켰었죠. 이는 단순한 기술 결함을 넘어, 현대 클라우드 인프라가 가진 복잡성의 필연적인 결과를 보여주는 사건이었습니다. 수많은 마이크로서비스가 얽혀 작동하는 오늘날의 분산 시스템에서, 한 구성 요소의 작은 오류는 전체 시스템을 마비시키는 연쇄 반응을 일으킬 수 있어요. 전문가들은 이러한 장애를 시스템 복잡성 심화와 데이터 폭증이 만든 구조적 문제라고 분석합니다.

이러한 환경에서 우리가 추구해야 할 목표는 '실패 없는 완벽한 시스템'이 아니에요. 오히려 실패는 언제든 발생할 수 있는 시스템의 '특성'으로 인정하고, 그 실패가 사용자에게 미치는 영향을 최소화하는 것에 집중해야 합니다. 이것이 바로 '우아한 성능 저하(Graceful Degradation)'의 핵심 철학이에요. '하드 실패(Hard Failure)'가 사용자에게 500 오류 페이지나 멈춘 화면을 보여주는 것이라면, '우아한 실패'는 약간 오래된 데이터를 보여주거나 핵심 기능만 간소화하여 제공함으로써 사용자의 경험을 끊지 않는 것을 의미해요.

이러한 회복성(Resilience)을 구현하기 위한 강력한 도구가 바로 Resilience4j입니다. Netflix Hystrix의 후계자로 여겨지는 이 라이브러리는 서킷 브레이커, 재시도 등 다양한 장애 극복 패턴을 제공하며, 우리가 '더 나은 실패 경험'을 설계하도록 도와줘요. 이 글에서는 Resilience4j의 서킷 브레이커와 Fallback 메커니즘을 중심으로, 단순한 장애 차단을 넘어 사용자에게 실질적인 가치를 제공하는 고급 Fallback 전략들을 탐구해 보겠습니다.

마이크로서비스 아키텍처는 유연성과 확장성이라는 장점 이면에 연쇄 장애와 같은 피할 수 없는 복잡성을 가지고 있어요. 이 때문에 회복성 설계는 선택이 아닌 필수이며, 정교한 Fallback 전략에 대한 투자는 비즈니스 연속성을 보장하는 효과적인 방법 중 하나입니다.


II. 마이크로서비스 실패의 해부학: 연쇄 붕괴의 이해

도미노 효과: 연쇄 장애의 발생 과정

마이크로서비스 환경에서 장애는 조용하고 빠르게 전파돼요. 이 과정을 이해하기 위해 서비스 A(사용자 요청 처리), 서비스 B(상품 정보 조회), 서비스 C(재고 확인)가 있다고 가정해 볼게요.

  1. 장애의 시작: 재고 서비스 C가 데이터베이스 문제로 응답이 느려지기 시작합니다.
  2. 자원의 고갈: 상품 서비스 B는 동기(Synchronous) 방식으로 서비스 C를 호출해요. C의 응답이 지연되면서, B에서 C를 호출하는 스레드(Thread)들은 응답을 기다리며 블로킹(Blocking) 상태에 빠집니다.
  3. 전파: 새로운 요청이 계속 B로 유입되면, 가용한 스레드가 모두 고갈되어 스레드 풀(Thread Pool)이 바닥나요. 이제 B는 C와 관련 없는 요청조차 처리할 수 없는 상태가 됩니다.
  4. 연쇄 붕괴: 이 현상은 그대로 서비스 A로 전파돼요. A 역시 B를 호출하다가 스레드 풀이 고갈되고, 결국 대규모 장애로 이어집니다.

이것이 바로 '연쇄 장애(Cascading Failure)'의 전형적인 모습이에요. 하나의 작은 불안정이 시스템 전체를 붕괴시키는 도미노 효과를 일으키는 것이죠.

1차 방어선: 서킷 브레이커

이러한 도미노 효과를 막는 핵심적인 방법이 바로 '서킷 브레이커(Circuit Breaker)' 패턴이에요. 전기 회로의 차단기처럼, 장애가 발생한 서비스로의 요청을 일시적으로 차단하여 장애 전파를 막습니다. 서킷 브레이커의 중요한 역할은 장애 서비스를 보호하는 것뿐만 아니라, 호출하는 클라이언트 스스로가 자원 고갈로 무너지는 것을 방지하는 '자기 보존'에 있어요.

Resilience4j의 서킷 브레이커는 세 가지 상태를 통해 이 과정을 관리해요.

  • CLOSED (닫힘): 정상 상태예요. 모든 요청은 정상적으로 전달되며, 서킷 브레이커는 호출의 성공/실패 여부만 조용히 관찰합니다.
  • OPEN (열림): 실패율이 설정된 임계치를 초과하면 서킷이 열린 상태로 전환돼요. 이 상태에서는 대상 서비스로의 모든 요청이 즉시 차단되고 예외가 발생합니다. 이는 스레드 자원을 낭비하지 않고 즉시 실패(Fast-Fail)시켜 시스템을 보호하는 핵심 기능이에요.
  • HALF_OPEN (반-열림): OPEN 상태에서 설정된 대기 시간이 지나면 이 상태로 변경돼요. 제한된 수의 테스트 요청만을 허용하여 서비스의 복구 여부를 확인합니다. 이 요청이 성공하면 CLOSED 상태로, 실패하면 다시 OPEN 상태로 돌아가요.

실패를 감지하는 방법: 슬라이딩 윈도우

서킷 브레이커가 OPEN 상태로 전환되는 결정은 '슬라이딩 윈도우(Sliding Window)'라는 메커니즘을 통해 이루어져요. 최근 요청들의 결과를 집계하여 실패율을 계산하는 방식이죠.

Resilience4j는 두 가지 유형의 슬라이딩 윈도우를 제공해요.

  • COUNT_BASED (카운트 기반): 최근 N개의 요청을 기준으로 실패율을 계산합니다.
  • TIME_BASED (시간 기반): 최근 N초 동안의 요청을 기준으로 실패율을 계산합니다.

이 외에도 slowCallRateThreshold(느린 호출 비율), minimumNumberOfCalls(최소 호출 횟수) 등의 설정을 통해 각 서비스의 특성에 맞게 서킷 브레이커의 동작을 정밀하게 제어할 수 있어요.


III. Fallback 스펙트럼: 단순한 해결책부터 정교한 전략까지

서킷 브레이커가 장애 전파를 막는 1차 방어선이라면, Fallback은 그 이후 사용자에게 무엇을 보여줄지 결정하는 2차 방어선이자, '더 나은 실패 경험'을 만드는 핵심 전략이에요.

Fallback 전략 성숙도 모델

Fallback 전략구현 복잡도사용자 경험 영향데이터 최신성이상적인 사용 사례
정적 기본값 (Static Default)낮음낮음 (오류보다는 나음)N/A (정적)중요하지 않은 UI 요소, 선택적 기능, 검색 결과에 대한 빈 목록 반환.
오래된 캐시 (Stale Cache)중간높음 (약간 오래된 데이터는 대부분 수용 가능)잠재적으로 오래됨데이터 변경이 잦지 않은 읽기 중심 서비스 (예: 상품 카탈로그, 사용자 프로필).
2차 데이터 소스 (Secondary Source)높음높음 (대체 실시간 데이터 제공)최신 (2차 소스 기준)데이터 가용성이 매우 중요한 미션 크리티컬 서비스 (예: 결제, 인증).
계산된/지능형 Fallback중간-높음중간-높음 (합리적인 근사치 제공)합성 데이터계산된 추정치가 데이터 없음보다 나은 서비스 (예: 분석 대시보드).

Level 1: 정적 기본값 - 최초의 안전망

가장 기본적이고 구현하기 쉬운 Fallback 전략이에요. 서비스 호출이 실패했을 때, 미리 정의된 정적(Static) 값을 반환하는 방식입니다.

사용 사례: 실패하더라도 전체 기능에 큰 영향을 주지 않는 비핵심 기능에 적합해요. 예를 들어, '추천 상품' 목록을 가져오지 못했을 때 빈 리스트를 반환하여 해당 섹션을 숨기는 것이 오류 메시지를 보여주는 것보다 훨씬 나은 방법입니다.

// ProductController.java
@GetMapping("/recommendations")
@CircuitBreaker(name = "recommendationService", fallbackMethod = "getEmptyRecommendations")
public List<Product> getRecommendations() {
    // 외부 추천 서비스 API 호출
    return recommendationService.fetchPersonalizedRecommendations();
}

// 동일 클래스 내 Fallback 메서드
public List<Product> getEmptyRecommendations(Throwable t) {
    log.warn("추천 서비스 호출에 실패하여 빈 목록을 반환합니다. 원인: {}", t.getMessage());
    return Collections.emptyList();
}

이 전략은 구현이 간단하지만, 사용자에게 제공하는 가치는 제한적인 방어적 전략이라고 할 수 있어요.

Level 2: 오래된 캐시 Fallback - 과거 데이터로 현재 구하기

여기서부터 사용자 경험이 크게 향상돼요. 실시간 데이터 소스에 접근할 수 없을 때, 마지막으로 성공했던 요청의 데이터를 캐시(예: Redis, Caffeine)에서 가져와 제공하는 전략입니다. 사용자는 약간 오래된(Stale) 데이터를 받게 되지만, 대부분의 경우 아무것도 없는 것보다 훨씬 유용해요.

구현 예시:

  • 정상 동작: 주 메서드는 실제 네트워크를 호출하며, @Cacheable 어노테이션을 통해 성공 시 결과를 캐시에 저장해요.
  • Fallback 로직: 서킷 브레이커의 fallbackMethod는 실제 서비스를 호출하는 대신, 캐시에서 직접 데이터를 조회합니다.
// UserProfileService.java
@Autowired
private CacheManager cacheManager;

@Cacheable("userProfiles") // 정상 동작 시 'userProfiles' 캐시를 사용하고 채움
@CircuitBreaker(name = "userService", fallbackMethod = "getCachedUserProfile")
public UserProfile getUserProfile(String userId) {
    // 실제 외부 사용자 서비스 API 호출
    return restTemplate.getForObject("http://user-service/profiles/" + userId, UserProfile.class);
}

// Fallback 메서드
public UserProfile getCachedUserProfile(String userId, Throwable t) {
    log.warn("사용자 서비스 장애 발생. 사용자 ID '{}'에 대해 캐시 데이터로 Fallback합니다.", userId);
    // CacheManager를 통해 수동으로 캐시에 접근하여 오래된 데이터를 가져옴
    Cache userProfileCache = cacheManager.getCache("userProfiles");
    if (userProfileCache != null) {
        Cache.ValueWrapper valueWrapper = userProfileCache.get(userId);
        if (valueWrapper != null) {
            return (UserProfile) valueWrapper.get();
        }
    }
    // 캐시에도 데이터가 없으면 최후의 수단으로 기본값 반환
    return getDefaultUserProfile(userId);
}

고려사항:

  • 분산 환경에서는 여러 인스턴스가 일관된 캐시 데이터를 공유해야 하므로 Redis와 같은 외부 분산 캐시를 사용하는 것이 일반적이에요.
  • 캐시 데이터의 TTL(Time-To-Live) 설정은 데이터의 특성과 비즈니스 요구사항을 고려하여 적절히 설정하는 것이 중요합니다.

Level 3: 2차 데이터 소스 Failover - 예비 계획 가동

기본 데이터 소스가 실패했을 때, 시스템이 자동으로 2차 데이터 소스로 전환하여 요청을 처리하는 전략이에요.

예시:

  • 주 결제 게이트웨이 실패 시, 예비 결제 게이트웨이 호출
  • 주 데이터베이스 실패 시, 읽기 전용 복제본(Read-Replica) 조회
  • 내부 서비스 실패 시, 기능이 제한적인 외부 상용 API 호출
// WeatherService.java

// 1. 주 날씨 API 호출 시도
@CircuitBreaker(name = "primaryWeatherApi", fallbackMethod = "fallbackToSecondaryApi")
public WeatherData getWeatherData(String city) {
    return primaryWeatherApiClient.fetchWeather(city);
}

// 2. 첫 번째 Fallback: 2차 날씨 API 호출 시도
@CircuitBreaker(name = "secondaryWeatherApi", fallbackMethod = "fallbackToDefault")
public WeatherData fallbackToSecondaryApi(String city, Throwable t) {
    log.warn("주 날씨 API 실패. 2차 API를 시도합니다. 원인: {}", t.getMessage());
    return secondaryWeatherApiClient.fetchWeather(city);
}

// 3. 최종 Fallback: 모든 API가 실패하면 기본값 반환
public WeatherData fallbackToDefault(String city, Throwable t) {
    log.error("도시 '{}'의 모든 날씨 API 호출에 실패했습니다. 기본값을 반환합니다. 원인: {}", city, t.getMessage());
    return new WeatherData("N/A", "날씨 정보를 현재 가져올 수 없습니다.");
}

이 전략은 2차 데이터 소스를 유지하는 데 복잡성과 비용이 수반되므로, 데이터 가용성이 매우 중요한 미션 크리티컬 기능에 한정하여 적용해야 해요.

Level 4: 계산된/지능형 Fallback - 현명한 추론

가장 정교한 접근 방식으로, 정확한 데이터를 사용할 수 없을 때 다른 가용 데이터와 비즈니스 로직을 조합하여 합리적인 근사치를 계산해내는 전략이에요.

사용 사례:

  • 배송비 계산: 실시간 배송비 계산 서비스가 다운되었을 때, 사용자의 위치와 상품 무게를 기반으로 '예상 배송비'를 계산해서 보여줍니다.
  • 추천 엔진: 개인화 추천 엔진이 응답하지 않을 때, '전체 인기 상품' 목록을 대신 보여줍니다.
// ShippingService.java
@CircuitBreaker(name = "shippingCalculator", fallbackMethod = "getEstimatedShipping")
public ShippingCost getRealtimeShippingCost(Cart cart) {
    return realTimeShippingService.calculate(cart);
}

public ShippingCost getEstimatedShipping(Cart cart, Throwable t) {
    log.warn("실시간 배송비 계산 실패. 예상치를 제공합니다.");
    // 무게 기반으로 예상 배송비를 계산하는 비즈니스 로직
    double estimatedCost = estimateCostBasedOnWeight(cart.getTotalWeight());
    String message = "예상 배송비 (결제 시 최종 금액 확인)";
    return new ShippingCost(estimatedCost, message);
}

이 전략은 사용자에게 제공되는 데이터가 실제 값이 아님을 명확하게 전달하는 것이 중요해요. 이러한 투명성은 장애 상황에서 사용자의 신뢰를 유지하는 데 결정적인 역할을 합니다.


IV. 구현 마스터하기: 견고한 서비스 메시 엮기

고급 Fallback 전략은 여러 회복성 패턴을 조합할 때 진정한 시너지를 발휘해요.

더 깊은 회복성을 위한 패턴 조합

Resilience4j의 어노테이션은 기본적으로 Retry -> CircuitBreaker -> RateLimiter -> Bulkhead 순서로 적용돼요. 가장 고전적인 조합은 재시도 후 서킷 브레이커입니다. @Retry 어노테이션으로 일시적인 오류를 몇 차례 재시도하고, 계속 실패하면 CircuitBreaker가 시스템 전체를 보호하도록 하는 매우 효율적인 구조예요.

예외 유형별 Fallback을 통한 정밀 제어

하나의 메서드에 여러 Fallback 메서드를 정의하고, 각 Fallback이 서로 다른 예외 유형을 처리하도록 하여 정밀하게 대응할 수 있어요.

  • CallNotPermittedException Fallback: 서킷이 OPEN 상태일 때 대응합니다.
  • TimeoutException Fallback: 응답 지연 문제에 특화된 대응을 합니다.
  • BulkheadFullException Fallback: 동시 요청이 너무 많을 때의 상황을 처리합니다.
// FallbackController.java
@CircuitBreaker(name = "backendA", fallbackMethod = "fallback")
@Bulkhead(name = "backendA")
public Mono<String> processRequest(String param) {
    //... 로직...
}

// 1. 서킷이 열렸을 때 호출됨
private Mono<String> fallback(String param, CallNotPermittedException e) {
    return Mono.just("서비스가 일시적으로 차단되었습니다. 잠시 후 다시 시도해주세요.");
}

// 2. 벌크헤드가 가득 찼을 때 호출됨
private Mono<String> fallback(String param, BulkheadFullException e) {
    return Mono.just("현재 요청이 많아 처리할 수 없습니다. 잠시 후 다시 시도해주세요.");
}

// 3. 그 외 모든 예외에 대해 호출됨 (가장 마지막 순위)
private Mono<String> fallback(String param, Exception e) {
    log.error("알 수 없는 오류 발생: {}", e.getMessage());
    return Mono.just("오류가 발생했습니다. 관리자에게 문의하세요.");
}

코드로 관리하는 설정 및 모니터링

application.yml을 통해 각 외부 서비스에 맞는 서킷 브레이커 인스턴스를 개별적으로 설정하는 것이 중요해요. 또한, Spring Boot Actuator를 연동하면 /actuator/circuitbreakers와 같은 엔드포인트를 통해 Resilience4j의 상태를 실시간으로 모니터링할 수 있습니다.


V. 회복성 철학: 더 나은 실패를 위한 설계

지금까지 논의한 기술적인 패턴들은 결국 '더 나은 실패를 설계하는 것'이라는 하나의 목표를 향해요. 진정한 회복성은 단순히 코드 레벨의 기법을 넘어, 엔지니어링, 제품, 디자인 팀이 함께 고민해야 할 문화이자 철학입니다.

우리가 구현한 기술적 Fallback 전략들은 핵심적인 UX 원칙들과 직접적으로 연결돼요.

  • 오래된 캐시 Fallback은 사용자의 작업 흐름(Flow)을 유지시켜 좌절감을 줄여줍니다.
  • 지능형 Fallback은 유용한 대안을 제시함으로써 사용자에 대한 공감(Empathy)을 보여줍니다.

가장 중요한 것은 명확한 의사소통이에요. Fallback이 동작 중일 때 작은 안내 문구를 표시하는 것만으로도 사용자의 신뢰를 크게 얻을 수 있습니다.

결론: 완벽함이 아닌 회복력을 향하여

복잡한 분산 시스템의 세계에서 완벽함은 환상에 불과해요. 견고한 시스템의 진정한 척도는 실패하지 않는 것이 아니라, 실패했을 때 얼마나 우아하게 대처하는가에 달려 있습니다. 실패를 두려워하지 않고, 실패를 설계의 일부로 받아들이는 것, 그것이 바로 진정한 회복성을 향한 첫걸음 아닐까요!

profile
Small Big Cycle

0개의 댓글