타임아웃을 걸었는데도 서비스 전체가 느려진다면

타임아웃을 걸어 뒀는데도 의존 서비스 하나가 느려지자 서비스 전체가 느려졌다. 실패할 걸 알면서 계속 기다리고 있었으니까. 그 고리를 끊는 게 서킷 브레이커다.

의존하는 서비스 하나가 느려졌다. 타임아웃을 걸어 뒀으니 괜찮겠거니 했다. 그런데 우리 서비스 전체가 느려졌다.

계산해 보면 이렇다. 타임아웃은 3초, 그 서비스로 가는 요청은 초당 500건. 전부 3초씩 기다렸다가 실패하니 매 순간 1,500개의 요청이 줄 서서 대기한다. 스레드와 커넥션도 딱 그만큼 묶여 있다.

타임아웃은 제 할 일을 했다. 끝없이 기다리진 않았으니까. 하지만 실패할 걸 알면서 계속 시도하는 건 못 막았다.

여기선 다른 종류의 판단이 필요하다. "이번 요청을 얼마나 기다릴까"가 아니라 "지금 이 상대에게 요청을 보내는 게 의미가 있나"를 묻는 판단.

계속 실패하면 아예 안 보낸다

서킷 브레이커는 이름 그대로 회로 차단기다. 실패가 이어지면 시도 자체를 멈춘다.

상태는 셋이다. 닫힘은 정상이라 요청을 그대로 보낸다. 실패가 기준을 넘으면 열림으로 바뀌는데, 이때는 요청을 보내지 않고 바로 실패시킨다. 일정 시간이 지나면 반열림이 되어 몇 개만 시험 삼아 보내 보고, 성공하면 닫힘으로 돌아간다.

이걸로 얻는 게 꽤 크다.

우선 우리 자원이 지켜진다. 실패할 요청에 스레드와 커넥션을 묶어 두지 않으니까. 바로 실패하니 그 자원으로 다른 일을 할 수 있다.

상대도 지켜진다. 이미 힘든 서비스에 요청을 계속 밀어 넣는 건 일어설 틈을 안 주는 짓이다. 잠깐 멈춰 주면 상대가 밀린 걸 처리하고 다시 설 수 있다.

빠른 실패는 다른 길도 열어 준다. 3초 기다렸다 실패하면 대안을 꺼낼 시간이 없다. 바로 실패하면 캐시된 값을 쓰거나 기본값으로 응답할 수 있다.

재시도와는 짝이다. 재시도가 없으면 잠깐 지나가는 실패를 못 넘기고, 서킷 브레이커가 없으면 계속되는 실패에서 못 빠져나온다. 둘 다 있어야 한다.

세 상태가 바뀌는 조건

세 상태와 그 사이의 전환:

코드
· 닫힘 — 정상. 요청을 보낸다
 실패율이 기준(예: 50%)을 넘으면 → 열림
 최소 요청 수 조건을 함께 둔다
 3건 중 2건 실패로 열면 오탐이 잦다

· 열림 — 요청을 보내지 않고 즉시 실패
 일정 시간(예: 30초) 뒤 → 반열림
 이 시간 동안 대안을 쓴다 (캐시·기본값·축소된 기능)

· 반열림 — 소수만 시험 삼아 보낸다
 성공하면 → 닫힘
 실패하면 → 다시 열림 (대기 시간을 늘려 가며)

· 상대별로 따로 둔다
 하나로 묶으면 A 의 장애가 B 호출까지 막는다

✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.

설정에서 자주 틀리는 곳

설정에서 실수하기 쉬운 데가 몇 군데 있다.

여는 기준은 실패 횟수가 아니라 비율로 잡되, 최소 요청 수 조건을 같이 둬야 한다. 3건 중 2건 실패로 열어 버리면 트래픽이 적을 때 오탐이 잦다. 예를 들면 "최근 20건 이상이면서 실패율 50% 이상" 같은 식이다.

무엇을 실패로 셀지도 정해야 한다. 타임아웃과 연결 실패는 당연히 실패다. 그런데 상대가 400 을 돌려줬다면? 우리 요청이 잘못된 거지 상대가 아픈 게 아니다. 이걸 실패로 세면 우리 버그 때문에 회로가 열린다.

반열림에서 보내는 요청 수도 신경 쓸 곳이다. 너무 많이 보내면 아직 덜 회복된 상대를 다시 쓰러뜨린다. 하나나 둘이면 충분하다.

그리고 실제로 제일 중요한 건 열려 있는 동안 무엇을 하느냐다. 에러만 던지고 끝내면 사용자 눈엔 그냥 장애다. 캐시된 값, 기본값, 기능 축소, 나중에 처리하기. 이런 대안이 있어야 의미가 생긴다.

결국 설계 질문 하나로 이어진다. 의존성 하나가 죽어도 서비스가 부분적으로라도 돌 수 있나? 추천 목록을 못 가져오면 빈 목록을 보여 주고, 프로필 이미지를 못 가져오면 기본 이미지를 쓰는 식이다. 이런 준비가 돼 있을 때 서킷 브레이커가 진짜 힘을 낸다.

회로를 상대별로 나누는 것도 빠뜨리면 안 된다. 회로 하나로 외부 호출을 몽땅 묶으면 A 서비스 장애 때문에 멀쩡한 B 호출까지 막힌다. 상대별로, 필요하면 엔드포인트별로 나눠야 한다.

회로를 달기 전에 정할 것

  1. 실패 비율과 최소 요청 수를 함께 조건으로 둔다. 횟수만 보면 오탐이 잦다.
  2. 무엇을 실패로 셀지 정한다. 우리 잘못인 4xx 를 실패로 세면 안 된다.
  3. 열려 있는 동안의 대안이 없다면 그 회로는 그냥 빠른 장애다. 대안부터 준비하자.
  4. 회로는 상대별로 나눈다. 하나로 묶으면 장애가 번진다.
  5. 반열림에서는 몇 개만 보낸다. 아직 회복 중인 상대를 다시 쓰러뜨리지 않게.
  6. 상태가 바뀔 때마다 로그와 지표로 남긴다. 언제 열리고 언제 닫혔는지가 장애 분석의 가장 큰 단서다.
  7. 재시도와 같이 쓴다면 순서를 정해 둔다. 회로가 열려 있으면 재시도도 하지 말아야 한다.
  8. 기술 설정만으로는 완성이 안 된다. 회로가 열렸을 때 사용자에게 뭘 보여 줄지 UI 까지 설계해야 한다.
  9. 회로가 열렸다는 사실은 알림으로 보낸다. 우리 서비스가 우아하게 버티고 있어도 의존성이 죽었다는 건 누군가 알아야 한다.

우리 회로는 언제 열렸나

회로가 언제 열렸는가:

코드
· 상태 전환을 지표로 남긴다
 circuit_state{target='payment'} 0=닫힘 1=반열림 2=열림
 장애 분석에서 가장 먼저 보는 그래프가 된다

· 진동하고 있지 않은가
 열림↔닫힘이 반복되면 회복 판단이 성급하다
 연속 성공 요구 · 대기 시간 점증으로 완화

· 인스턴스별로 다른 상태인지
 인스턴스마다 회로가 따로 있다
 일부만 열려 있으면 사용자는 간헐적 실패를 겪는다

· 열려 있는 동안 대안이 실제로 동작했는가
 대안 응답 수를 따로 센다
 0 이면 그냥 에러를 던지고 있었던 것이다

핵심 한 줄타임아웃은 한 번을 끊고, 서킷 브레이커는 계속 시도하는 것을 끊는다.

자주 묻는 것

타임아웃과 서킷 브레이커는 뭐가 다를까?

타임아웃은 요청 한 번을 얼마나 기다릴지를 끊고, 서킷 브레이커는 실패할 걸 알면서 계속 시도하는 걸 끊는다. 실패가 기준을 넘으면 요청을 보내지 않고 바로 실패시킨다.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

장애·확장 글 22편 모두 보기 →