API 장애 전파를 막는 회복탄력성 패턴: Timeout·Retry·Circuit Breaker·Bulkhead 실전 설계

장애는 실패한 API 하나에서 끝나지 않는다
마이크로서비스 환경에서 장애는 거의 항상 전파됩니다. 결제 서비스의 응답이 느려졌을 뿐인데 주문 API의 스레드가 대기하고, 주문 API가 느려지자 게이트웨이의 커넥션이 쌓이고, 결국 로그인이나 상품 조회처럼 결제와 직접 관련 없는 API까지 함께 느려집니다. 사용자가 보는 것은 "결제가 잠깐 느리다"가 아니라 "서비스 전체가 멈췄다"입니다.
이런 장애의 공통점은 실패를 너무 오래 붙잡는다는 것입니다. 외부 API가 10초 동안 응답하지 않는데 내부 서비스가 그대로 기다리고, 실패한 요청을 즉시 여러 번 재시도하고, 같은 dependency를 바라보는 모든 기능이 하나의 connection pool을 공유합니다. 개별 코드는 성실하게 "성공할 때까지 노력"하지만, 시스템 전체로 보면 장애를 증폭시키는 행동입니다.
회복탄력성(resilience) 패턴의 목적은 실패를 없애는 것이 아닙니다. 실패를 빠르게 감지하고, 전파 범위를 좁히고, 사용자에게 가능한 수준의 응답을 돌려주는 것입니다. 이 글에서는 timeout budget, retry with backoff, circuit breaker, bulkhead, fallback, idempotency를 API 설계와 운영 지표 관점에서 정리합니다.
1. Timeout은 가장 기본적인 격리 장치다
Timeout이 없거나 너무 길면 장애는 대기열로 바뀝니다. Downstream 서비스가 응답하지 않는데 caller가 계속 기다리면 worker thread, event loop, connection pool이 점유됩니다. 트래픽이 조금만 늘어도 대기 중인 요청이 새 요청을 밀어내고, 결국 정상 dependency를 호출하는 요청까지 영향을 받습니다.
Timeout은 임의의 숫자가 아니라 budget으로 설계해야 합니다. 사용자에게 1초 안에 응답해야 하는 API가 내부적으로 세 개의 서비스를 순차 호출한다면, 각 호출에 1초 timeout을 줄 수 없습니다. 네트워크 왕복, 애플리케이션 처리, fallback 생성 시간을 포함해 전체 예산을 나눠야 합니다. 예를 들어 gateway 800ms, order 500ms, payment 250ms, inventory 150ms처럼 상위 호출의 deadline이 하위 호출로 전파되어야 합니다.
중요한 것은 connect timeout과 read timeout을 분리하는 것입니다. 연결 자체가 안 되는 상황과 연결 후 응답이 늦는 상황은 원인이 다릅니다. Connect timeout은 짧게 잡아도 되는 경우가 많고, read timeout은 dependency의 정상 p99와 SLO를 기준으로 잡습니다. DNS timeout, TLS handshake timeout, connection acquisition timeout도 별도로 봐야 합니다. 많은 장애는 HTTP read timeout이 아니라 connection pool에서 커넥션을 얻지 못해 시작됩니다.
Timeout이 발생하면 caller는 반드시 자원을 해제해야 합니다. Future를 취소하지 않거나, abort signal을 전파하지 않거나, DB 쿼리를 그대로 실행하게 두면 사용자 요청은 끝났는데 backend 작업은 계속 남습니다. Timeout은 응답 포기 선언이면서 동시에 downstream 작업 중단 요청이어야 합니다.
2. Retry는 약이지만 과하면 독이다
Retry는 일시적인 네트워크 오류와 짧은 dependency 스파이크를 흡수하는 데 효과적입니다. 하지만 무조건 재시도는 장애를 증폭시킵니다. 요청 하나가 실패할 때마다 즉시 세 번 재시도하면, 장애 중인 서비스는 기존보다 네 배 많은 트래픽을 받습니다. 회복해야 할 dependency에 더 큰 부하를 주는 셈입니다.
Retry에는 조건이 필요합니다. 500, 502, 503, 504처럼 일시 장애 가능성이 있는 응답은 재시도할 수 있습니다. 400, 401, 403, 404처럼 요청 자체가 잘못된 경우는 재시도하지 않습니다. 결제 승인, 주문 생성, 포인트 적립처럼 side effect가 있는 요청은 idempotency key 없이 재시도하면 안 됩니다. 네트워크 타임아웃은 서버가 요청을 처리했는지 알 수 없기 때문에 더 위험합니다.
Backoff와 jitter도 필수입니다. 모든 caller가 100ms 뒤 동시에 재시도하면 retry wave가 생깁니다. Exponential backoff에 랜덤 jitter를 넣어 재시도 시점을 분산해야 합니다. 재시도 횟수도 전체 timeout budget 안에 들어와야 합니다. 300ms timeout API에서 200ms 요청을 세 번 재시도하는 구성은 처음부터 불가능합니다.
Retry는 observability와 함께 설계해야 합니다. 단순 error rate만 보면 retry가 문제를 숨길 수 있습니다. 최종 응답은 성공이지만 내부적으로 재시도가 급증하고 있다면 dependency는 이미 경고 신호를 보내고 있습니다. retry count, retry success ratio, retry exhausted count, dependency별 timeout count를 지표로 봐야 합니다.
3. Circuit Breaker는 실패를 빠르게 거절한다
Circuit breaker는 실패율이 높은 dependency 호출을 잠시 차단하는 패턴입니다. 정상 상태(closed)에서는 요청을 통과시킵니다. 실패율이나 timeout 비율이 임계값을 넘으면 open 상태가 되어 요청을 즉시 실패시키거나 fallback으로 보냅니다. 일정 시간이 지나면 half-open 상태에서 일부 요청만 시험적으로 통과시키고, 성공하면 closed로 돌아갑니다.
이 패턴의 핵심은 "실패한 dependency를 계속 두드리지 않는다"입니다. Downstream이 이미 과부하인데 caller가 계속 요청을 보내면 회복이 늦어집니다. Circuit breaker는 caller 자원을 보호하고, downstream에 회복 시간을 줍니다. 특히 외부 결제사, 배송사, 인증 provider처럼 우리가 직접 확장할 수 없는 dependency에 중요합니다.
임계값은 호출량을 고려해야 합니다. 10개 중 5개 실패와 10,000개 중 5,000개 실패는 같은 50%지만 신뢰도가 다릅니다. 최소 요청 수, rolling window, failure type을 함께 봐야 합니다. Timeout, connection refused, 5xx는 실패로 볼 수 있지만, 비즈니스 거절 응답은 실패로 넣지 않는 편이 맞습니다.
Circuit breaker가 열렸을 때 사용자 경험도 설계해야 합니다. 모든 API에서 무조건 500을 반환하면 장애는 줄어도 서비스 가치는 사라집니다. 상품 추천은 빈 목록이나 캐시된 결과를 줄 수 있고, 배송 예정일은 "확인 중"으로 degrade할 수 있습니다. 결제 승인처럼 fallback이 위험한 기능은 명확히 실패를 반환해야 합니다. Fallback은 도메인별로 다릅니다.
4. Bulkhead는 장애의 방을 나눈다
Bulkhead는 선박의 격벽에서 나온 개념입니다. 한 구역에 물이 들어와도 배 전체가 가라앉지 않도록 구역을 나누는 것입니다. 시스템에서는 thread pool, connection pool, queue, rate limit을 기능이나 dependency별로 분리하는 방식으로 구현합니다.
예를 들어 상품 상세 API와 추천 API가 같은 DB connection pool을 공유한다고 합시다. 추천 쿼리가 느려져 pool을 모두 점유하면 상품 상세도 함께 실패합니다. 추천은 부가 기능이고 상품 상세는 핵심 기능이라면 둘은 같은 pool을 공유하면 안 됩니다. 최소한 추천 호출에는 별도 concurrency limit과 짧은 timeout, fallback이 있어야 합니다.
Bulkhead는 외부 API에도 적용됩니다. 결제사 A 호출이 느려졌다고 결제사 B 호출까지 막히면 안 됩니다. Dependency별 HTTP client, connection pool, circuit breaker를 분리해야 합니다. 하나의 거대한 client 인스턴스에 모든 호출을 몰아넣으면 지표도 섞이고 장애 격리도 불가능합니다.
Queue 기반 worker에서도 bulkhead가 필요합니다. 느린 작업과 빠른 작업을 같은 queue와 consumer group에서 처리하면 느린 작업이 빠른 작업을 뒤로 밀어냅니다. 이미지 변환, 이메일 발송, 정산 이벤트는 처리 시간과 중요도가 다릅니다. 큐를 나누고, worker 수와 retry 정책을 따로 잡아야 합니다.
5. Idempotency는 Retry의 안전벨트다
Side effect가 있는 API에서 retry를 하려면 idempotency가 필요합니다. 같은 요청이 두 번 도착해도 결과가 한 번만 반영되어야 합니다. 결제 승인 API라면 client가 Idempotency-Key를 보내고, 서버는 그 키로 처리 결과를 저장합니다. 같은 키가 다시 오면 새 결제를 만들지 않고 이전 결과를 반환합니다.
Idempotency key는 요청 단위가 아니라 비즈니스 의도 단위여야 합니다. 사용자가 같은 주문에 대해 같은 결제를 재시도하는 것은 같은 의도입니다. 하지만 결제 실패 후 다른 카드로 다시 결제하는 것은 다른 의도일 수 있습니다. 키 설계가 애매하면 중복을 막아야 할 곳에서 못 막거나, 새 요청을 이전 요청으로 잘못 접습니다.
서버 구현에서는 유니크 제약이 중요합니다. 먼저 조회하고 없으면 insert하는 방식은 동시성 경쟁에 취약합니다. Idempotency table에 key를 유니크로 잡고 insert를 먼저 시도해야 합니다. 이미 존재하면 저장된 상태를 보고 in-progress, success, failed를 반환합니다. 처리 중인 요청에 대해서는 짧은 대기나 409 응답을 선택할 수 있습니다.
보존 기간도 정책입니다. 결제 idempotency key는 며칠 이상 보존할 수 있지만, 검색 API 같은 읽기 요청에는 필요 없습니다. Retry window와 고객지원 재처리 기간을 고려해 TTL을 정해야 합니다. 너무 짧으면 지연된 재시도가 중복 처리를 만들고, 너무 길면 저장소가 불필요하게 커집니다.
6. 운영 대시보드: 성공률보다 방어 동작을 본다
회복탄력성 패턴은 동작하고 있을 때 조용합니다. 그래서 지표가 없으면 실제로 보호하고 있는지 알 수 없습니다. API별 timeout count, retry count, circuit breaker state, fallback count, bulkhead rejection count, idempotency duplicate count를 봐야 합니다. 이 값들은 장애가 터지기 전에 dependency 악화를 알려주는 조기 신호입니다.
알림은 사용자 SLO와 연결해야 합니다. Circuit breaker가 한 번 열렸다고 무조건 사람을 깨우면 피로해집니다. 하지만 checkout dependency breaker가 열리고 fallback 불가능 요청이 증가한다면 즉시 대응해야 합니다. Fallback으로 사용자 영향이 없는 경우와 핵심 기능 실패는 다르게 다뤄야 합니다.
분산 trace도 유용합니다. 느린 요청 하나를 열었을 때 어떤 dependency에서 timeout budget을 썼는지, retry가 몇 번 일어났는지, breaker가 열렸는지 span attribute로 보여야 합니다. 장애 대응 중에는 로그보다 trace waterfall이 원인 후보를 더 빨리 좁혀줍니다.
실무 체크리스트
- 모든 outbound 호출에 connect/read/acquire timeout이 명시되어 있는가
- 상위 요청 deadline이 하위 dependency 호출로 전파되는가
- Retry 대상 상태 코드와 예외가 제한되어 있는가
- Retry에 exponential backoff와 jitter가 들어가는가
- Side effect API는 idempotency key 없이 재시도하지 않는가
- Dependency별 circuit breaker와 connection pool이 분리되어 있는가
- 핵심 기능과 부가 기능이 같은 pool/queue를 공유하지 않는가
- Fallback이 도메인별로 명확히 정의되어 있는가
- Timeout, retry, breaker, fallback, rejection 지표가 대시보드에 있는가
결론: 실패를 인정해야 시스템이 살아남는다
안정적인 API는 실패하지 않는 API가 아닙니다. 실패할 때 오래 붙잡지 않고, 재시도할 때 예의를 지키며, 회복 중인 dependency를 계속 두드리지 않고, 중요하지 않은 기능의 장애가 핵심 기능을 끌고 가지 않게 만든 API입니다. Timeout, retry, circuit breaker, bulkhead는 각각 작은 패턴처럼 보이지만 함께 설계될 때 장애 전파를 끊는 안전망이 됩니다.
운영에서 필요한 태도는 단순합니다. 모든 dependency는 언젠가 느려지고, 모든 네트워크 호출은 언젠가 실패하며, 모든 retry는 중복 실행 가능성을 만든다고 가정하는 것입니다. 이 가정을 코드와 지표에 반영하면 장애는 더 작고 짧아집니다.