벌크헤드 패턴: 내결함성 마이크로서비스 설계

벌크헤드 패턴: 내결함성 마이크로서비스 설계

마이크로서비스 아키텍처에서는 단일 애플리케이션이 수십 또는 수백 개의 독립적인 협업 서비스로 구분됩니다. 이 설계는 모듈성과 확장성을 향상시키지만 다음과 같은 주요 위험도 발생합니다. 한 서비스에 장애가 발생하면 전체 시스템이 중단될 수 있습니다.

다운스트림 서비스가 느려지거나 응답하지 않으면 업스트림 서비스로 들어오는 요청이 쌓이기 시작합니다. 모두 동일한 메모리, CPU 또는 스레드 풀을 공유하는 경우 종속성이 느려지면 사용 가능한 모든 리소스가 빠르게 소진되어 전체 애플리케이션이 중단될 수 있습니다.

이러한 계단식 실패를 도미노 효과라고 합니다. 이를 방지하기 위해 시스템 설계자는 벌크헤드 패턴을 사용합니다.

이 가이드에서는 Bulkhead 패턴이 무엇인지, 어떻게 작동하는지, Java(Resilience4j) 및 Go에서 간단한 비유, 아키텍처 개념, 코드 예제를 사용하여 구현하는 방법을 살펴보겠습니다.


실제 비유: 방수 선박 격벽

이 패턴의 이름은 조선 산업에서 유래되었습니다.

격벽은 선박 선체 내부에 건설된 방수 벽입니다. 선박의 선체 내부에 하나의 거대한 열린 공간을 갖는 대신 내부는 여러 개의 독립적이고 밀봉된 구획으로 분할됩니다.

공유 스레드 풀과 격리된 스레드 풀을 보여주는 벌크헤드 패턴 아키텍처 다이어그램

선박이 장애물과 충돌하여 선체가 파손되면 손상된 구획으로 물이 넘치게 됩니다. 그러나 방수 격벽으로 인해 물은 그 단일 구획에만 담겨 있습니다. 배의 나머지 부분은 건조하고 부력이 있는 상태로 유지되어 해상에 머물면서 안전에 도달할 수 있습니다.

격벽이 없으면 물이 선체 전체에 자유롭게 흐르고 결국 배는 침몰하게 됩니다.

소프트웨어 공학에서는:

  • The Ship은 전체 애플리케이션 또는 서비스입니다.
  • 구획은 격리된 리소스 풀(스레드, 연결, CPU)입니다.
  • Hull Breach는 다운스트림 마이크로서비스의 오류 또는 속도 저하를 의미합니다.
  • 홍수는 자원 고갈입니다.

문제: 공유 리소스 풀 및 스레드 고갈

격벽이 필요한 이유를 이해하기 위해 리소스가 전역적으로 공유될 때 어떤 일이 발생하는지 살펴보겠습니다.

API 게이트웨이나 사용자 요청을 처리하는 웹 서버를 상상해 보세요. 모든 수신 호출을 처리하기 위해 100개의 스레드로 구성된 단일 전역 스레드 풀이 있습니다. 서버는 세 가지 다운스트림 서비스와 상호 작용합니다.

  1. 카탈로그 서비스 (빠른, 제품 목록 읽기)
  2. 결제 서비스 (빠른 결제 진행)
  3. 추천 서비스 (느리게, 맞춤 아이템을 계산해줌)

일반적으로 모든 것이 잘 작동합니다. 그러나 추천 서비스에 데이터베이스 교착 상태가 발생하여 응답하는 데 200밀리초가 아닌 30초가 걸리기 시작한다고 가정해 보겠습니다.

일어나는 일은 다음과 같습니다.

  1. 사용자는 계속해서 홈페이지를 방문하여 추천 서비스에 대한 요청을 실행합니다.
  2. 서버는 전역 풀의 스레드를 각 요청에 할당합니다.
  3. 권장 사항 서비스가 느리기 때문에 이러한 스레드는 응답을 기다리고 있습니다.
  4. 몇 초 내에 풀의 스레드 100개 모두 권장 서비스를 기다리고 있습니다.
  5. 새 사용자가 카탈로그를 체크아웃하거나 보려고 하면 서버에 해당 요청을 처리할 스레드가 남아 있지 않습니다.

카탈로그 및 결제 서비스가 완전히 정상이더라도 느린 추천 서비스로 인해 공유 스레드 풀이 소진되었기 때문에 이제 연결할 수 없습니다. 전체 시스템이 오프라인 상태가 되었습니다.


해결책: 벌크헤드 패턴

벌크헤드 패턴은 한 영역의 장애가 다른 영역에 영향을 미치지 않도록 리소스 풀을 분할하여 이 문제를 해결합니다.

단일 글로벌 풀 대신 각 서비스 또는 다운스트림 종속성에 대해 별도의 제한된 풀을 할당합니다.

권장 사항 서비스에 특별히 10개의 스레드를 할당하면 최대 10개의 스레드가 이를 대기하면서 차단될 수 있습니다. 권장 사항 서비스 속도가 느려지면 해당 10개 스레드가 소진되고 후속 권장 사항 요청이 즉시 거부됩니다(빠른 실패).

그러나 나머지 90개 스레드는 여전히 카탈로그 및 결제 서비스용으로 예약되어 있습니다. 추천 위젯을 일시적으로 사용할 수 없는 경우에도 사용자는 계속 제품을 탐색하고 구매할 수 있습니다.


격벽 격리 유형

소프트웨어 시스템에서 격벽을 구현하는 두 가지 기본 방법이 있습니다.

1. 스레드 풀 격리

이 모델에서는 각 다운스트림 종속성에 전용 스레드 풀과 실행 큐가 할당됩니다.

작업자 스레드에 대해 대기 중인 수신 요청을 보여주는 스레드 풀 격리 다이어그램
  • 작동 방식: 기본 애플리케이션 스레드는 작업을 특정 스레드 풀에 전달합니다. 풀이 가득 차면 요청이 대기되거나 거부됩니다.
  • 장점: 완전한 격리를 제공합니다. 서비스가 느려지면 해당 스레드 풀만 영향을 받습니다. 스레드는 운영 체제/JVM 수준에서 격리됩니다.
  • 단점: 스레드 예약, 컨텍스트 전환 및 대기열 관리로 인해 추가 CPU 오버헤드가 발생합니다.

2. 세마포어 분리

새 스레드 풀을 생성하는 대신 세마포 격리는 카운터(세마포)를 사용하여 특정 서비스에 허용되는 동시 호출 수를 제한합니다.

허가를 받은 후 작업을 실행하는 요청 스레드를 보여주는 세마포어 격리 다이어그램
  • 작동 방식: 요청이 시작되면 세마포어에서 허가를 얻으려고 시도합니다. 허가가 가능하면 호출 스레드에서 요청을 실행하고 완료되면 허가를 해제합니다. 사용 가능한 허가가 없으면 요청이 즉시 거부됩니다.
  • 장점: 스레드 컨텍스트 전환이 포함되지 않으므로 오버헤드가 거의 없고 매우 가볍습니다.
  • 단점: 실 분리가 되지 않습니다. 적절한 시간 제한 없이 네트워크 소켓에서 호출이 차단되는 경우 호출 스레드가 계속 차단될 수 있습니다.

구현 예

널리 사용되는 두 가지 백엔드 언어로 격벽을 구현하는 방법을 살펴보겠습니다.

1. 자바(Resilience4j & Spring Boot)

Resilience4j는 Java용으로 설계된 가볍고 사용하기 쉬운 내결함성 라이브러리입니다. 다음은 Spring Boot 애플리케이션에서 다운스트림 결제 서비스에 대한 격벽을 구성하는 방법입니다.

구성(application.yml)

resilience4j.bulkhead:
  instances:
    paymentService:
      maxConcurrentCalls: 10
      maxWaitDuration: 10ms

resilience4j.threadpoolbulkhead:
  instances:
    paymentService:
      maxThreadPoolSize: 10
      coreThreadPoolSize: 5
      queueCapacity: 20

코드 구현

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

@Service
public class OrderService {

    private final RestTemplate restTemplate;

    public OrderService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply semaphore bulkhead
    @Bulkhead(name = "paymentService", fallbackMethod = "paymentFallback")
    public String processPayment(OrderDetails details) {
        return restTemplate.postForObject("http://payment-service/charge", details, String.class);
    }

    // Fallback method executed when the bulkhead is full
    public String paymentFallback(OrderDetails details, Throwable throwable) {
        return "Payment service is currently busy. Please try again later.";
    }
}

2. 고(고랭)

Go에서는 언어가 고루틴 및 버퍼 채널과 같은 기본 동시성 기본 요소를 제공하기 때문에 무거운 프레임워크가 반드시 필요하지 않습니다. 버퍼링된 채널을 사용하여 깨끗한 세마포어 격벽을 구현할 수 있습니다.

package main

import (
	"errors"
	"fmt"
	"net/http"
	"time"
)

// Bulkhead represents a concurrency limiter
type Bulkhead struct {
	semaphore chan struct{}
}

// NewBulkhead initializes a bulkhead with a max concurrency limit
func NewBulkhead(maxConcurrency int) *Bulkhead {
	return &Bulkhead{
		semaphore: make(chan struct{}, maxConcurrency),
	}
}

// Execute runs the task if resource permit is available, otherwise returns error
func (b *Bulkhead) Execute(task func() error) error {
	select {
	case b.semaphore <- struct{}{}:
		// Acquired permit
		defer func() { <-b.semaphore }() // Release permit
		return task()
	default:
		// Bulkhead is full, reject immediately
		return errors.New("bulkhead is full: request rejected")
	}
}

func main() {
	// Allow maximum of 3 concurrent calls
	paymentBulkhead := NewBulkhead(3)

	mockTask := func() error {
		fmt.Println("Processing payment...")
		time.Sleep(2 * time.Second) // Simulate network delay
		return nil
	}

	// Simulate 5 rapid requests
	for i := 1; i <= 5; i++ {
		go func(reqID int) {
			err := paymentBulkhead.Execute(mockTask)
			if err != nil {
				fmt.Printf("Request %d failed: %v\n", reqID, err)
			} else {
				fmt.Printf("Request %d completed successfully\n", reqID)
			}
		}(i)
	}

	// Keep main alive to watch output
	time.Sleep(3 * time.Second)
}

벌크헤드 패턴의 일반적인 사용 사례

격벽 패턴 구현이 중요한 몇 가지 일반적인 시나리오는 다음과 같습니다.

  • API 게이트웨이 라우팅: 다양한 백엔드 서비스에 대한 경로를 격리합니다. 권장 사항 서비스가 중단되더라도 게이트웨이의 주문 서비스 경로는 완전히 작동합니다.
  • 데이터베이스 연결 풀: 서비스 또는 테넌트별로 데이터베이스 연결 풀을 나눕니다. 한 테넌트의 과도한 분석 쿼리가 급증해도 사용 가능한 모든 연결 핸들이 고갈되지 않아 다른 테넌트에 대한 트랜잭션 쿼리가 저장됩니다.
  • 다중 테넌트 SaaS 애플리케이션: 프리미엄 테넌트와 무료 테넌트에 대해 컴퓨팅 리소스 또는 실행 대기열을 분리합니다. 무료 계층 리소스 급증으로 인해 CPU 또는 메모리에 대한 프리미엄 계층 요청이 중단되지 않습니다.
  • 타사 API 통합: 외부 결제 대행사, 배송업체 또는 알림 엔진을 위한 별도의 HTTP 클라이언트 풀을 지정합니다. 한 타사 서비스의 속도가 느려지면 다른 외부 상호 작용이 중단 없이 계속됩니다.

Kafka/메시지 브로커가 벌크헤드 패턴을 대체할 수 없는 이유

일반적인 질문은 다음과 같습니다. “Apache Kafka와 같은 메시지 브로커가 있는 경우 벌크헤드 패턴이 필요한 이유는 무엇입니까? 요청을 버퍼링하기 위해 대기열을 사용할 수는 없나요?”

메시지 브로커는 시스템을 분리하지만 벌크헤드 패턴을 대체할 수는 없습니다. 이유는 다음과 같습니다.

1. 동기식 통신과 비동기식 통신

Kafka는 비동기식, 이벤트 중심 아키텍처용으로 설계되었습니다. 생산자는 주제에 메시지를 푸시하고 소비자는 최종적으로 이를 처리합니다. 그러나 사용자 대상 애플리케이션에는 동기식(요청-응답) 통신이 필요한 경우가 많습니다(예: 제품 카탈로그 로드 또는 REST/gRPC API를 통한 신용카드 청구). 여기서 Kafka를 도입하려면 복잡한 요청-응답 패턴이 필요하고 대기 시간이 길어지며 오버헤드가 추가됩니다. 벌크헤드는 이러한 동기 실행 스레드를 실시간으로 보호하도록 특별히 설계되었습니다.

2. Kafka 소비자 내부의 스레드 고갈

시스템이 완전히 이벤트 중심이고 Kafka를 사용하더라도 여전히 격벽이 필요합니다! 단일 소비자 마이크로서비스가 여러 Kafka 주제(예: user-registrationsvideo-transcoding)를 수신한다고 가정합니다. 소비자가 느린 video-transcoding 작업의 대량 배치를 처리하기 위해 모든 내부 작업자 스레드를 할당하면 스레드 부족 현상이 발생합니다. 해당 파티션이 정상이더라도 소비자는 가벼운 user-registrations 메시지를 처리할 수 없습니다. 작업을 격리하려면 소비자 서비스 내부에 내부 격벽(별도의 스레드 풀)이 여전히 필요합니다.

3. 클라이언트 측 오버헤드 및 빠른 장애 요구 사항

다운스트림 서비스가 다운되면 벌크헤드를 통해 호출 서비스가 빠른 실패를 방지하고 즉시 대체 응답을 반환할 수 있습니다. 대신 Kafka의 모든 항목을 대기열에 추가하면 대기열이 무한정 커져 부실 요청, 높은 메모리 소비 및 시스템 복구 시 시간 초과 지연이 발생할 수 있습니다.

간단히 말해서, Kafka는 네트워크를 통해 시스템 간 통신을 분리하는 반면, 벌크헤드는 실행 중인 애플리케이션 인스턴스 내에서 리소스 실행을 격리합니다. 그것들은 상호 배타적이지 않고 보완적입니다.


격벽 사용 시 모범 사례

  • 항상 시간 제한 설정: 벌크헤드는 동시성을 제한하지만 느린 소켓 읽기 문제를 해결하지는 않습니다. 스레드를 최대한 빨리 릴리스하려면 엄격한 네트워크 시간 제한과 격벽을 결합하세요.
  • 회로 차단기와 결합: 회로 차단기와 함께 격벽을 사용합니다. 격벽이 지속적으로 요청을 거부하기 시작하면 회로 차단기가 작동하여 트래픽을 완전히 중지하고 다운스트림 서비스에 복구할 공간을 제공해야 합니다.
  • 풀 포화도 모니터링: 격벽 대기열 길이 및 활성 스레드 수에 대한 경고를 구현합니다. 격벽이 지속적으로 가득 차면 인프라를 확장하거나 다운스트림 서비스를 최적화해야 할 수 있습니다.
  • 개별적으로 크기 조정: 모든 경우에 적용되는 일률적인 제한을 사용하지 마세요. 각 종속성의 대기 시간과 요청 속도를 측정하여 올바른 벌크헤드 제한을 결정하세요.

결론

벌크헤드 패턴은 탄력적인 클라우드 규모 시스템을 구축하기 위한 필수 디자인 패턴입니다. 리소스를 분할하면 오류를 격리하고, 연쇄 효과를 방지하고, 현지화된 버그가 글로벌 중단으로 바뀌지 않도록 할 수 있습니다.