🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

마이크로서비스 복원력 패턴 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 22. · 2 Views

마이크로서비스 복원력 패턴 완벽 가이드

분산 시스템에서 장애가 발생해도 서비스가 멈추지 않도록 하는 복원력 패턴을 배웁니다. 서킷 브레이커, 재시도, 벌크헤드, 타임아웃 등 실무에서 필수적인 패턴을 실제 코드와 함께 이해합니다.


목차

  1. 복원력이란_무엇인가
  2. 장애_유형과_대응
  3. 서킷_브레이커_패턴
  4. 재시도_패턴
  5. 벌크헤드_패턴
  6. 타임아웃_패턴

1. 복원력이란 무엇인가

어느 날 김개발 씨는 회사에서 운영 중인 쇼핑몰 서비스가 갑자기 전체 다운되는 사고를 경험했습니다. 알고 보니 결제 서비스 하나가 응답하지 않으면서 다른 모든 서비스까지 연쇄적으로 멈춰버린 것이었습니다.

박시니어 씨가 말했습니다. "복원력 패턴을 적용하지 않아서 생긴 문제예요."

복원력(Resilience)이란 시스템 일부에 장애가 발생해도 전체 서비스가 계속 동작할 수 있는 능력을 의미합니다. 마치 사람의 면역 체계가 바이러스 침입에도 몸을 지키듯이, 시스템도 일부 장애를 견디고 회복할 수 있어야 합니다.

마이크로서비스 아키텍처에서는 더욱 중요한 개념입니다.

다음 코드를 살펴봅시다.

// 복원력이 없는 코드 예시
public class OrderService {
    private PaymentService paymentService;

    public Order createOrder(OrderRequest request) {
        // 결제 서비스 호출 - 무한 대기 가능
        PaymentResult payment = paymentService.processPayment(request);

        // 결제 서비스가 응답하지 않으면 여기서 멈춤
        if (payment.isSuccess()) {
            return saveOrder(request);
        }
        throw new PaymentFailedException();
    }
}

김개발 씨는 이제 3년 차 개발자가 되었습니다. 그동안 여러 프로젝트를 진행하면서 단일 애플리케이션을 개발해왔습니다.

그런데 이번에는 회사에서 마이크로서비스 아키텍처로 시스템을 전환한다고 합니다. "마이크로서비스가 뭔가요?" 김개발 씨가 박시니어 씨에게 물었습니다.

"하나의 큰 애플리케이션을 여러 개의 작은 서비스로 나누는 거예요. 각 서비스는 독립적으로 배포하고 확장할 수 있죠." 그렇게 마이크로서비스 프로젝트가 시작되었습니다.

주문 서비스, 결제 서비스, 배송 서비스, 재고 서비스 등 여러 서비스로 나뉘었습니다. 처음에는 모든 것이 순조로워 보였습니다.

그런데 문제가 발생했습니다. 어느 날 새벽, 결제 서비스를 담당하는 외부 API가 응답하지 않기 시작했습니다.

네트워크 문제였습니다. 그런데 이상한 일이 벌어졌습니다.

결제 서비스뿐만 아니라 주문 서비스도 멈췄고, 사용자는 상품 조회조차 할 수 없게 되었습니다. 왜 이런 일이 벌어진 걸까요?

주문 서비스는 결제 서비스를 호출할 때 응답을 무한정 기다렸습니다. 결제 서비스가 응답하지 않으니 주문 서비스의 모든 스레드가 대기 상태로 묶여버렸습니다.

마치 도미노처럼 한 서비스의 장애가 다른 서비스로 전파된 것입니다. 박시니어 씨가 설명했습니다.

"이것을 장애 전파(Failure Propagation)라고 해요. 하나의 서비스 장애가 연쇄적으로 전체 시스템을 마비시키는 현상이죠." 그렇다면 어떻게 해야 할까요?

여기서 등장하는 개념이 바로 복원력(Resilience)입니다. 복원력이 있는 시스템은 일부 구성 요소가 실패해도 전체 시스템이 계속 동작합니다.

마치 자동차의 여러 시스템 중 에어컨이 고장나도 운전은 계속할 수 있는 것처럼 말입니다. 복원력을 구현하는 방법에는 여러 가지가 있습니다.

첫 번째는 격리(Isolation)입니다. 각 서비스의 장애를 다른 서비스로 전파되지 않도록 격리하는 것입니다.

두 번째는 대체(Fallback)입니다. 주요 기능이 실패하면 대체 방안을 제공합니다.

세 번째는 자동 복구(Self-Healing)입니다. 장애를 감지하고 자동으로 복구를 시도합니다.

실제 현업에서는 어떻게 적용할까요? 넷플릭스는 복원력 패턴의 선구자로 유명합니다.

그들은 카오스 엔지니어링이라는 개념까지 도입해서 의도적으로 장애를 발생시켜 시스템의 복원력을 테스트합니다. 아마존, 구글 등 대형 기업들도 모두 복원력 패턴을 적극 활용합니다.

복원력을 확보하지 않으면 어떤 문제가 생길까요? 한 연구에 따르면 시스템 다운타임 1시간당 평균 수백만 원에서 수억 원의 손실이 발생한다고 합니다.

더 큰 문제는 고객 신뢰의 상실입니다. 한번 크게 장애를 겪은 서비스는 사용자들이 떠나버립니다.

김개발 씨는 이제 복원력의 중요성을 깨달았습니다. 다음 장부터는 구체적인 복원력 패턴들을 하나씩 살펴보겠습니다.

서킷 브레이커, 재시도, 벌크헤드, 타임아웃 등 실무에서 꼭 필요한 패턴들입니다. 복원력은 선택이 아니라 필수입니다.

특히 마이크로서비스처럼 여러 서비스가 네트워크로 연결된 분산 시스템에서는 더욱 그렇습니다. 하나의 서비스는 언제든 실패할 수 있다는 전제하에 시스템을 설계해야 합니다.

실전 팁

💡 - 모든 외부 호출은 실패할 수 있다고 가정하고 설계하세요

  • 복원력 패턴은 조합해서 사용할 때 더욱 효과적입니다
  • 모니터링과 알림 시스템을 함께 구축해야 장애를 빠르게 감지할 수 있습니다

2. 장애 유형과 대응

복원력의 중요성을 깨달은 김개발 씨는 박시니어 씨에게 물었습니다. "그럼 어떤 장애들을 대비해야 하나요?" 박시니어 씨가 화이트보드에 그림을 그리며 설명하기 시작했습니다.

"분산 시스템에서 발생하는 장애 유형은 크게 네 가지로 나눌 수 있어요."

분산 시스템에서는 일시적 장애, 타임아웃, 서비스 과부하, 완전 장애 등 다양한 유형의 장애가 발생합니다. 각 장애 유형마다 적절한 대응 전략이 다릅니다.

마치 병원에서 감기와 골절을 다르게 치료하듯이, 장애 유형에 맞는 패턴을 선택해야 합니다.

다음 코드를 살펴봅시다.

// 장애 유형 분류 예시
public enum FailureType {
    // 일시적 장애 - 재시도로 해결 가능
    TRANSIENT,

    // 타임아웃 - 응답이 너무 느림
    TIMEOUT,

    // 과부하 - 서비스가 과도한 요청을 받음
    OVERLOAD,

    // 완전 장애 - 서비스가 완전히 다운됨
    COMPLETE_FAILURE
}

// 장애 감지 및 분류
public class FailureDetector {
    public FailureType detectFailureType(Exception e) {
        if (e instanceof TimeoutException) {
            return FailureType.TIMEOUT;
        } else if (e instanceof ServiceUnavailableException) {
            return FailureType.OVERLOAD;
        } else if (e instanceof TransientException) {
            return FailureType.TRANSIENT;
        }
        return FailureType.COMPLETE_FAILURE;
    }
}

김개발 씨는 지난 한 달간 여러 번의 장애를 겪었습니다. 매번 상황이 달랐고, 대응 방법도 달라야 했습니다.

이제는 장애의 패턴이 보이기 시작했습니다. 첫 번째로 겪은 장애는 새벽 3시에 발생했습니다.

결제 서비스가 갑자기 오류를 반환하기 시작했습니다. 그런데 신기하게도 1-2초 후에 재시도하면 정상적으로 동작했습니다.

이것이 바로 일시적 장애(Transient Failure)입니다. 일시적 장애는 네트워크 순간 끊김, 데이터베이스 잠금 대기, 임시적인 리소스 부족 등으로 발생합니다.

이런 장애의 특징은 짧은 시간 내에 자동으로 해결된다는 것입니다. 마치 길을 가다가 작은 돌부리에 걸려 넘어질 뻔했지만 균형을 되찾는 것과 비슷합니다.

두 번째 장애는 더 까다로웠습니다. 서비스가 완전히 죽지는 않았지만, 응답 시간이 점점 느려졌습니다.

정상적으로는 100ms에 응답하던 API가 10초, 20초, 심지어 응답하지 않는 경우도 생겼습니다. 이것이 타임아웃(Timeout) 상황입니다.

타임아웃은 서비스가 살아있지만 매우 느리게 응답할 때 발생합니다. 원인은 다양합니다.

데이터베이스 쿼리가 복잡해서 오래 걸릴 수도 있고, 외부 API가 느릴 수도 있습니다. 무한정 기다리면 안 되기에 적절한 타임아웃 설정이 필요합니다.

세 번째는 출시 이벤트 날 겪은 장애였습니다. 신제품 출시로 평소보다 10배 많은 트래픽이 몰렸습니다.

서비스는 429 Too Many Requests 오류를 반환하기 시작했습니다. 이것이 서비스 과부하(Service Overload)입니다.

과부하는 서비스가 처리할 수 있는 용량을 초과하는 요청이 들어올 때 발생합니다. 마치 좁은 도로에 차가 너무 많이 몰려 교통 체증이 발생하는 것과 같습니다.

이럴 때는 요청을 제한하거나 대기열에 넣는 등의 전략이 필요합니다. 네 번째는 가장 심각한 장애였습니다.

배송 서비스를 담당하는 서버가 하드웨어 문제로 완전히 다운되었습니다. 재시도를 해도 소용없고, 기다려도 응답이 없습니다.

이것이 완전 장애(Complete Failure)입니다. 완전 장애는 서비스가 아예 동작하지 않는 상태입니다.

서버 크래시, 네트워크 단절, 데이터센터 장애 등이 원인입니다. 이럴 때는 재시도가 의미 없고, 대체 방안을 제공하거나 서비스를 우아하게 저하시켜야 합니다.

그렇다면 각 장애에 어떻게 대응해야 할까요? 일시적 장애에는 재시도 패턴이 효과적입니다.

몇 번 재시도하면 성공할 가능성이 높기 때문입니다. 타임아웃에는 타임아웃 패턴으로 적절한 시간 제한을 두어야 합니다.

과부하에는 벌크헤드 패턴이나 레이트 리미팅으로 자원을 보호합니다. 완전 장애에는 서킷 브레이커 패턴으로 빠르게 실패하고 대체 방안을 제공합니다.

박시니어 씨가 강조했습니다. "중요한 것은 장애 유형을 빠르게 파악하고 적절한 패턴을 적용하는 거예요.

모든 장애를 같은 방식으로 처리하면 안 됩니다." 실제 프로덕션 환경에서는 여러 장애가 동시에 발생하기도 합니다. 예를 들어 일시적 장애가 반복되면서 타임아웃으로 이어지고, 이것이 누적되어 과부하가 발생할 수 있습니다.

따라서 여러 패턴을 조합해서 사용하는 것이 중요합니다. 김개발 씨는 이제 장애가 발생하면 먼저 유형을 분류하게 되었습니다.

"이건 일시적 장애니까 재시도를 해봐야겠다", "이건 완전 장애니까 서킷 브레이커를 열어야겠다" 같은 식으로 말입니다.

실전 팁

💡 - 각 장애 유형에 대한 메트릭을 수집하고 모니터링하세요

  • 장애 유형별 대응 전략을 문서화해두면 팀원들과 공유하기 좋습니다
  • 프로덕션 배포 전에 다양한 장애 시나리오를 테스트해보세요

3. 서킷 브레이커 패턴

김개발 씨는 또다시 장애 알림을 받았습니다. 외부 추천 서비스가 다운되면서 주문 서비스까지 느려지고 있었습니다.

박시니어 씨가 급히 달려와 말했습니다. "서킷 브레이커를 적용할 때가 됐어요.

이런 상황을 막기 위한 패턴이죠."

서킷 브레이커 패턴은 전기 차단기처럼 동작하는 장애 대응 패턴입니다. 연속된 실패가 감지되면 자동으로 회로를 차단하여 더 이상 실패하는 서비스를 호출하지 않습니다.

일정 시간 후 자동으로 복구를 시도하며, 시스템 전체가 연쇄적으로 무너지는 것을 방지합니다.

다음 코드를 살펴봅시다.

public class CircuitBreaker {
    private enum State { CLOSED, OPEN, HALF_OPEN }

    private State state = State.CLOSED;
    private int failureCount = 0;
    private final int threshold = 5;  // 5번 실패시 열림
    private long lastFailureTime;

    public <T> T call(Supplier<T> supplier) throws Exception {
        if (state == State.OPEN) {
            // 일정 시간 경과 확인
            if (System.currentTimeMillis() - lastFailureTime > 60000) {
                state = State.HALF_OPEN;  // 60초 후 반열림
            } else {
                throw new CircuitOpenException("서킷이 열려있습니다");
            }
        }

        try {
            T result = supplier.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure();
            throw e;
        }
    }

    private void onSuccess() {
        failureCount = 0;
        state = State.CLOSED;
    }

    private void onFailure() {
        failureCount++;
        lastFailureTime = System.currentTimeMillis();
        if (failureCount >= threshold) {
            state = State.OPEN;  // 임계값 도달시 열림
        }
    }
}

김개발 씨는 집에 있는 전기 차단기를 떠올렸습니다. 과부하가 걸리면 자동으로 전기가 차단되어 화재를 방지하는 장치 말입니다.

"서킷 브레이커 패턴도 비슷한 원리인가요?" 박시니어 씨가 고개를 끄덕였습니다. "정확해요.

서비스 호출이 계속 실패하면 자동으로 차단해서 불필요한 시도를 막는 거예요." 그렇다면 왜 이런 패턴이 필요할까요? 지난주 김개발 씨는 답답한 경험을 했습니다.

외부 추천 API가 다운되었는데, 주문 서비스는 계속해서 그 API를 호출하려고 시도했습니다. 매번 10초씩 타임아웃을 기다렸고, 그 사이에 다른 정상적인 요청들도 처리되지 못했습니다.

이것이 바로 캐스케이딩 실패(Cascading Failure)입니다. 한 서비스의 장애가 연쇄적으로 다른 서비스들을 마비시키는 현상입니다.

마치 도미노가 넘어지듯이 시스템 전체가 무너집니다. 이미 죽은 서비스를 계속 호출하는 것은 자원 낭비이자 상황을 더 악화시킵니다.

서킷 브레이커는 이런 상황을 방지합니다. 서킷 브레이커는 세 가지 상태를 가집니다.

Closed(닫힘), Open(열림), Half-Open(반열림)입니다. 평소에는 Closed 상태로 모든 요청을 정상적으로 전달합니다.

실패가 연속으로 발생하면 어떻게 될까요? 서킷 브레이커는 실패 횟수를 카운트합니다.

위 코드에서는 5번으로 설정했습니다. 5번 연속 실패하면 서킷이 Open 상태로 바뀝니다.

이제 더 이상 실제 서비스를 호출하지 않고 즉시 예외를 발생시킵니다. "그럼 영원히 호출하지 않나요?" 김개발 씨가 물었습니다.

아닙니다. 일정 시간이 지나면 서킷 브레이커는 Half-Open 상태로 전환됩니다.

위 코드에서는 60초로 설정했습니다. Half-Open 상태에서는 제한적으로 요청을 보내서 서비스가 복구되었는지 확인합니다.

만약 이 요청이 성공하면 어떻게 될까요? 서킷이 다시 Closed 상태로 돌아갑니다.

서비스가 정상화되었다고 판단하고 모든 요청을 다시 전달합니다. 반대로 여전히 실패한다면 다시 Open 상태로 돌아가서 60초를 더 기다립니다.

실제 프로덕션에서는 어떻게 활용할까요? 넷플릭스는 Hystrix라는 서킷 브레이커 라이브러리를 개발해서 공개했습니다.

현재는 유지보수 모드이지만, Resilience4j라는 더 현대적인 라이브러리가 많이 사용됩니다. 스프링 클라우드에도 서킷 브레이커 기능이 포함되어 있습니다.

김개발 씨의 팀은 추천 서비스 호출에 서킷 브레이커를 적용했습니다. 이제 추천 서비스가 다운되어도 주문 서비스는 즉시 기본 추천 목록을 보여주며 정상 동작합니다.

사용자는 추천 기능이 잠시 제한되지만, 주문은 계속할 수 있습니다. 주의할 점도 있습니다.

임계값을 너무 낮게 설정하면 일시적인 오류에도 서킷이 열려버립니다. 반대로 너무 높게 설정하면 장애 대응이 늦어집니다.

적절한 값은 서비스 특성과 요구사항에 따라 달라지므로, 모니터링하면서 조정해야 합니다. 또한 서킷이 Open 상태일 때 사용자에게 적절한 대체 응답을 제공해야 합니다.

단순히 오류만 반환하면 사용자 경험이 나빠집니다. 캐시된 데이터를 보여주거나, 기본값을 제공하거나, 친절한 안내 메시지를 보여주는 것이 좋습니다.

박시니어 씨가 말했습니다. "서킷 브레이커는 빠른 실패(Fail Fast)를 가능하게 해요.

이미 죽은 서비스를 계속 두드리는 대신, 빠르게 포기하고 대안을 제시하는 거죠." 김개발 씨는 이제 모든 외부 서비스 호출에 서킷 브레이커를 적용하기로 했습니다. 시스템이 한층 더 견고해진 것을 느낄 수 있었습니다.

실전 팁

💡 - 서킷 브레이커 상태 변화를 로그로 남기고 모니터링 대시보드에 표시하세요

  • 각 서비스마다 독립적인 서킷 브레이커를 사용하세요 (하나의 서킷을 공유하지 마세요)
  • Half-Open 상태에서는 소수의 요청만 보내서 테스트하는 것이 안전합니다

4. 재시도 패턴

어느 날 김개발 씨는 로그를 분석하다가 흥미로운 사실을 발견했습니다. 실패한 API 호출의 70%가 재시도했을 때는 성공했다는 것입니다.

박시니어 씨에게 보고하자 이렇게 말했습니다. "그래서 재시도 패턴이 필요한 거예요.

하지만 아무렇게나 재시도하면 오히려 독이 됩니다."

재시도 패턴은 일시적인 장애가 발생했을 때 자동으로 요청을 다시 시도하는 패턴입니다. 네트워크 순간 끊김이나 일시적인 서비스 오류처럼 짧은 시간 내에 해결되는 문제에 효과적입니다.

하지만 무분별한 재시도는 서버 과부하를 일으킬 수 있으므로, 재시도 횟수와 간격을 전략적으로 설계해야 합니다.

다음 코드를 살펴봅시다.

public class RetryPolicy {
    private final int maxAttempts = 3;
    private final long initialDelay = 1000;  // 1초

    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempt = 0;
        Exception lastException = null;

        while (attempt < maxAttempts) {
            try {
                return operation.get();
            } catch (TransientException e) {
                lastException = e;
                attempt++;

                if (attempt < maxAttempts) {
                    // 지수 백오프: 1초, 2초, 4초
                    long delay = initialDelay * (long) Math.pow(2, attempt - 1);
                    // 랜덤 지터 추가 (0~500ms)
                    delay += (long) (Math.random() * 500);

                    Thread.sleep(delay);
                    System.out.println("재시도 " + attempt + "회, " +
                                     delay + "ms 대기 후");
                }
            } catch (PermanentException e) {
                // 영구적 오류는 재시도하지 않음
                throw e;
            }
        }

        throw new MaxRetriesExceededException(lastException);
    }
}

김개발 씨는 처음에 재시도를 간단하게 생각했습니다. "실패하면 다시 한 번 시도하면 되겠지?" 하지만 프로덕션에 배포한 후 예상치 못한 문제가 발생했습니다.

사용자 한 명이 클릭을 연타했고, 각 요청이 실패하면서 재시도가 동시다발적으로 일어났습니다. 순식간에 서버로 수백 개의 요청이 쏟아졌고, 서버는 과부하로 다운되었습니다.

이것을 재시도 폭풍(Retry Storm)이라고 합니다. 박시니어 씨가 코드 리뷰를 하며 지적했습니다.

"재시도를 구현할 때는 세 가지를 반드시 고려해야 해요." 첫 번째는 재시도 가능한 오류를 구분하는 것입니다. 모든 오류가 재시도로 해결되는 것은 아닙니다.

네트워크 타임아웃이나 일시적인 503 오류는 재시도하면 성공할 수 있습니다. 하지만 잘못된 요청 데이터(400 Bad Request)나 권한 오류(403 Forbidden)는 아무리 재시도해도 실패합니다.

위 코드에서는 TransientException과 PermanentException을 구분했습니다. 일시적 오류만 재시도하고, 영구적 오류는 즉시 실패시킵니다.

이렇게 해야 불필요한 재시도를 줄일 수 있습니다. 두 번째는 재시도 간격을 전략적으로 설정하는 것입니다.

즉시 재시도하면 어떻게 될까요? 서버가 일시적으로 과부하 상태라면 더 많은 요청이 쏟아져서 상황을 악화시킵니다.

마치 넘어진 사람을 일으키려고 여러 명이 동시에 잡아당기면 오히려 더 넘어지는 것과 같습니다. 그래서 지수 백오프(Exponential Backoff) 전략을 사용합니다.

첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후처럼 점점 간격을 늘립니다. 이렇게 하면 서버가 회복할 시간을 벌어줍니다.

세 번째는 지터(Jitter)를 추가하는 것입니다. 만약 동시에 100개의 요청이 실패했다면 어떻게 될까요?

지수 백오프만 사용하면 모두 정확히 1초 후에 재시도합니다. 또다시 동시에 100개의 요청이 서버로 향합니다.

이것도 일종의 과부하입니다. 지터는 재시도 간격에 랜덤한 값을 추가합니다.

위 코드에서는 0부터 500ms 사이의 랜덤한 시간을 더했습니다. 이렇게 하면 재시도 시점이 분산되어 서버 부하가 고르게 분산됩니다.

실제 사례를 살펴볼까요? AWS의 SDK는 자동으로 재시도 기능을 제공합니다.

기본적으로 3번까지 재시도하며, 지수 백오프와 지터를 사용합니다. 구글 클라우드의 클라이언트 라이브러리도 비슷한 전략을 사용합니다.

김개발 씨의 팀은 결제 API 호출에 재시도 패턴을 적용했습니다. 결제 서비스는 특히 일시적 오류가 자주 발생하는 외부 서비스였습니다.

재시도 패턴 적용 후 성공률이 95%에서 99.5%로 향상되었습니다. 하지만 주의할 점이 있습니다.

멱등성(Idempotency)을 보장해야 합니다. 같은 요청을 여러 번 보내도 결과가 동일해야 한다는 의미입니다.

예를 들어 결제 요청을 재시도했는데 첫 번째 요청이 실제로는 성공했다면, 중복 결제가 발생할 수 있습니다. 따라서 각 요청에 고유한 ID를 부여하거나, 서버 측에서 중복 요청을 감지하는 로직이 필요합니다.

많은 결제 시스템들이 멱등성 키(Idempotency Key)를 지원하는 이유입니다. 또한 재시도 횟수를 무제한으로 설정하면 안 됩니다.

위 코드에서는 최대 3번으로 제한했습니다. 계속 실패하는 요청을 영원히 재시도하는 것은 자원 낭비입니다.

적절한 시점에 포기하고 사용자에게 오류를 알려야 합니다. 박시니어 씨가 정리했습니다.

"재시도는 양날의 검이에요. 잘 사용하면 시스템 안정성을 크게 높이지만, 잘못 사용하면 오히려 장애를 키웁니다." 김개발 씨는 이제 재시도 패턴을 신중하게 적용합니다.

재시도 가능한 오류만 선별하고, 적절한 간격과 횟수를 설정하며, 멱등성을 보장합니다.

실전 팁

💡 - 재시도 가능한 HTTP 상태 코드: 408(Timeout), 429(Too Many Requests), 500, 502, 503, 504

  • 재시도할 수 없는 코드: 400(Bad Request), 401(Unauthorized), 403(Forbidden), 404(Not Found)
  • 모든 재시도 시도를 로그로 남겨서 패턴을 분석하고 설정을 최적화하세요

5. 벌크헤드 패턴

김개발 씨는 이번 주에 또 다른 유형의 장애를 겪었습니다. 이미지 업로드 기능이 느려지면서 전혀 관련 없는 사용자 로그인까지 느려진 것입니다.

박시니어 씨가 설명했습니다. "자원이 공유되면서 생긴 문제예요.

벌크헤드 패턴으로 격리해야 합니다."

벌크헤드 패턴은 배의 격벽처럼 시스템 자원을 분리하는 패턴입니다. 하나의 기능이 모든 자원을 독차지하지 못하도록 격리하여, 한 부분의 장애가 다른 부분에 영향을 주지 않도록 합니다.

마치 배에 구멍이 나도 한 칸만 침수되고 배 전체는 가라앉지 않는 것처럼 말입니다.

다음 코드를 살펴봅시다.

import java.util.concurrent.*;

public class BulkheadExecutor {
    // 각 기능별로 독립적인 스레드 풀 생성
    private final ExecutorService imageUploadPool =
        new ThreadPoolExecutor(
            5, 10,  // 최소 5개, 최대 10개 스레드
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(20)  // 대기열 크기 20
        );

    private final ExecutorService userAuthPool =
        new ThreadPoolExecutor(
            10, 20,  // 최소 10개, 최대 20개 스레드
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(50)
        );

    // 이미지 업로드는 전용 스레드 풀 사용
    public Future<String> uploadImage(byte[] imageData) {
        return imageUploadPool.submit(() -> {
            // 이미지 업로드 처리
            return "image-url";
        });
    }

    // 사용자 인증은 별도 스레드 풀 사용
    public Future<User> authenticateUser(String token) {
        return userAuthPool.submit(() -> {
            // 인증 처리
            return new User();
        });
    }
}

김개발 씨는 타이타닉 이야기를 떠올렸습니다. 배 안에 격벽이 있어서 한 칸에 물이 차도 다른 칸은 안전하게 유지되는 구조였습니다.

물론 타이타닉은 침몰했지만, 그 설계 원리는 여전히 유효합니다. 벌크헤드 패턴도 같은 원리입니다.

지난주 김개발 씨가 겪은 장애를 자세히 살펴보겠습니다. 어떤 사용자가 매우 큰 이미지 파일을 계속 업로드했습니다.

이미지 처리는 CPU와 메모리를 많이 사용합니다. 문제는 이미지 업로드와 사용자 인증이 같은 스레드 풀을 공유하고 있었다는 것입니다.

이미지 업로드 요청들이 모든 스레드를 차지해버렸습니다. 새로운 로그인 요청이 들어와도 처리할 스레드가 없어서 대기해야 했습니다.

결국 로그인 타임아웃이 발생했고, 사용자들은 서비스를 이용할 수 없었습니다. 이것이 바로 자원 고갈(Resource Exhaustion) 문제입니다.

한 기능이 모든 자원을 독차지하면서 다른 기능들이 제대로 동작하지 못하는 상황입니다. 마치 식탁에서 한 사람이 음식을 다 가져가면 다른 사람들이 굶는 것과 같습니다.

벌크헤드 패턴은 이 문제를 어떻게 해결할까요? 자원을 기능별로 격리합니다.

위 코드에서는 이미지 업로드용 스레드 풀과 사용자 인증용 스레드 풀을 별도로 만들었습니다. 이미지 업로드는 최대 10개의 스레드만 사용할 수 있고, 사용자 인증은 최대 20개를 사용할 수 있습니다.

이제 이미지 업로드 요청이 폭주해도 어떻게 될까요? 이미지 업로드 스레드 풀의 10개 스레드가 모두 사용 중이 되고, 새로운 이미지 업로드 요청은 대기열에서 기다리거나 거부됩니다.

하지만 사용자 인증 스레드 풀은 영향을 받지 않습니다. 로그인은 여전히 빠르게 처리됩니다.

이것이 격리의 힘입니다. 실제로는 스레드 풀뿐만 아니라 다양한 자원을 격리할 수 있습니다.

데이터베이스 커넥션 풀을 기능별로 나누거나, 메모리 할당량을 제한하거나, 네트워크 대역폭을 분할할 수도 있습니다. 넷플릭스는 벌크헤드 패턴을 적극적으로 활용합니다.

그들의 Hystrix 라이브러리는 각 외부 서비스 호출마다 독립적인 스레드 풀을 생성합니다. A 서비스 호출이 느려져도 B 서비스 호출에는 영향이 없습니다.

이렇게 수백 개의 마이크로서비스를 안정적으로 운영합니다. 김개발 씨의 팀은 기능을 중요도에 따라 분류했습니다.

핵심 기능인 로그인, 주문, 결제는 많은 자원을 할당했습니다. 부가 기능인 추천, 이미지 업로드, 통계는 제한된 자원만 사용하도록 했습니다.

이렇게 하면 부가 기능에 문제가 생겨도 핵심 기능은 안정적으로 동작합니다. 하지만 주의할 점이 있습니다.

너무 많은 격리는 오히려 비효율적입니다. 기능마다 스레드 풀을 만들면 전체적으로 더 많은 자원이 필요합니다.

100개의 스레드를 10개씩 나누면 각각의 효율은 떨어질 수 있습니다. 적절한 균형이 필요합니다.

또한 격리된 자원의 크기를 잘 설정해야 합니다. 너무 작으면 정상 상황에서도 자원이 부족합니다.

너무 크면 격리의 의미가 없습니다. 트래픽 패턴을 분석하고, 부하 테스트를 통해 적절한 값을 찾아야 합니다.

박시니어 씨가 말했습니다. "벌크헤드는 방어적 프로그래밍의 핵심이에요.

최악의 상황을 가정하고, 한 부분의 실패가 전체로 번지지 않도록 설계하는 거죠." 김개발 씨는 이제 새로운 기능을 추가할 때마다 자원 격리를 고려합니다. 이 기능이 다른 기능들과 자원을 공유해도 괜찮은지, 독립적인 자원이 필요한지 판단합니다.

실제 프로덕션에 적용한 후 효과는 놀라웠습니다. 한 기능에 장애가 발생해도 다른 기능들은 정상 동작했습니다.

사용자들은 일부 기능만 제한되고 대부분의 기능은 사용할 수 있었습니다.

실전 팁

💡 - 중요한 기능과 덜 중요한 기능을 구분하여 자원을 차등 할당하세요

  • 각 스레드 풀의 사용률을 모니터링하여 크기를 조정하세요
  • 세마포어(Semaphore)를 사용하면 더 가벼운 격리를 구현할 수 있습니다

6. 타임아웃 패턴

김개발 씨는 새벽에 장애 알림을 받았습니다. 서버가 응답하지 않는다는 내용이었습니다.

급히 로그를 확인해보니 외부 API 호출이 무한정 대기하고 있었습니다. 박시니어 씨가 다음날 출근해서 말했습니다.

"타임아웃을 설정하지 않았군요. 이건 기본 중의 기본이에요."

타임아웃 패턴은 모든 외부 호출에 시간 제한을 두는 패턴입니다. 정해진 시간 내에 응답이 오지 않으면 대기를 중단하고 오류를 반환합니다.

무한정 기다리는 것을 방지하여 자원이 묶이지 않도록 합니다. 네트워크 호출, 데이터베이스 쿼리, 외부 API 호출 등 모든 I/O 작업에 필수적입니다.

다음 코드를 살펴봅시다.

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

public class TimeoutHandler {
    private final ExecutorService executor = Executors.newFixedThreadPool(10);

    // 타임아웃이 있는 HTTP 호출
    public String callExternalApiWithTimeout(String url, Duration timeout)
            throws TimeoutException {
        Future<String> future = executor.submit(() -> {
            // 외부 API 호출 (느릴 수 있음)
            return makeHttpCall(url);
        });

        try {
            // 지정된 시간만 대기
            return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            // 타임아웃 발생 시 작업 취소
            future.cancel(true);
            throw new TimeoutException("API 호출 시간 초과: " + url);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    // 계층별 타임아웃 설정
    public Order createOrder(OrderRequest request) {
        try {
            // 전체 작업: 10초 타임아웃
            return CompletableFuture.supplyAsync(() -> {
                // 결제: 5초 타임아웃
                Payment payment = callPaymentService(request, Duration.ofSeconds(5));
                // 재고 확인: 3초 타임아웃
                Inventory inv = checkInventory(request, Duration.ofSeconds(3));
                return new Order(payment, inv);
            }).get(10, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            throw new OrderTimeoutException("주문 처리 시간 초과");
        }
    }
}

김개발 씨는 그날 새벽의 사건을 되돌아봤습니다. 외부 추천 API가 응답하지 않았는데, 코드는 계속 응답을 기다렸습니다.

하나의 요청이 대기하고, 두 번째 요청이 대기하고, 곧 수백 개의 요청이 모두 대기 상태가 되었습니다. 결국 모든 스레드가 대기 상태로 묶였습니다.

새로운 요청을 처리할 스레드가 없어졌고, 서비스 전체가 멈춰버렸습니다. 단 하나의 외부 API가 느려진 것뿐인데 말입니다.

"왜 이런 일이 벌어진 거죠?" 김개발 씨가 물었습니다. 박시니어 씨가 화이트보드에 그림을 그렸습니다.

"타임아웃을 설정하지 않으면 네트워크 소켓이 무한정 열려있어요. TCP 연결은 기본적으로 영원히 기다릴 수 있거든요." 이것은 매우 위험합니다.

네트워크는 신뢰할 수 없습니다. 패킷이 유실될 수 있고, 서버가 응답하지 않을 수 있고, 라우터가 먹통이 될 수 있습니다.

마치 우편물을 보냈는데 답장이 오지 않는 것처럼 말입니다. 영원히 기다릴 수는 없습니다.

그렇다면 타임아웃을 얼마로 설정해야 할까요? 이것은 정답이 없는 어려운 질문입니다.

너무 짧으면 정상적인 요청도 실패합니다. 너무 길면 문제가 생겼을 때 대응이 늦어집니다.

서비스의 특성과 요구사항에 따라 달라집니다. 일반적인 가이드라인을 살펴보겠습니다.

빠른 API 호출은 1-3초가 적당합니다. 데이터베이스 쿼리는 5-10초, 파일 업로드나 이미지 처리처럼 오래 걸리는 작업은 30-60초를 설정합니다.

중요한 것은 반드시 설정해야 한다는 것입니다. 더 나아가 계층별 타임아웃을 설정할 수 있습니다.

위 코드의 createOrder 메서드를 보세요. 전체 주문 처리는 10초, 그 안에서 결제는 5초, 재고 확인은 3초로 설정했습니다.

이렇게 하면 각 단계마다 적절한 시간 제한을 둘 수 있습니다. 주의할 점이 있습니다.

하위 타임아웃의 합이 상위 타임아웃보다 크면 안 됩니다. 결제 5초 + 재고 3초 = 8초인데, 전체 타임아웃이 5초라면 모순입니다.

합리적인 값을 설정해야 합니다. 실제 프로덕션 환경에서는 타임아웃을 동적으로 조정하기도 합니다.

평소에는 3초 타임아웃을 사용하다가, 서버가 느려지는 것이 감지되면 자동으로 5초로 늘립니다. 반대로 서버가 매우 빠르게 응답하면 1초로 줄입니다.

이것을 적응형 타임아웃(Adaptive Timeout)이라고 합니다. 김개발 씨의 팀은 모든 외부 호출에 타임아웃을 추가했습니다.

HTTP 클라이언트 설정에서 연결 타임아웃과 읽기 타임아웃을 모두 지정했습니다. 데이터베이스 쿼리에도 타임아웃을 설정했습니다.

연결 타임아웃과 읽기 타임아웃은 다릅니다. 연결 타임아웃은 서버에 연결을 맺는 데 걸리는 시간입니다.

보통 3-5초면 충분합니다. 읽기 타임아웃은 연결 후 응답을 기다리는 시간입니다.

이것은 작업에 따라 다르게 설정해야 합니다. 타임아웃이 발생하면 어떻게 처리해야 할까요?

단순히 오류를 반환하는 것보다, 대체 방안을 제공하는 것이 좋습니다. 예를 들어 추천 API가 타임아웃되면 캐시된 추천 목록을 보여줍니다.

결제 API가 타임아웃되면 "잠시 후 다시 시도해주세요"라는 친절한 메시지를 보여줍니다. 또한 타임아웃을 모니터링해야 합니다.

타임아웃이 자주 발생한다면 무언가 문제가 있다는 신호입니다. 서버 성능이 나빠졌거나, 네트워크에 문제가 있거나, 데이터베이스 쿼리가 느려졌을 수 있습니다.

박시니어 씨가 강조했습니다. "타임아웃은 마이크로서비스의 안전벨트예요.

안전벨트 없이 운전하지 않듯이, 타임아웃 없이 외부 호출을 하면 안 됩니다." 김개발 씨는 이제 새로운 API를 호출할 때마다 가장 먼저 타임아웃을 설정합니다. 처음에는 보수적으로 길게 설정하고, 모니터링하면서 점차 최적화합니다.

실제 효과는 분명했습니다. 외부 서비스가 느려져도 타임아웃으로 빠르게 실패하고, 자원이 묶이지 않았습니다.

시스템 전체의 안정성이 크게 향상되었습니다.

실전 팁

💡 - HTTP 클라이언트는 연결 타임아웃과 읽기 타임아웃을 모두 설정하세요

  • 타임아웃 발생률을 메트릭으로 수집하고 알림을 설정하세요
  • 부하 테스트로 99 퍼센타일 응답 시간을 측정하고, 그것의 1.5-2배를 타임아웃으로 설정하는 것이 좋은 출발점입니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#마이크로서비스#서킷브레이커#복원력패턴#장애대응#분산시스템

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.