bulkhead 패턴

2024. 12. 12. 18:08Spring Microservice

1. 벌크헤드 패턴이란?

벌크헤드 패턴은 소프트웨어 시스템에서 장애가 한 부분에서 발생하더라도, 다른 부분으로 확산되지 않도록 리소스를 분리하고 격리하는 설계 방식입니다. 이 방식은 실제 선박의 방수벽(Bulkhead)에서 아이디어를 얻은 것입니다. 선박에서 방수벽이 있는 구역이 침수되어도, 다른 구역은 침수되지 않는 것처럼, 소프트웨어 시스템도 리소스를 구역별로 격리하여 안정성을 높입니다.

2. 벌크헤드 패턴의 필요성

문제 상황

예를 들어, 대규모 전자 상거래 사이트가 다음과 같은 3가지 주요 서비스를 운영한다고 가정합시다:

  1. 주문 처리 서비스 (Order Service):
    • 고객의 주문을 생성하고 저장하는 서비스.
  2. 결제 처리 서비스 (Payment Service):
    • 고객의 결제를 처리하고 승인하는 서비스.
  3. 배송 추적 서비스 (Delivery Tracking Service):
    • 배송 상태를 업데이트하고 추적 정보를 제공하는 서비스.

이 시스템에서 다음과 같은 상황이 발생할 수 있습니다:

  • 주문 서비스에 과부하 발생: 특정 시간대(예: 세일 이벤트) 동안 많은 주문 요청이 들어와 주문 서비스가 과부하 상태에 빠집니다.
  • 결제와 배송 추적에 영향: 모든 서비스가 같은 스레드 풀이나 네트워크 연결을 공유한다면, 주문 서비스의 과부하가 결제 및 배송 추적에도 영향을 미치게 됩니다.
  • 전체 시스템 중단: 주문 서비스의 과부하로 인해 전체 시스템이 응답하지 않게 됩니다.
벌크헤드 패턴의 해결 방법
  • 각 서비스가 독립적으로 관리되는 리소스(스레드 풀, 네트워크 연결, 데이터베이스 연결 등)를 사용하도록 설계합니다.
  • 예를 들어:
    • 주문 서비스: 스레드 50개, 네트워크 연결 100개.
    • 결제 서비스: 스레드 30개, 네트워크 연결 50개.
    • 배송 추적 서비스: 스레드 10개, 네트워크 연결 20개.

이렇게 하면 주문 서비스가 과부하 상태여도 결제 서비스와 배송 추적 서비스는 정상적으로 동작합니다.

3. 실질적인 벌크헤드 패턴 구현 예시

시스템 설명

시나리오:

  • 사용자가 전자 상거래 플랫폼에 접속하여 상품을 주문하고, 결제를 진행한 후 배송 상태를 확인합니다.
  • 3개의 마이크로서비스가 각각의 작업을 처리합니다:
    1. OrderService: 주문 생성 및 저장.
    2. PaymentService: 결제 승인.
    3. DeliveryService: 배송 추적.
문제 발생 시나리오
  • OrderService가 프로모션 이벤트로 인해 과부하 상태가 되어, 모든 스레드를 사용 중입니다.
  • OrderService와 PaymentService가 동일한 스레드 풀을 공유하고 있다면, 결제 요청이 처리되지 못해 결제 서비스도 응답하지 않게 됩니다.
  • 결과적으로, 사용자 경험이 크게 저하되고 시스템이 신뢰성을 잃게 됩니다.
벌크헤드 패턴 적용
  • 각 서비스에 독립적인 리소스를 할당하여 격리합니다.
  • 예제 구현 코드와 함께 설명:

4. Spring Boot를 활용한 벌크헤드 패턴 예제

의존성 추가

pom.xml에서 Resilience4j를 추가합니다:

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.0.2</version>
</dependency>
설정 파일 작성

application.yml에서 서비스별로 리소스를 격리합니다:

resilience4j:
  bulkhead:
    configs:
      default:
        maxConcurrentCalls: 10 # 동시에 처리할 수 있는 최대 요청 수
        maxWaitDuration: 2s    # 요청 대기 시간
    instances:
      orderService:
        maxConcurrentCalls: 50
        maxWaitDuration: 5s
      paymentService:
        maxConcurrentCalls: 30
        maxWaitDuration: 3s
      deliveryService:
        maxConcurrentCalls: 10
        maxWaitDuration: 1s
서비스별 스레드 풀 분리
1. 주문 서비스 (Order Service)
@Service
public class OrderService {

    @Bulkhead(name = "orderService", type = Bulkhead.Type.THREADPOOL)
    public String placeOrder() {
        // 주문 생성 로직
        simulateProcessing(1000); // 처리 지연 시뮬레이션
        return "Order placed successfully";
    }

    private void simulateProcessing(long milliseconds) {
        try {
            Thread.sleep(milliseconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
2. 결제 서비스 (Payment Service)
@Service
public class PaymentService {

    @Bulkhead(name = "paymentService", type = Bulkhead.Type.THREADPOOL)
    public String processPayment() {
        // 결제 처리 로직
        simulateProcessing(500); // 처리 지연 시뮬레이션
        return "Payment processed successfully";
    }

    private void simulateProcessing(long milliseconds) {
        try {
            Thread.sleep(milliseconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
3. 배송 추적 서비스 (Delivery Service)
@Service
public class DeliveryService {

    @Bulkhead(name = "deliveryService", type = Bulkhead.Type.THREADPOOL)
    public String trackDelivery() {
        // 배송 추적 로직
        simulateProcessing(300); // 처리 지연 시뮬레이션
        return "Delivery status updated";
    }

    private void simulateProcessing(long milliseconds) {
        try {
            Thread.sleep(milliseconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
API 호출을 위한 Controller
@RestController
@RequestMapping("/api")
public class BulkheadController {

    private final OrderService orderService;
    private final PaymentService paymentService;
    private final DeliveryService deliveryService;

    public BulkheadController(OrderService orderService, PaymentService paymentService, DeliveryService deliveryService) {
        this.orderService = orderService;
        this.paymentService = paymentService;
        this.deliveryService = deliveryService;
    }

    @GetMapping("/order")
    public String placeOrder() {
        return orderService.placeOrder();
    }

    @GetMapping("/payment")
    public String processPayment() {
        return paymentService.processPayment();
    }

    @GetMapping("/delivery")
    public String trackDelivery() {
        return deliveryService.trackDelivery();
    }
}

 

5. 벌크헤드 패턴의 실제 동작

시나리오 1: 주문 서비스 과부하
  • OrderService가 과부하 상태가 되어 스레드 50개가 모두 사용 중.
  • OrderService에만 영향을 미치며, PaymentServiceDeliveryService는 각각 독립적인 스레드 풀을 사용하기 때문에 정상 동작.
시나리오 2: 배송 추적 서비스 지연
  • 배송 추적 API에서 외부 API 호출 지연 발생.
  • DeliveryService 스레드 풀(10개)만 영향을 받으며, 주문과 결제는 정상적으로 처리.

6. 장점

  1. 장애 격리:
    • 특정 서비스의 장애가 전체 시스템에 영향을 미치지 않음.
  2. 안정성 향상:
    • 하나의 서비스가 과부하 상태여도, 다른 서비스는 정상 동작.
  3. 독립적 확장:
    • 각 서비스의 리소스를 독립적으로 확장 가능.
  4. 사용자 경험 개선:
    • 하나의 기능이 중단되어도, 다른 기능은 계속 제공 가능.

7. 고려 사항

  1. 리소스 과소 할당:
    • 특정 서비스에 리소스를 너무 적게 할당하면 성능 병목이 발생할 수 있음.
  2. 설계 복잡성 증가:
    • 모든 서비스의 리소스를 분리하고 관리하는 데 추가적인 작업 필요.
  3. 운영 비용 증가:
    • 리소스 분리로 인해 하드웨어나 클라우드 비용 증가 가능.

벌크헤드 패턴에서 실제 스레드 풀을 설정하고 사용하는 코드는 핵심입니다. Resilience4j에서는 기본적으로 ThreadPoolBulkhead를 제공하여 스레드 풀을 통해 벌크헤드 패턴을 구현할 수 있습니다. 이 방식은 서비스별로 독립적인 스레드 풀을 설정하고, 동시 요청 수를 제한하여 리소스를 격리합니다.

아래는 실제 스레드 풀 코드와 함께 더 구체적으로 설명한 내용입니다.


1. Resilience4j에서 ThreadPoolBulkhead 설정

ThreadPoolBulkhead는 벌크헤드 패턴의 핵심 요소로, 각 서비스별로 독립적인 스레드 풀을 제공합니다.

1) application.yml에서 스레드 풀 설정

서비스별로 스레드 풀을 설정합니다:

resilience4j:
  thread-pool-bulkhead:
    configs:
      default:
        maxThreadPoolSize: 10         # 최대 스레드 수
        coreThreadPoolSize: 5         # 기본 스레드 수
        queueCapacity: 20             # 대기열 크기
        keepAliveDuration: 10s        # 유휴 스레드 유지 시간
    instances:
      orderService:
        baseConfig: default           # 기본 설정 사용
        maxThreadPoolSize: 50         # 주문 서비스는 스레드 50개
        coreThreadPoolSize: 25
        queueCapacity: 100
      paymentService:
        baseConfig: default           # 결제 서비스는 스레드 30개
        maxThreadPoolSize: 30
        coreThreadPoolSize: 15
        queueCapacity: 50
      deliveryService:
        baseConfig: default           # 배송 서비스는 스레드 10개
        maxThreadPoolSize: 10
        coreThreadPoolSize: 5
        queueCapacity: 20

 

2) 서비스별 ThreadPoolBulkhead 적용

각 서비스에 ThreadPoolBulkhead를 적용합니다. 이 방식은 각 서비스가 독립적인 스레드 풀에서 작업을 처리하도록 보장합니다.

OrderService 예제:

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

@Service
public class OrderService {

    @ThreadPoolBulkhead(name = "orderService", type = ThreadPoolBulkhead.Type.THREADPOOL)
    public String placeOrder() {
        // 주문 처리 로직 (예: 외부 API 호출)
        simulateProcessing(1000); // 1초 동안 처리 지연 시뮬레이션
        return "Order placed successfully";
    }

    private void simulateProcessing(long milliseconds) {
        try {
            Thread.sleep(milliseconds); // 작업 지연 시뮬레이션
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

PaymentService 예제:

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

@Service
public class PaymentService {

    @ThreadPoolBulkhead(name = "paymentService", type = ThreadPoolBulkhead.Type.THREADPOOL)
    public String processPayment() {
        // 결제 처리 로직
        simulateProcessing(500); // 0.5초 동안 처리 지연 시뮬레이션
        return "Payment processed successfully";
    }

    private void simulateProcessing(long milliseconds) {
        try {
            Thread.sleep(milliseconds); // 작업 지연 시뮬레이션
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

 

2. ThreadPoolBulkhead의 실제 동작 시나리오

예시 1: 주문 서비스 과부하

  1. OrderService에 80개의 동시 요청이 발생.
  2. maxThreadPoolSize: 50이므로, 최대 50개의 스레드가 동작하고, 나머지 30개 요청은 대기열(queueCapacity: 100)로 이동.
  3. 대기열이 가득 차면 초과 요청은 즉시 실패(Fallback 호출).

예시 2: 결제 서비스와 배송 서비스 정상 동작

  • 결제 서비스와 배송 서비스는 각각 독립적인 스레드 풀을 사용하므로, 주문 서비스의 과부하와 관계없이 정상적으로 요청을 처리.

3. Fallback 적용

과부하가 발생하거나 요청이 대기열에서 처리되지 못하면 Fallback을 통해 대체 응답을 제공합니다.

OrderService에 Fallback 추가:

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

@Service
public class OrderService {

    @ThreadPoolBulkhead(name = "orderService", type = ThreadPoolBulkhead.Type.THREADPOOL, fallbackMethod = "fallbackOrder")
    public String placeOrder() {
        simulateProcessing(1000); // 1초 지연
        return "Order placed successfully";
    }

    private String fallbackOrder(Throwable t) {
        return "Order service is currently unavailable. Please try again later.";
    }

    private void simulateProcessing(long milliseconds) {
        try {
            Thread.sleep(milliseconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

 

4. 전체 Controller

API를 통해 각 서비스를 호출하는 Controller를 작성합니다:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class BulkheadController {

    private final OrderService orderService;
    private final PaymentService paymentService;
    private final DeliveryService deliveryService;

    public BulkheadController(OrderService orderService, PaymentService paymentService, DeliveryService deliveryService) {
        this.orderService = orderService;
        this.paymentService = paymentService;
        this.deliveryService = deliveryService;
    }

    @GetMapping("/order")
    public String placeOrder() {
        return orderService.placeOrder();
    }

    @GetMapping("/payment")
    public String processPayment() {
        return paymentService.processPayment();
    }

    @GetMapping("/delivery")
    public String trackDelivery() {
        return deliveryService.trackDelivery();
    }
}

 

5. 동작 확인

1) 정상 동작

  • 주문, 결제, 배송 추적 API를 각각 호출하면 독립적인 스레드 풀에서 작업이 처리됩니다.
  • 스레드 풀 설정에 따라 병렬 처리가 제한됩니다.

2) 과부하 발생

  • 특정 서비스(예: OrderService)에 동시 요청이 과도하게 발생하면:
    • 설정된 maxThreadPoolSize 이상의 요청은 대기열로 이동.
    • 대기열도 초과하면 Fallback 응답 반환.

3) 다른 서비스 영향 방지

  • OrderService에 과부하가 발생하더라도, PaymentService와 DeliveryService는 독립적인 스레드 풀로 동작하므로 정상적으로 작동합니다.

6. 장점 요약

  1. 독립적 리소스 관리:
    • 각 서비스는 고유한 스레드 풀을 사용하므로, 장애 전파를 방지.
  2. Fallback 대응:
    • 서비스 과부하나 실패 시에도 대체 응답 제공 가능.
  3. 시스템 안정성 향상:
    • 특정 서비스가 과부하 상태에 빠지더라도 전체 시스템의 안정성을 유지.

7. 결론

이 코드는 실제로 각 서비스에 독립적인 ThreadPoolBulkhead를 적용하고, 과부하 상황에서도 서비스 간 리소스 격리를 보장합니다. 이를 통해 시스템의 안정성과 사용자 경험을 동시에 유지할 수 있습니다.


네트워크 풀(Network Pool) 예제: 벌크헤드 패턴

벌크헤드 패턴에서 네트워크 연결 풀(Network Pool)을 사용하면, 서비스 간 네트워크 리소스를 독립적으로 관리하여 특정 서비스의 과부하가 다른 서비스에 영향을 미치지 않도록 합니다. 네트워크 연결 풀은 주로 Apache HttpClient, OkHttp, Netty와 같은 라이브러리를 사용하여 설정합니다.

1. Apache HttpClient를 활용한 네트워크 풀 설정

1) Maven 의존성 추가

pom.xml에 Apache HttpClient 의존성을 추가합니다:

<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
    <version>5.2</version>
</dependency>

 

2) 네트워크 풀 설정

서비스별로 독립적인 HttpClient 풀을 설정합니다.

HttpClientConfig.java:

import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.core5.util.TimeValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class HttpClientConfig {

    @Bean(name = "orderHttpClient")
    public CloseableHttpClient orderHttpClient() {
        return createHttpClient(50, 100, "Order Service Pool");
    }

    @Bean(name = "paymentHttpClient")
    public CloseableHttpClient paymentHttpClient() {
        return createHttpClient(30, 50, "Payment Service Pool");
    }

    @Bean(name = "deliveryHttpClient")
    public CloseableHttpClient deliveryHttpClient() {
        return createHttpClient(10, 20, "Delivery Service Pool");
    }

    private CloseableHttpClient createHttpClient(int maxTotal, int maxPerRoute, String poolName) {
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setMaxTotal(maxTotal);                // 전체 연결 수
        connectionManager.setDefaultMaxPerRoute(maxPerRoute);  // 호스트당 최대 연결 수
        connectionManager.closeIdle(TimeValue.ofSeconds(30));  // 유휴 연결 해제 시간

        return HttpClients.custom()
                .setConnectionManager(connectionManager)
                .setUserAgent(poolName) // 네트워크 풀 이름
                .build();
    }
}

 

3) 서비스에서 HttpClient 사용

서비스별로 독립적인 HttpClient를 주입받아 사용합니다.

OrderService.java:

import org.apache.hc.client5.http.fluent.Request;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final CloseableHttpClient httpClient;

    public OrderService(@Qualifier("orderHttpClient") CloseableHttpClient httpClient) {
        this.httpClient = httpClient;
    }

    public String placeOrder() {
        try {
            // 네트워크 요청 (샘플 API 호출)
            return Request.get("https://jsonplaceholder.typicode.com/posts/1")
                    .via(httpClient)
                    .execute()
                    .returnContent()
                    .asString();
        } catch (Exception e) {
            return "Order Service: Failed to connect to external API";
        }
    }
}

PaymentService.java:

import org.apache.hc.client5.http.fluent.Request;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final CloseableHttpClient httpClient;

    public PaymentService(@Qualifier("paymentHttpClient") CloseableHttpClient httpClient) {
        this.httpClient = httpClient;
    }

    public String processPayment() {
        try {
            // 네트워크 요청 (샘플 API 호출)
            return Request.get("https://jsonplaceholder.typicode.com/posts/2")
                    .via(httpClient)
                    .execute()
                    .returnContent()
                    .asString();
        } catch (Exception e) {
            return "Payment Service: Failed to connect to external API";
        }
    }
}

DeliveryService.java:

import org.apache.hc.client5.http.fluent.Request;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

@Service
public class DeliveryService {

    private final CloseableHttpClient httpClient;

    public DeliveryService(@Qualifier("deliveryHttpClient") CloseableHttpClient httpClient) {
        this.httpClient = httpClient;
    }

    public String trackDelivery() {
        try {
            // 네트워크 요청 (샘플 API 호출)
            return Request.get("https://jsonplaceholder.typicode.com/posts/3")
                    .via(httpClient)
                    .execute()
                    .returnContent()
                    .asString();
        } catch (Exception e) {
            return "Delivery Service: Failed to connect to external API";
        }
    }
}

 

4) Controller에서 호출

BulkheadController.java:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class BulkheadController {

    private final OrderService orderService;
    private final PaymentService paymentService;
    private final DeliveryService deliveryService;

    public BulkheadController(OrderService orderService, PaymentService paymentService, DeliveryService deliveryService) {
        this.orderService = orderService;
        this.paymentService = paymentService;
        this.deliveryService = deliveryService;
    }

    @GetMapping("/order")
    public String placeOrder() {
        return orderService.placeOrder();
    }

    @GetMapping("/payment")
    public String processPayment() {
        return paymentService.processPayment();
    }

    @GetMapping("/delivery")
    public String trackDelivery() {
        return deliveryService.trackDelivery();
    }
}

 

4. 동작 확인

  1. 정상 호출:
    • 각 서비스가 orderHttpClient, paymentHttpClient, deliveryHttpClient라는 독립적인 네트워크 풀을 사용.
    • 설정된 maxTotalmaxPerRoute에 따라 동시 연결 제한.
  2. 과부하 시나리오:
    • 주문 서비스에서 과도한 연결 요청이 발생하더라도, 결제와 배송 추적 서비스는 각각 독립적인 네트워크 풀을 사용하므로 정상적으로 동작.
  3. Fallback 동작:
    • 네트워크 연결 풀 한도를 초과하면 예외 발생 -> 서비스에서 Fallback으로 대체 응답 제공.

5. 추가: 네트워크 풀 모니터링

네트워크 풀 상태를 모니터링하려면 Apache HttpClient의 연결 상태를 주기적으로 확인할 수 있습니다.

HttpClientConnectionManager의 상태 확인:

import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class ConnectionPoolMonitor {

    private final PoolingHttpClientConnectionManager connectionManager;

    public ConnectionPoolMonitor(PoolingHttpClientConnectionManager connectionManager) {
        this.connectionManager = connectionManager;
    }

    @Scheduled(fixedRate = 10000) // 10초마다 실행
    public void logConnectionStats() {
        System.out.println("Connection Stats: " + connectionManager.getTotalStats());
    }
}

 

6. 결론

이 코드는 네트워크 연결 풀을 벌크헤드 패턴으로 구현한 실질적인 예입니다. 각 서비스가 독립적으로 네트워크 연결 풀을 관리하므로 과부하나 장애가 발생해도 시스템 전체의 안정성을 유지할 수 있습니다.

'Spring Microservice' 카테고리의 다른 글

@CircuitBreaker의 name 속성  (0) 2024.12.12
ring buffer bit  (0) 2024.12.12
fallback 패턴 활용 예  (0) 2024.12.12
fallback 패턴  (0) 2024.12.12
Circuit Breaker 패턴  (0) 2024.12.12