LabHub
배우기 러닝패스 코스

통합과 배포 · 시간에도 예산이 있다 · 이론

넉넉한 타임아웃이 장애를 키운다

LabHub 에서 이어서 보기

한 줄 요약

타임아웃은 호출마다 따로 정하는 숫자가 아니라 상위 요청 하나에 주어진 예산을 아래로 나눠 주는 일이고, 예산이 모자라면 호출하지 않고 곧바로 실패하는 것이 가장 싼 선택이다.

왜 이게 필요했나

"타임아웃을 좀 넉넉히 잡아 두자" 는 말은 안전해 보이지만 반대다. 상대가 느려졌을 때 무슨 일이 벌어지는지 따라가 보면 알 수 있다.

상대의 응답이 평소 200ms 인데 어느 날 30초가 됐다. 우리 타임아웃이 60초라면 우리 워커는 30초 동안 그 호출에 묶인다. 워커가 50개라면 초당 처리량이 급격히 떨어지고, 우리를 부르는 쪽에서 보면 우리가 느려진 것이다. 그쪽도 타임아웃이 넉넉하면 같은 일이 한 계층 위에서 또 벌어진다. 한 시스템의 지연이 위로 번져 올라가는 이 현상 때문에, 타임아웃은 "상대를 기다려 주는 배려" 가 아니라 우리를 지키는 차단기다.

여기서 흔한 두 번째 실수가 타임아웃을 한 값으로만 잡는 것이다. [파이썬 requests 의 고급 사용 문서](https://requests.readthedocs.io/en/latest/user/advanced/)는 타임아웃을 (connect, read) 두 값으로 줄 수 있다고 적는다. 둘을 나누는 이유는 의미가 완전히 다르기 때문이다.

연결 실패의 모양도 갈린다. Connection refused 는 패킷이 목적지까지 갔다 돌아온 것이라 거의 즉시 끝나고, 아무도 대답하지 않는 경우에는 상한까지 기다린 뒤 타임아웃이 난다. 실험용으로는 [RFC 5737](https://www.rfc-editor.org/rfc/rfc5737.html)이 문서용으로 예약해 둔 192.0.2.0/24 같은 주소를 쓴다 — 어디로도 라우팅되지 않으므로 패킷이 조용히 사라지고, 연결 타임아웃이 어떤 모양인지 안전하게 볼 수 있다.

어떻게 동작하나

예산으로 생각하면 규칙은 세 줄로 줄어든다.

첫째, 상위 요청에 총 예산을 정한다. 화면이 2초 안에 그려져야 한다면 그 요청의 예산은 2000ms 다.

둘째, 각 하위 호출의 읽기 상한은 그 순간 남은 예산이다. 첫 호출에 2000ms, 그것이 300ms 를 쓰면 다음 호출에는 1700ms. 이렇게 하면 어느 호출도 총 예산을 넘길 수 없다.

셋째, 남은 예산이 하한보다 작으면 호출하지 않는다. 200ms 남았는데 평소 400ms 걸리는 호출을 거는 것은 실패를 200ms 뒤로 미루는 일일 뿐이다. 그 자리에서 실패하면 그 200ms 로 부분 응답이라도 만들 수 있다.

재시도는 예산을 먹는다. 이 계산을 빠뜨리는 구현이 아주 많다. 읽기 상한 2초에 재시도 3회면 최악의 경우 6초에 대기 시간까지 더해진다. 대기는 지수로 늘리고 무작위를 섞는 것이 정석인데(같은 순간에 실패한 클라이언트들이 같은 순간에 다시 몰리는 것을 막는다), 그 대기도 예산에서 나간다. 그래서 재시도 직전에 "지금 기다리고 한 번 더 걸면 예산 안에 들어오는가" 를 묻고, 아니면 멈춘다.

남은 예산은 아래로 넘긴다. 우리가 1200ms 만 남았는데 하위 서비스가 자기 기준으로 5초를 기다리면, 그 4초는 아무도 안 보는 답을 만드는 데 쓰인다. 그래서 남은 예산을 헤더에 실어 보내고 받는 쪽이 자기 상한으로 삼게 한다. 상태 코드 쪽에서는 [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html)이 정의하는 504(Gateway Timeout)가 "상위가 기다리다 끊었다" 를, [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html)의 429(Too Many Requests)가 "속도를 줄여라" 를 뜻한다. 둘은 다른 신호이고 대응도 다르다.

예산 2000ms  ├─ 호출 A  남은 2000 → 300ms 사용  ├─ 호출 B  남은 1700 → 250ms 사용  ├─ 재시도  남은 1450 → 대기 200 + 호출 400  └─ 호출 C  남은  850 → 하한 1000 미만이면 걸지 않고 즉시 실패

현장에서 만나는 모습

첫째, 설정한 타임아웃이 실제로 안 걸리는 경우가 흔하다. 앱 설정은 30초인데 실패는 127초에 났다면, 그 값이 적용되지 않고 커널의 SYN 재전송이 소진된 것이다. 설정을 고치기 전에 실제로 몇 초에 끊기는지 재 본다.

둘째, 타임아웃과 커넥션 풀은 함께 움직인다. 풀의 대기 시간에도 상한이 없으면, 호출 자체의 타임아웃이 짧아도 풀에서 기다리느라 전체 시간이 길어진다.

셋째, 예산을 넘겼을 때 무엇을 할지 미리 정해 두지 않는다. 선택지는 대개 셋이다. 부분 응답을 준다, 캐시의 옛 값을 준다, 실패를 준다. 무엇을 고르든 상관없지만 고르지 않으면 코드가 가장 나쁜 것을 고른다 — 끝까지 기다린다.

넷째, 숫자의 근거를 안 남긴다. "읽기 3초" 가 어디서 나왔는지 적혀 있지 않으면 아무도 못 바꾼다. 상대의 관측된 99분위에 여유를 곱해 잡았다고 한 줄 적어 두는 것으로 충분하다.

다음 실습에서 할 것

느려지는 파트너 서버를 띄워 연결 거부·연결 타임아웃·읽기 타임아웃 세 가지 실패 모양을 직접 재 본다. 연결과 읽기의 상한을 나눠 잡는 호출기를 만들고, 그 위에 총 예산을 지키는 호출기를 얹어 남은 예산이 곧 다음 호출의 상한이 되게 한다. 예산이 하한에 못 미치면 걸지 않고 건너뛰게 하고, 재시도가 예산을 먹는 계산까지 넣는다. 마지막으로 남은 예산을 헤더로 아래에 넘기고, 화면 하나의 예산표를 숫자로 적는다.