타임아웃과 재시도, 서킷 브레이커
장애는 이렇게 번진다
결제 API 지연 3s → 요청 스레드 점유 → 스레드 풀 고갈
→ 무관한 상품 조회까지 응답 불가 (연쇄 장애)
- 원인은 대개 완전한 다운이 아니라 느려짐이다. 느린 응답이 자원을 잠식한다
3층 방어
| 장치 | 역할 | 기준 |
|---|
| 타임아웃 | 무한 대기 차단 | 하류 p99보다 약간 크게, 상류 타임아웃보다는 짧게 |
| 재시도 | 일시 오류 흡수 | 멱등 요청만, 지수 백오프와 지터, 1~2회 |
| 서킷 브레이커 | 실패가 지속되면 호출 자체를 차단 | 실패율 임계 초과 시 open, 일정 시간 후 half-open으로 탐색 |
재시도가 장애를 키우는 경우
상대가 느려진 이유가 부하면, 재시도는 그 부하를 더 실어 줍니다. 모두가 동시에 다시 보내면
상대는 회복할 틈이 없습니다.
간격을 점점 벌린다
그 간격에 무작위를 섞는다
전체 재시도 횟수에 예산을 둔다
셋째가 자주 빠집니다. 호출 단계가 셋이고 각 단계가 세 번 재시도하면 총 27번이 됩니다.
전체 예산을 두지 않으면 이 곱셈이 조용히 일어납니다.
어디에 무엇을 두나
| 자리 | 두는 것 |
|---|
| 호출마다 | 기다림 상한 |
| 같은 대상 호출을 묶어 | 실패율을 보고 끊기 |
| 요청 전체에 | 남은 시간 예산 |
마지막이 있으면 안쪽에서 무한히 재시도하는 일이 막힙니다. 남은 예산을 넘기면 더 시도하지
않고 바로 실패로 돌립니다. 그러면 사용자는 오래 기다리다 실패하는 대신 빨리 실패를 받습니다.
실무 포인트
- 타임아웃을 설정하지 않은 HTTP 클라이언트가 가장 흔한 사고 원인이다. 연결과 읽기 모두 지정한다
- 계층마다 재시도를 걸면 재시도 폭풍이 된다. 2단계씩 2회면 하류에는 8배가 도달한다
- open 상태에서 무엇을 반환할지(캐시된 값, 부분 응답, 명시적 오류) 미리 정한다
- 자원 격리(벌크헤드)로 결제 호출용 커넥션 풀과 조회용 풀을 분리한다
Q.외부 API 하나가 느려졌을 때 전체 서비스가 멈추는 이유는?
느린 호출이 스레드나 커넥션을 붙잡고 있어 다른 요청이 쓸 자원이 사라지기 때문입니다.
| 순서 | 벌어지는 일 |
|---|
| 1 | 외부 호출이 5초씩 걸린다 |
| 2 | 그 호출을 하는 요청이 스레드를 5초간 점유한다 |
| 3 | 스레드풀이 그 요청들로 가득 찬다 |
| 4 | 외부 호출과 무관한 요청도 스레드를 못 받는다 |
| 5 | 전체 서비스가 응답하지 않는다 |
4번이 핵심입니다. 문제는 외부 API 하나인데 증상은 전체 장애로 나타납니다. 그래서 원인을 찾기 어렵습니다.
| 막는 방법 | 내용 |
|---|
| 타임아웃 | 5초가 아니라 상위 목표 안으로 짧게 |
| 격리 | 외부 호출용 자원을 따로 둔다. 그것만 마른다 |
| 차단기 | 실패가 지속되면 즉시 실패로 응답한다 |
| 대체 응답 | 캐시된 값이나 축소된 응답을 준다 |
| 비동기화 | 즉시 필요 없는 호출은 큐로 뺀다 |
두 번째가 근본입니다. 자원을 나눠 두면 그 부분만 고장 나고 나머지는 계속 돕니다.
흔한 실수: 타임아웃만 줄여 해결하려는 것. 짧게 해도 그 시간 동안 자원을 점유하는 것은 같아서, 호출량이 많으면 여전히 전체가 막힙니다.
Q.서킷 브레이커의 상태 전이를 설명해주세요
| 상태 | 행동 | 다음으로 넘어가는 조건 |
|---|
| 닫힘 | 정상적으로 호출한다 | 실패율이 임계를 넘으면 열림으로 |
| 열림 | 호출하지 않고 즉시 실패한다 | 정해진 시간이 지나면 반열림으로 |
| 반열림 | 소수의 호출만 시험해 본다 | 성공하면 닫힘, 실패하면 다시 열림 |
열림 상태의 의미가 중요합니다. 실패할 것이 뻔한 호출을 하지 않는 것입니다. 그러면 우리 자원을 점유하지 않고, 상대에게 부하를 더 얹지도 않습니다.
반열림이 필요한 이유는 회복을 감지하기 위해서입니다. 열림을 영구히 유지하면 상대가 복구돼도 모릅니다. 소수만 보내 확인하고, 괜찮으면 전체를 재개합니다.
설정에서 정할 것들입니다.
| 항목 | 판단 |
|---|
| 실패율 임계 | 낮으면 자주 끊긴다. 보통 50% 안팎 |
| 최소 호출 수 | 호출이 적을 때 우연한 실패로 열리지 않게 |
| 열림 유지 시간 | 상대의 회복 시간을 고려한다 |
| 무엇을 실패로 볼지 | 타임아웃과 5xx 는 실패, 4xx 는 아니다 |
흔한 실수: 마지막 항목을 구분하지 않는 것. 잘못된 요청(4xx)을 실패로 세면, 우리 버그 때문에 정상인 상대를 차단하게 됩니다.
Q.벌크헤드(격리)는 무엇을 막아주나요?
자원을 칸으로 나눠 한 칸이 마르더라도 다른 칸은 계속 돌게 하는 것입니다. 배의 격벽에서 온 이름입니다.
| 나누는 대상 | 예 |
|---|
| 스레드풀 | 외부 호출용과 내부 처리용을 분리 |
| 커넥션 풀 | 조회용과 쓰기용, 또는 기능별 |
| 프로세스나 인스턴스 | 중요 API 와 배치를 다른 서버로 |
| 큐 | 우선순위별로 다른 큐 |
없을 때와 있을 때가 이렇게 갈립니다.
| 구성 | 외부 API 가 느려지면 |
|---|
| 공용 풀 하나 | 그 호출이 풀을 채워 전체 서비스가 멈춘다 |
| 기능별 분리 | 그 기능만 실패하고 나머지는 정상 |
차단기와 함께 쓰면 더 낫습니다. 격리가 피해 범위를 가두고, 차단기가 그 안에서도 빨리 포기하게 만듭니다.
대가는 자원 효율입니다. 나눠 두면 한쪽이 남고 다른 쪽이 부족한 상황이 생깁니다. 전체 활용도를 조금 내주고 장애 격리를 얻는 거래입니다.
흔한 실수: 격리를 인스턴스 분리로만 생각하는 것. 한 프로세스 안에서 스레드풀을 나누는 것만으로도 큰 효과가 있고, 훨씬 값쌉니다.
Q.부하가 지속될 때 요청을 거부하는 것이 왜 필요한가요?
받을 수 없는 요청을 받으면 아무도 응답받지 못합니다. 일부를 거절하는 것이 전체를 지키는 방법입니다.
| 거절하지 않으면 | 결과 |
|---|
| 큐에 계속 쌓인다 | 대기가 길어져 이미 타임아웃된 요청을 처리한다 |
| 메모리가 는다 | 대기 중인 요청 객체가 쌓인다 |
| 지연이 무한히 커진다 | 모든 사용자가 실패한다 |
핵심은 이미 포기한 요청을 처리하는 낭비입니다. 클라이언트가 3초에 타임아웃하는데 큐 대기가 10초면, 처리한 결과를 받을 사람이 없습니다. 그런데도 자원은 씁니다.
| 방법 | 내용 |
|---|
| 큐 길이 제한 | 넘으면 즉시 429 로 응답한다 |
| 대기 시간 기준 폐기 | 큐에서 오래된 요청은 버린다 |
| 우선순위 | 결제는 받고 추천은 거절한다 |
| 동시 처리 수 제한 | 처리 중인 요청 수에 상한을 둔다 |
| 축소 응답 | 무거운 부분을 빼고 핵심만 준다 |
빨리 실패하는 것이 사용자에게도 낫습니다. 10초 기다려 실패하는 것보다 즉시 실패해 재시도 안내를 받는 편이 낫습니다.
흔한 실수: 큐를 무한으로 두는 것. 요청을 잃지 않으려는 의도지만, 결과적으로 모든 요청을 잃습니다.
먼저 스스로 답해보고 아래 답변과 견줘보세요. 막히는 부분은 문제로 확인할 수 있어요.
읽었으면 문제로 확인해보세요
분산 시스템 문제를 풀면 틀린 문제가 자동으로 노트에 쌓입니다. 가입 없이 5문제를 먼저 풀어볼 수도 있어요.