본문으로 바로가기
반응형

현재 다니고 있는 자사 이커머스 서비스는 이벤트가 자주 열립니다. 특정 브랜드의 경우에는 이벤트 시점에 훨씬 많은 트래픽이 몰립니다. 문제는 이런 순간에 외부 서비스(결제, 쿠폰, 추천 API, DB 등)에 병목이 생기면 내부 시스템까지 연쇄적으로 영향을 받는다는점 입니다. 실제 운영 환경에서도 이벤트 순간 특정 외부 API에 요청이 몰리다 보니 Connection Pool이 모두 소진되고, 대기하던 요청들이 타임아웃으로 떨어지면서 WAS까지 흔들리는 장애가 발생했습니다.

 

외부 서비스가 느려지거나 응답하지 못할 때, 이를 내부 서비스까지 전파하지 않고 안정적으로 흡수할 방법이 필요했습니다.
그래서 운영 중엔 어떤 방식으로 리스크를 줄일 수 있을지 정리하게 되었습니다. 결론적으로는 타임아웃(Timeout), 벌크헤드(Bulkhead), 서킷 브레이커(Circuit Breaker) 세 가지를 정리해보려고 합니다.

 


1. Timeout - 기다리지 않아야 전체가 죽지 않는다.

가장 기본적이면서도 가장 중요한 설정.
외부 API가 응답을 주지 않는다고 계속 기다리면 Connection Pool은 금방 고갈되고, 곧바로 WAS 스레드 점유 -> API 장애로 이어집니다.
- 짧더라도 명확한 Timeout 설정은 필수.
- API 성경에 따라 connectTimeout, readTimeout을 분리해서 설정.
- 실패 시 빠르게 다음 로직으로 넘어갈 수 있게 fallback 전략도 필수.

 

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class WebClientConfig {

    private final int CONNECT_TIMEOUT_MILLIS = 3000; // 3초
    private final Duration READ_TIMEOUT = Duration.ofSeconds(5); // 5초
    private final Duration WRITE_TIMEOUT = Duration.ofSeconds(5); // 5초

    @Bean
    public WebClient externalServiceWebClient() {
        // 1. HttpClient (Reactor Netty) 설정
        HttpClient httpClient = HttpClient.create()
                // Connect Timeout 설정
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MILLIS) 
                .responseTimeout(READ_TIMEOUT) // 응답 전체에 대한 타임아웃 (Connect + Read)
                .doOnConnected(conn -> conn
                        // Read Timeout 설정 (읽기 시작 후 데이터가 없을 때)
                        .addHandlerLast(new ReadTimeoutHandler(READ_TIMEOUT.toSeconds(), TimeUnit.SECONDS))
                        // Write Timeout 설정 (쓰기 시작 후 데이터 전송이 지연될 때)
                        .addHandlerLast(new WriteTimeoutHandler(WRITE_TIMEOUT.toSeconds(), TimeUnit.SECONDS))
                );

        // 2. WebClient 생성 및 HttpClient 연결
        return WebClient.builder()
                .baseUrl("http://external-api.com")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

 

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.concurrent.TimeoutException;

@Service
public class ExternalApiCaller {

    private final WebClient webClient;

    public ExternalApiCaller(WebClient webClient) {
        this.webClient = webClient;
    }

    public Mono<String> callDataWithTimeout() {
        return webClient.get()
                .uri("/data")
                .retrieve()
                .bodyToMono(String.class)
                // 3. Reactor Level Timeout: WebClient 호출 전체에 대한 타임아웃 (ex: 8초)
                // Netty 설정을 오버라이드하거나, 추가적인 안전 장치로 사용 가능
                .timeout(Duration.ofSeconds(8)) 
                .onErrorResume(TimeoutException.class, e -> {
                    // 타임아웃 발생 시 실행되는 Fallback 로직
                    System.out.println("WebClient 요청 타임아웃 발생. 기본값으로 대체.");
                    return Mono.just("fallback-data (from timeout)");
                })
                .onErrorResume(throwable -> {
                    // 기타 HTTP 에러 발생 시 Fallback
                    System.out.println("외부 서비스 에러 발생: " + throwable.getMessage());
                    return Mono.just("fallback-data (from error)");
                });
    }
}

2. Bulkhead - 외부 서비스가 죽어도 나머지 기능 보호하기

Bulkhead는 '격벽'이라는 뜻으로, 비행기나 배를 구획으로 나누어 한 구간이 침수되더라도 전체가 침몰하지 않도록 하는 구조에서 나온 개념.
- 특정 외부 API 호출 로직 별도 ThreadPool로 격리.
- 외부 서비스 장애가 나도 다른 기능의 Thread를 잠식하지 못함.
- CircuitBreaker와 함께 쓰면 더 효과적.

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;

@Service
public class ExternalApiServiceImpl {

    private static final String RECOMMENDED_SERVICE = "recommendationService";

    /**
     * @Bulkhead 설정: 추천 API 호출 로직을 별도의 ThreadPool로 격리
     * name = Bulkhead 인스턴스 이름
     * type = SEMAPHORE(동기) 또는 THREADPOOL(비동기)
     */
    @Bulkhead(name = RECOMMENDED_SERVICE, type = Bulkhead.Type.THREADPOOL)
    public String getRecommendedProducts(String userId) {
        // 외부 추천 API 호출 로직 (응답 지연 가능성이 있음)
        System.out.println("외부 추천 API 호출 시작 (격리된 스레드 풀 사용)");
        
        // **가정:** 외부 API 호출
        // Thread.sleep(3000); 

        return "추천 상품 목록 (Bulkhead 보호)";
    }

    // 일반 결제 로직 (다른 ThreadPool 사용)
    public boolean processPayment() {
        // 외부 결제 API 호출 로직 (일반 스레드 풀 사용)
        System.out.println("결제 로직 실행 (일반 스레드 풀 사용)");
        return true;
    }
}

// application.yml 설정 예시 (ThreadPool Bulkhead 구성)
/*
resilience4j.bulkhead:
  configs:
    default:
      maxWaitDuration: 100ms
      maxConcurrentCalls: 5 # 최대 동시 요청 수 (Semaphore)
  instances:
    recommendationService:
      baseConfig: default
      type: THREADPOOL # 스레드 풀 격리 사용
      maxThreadPoolSize: 10 # 스레드 풀 크기
      queueCapacity: 5 # 큐 크기
*/

3. Circuit Breaker - 실패를 감지하면 스위치를 내려라

Circuit Breaker는 실패율이 일정 threshold를 넘어서면 회로를 열어서 더 이상 외부 서비스로 요청을 보내지 않게하는 패턴.
- 외부 서비스가 장애 상태라면 요청을 보내지 않음.
- fallback 로직으로 graceful degrade 가능.
- 일정 시간 후 다시 Half-Open 상태로 테스트 요청을 보내 복구 여부 확인.

이 패턴의 핵심 목표는 불필요한 요청을 보내지 않음으로써 내부 리소스를 보호하는 것.
장애 상황에서는 Timeout + Bulkhead와 함께 Circuit Breaker가 가장 강력한 안정화 조합이 됨.

 

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;

@Service
public class CouponService {

    private static final String COUPON_SERVICE = "couponService";

    /**
     * @CircuitBreaker 설정: 쿠폰 API 호출에 적용
     * name = Circuit Breaker 인스턴스 이름
     * fallbackMethod = Circuit Breaker가 Open되거나 예외 발생 시 실행할 메서드
     */
    @CircuitBreaker(name = COUPON_SERVICE, fallbackMethod = "getFallbackCoupons")
    public String getAvailableCoupons(String userId) {
        // 외부 쿠폰 API 호출 로직 (느리거나 실패 가능성이 있음)
        
        // **가정:** 외부 API 호출. 
        // 특정 조건에서 예외(Timeout, 5xx 에러) 발생 가정
        if (System.currentTimeMillis() % 10 < 3) { // 30% 확률로 실패 가정
            throw new RuntimeException("외부 쿠폰 API에서 500 에러 발생"); 
        }

        System.out.println("외부 쿠폰 API 정상 응답");
        return "사용 가능한 쿠폰 목록";
    }

    /**
     * Fallback 메서드: Circuit Breaker가 Open되었거나 호출 실패 시 실행
     */
    public String getFallbackCoupons(String userId, Throwable t) {
        System.out.println("Circuit Breaker 작동 또는 예외 발생: " + t.getMessage());
        // 내부 캐시 데이터 또는 빈 리스트 등 'Graceful Degrade' 전략 실행
        return "쿠폰 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해 주세요. (기본 쿠폰 1종)";
    }
}

// application.yml 설정 예시 (Circuit Breaker 구성)
/*
resilience4j.circuitbreaker:
  instances:
    couponService:
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 10 # 최근 10개 요청 기반으로 실패율 계산
      failureRateThreshold: 50 # 50% 이상 실패 시 Open
      waitDurationInOpenState: 60s # Open 상태 유지 시간 (60초 후 Half-Open)
*/

외부 서비스는 언제든지 장애가 날 수 있고, 그 장애가 내부 서비스까지 그대로 전파되면 최악의 상황에 도달합니다.
운영 환경에서 흔히 겪는 Connection Pool 고갈, 스레드 대기 문제는 대부분 외부 서비스가 느린데 

외부 서비스는 언제든지 장애가 날 수 있고, 그 장애가 내부 서비스까지 그대로 전파되면 최악의 상황에 도달합니다.
특히 트래픽이 집중되는 이벤트 상황에서는 이런 문제가 더 빠르게 드러나는것 같습니다. 
시스템 안정성을 높이기 위한 핵심은 “문제 발생 시 얼마나 빠르게 차단하고, 얼마나 작은 범위로 묶어두느냐”로 결정되는것 같습니다.
Timeout은 문제가 생겼을 때 불필요하게 리소스를 붙잡아두지 않게 해주고, Bulkhead는 특정 기능에 문제가 생겨도 전체 서비스가 흔들리지 않도록 격리해주며, Circuit Breaker는 반복적인 실패가 시스템 전반으로 확산되는 것을 미리 차단해줍니다.
이 세 가지를 정리하면서 외부 서비스를 사용하는 모든 서비스라면 기본값처럼 고려해야 하고 사실상 필수에 가깝다고 생각하게 되었습니다.

 

반응형