타임아웃 기본값이 「무한」인 클라이언트가 많다 — 연결·읽기·전체 타임아웃

외부 API 가 에러도 없이 응답만 안 주자, 우리 서버 스레드가 하나씩 묶여 몇 분 만에 서비스가 멈췄어요. 타임아웃을 안 걸면 무한정 기다리는 클라이언트가 많은 이유와 값 정하는 법을 다뤄요.

외부 API 를 부르는 코드가 있어요. 평소엔 200ms 면 끝나는 호출이라 타임아웃을 따로 안 걸었어요.

어느 날 그 API 가 응답을 안 주기 시작했어요. 에러를 주는 것도 아니고 그냥 안 줘요. 우리 서버의 스레드가 하나씩 거기 붙잡히고, 몇 분 만에 전부 소진돼요.

서비스 전체가 멈췄는데 CPU 는 낮고 메모리도 여유가 있어요. 모든 스레드가 응답을 기다리며 잠들어 있을 뿐이에요.

타임아웃을 안 건 게 아니라 무한대로 건 거예요. 명시하지 않으면 많은 클라이언트의 기본값이 그렇습니다.

그리고 이 사고는 상대가 완전히 죽었을 때가 아니라 반쯤 살아 있을 때 일어나요.

왜 그런가

네트워크 호출은 실패하는 방식이 두 가지예요. 빨리 실패하거나, 영원히 안 끝나거나.

빨리 실패하는 건 다루기 쉬워요. 연결이 거부되면 즉시 예외가 나고, 코드가 그걸 처리하면 돼요.

문제는 두 번째예요. 상대가 연결은 받았는데 응답을 안 주면, 우리 쪽은 계속 기다려요. 타임아웃이 없으면 정말 영원히요. 그동안 그 호출을 한 스레드와 커넥션은 묶여 있어요.

그래서 상대의 장애가 우리 장애로 번져요. 상대가 완전히 죽으면 오히려 나아요 — 연결이 거부되니 빨리 실패하거든요. 가장 나쁜 건 상대가 반쯤 살아 있는 상태예요. 연결은 받고 응답은 안 주는 상태요.

그리고 타임아웃은 하나가 아니에요. 연결을 맺는 데 걸리는 시간, 응답을 기다리는 시간, 전체 요청이 끝나는 시간이 각각 따로예요. 연결 타임아웃만 걸어 두고 안심하는 경우가 많은데, 위 상황에서는 연결이 이미 성공했으니 그 값은 아무 역할도 안 해요.

앞 편의 재시도와 겹치면 더 나빠져요. 타임아웃이 30초인데 3번 재시도하면 한 요청이 90초를 잡아요. 사용자는 이미 떠났는데 서버는 계속 일하고 있는 상태입니다.

코드로 보면

세 가지 타임아웃을 각각 건다:

코드
 httpClient.get(url)   // 타임아웃 미지정 = 영원히

 연결 타임아웃  : 1~3초    (상대가 살아 있는가)
 읽기 타임아웃  : 3~10초   (응답이 오는가)
 전체 타임아웃  : 위 둘의 합보다 조금 크게

· 타임아웃은 아래로 갈수록 짧아야 한다
 사용자 요청 30초
 → 내부 서비스 호출 10초
 → DB 쿼리 3초
 반대면 아래층은 아직 일하는데 위층이 포기한다
 버려진 작업이 계속 쌓인다

· DB 쪽에도 각각 있다
 statement_timeout / lock_timeout / idle_in_transaction_session_timeout

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

타임아웃 값을 정하는 기준이 있어요. 감으로 정하면 대개 너무 길게 잡습니다.

기준은 상대의 응답 시간 분포예요. 정상 응답의 상위 백분위수를 보고, 거기에 여유를 조금 더한 값으로 잡아요. p99 가 300ms 라면 1초 정도가 합리적이에요. 30초는 "실패를 감지하지 않겠다"는 선언에 가까워요.

그리고 계층 간 관계가 중요해요. 타임아웃은 아래로 갈수록 짧아야 해요. 사용자 요청이 30초라면 내부 호출은 10초, DB 는 3초 같은 식이요. 반대가 되면 아래층은 아직 처리 중인데 위층이 포기해요. 그 작업은 결과를 받을 사람이 없는데도 계속 자원을 씁니다.

여기에 예산 개념을 쓰면 더 정확해요. 요청 전체에 쓸 수 있는 시간을 정해 두고, 각 단계가 남은 시간을 넘겨받는 방식이요. 앞 단계가 오래 걸렸으면 뒤 단계는 그만큼 적게 씁니다. 이러면 전체 시간이 보장돼요.

취소를 전파하는 것도 중요해요. 위층이 포기했을 때 아래층에 "이제 필요 없다"를 알려 주면 자원을 낭비하지 않아요. 많은 환경이 이걸 지원하는데 실제로 연결해 두지 않은 경우가 많습니다.

타임아웃만으로는 부족한 경우도 있어요. 상대가 계속 느리면 매 요청이 타임아웃까지 기다렸다 실패해요. 그 시간 동안 자원이 묶이는 건 마찬가지예요. 그래서 서킷 브레이커가 함께 필요해요 — 실패가 반복되면 아예 시도하지 않는 거죠.

마지막으로 타임아웃이 발생했을 때 무엇을 할지 정해야 해요. 실패로 처리할지, 기본값을 쓸지, 캐시된 값을 쓸지. 정하지 않으면 예외가 위로 올라가 사용자에게 오류로 보입니다.

처방

  1. 모든 외부 호출에 타임아웃을 명시하세요. 라이브러리 기본값을 확인하고, 무한대라면 반드시 바꾸세요.
  2. 연결·읽기·전체 세 가지를 각각 거세요. 하나만으로는 부족합니다.
  3. 값은 상대의 응답 시간 분포에서 정하세요. p99 에 여유를 더한 값이 출발점이에요.
  4. 계층 간 타임아웃을 정렬하세요. 아래로 갈수록 짧아야 합니다.
  5. 취소 전파를 연결하세요. 위층이 포기하면 아래층도 멈춰야 자원이 안 낭비됩니다.
  6. 서킷 브레이커를 함께 쓰세요. 타임아웃만으로는 계속 느린 상대를 다룰 수 없어요.
  7. 타임아웃 시 무엇을 할지 정하세요. 실패, 기본값, 캐시된 값 중에 미리 골라 두세요.
  8. 타임아웃 발생률을 지표로 보세요. 이 값이 오르는 게 의존성 장애의 가장 빠른 신호일 때가 많습니다.
  9. 값을 너무 짧게 잡지도 마세요. 정상 응답까지 자르면 멀쩡한 요청이 실패하고, 재시도가 붙어 있으면 부하만 늘어납니다.

지금 확인해 보세요

정말 걸려 있는가:

코드
· 라이브러리 기본값부터 확인한다
 HTTP 클라이언트 · DB 드라이버 · 캐시 클라이언트 · SDK
 명시 안 했으면 무한대인 경우가 많다

· 실제로 지켜지는지 시험한다
 응답을 지연시키는 가짜 서버를 두고 호출해 본다
 설정한 시간에 끊기는지 눈으로 확인

· 계층 간 정렬 확인
 위층 타임아웃 < 아래층 타임아웃 이면 잘못됐다
 사용자 30s > 내부 호출 10s > DB 3s

· 타임아웃 발생률 — 의존성 장애의 조기 신호
 sum(rate(http_client_timeouts_total[1m])) by (target)

· 스레드가 어디서 잠들어 있는지
 스레드 덤프를 두 번 떠서 같은 지점이면 묶인 것이다

핵심 한 줄타임아웃을 안 걸었다면 무한대로 건 것이다.

브루니가 적어요

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

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