본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Backend

장애 확산을 막는 복원력 패턴

작성 2026. 11. 1.추가
목차10개 항목
출처와 참고

ByteByteGo의 System Design 101 가이드 「Resiliency Patterns」, 「Retry Strategies for System Failures」, 「How to Handle Web Request Errors」, 「Top 6 Cases to Apply Idempotency」를 바탕으로 재구성한 노트다. 속도 제한 알고리즘, 벌크헤드 격리 방식, 배압 전략은 「fru1tworld/cs-basic 모의 면접 질문 목록」이 묻는 개념을 채워 넣었다. 빠른 실패·안정 상태 패턴은 Jonas Bonér의 「Scalability, Availability & Stability Patterns」로, 크래시 허용의 실제 효과는 「Erlang at Facebook」(2009)로 보강했다. karanpratapsingh의 「System Design」 강의 저장소를 바탕으로 멱등성의 정의(상태와 응답의 구분), 멱등 키 구현의 세부, 멱등 소비자 기법, 사실상 한 번 처리를 보강했다.

복원력이란

큰 장애는 대개 작은 오류에서 시작한다. 한 의존성이 느려지면 호출자의 스레드가 묶이고, 묶인 호출자가 또 그 호출자를 느리게 만들며 눈덩이처럼 커진다(연쇄 장애, cascading failure). 복원력(resiliency) 패턴은 실패를 없애는 것이 아니라 실패가 번지는 범위를 줄이는 장치다.

이 패턴들은 혼자 쓰지 않는다. 타임아웃이 있어야 재시도가 의미 있고, 재시도에는 서킷 브레이커와 백오프가 있어야 상대를 죽이지 않는다.

여덟 가지 패턴

패턴하는 일막는 것한계
타임아웃(timeout)정해진 시간 안에 응답이 없으면 포기한다느린 의존성에 자원이 무한히 묶이는 것너무 짧으면 정상 요청도 끊는다. 하류 타임아웃은 상류보다 짧게 잡는다
재시도(retry)일시적 실패를 다시 시도한다순간적인 네트워크 오류로 인한 실패멱등하지 않은 작업은 중복 실행된다. 백오프 없이 하면 재시도 폭풍이 된다
서킷 브레이커(circuit breaker)실패율이 임계치를 넘으면 호출 자체를 막고 즉시 폴백을 준다죽은 의존성을 계속 두드려 복구를 방해하는 것임계치와 반열림 시험량을 잘못 잡으면 너무 쉽게 열리거나 늦게 닫힌다
속도 제한(rate limiting)클라이언트별 요청 빈도를 제한한다. 토큰 버킷(버스트 허용)과 리키 버킷(일정 속도로 평탄화)이 대표적이다한 클라이언트가 자원을 독차지하는 것정상 사용자의 순간 폭주도 막을 수 있다
부하 차단(load shedding)서버가 감당할 수 없을 만큼 요청이 오면 일부를 일찍 거절한다. 중요도가 낮은 요청부터 버린다모든 요청이 다 같이 느려져 모두 실패하는 것무엇을 버릴지 우선순위가 정해져 있어야 한다
벌크헤드(bulkhead)의존성별로 스레드 풀·커넥션 풀을 나눈다. 배의 격벽처럼 한 칸이 잠겨도 나머지는 뜬다의존성 하나의 장애가 공용 자원을 다 먹는 것풀이 잘게 나뉘면 전체 자원 활용률이 떨어진다
배압(back pressure)소비자가 처리 속도를 넘는 입력을 받지 않고 생산자에게 늦추라고 알린다큐·메모리가 무한히 쌓이는 것생산자도 늦출 방법(버퍼, 거절)을 갖고 있어야 한다
크래시 허용(let it crash)이상한 상태를 고치려 하지 않고 프로세스를 죽인 뒤 감독자가 깨끗한 상태로 다시 띄운다(Erlang 감독 트리)오염된 상태로 계속 도는 것재시작이 빠르고 상태가 밖에 있어야 한다

속도 제한·벌크헤드·배압의 구체적인 방식은 아래 절에 있다. 서킷 브레이커의 세 상태(Closed·Open·Half-Open)와 구현 라이브러리는 대규모 트래픽에서 서비스를 지키는 설계에 있다.

표에 더해 두 가지 안정성 패턴이 함께 쓰인다.

  • 빠른 실패(fail fast): 느린 실패가 빠른 실패보다 나쁘다. 비싼 작업을 시작하기 전에 필요한 자원(커넥션, 하류 서비스)이 있는지 먼저 확인하고, 입력 검증은 맨 앞에서 한다. 자원이 없는 시스템 오류와 잘못된 입력 같은 애플리케이션 오류를 구분해 다르게 답한다.
  • 안정 상태(steady state): 사람이 손대지 않아도 오래 돌 수 있게 스스로 치운다. 로그는 순환(logrotate)시키고 가능하면 별도 디스크에 두며, 임시 파일·캐시·세션도 만료로 정리한다. 디스크가 차서 멈추는 장애가 가장 흔한 「청소 안 함」 장애다.

벌크헤드는 관리용 작업에도 쓴다. 관리 작업 전용 스레드 풀을 따로 두면 서비스 스레드가 모두 막혀도 진단·복구 명령은 돈다. 크래시 허용의 효과는 Facebook 채팅 초기 사례가 보여 준다. 스레드 누수, 의도치 않은 메시지 다중 전송, 접속 상태 집계 오류 같은 버그가 있었지만, C 서버였다면 세그폴트가 OS 프로세스와 모든 상태를 날렸을 일이 Erlang에서는 해당 경량 프로세스 하나만 죽이고 연결된 프로세스에 알리는 데서 끝나, 대부분 동작하는 시스템을 유지했다.

속도 제한 알고리즘

속도 제한은 키(API 키·사용자·IP)마다 일정 시간에 허용할 요청 수를 세는 문제다. 버스트를 받아 주는 토큰 버킷, 하류로 나가는 속도를 평탄하게 만드는 리키 버킷, 단순한 고정 윈도 카운터(경계에서 최대 2배 통과), 정확하지만 비싼 슬라이딩 윈도 로그, 카운터 두 개로 경계 문제를 줄이는 슬라이딩 윈도 카운터가 있다. 알고리즘별 비교표와 고르는 기준, 분산 환경의 원자적 카운팅, 429·Retry-After 응답은 API 보안 체크리스트의 레이트 리밋 알고리즘에 있다. 복원력 관점에서는 엣지·게이트웨이에서 먼저 걸고, 서비스 안에서는 비싼 의존성 호출 앞에 건다.

벌크헤드의 격리 방식

방식동작장점단점
스레드 풀 격리의존성마다 전용 스레드 풀을 두고 호출을 그 풀에 넘긴다. 풀이 차면 즉시 거절한다호출자 스레드가 묶이지 않고, 타임아웃이 지나면 호출자가 먼저 포기한다스레드 전환과 큐 비용, 풀마다 스레드가 늘어난다
세마포어 격리호출자 스레드에서 실행하되 동시 호출 수를 세마포어로 제한한다가볍다. 비동기·논블로킹 클라이언트와 잘 맞는다블로킹 호출이 멈추면 호출자 스레드도 함께 묶인다. 타임아웃은 클라이언트 설정에 기대야 한다
커넥션 풀 분리DB·외부 API마다 커넥션 풀을 따로 둔다한 의존성이 커넥션을 다 써도 다른 쪽은 산다전체 커넥션 수 관리가 복잡해진다
인스턴스·클러스터 분리중요한 기능이나 큰 고객을 별도 서버 묶음으로 처리한다. 극단적으로는 사용자를 셀(cell)로 나눠 셀마다 전체 스택을 둔다장애와 시끄러운 이웃(noisy neighbor)이 다른 묶음으로 번지지 않는다자원이 덜 공유되어 비용이 늘어난다

Resilience4j는 SemaphoreBulkhead와 ThreadPoolBulkhead를 모두 제공한다. 풀 크기는 리틀의 법칙으로 어림한다. 필요한 동시 호출 수 ≈ 초당 요청 수 × 응답 시간(p99)이고, 여기에 여유를 조금 둔다. 크게 잡으면 격리가 무의미해지고, 작게 잡으면 정상 트래픽도 거절된다.

배압 전략

배압(backpressure) 은 하류가 감당할 수 있는 속도를 상류에 알려, 처리 속도보다 빠른 입력이 어딘가에 끝없이 쌓이지 않게 하는 것이다. 생산자를 늦출지, 쌓을지, 버릴지, 거절할지 중 하나를 고른다.

전략동작맞는 곳
수요 신호(pull)소비자가 처리할 수 있는 만큼만 요청한다. Reactive Streams의 request(n), TCP 수신 윈도, Kafka 컨슈머의 poll()생산자를 늦출 수 있는 내부 파이프라인
생산자 막기크기가 정해진 큐가 차면 넣는 쪽이 기다린다(BlockingQueue.put, Kafka 프로듀서의 buffer.memory)생산자가 같은 프로세스·시스템 안에 있을 때
제한된 버퍼순간 폭주만 흡수할 크기의 버퍼를 둔다. 무한 버퍼는 배압이 아니라 메모리 부족을 미루는 것이다짧은 버스트
버리기·샘플링가장 새 것 또는 가장 오래된 것을 버리거나, 일부만 남긴다(Reactor의 onBackpressureDrop·onBackpressureLatest)지표, 위치·시세처럼 최신 값만 의미 있는 데이터
거절받지 않고 429·503과 Retry-After로 돌려보낸다. 부하 차단과 같다외부 클라이언트처럼 생산자를 통제할 수 없을 때
묶음 처리·확장묶어서 처리해 처리량을 올리거나 소비자를 늘린다병목이 소비자 자신일 때. 병목이 그 뒤의 DB면 소비자를 늘려도 소용없다

배압은 끝에서 끝까지 전달돼야 의미가 있다. 중간 한 단계가 무한 큐로 다 받아 주면 그 앞쪽은 하류가 느리다는 사실을 모른 채 계속 밀어 넣는다. 그래서 모든 큐에 상한을 두고, 큐 길이와 대기 시간을 지표로 본다. Node.js 스트림의 highWaterMark와 write() 반환값은 Node.js 스트림: 데이터를 조각으로 흘려 처리하기에 있다.

재시도 전략: 백오프와 지터

재시도 사이의 대기 시간을 어떻게 늘리느냐에 따라 네 가지로 나뉜다.

전략대기 시간장점단점
선형 백오프(linear)1s, 2s, 3s, 4s … 일정하게 늘린다단순하다많은 클라이언트가 같은 박자로 재시도해 부하가 몰린다
선형 + 지터(linear jitter)선형 대기에 무작위 값을 더한다재시도 시점이 흩어진다기본 간격이 천천히 늘어 고부하에서는 부족하다
지수 백오프(exponential)1s, 2s, 4s, 8s … 두 배씩, 상한까지상대 시스템의 부하를 빠르게 줄인다금방 풀릴 문제도 오래 기다린다
지수 + 지터(exponential jitter)지수 대기에 무작위를 더하거나 곱한다부하 감소와 시점 분산을 둘 다 얻는다지터가 크면 불필요하게 오래 기다리기도 한다
지수 + 지터의 흔한 형태 (full jitter)
  sleep = random(0, min(상한, 기본값 × 2^시도횟수))

기본값은 지수 백오프 + 지터 + 최대 재시도 횟수다. 지터가 없으면 같은 순간에 실패한 클라이언트들이 같은 순간에 다시 몰려와 복구 중인 서버를 또 쓰러뜨린다(thundering herd).

웹 요청 오류에 대처하는 규칙

상황규칙
4xx (클라이언트 오류)재시도하지 않는다. 같은 요청은 같은 이유로 또 실패한다. 단, 429(Too Many Requests)와 408(Request Timeout)은 예외로, Retry-After를 지키며 기다렸다 다시 보낸다
5xx (서버 오류)백오프를 두고 조심스럽게 재시도하고, 횟수에 상한을 둔다
타임아웃·연결 끊김요청이 서버에 닿았는지 알 수 없다. 멱등한 요청만 재시도한다

클라이언트가 늘 얌전히 재시도한다는 보장은 없다. 그래서 서버는 스스로를 지킨다. 서버 앞에 흐름 제어 게이트웨이를 두고 속도 제한과 서킷 브레이커를 건다. 클라이언트의 백오프와 서버의 흐름 제어를 지나서도 남는 오류가 진짜 고쳐야 할 버그다.

멱등성: 재시도의 전제 조건

멱등(idempotent) 한 작업은 여러 번 실행해도 결과가 한 번 실행한 것과 같다. 재시도가 안전하려면 그 작업이 멱등해야 한다. HTTP 메서드별 멱등성은 REST API 설계 원칙에 있다.

적용할 곳막아야 할 것흔한 구현
API 요청재시도로 같은 작업이 두 번 실행되는 것멱등 메서드(PUT·DELETE) 사용, POST에는 Idempotency-Key 헤더
결제네트워크 오류 후 재시도로 두 번 청구되는 것요청마다 고유 키를 받아 처리 결과를 저장하고, 같은 키가 오면 저장된 결과를 돌려준다
주문주문 버튼 연타로 주문이 여러 건 생기고 재고가 여러 번 빠지는 것클라이언트가 만든 주문 ID에 유니크 제약
DB 작업같은 트랜잭션을 다시 적용해 상태가 또 바뀌는 것「+1」 같은 상대 변경 대신 「값을 X로」 같은 절대 변경, upsert
계정 관리가입 재시도로 계정이 둘 생기거나, 비밀번호 재설정이 여러 번 실행되는 것이메일 유니크 제약, 재설정 토큰을 한 번만 쓰게 하기
메시지 처리큐가 같은 메시지를 다시 배달해 두 번 처리되는 것처리한 메시지 ID를 기록해 중복을 거르는 멱등 소비자

대부분의 메시지 큐는 「적어도 한 번(at-least-once)」 배달을 보장하므로, 소비자는 같은 메시지를 두 번 받는다고 가정하고 만든다.

멱등은 서버 상태가 한 번 호출한 것과 같다는 뜻이지 응답까지 같다는 뜻이 아니다. 이미 지운 자원에 DELETE를 다시 보내면 200 대신 404가 올 수 있다. 결제 요청이 타임아웃되면 클라이언트는 서버가 요청을 못 받았는지, 처리했는데 응답만 잃었는지, 아직 처리 중인지 알 수 없다. 안전한 복구는 재시도뿐이고, 그래서 재시도할 작업을 멱등하게 만든다. 사용자가 버튼을 두 번 누르거나 새로 고침해 생기는 중복 제출도 같은 장치로 막는다.

멱등 키 구현의 세부

POST /payments처럼 본래 멱등하지 않은 요청은 클라이언트가 만든 고유 값(보통 UUID)을 Idempotency-Key 헤더로 보낸다. 서버는 키가 없으면 저장하고 처리한 뒤 응답을 키에 묶어 저장하고, 키가 있으면 처리 없이 저장된 응답을 돌려준다.

챙길 것이유와 방법
원자성「키가 있나 확인」과 「키 기록」 사이에 동시 재시도 둘이 함께 통과할 수 있다. DB 유니크 제약이나 Redis SET key value NX로 한 번에 처리한다
요청 지문요청 본문의 해시도 저장한다. 같은 키에 다른 본문이 오면 엉뚱한 저장 응답을 주지 말고 거절한다
처리 중 요청원 요청이 아직 처리 중일 때 재시도가 오면 409 Conflict를 주거나 끝날 때까지 기다리게 한다
만료키를 영원히 둘 필요는 없다. 몇 시간~며칠 보관하고 지워 저장 비용을 묶는다

비동기 소비자는 세 방법을 쓴다. 중복 제거 표에 처리한 메시지 ID를 남기되 부수 효과와 같은 트랜잭션으로 쓴다. 작업 자체를 본래 멱등하게 설계한다(「상태를 다음 단계로」 대신 「상태를 shipped로」). 버전·순번을 붙여 지금 상태보다 새롭지 않은 갱신은 버린다.

멱등성에는 키 저장·조회와 동시 요청 처리라는 비용이 든다. 네트워크 너머의 정확히 한 번 처리는 일반적으로 불가능하지만, 최소 한 번 전달과 멱등 처리를 합치면 겉으로는 같은 결과가 나온다. 이를 사실상 한 번(effectively-once) 처리라 부른다.

정리

  • 복원력 패턴은 실패를 없애지 않고, 실패가 번지는 범위를 줄인다.
  • 모든 원격 호출에는 타임아웃을 건다. 타임아웃 없는 재시도와 백오프 없는 재시도는 장애를 키운다.
  • 재시도는 지수 백오프 + 지터 + 횟수 상한으로 한다. 4xx는 재시도하지 않는다.
  • 속도 제한은 버스트를 받아 주는 토큰 버킷과 경계 문제가 적은 슬라이딩 윈도 카운터가 흔하다.
  • 벌크헤드는 스레드 풀·세마포어·커넥션 풀·인스턴스 단위로 나누고, 배압은 모든 큐에 상한을 두고 끝까지 전달한다.
  • 재시도하려면 먼저 멱등하게 만든다. 결제·주문·메시지 소비는 고유 키로 중복을 거른다.

관련 노트: 대규모 트래픽에서 서비스를 지키는 설계, API 보안 체크리스트, 고가용성 이중화 구조와 장애 감지, REST API 설계 원칙, 대기열 시스템(가상 대기실) 설계, 시스템 설계의 품질 속성과 트레이드오프

원본 기록8

댓글

GitHub 계정으로 댓글을 남길 수 있어요.