타임아웃 숫자를 4배로 올려 초록불을 만들었다면, 무엇을 고친 것인가
표 한 장

| 항목 | 이전 | 이후 |
|---|---|---|
| 서버 지연 | 700ms | 3000ms |
| 읽기 타임아웃 | 300ms | 1000ms |
| 타임아웃 판정 여유 | 400ms | 2000ms |
| 결과조회 예산 | 300ms | 1000ms |
숫자 네 개가 나란히 커졌다. 이런 표는 대개 “테스트가 간헐적으로 깨져서 값을 올렸다”의 흔적이다. 그리고 대개 초록불이 된다.
문제는 초록불이 되었다는 사실이 무엇을 고쳤는지 말해주지 않는다는 것이다. 이 글은 저 표에서 실제로 읽히는 것, 읽히지 않는 것, 그리고 저 조정이 결함을 없앤 것인지 확률을 낮춘 것인지를 구분한다.
표 안에 이미 들어 있는 산술
표만 보고도 확인되는 관계가 두 개 있다.
타임아웃 판정 여유 = 서버 지연 - 읽기 타임아웃
이전: 700 - 300 = 400
이후: 3000 - 1000 = 2000
결과조회 예산 = 읽기 타임아웃
이전: 300 = 300
이후: 1000 = 1000
즉 네 개의 독립 파라미터가 아니라 두 개의 자유변수(서버 지연, 읽기 타임아웃)와 그로부터 파생된 두 값이다. 그래서 “네 군데를 고쳤다”는 인상과 달리, 실제 결정은 하나다 — 읽기 타임아웃 대비 서버 지연을 얼마나 벌려 놓을 것인가.
그 벌어진 간격이 세 번째 행, 타임아웃 판정 여유다. 이 값이 하는 일은 하나뿐이다. “클라이언트가 이미 포기했음이 확실한 구간”의 폭을 정하는 것. 400ms였던 것이 2000ms가 되었다.
왜 400ms가 부족한가 — 마진은 평균이 아니라 꼬리와 겨룬다
400ms짜리 마진은 “서버가 700ms 걸리는데 클라이언트는 300ms에 포기하니 당연히 타임아웃”이라는 뜻이다. 산술로는 반박할 데가 없다. 반박은 실행 환경에서 나온다.
공용 CI 러너, 컨테이너 CPU 쿼터, JIT 워밍업, GC 정지, 스케줄러 지연 — 이들이 만드는 지연은 평균값이 작아도 꼬리가 길다. 마진 설계에서 겨뤄야 할 상대는 평균이 아니라 그 꼬리다.
이 비대칭이 왜 시스템 전체에서 증폭되는지는 Dean과 Barroso가 정량적으로 보여준 바 있다. 서버 하나가 평시 10ms, 99퍼센타일 1초로 응답한다고 하자. 요청이 그 서버 하나만 거치면 100건 중 1건이 느리다. 그런데 같은 서버 100대에서 병렬로 응답을 모아야 한다면 사용자 요청의 63%가 1초를 넘긴다1. 단일 서버에서 1만 건 중 1건만 1초를 넘기는 경우에도, 서버 2,000대를 거치는 서비스는 사용자 요청 다섯 건 중 거의 한 건이 1초를 넘긴다1.
테스트 환경에서도 성격은 같다. 400ms 마진은 “이 머신에서 400ms를 넘는 예기치 못한 정지는 없다”는 가정이고, 그 가정은 평시에 참이고 꼬리에서 거짓이다. 그리고 CI는 하루에 그 테스트를 수십 번 돌린다 — 꼬리를 뽑을 시행 횟수를 충분히 준다.
2000ms로 올리면 그 가정이 참일 확률이 크게 올라간다. 확률이 올라간다. 참이 되지는 않는다.
이 수정은 결함을 없앤 것이 아니라 확률을 낮춘 것이다
여기서 근거를 하나 놓고 가자. Luo 등은 51개 오픈소스 프로젝트에서 플레이키 테스트를 고친 것으로 보이는 커밋 201개를 정독해 분류했다2. 결과의 요지:
- 가장 큰 원인 범주는 Async Wait로, 분류된 커밋 161건 중 74건(45%)이었다2.
- 그 74건의 수정 중 42건(57%)이
waitFor계열(조건이 만족될 때까지 대기)을 썼고, 20건(27%)이sleep을 썼다2. sleep으로 고친 20건 중 60%는 이미 있던 sleep의 대기 시간을 늘린 것이었다2.- 효과 면에서
waitFor수정 42건 중 23건(55%)은 플레이키함을 완전히 제거했다. 반면 저자들은 sleep과 시간 제한이 걸린 waitFor는 플레이키 실패 확률을 낮출 뿐이며, 다른 머신에서 돌리면 다시 터질 수 있다고 명시한다2.
표의 조정은 정확히 저 “60%”에 해당하는 모양이다. 이미 있던 시간 파라미터를 키운 것.
그렇다고 이 조정이 틀렸다는 뜻은 아니다. 타임아웃 동작 자체를 검증하는 테스트는 어떤 형태로든 시간을 다뤄야 한다. 다만 커밋 메시지에 “flaky fix”라고 적을 자격은 없다. 정직한 문장은 이쪽이다 — “판정 여유를 400ms에서 2000ms로 넓혀 CI 지터에 대한 내성을 높였다. 근본 해법은 서버 스텁의 응답 시점을 벽시계가 아니라 테스트가 직접 제어하는 것이다.”
근본 해법의 모양도 위 논문이 가리키는 방향과 같다. 시간을 기다리지 말고 조건을 기다리거나, 시간 자체를 주입 가능한 의존성으로 만드는 것. 응답을 붙잡고 있다가 테스트가 래치를 풀 때 내보내는 스텁 서버, 혹은 Clock을 주입해 가짜 시계를 진행시키는 방식이면 마진이라는 개념 자체가 사라진다. 마진이 필요 없는 테스트가 마진이 큰 테스트보다 낫다.
읽기 타임아웃 1000ms는 “1초 안에 응답이 온다”는 뜻이 아니다
두 번째 행을 운영 관점에서 다시 읽어야 한다. 자바에서 흔히 말하는 “읽기 타임아웃”은 소켓의 SO_TIMEOUT이고, JDK 문서의 정의는 이렇다.
Enable/disable SO_TIMEOUT with the specified timeout, in milliseconds. With this option set to a positive timeout value, a
read()call on the InputStream associated with this Socket will block for only this amount of time. If the timeout expires, ajava.net.SocketTimeoutExceptionis raised, though the Socket is still valid.3
핵심은 a read() call — 개별 read 호출에 걸리는 제한이지 응답 전체에 걸리는 제한이 아니다. 서버가 1000ms 안에 1바이트씩만 계속 흘려보내면 타이머는 매번 리셋되고, 응답 완성은 무한정 늦어질 수 있다. 이 테스트가 “3000ms 뒤에 응답을 한 번에 보내는” 스텁을 쓰는 한 표의 산술은 맞지만, 같은 숫자를 운영 클라이언트에 그대로 옮기면서 “최악 1초”라고 이해하면 그건 틀린 이해다.
운영에서 응답 전체를 묶고 싶다면 요청 단위 타임아웃(예: Apache HttpClient의 response timeout, OkHttp의 call timeout, gRPC의 deadline)처럼 호출 전체를 덮는 층을 따로 걸어야 한다.
그 숫자는 어디서 와야 하는가 — 4배가 아니라 퍼센타일에서
“이전 값의 3~4배”는 근거가 아니다. 근거 있는 방법은 AWS Builders’ Library에 Marc Brooker가 적어둔 절차다. 아마존에서 서비스 간 호출의 타임아웃을 고를 때는 허용 가능한 오탐(false timeout) 비율을 먼저 정하고(예: 0.1%), 다운스트림 서비스의 그에 대응하는 지연 퍼센타일(이 예에서는 p99.9)을 본다4.
같은 문서가 그 방법의 함정도 같이 적어둔다. 하나는 연결 수립 비용이다. 저자는 타임아웃을 20ms 정도로 아주 낮게 잡았던 시스템에서, 배포 직후에만 소수의 타임아웃이 발생한 사례를 든다. 원인은 타이머에 새 보안 연결 수립 시간이 포함되어 있었고 그 수립이 20ms를 넘겼기 때문이었다. 최종 해법은 타임아웃을 올린 것이 아니라 프로세스 기동 시점에, 트래픽을 받기 전에 연결을 미리 맺어두는 것이었다4. 다른 하나는 p99.9가 p50에 바짝 붙어 있는 서비스인데, 이 경우엔 약간의 패딩이 없으면 작은 지연 증가가 대량의 타임아웃으로 번진다4.
표로 돌아오면 이런 질문이 남는다. 이후 값 1000ms는 다운스트림의 어느 퍼센타일인가? 그 안에 TLS 핸드셰이크가 포함되는가? 표는 대답하지 않는다. 그리고 대답하지 않는 표는 테스트 픽스처로는 충분하지만 운영 설정값의 근거로는 부족하다. 두 용도를 같은 상수로 공유하고 있다면 그 지점이 먼저 분리되어야 한다.
네 번째 행이 진짜 흥미로운 부분이다 — 타임아웃은 실패가 아니다
결과조회 예산이라는 항목이 있다는 것은, 이 시스템이 타임아웃을 “실패”로 처리하지 않는다는 뜻이다. 결제·ATM 계열에서 타임아웃은 실패가 아니라 미상(unknown) 이다. 클라이언트가 응답을 못 받았을 뿐, 서버는 거래를 이미 성공시켰을 수 있다. 그래서 타임아웃 뒤에 “그래서 그 거래가 어떻게 됐나”를 되묻는 조회가 따라오고, 그 조회에도 자기 예산이 필요하다.
여기서 클라이언트 체감 최악 시간이 이렇게 된다.
이전: 읽기 타임아웃 300 + 결과조회 예산 300 = 600ms
이후: 읽기 타임아웃 1000 + 결과조회 예산 1000 = 2000ms
숫자를 3.3배로 올린 결정은 곧 사용자 앞 화면이 멈춰 있는 최악 시간을 600ms에서 2000ms로 늘린 결정이기도 하다. 테스트 안정성과 사용자 대기 시간을 같은 상수로 묶어두면, 한쪽을 고칠 때 다른 쪽이 조용히 따라 움직인다. 표에서 가장 위험한 성질은 이것이다.
그리고 이 예산은 혼자 정해지면 안 된다
마지막으로, 하위 호출의 예산을 독립적으로 정하는 것 자체가 위험하다. Google SRE 책의 계단식 장애 장(章)은 이 문제를 명시적으로 다룬다. 서버는 백엔드로 나가는 데드라인을 새로 지어내지 말고 전파(deadline propagation)해야 한다. 프론트엔드에서 30초 데드라인이 잡혔고 서버 A가 7초를 썼다면 A→B 호출의 데드라인은 23초여야 한다5.
전파가 없을 때 벌어지는 일도 같은 문서가 예시로 든다. A가 B에 10초 데드라인으로 호출하고, B가 8초를 쓴 뒤 C를 부르면서 전파 대신 하드코딩된 20초를 쓰면, C는 이미 아무도 기다리지 않는 요청을 15초 여유가 있다고 믿으며 처리한다5. 같은 장의 표현대로 “RPC에서 지각 제출은 학점을 받지 못한다” — 데드라인이 지난 뒤의 작업은 자원만 쓰고 아무 진전도 만들지 않는다5.
gRPC 공식 문서도 같은 구조를 권한다. 데드라인은 시점(point in time)이고 타임아웃은 기간(duration)이며, 클라이언트가 정한 데드라인이 지나면 서버는 호출을 자동으로 CANCELLED 처리한다. 서버가 다시 클라이언트가 되는 구간에서는 원 데드라인을 존중해야 하고, 클록 스큐를 피하기 위해 gRPC는 데드라인을 이미 흐른 시간을 뺀 타임아웃 형태로 변환해 전달한다6.
이 렌즈로 표를 보면 마지막 행의 위치가 달라진다. “결과조회 예산 1000ms”는 독립 상수가 아니라 상위 거래 데드라인에서 떼어낸 몫이어야 한다. 상위가 2초짜리 거래인데 읽기 1초 + 조회 1초를 각각 잡아두면, 네트워크 왕복과 클라이언트 처리 시간이 들어갈 자리가 남지 않는다.
정리하면
저 표는 나쁜 조치가 아니다. 다만 저 표가 증명하는 것은 정확히 다음까지다.
- 증명된 것: 판정 여유가 400ms → 2000ms로 넓어졌고, 그만큼 환경 지터에 대한 내성이 커졌다.
- 증명되지 않은 것: 플레이키함이 제거되었다는 것. 시간 파라미터를 키우는 수정은 확률을 낮출 뿐이라는 것이 선행 연구의 결론이다2.
- 표가 말하지 않는 것: 1000ms가 어느 퍼센타일에서 왔는지, 연결 수립이 그 안에 포함되는지, 이 상수가 테스트 전용인지 운영과 공유되는지, 그리고 결과조회 예산이 상위 데드라인에서 떼어낸 몫인지.
체크리스트로 옮기면 이렇게 된다.
- 테스트 상수와 운영 설정값을 분리한다. 같은 상수를 공유하면 CI를 달래는 조정이 사용자 대기 시간을 늘린다.
- 운영 타임아웃은 배수가 아니라 허용 오탐률 → 대응 퍼센타일로 정한다4.
- 타이머 안에 연결 수립이 포함되는지 확인한다. 포함된다면 사전 연결로 빼낸다4.
SO_TIMEOUT같은 소켓 단위 타임아웃 위에 호출 전체를 덮는 타임아웃을 따로 건다3.- 하위 예산은 새로 짓지 말고 상위 데드라인에서 떼어낸다56.
- 시간을 기다리는 테스트를 조건을 기다리는 테스트로 바꾼다. 가능하면 시계를 주입한다2.
가장 먼저 확인해야 할 것은 6번이다. 마진을 늘리는 일은 언제든 다시 할 수 있고, 그래서 다시 하게 된다.
아직 안 풀린 것
이 글은 표 한 장에서 읽히는 범위까지만 다뤘다. 저 값들을 만든 코드는 보지 않았으므로, 세 가지는 열려 있다.
- 서버 지연 3000ms를 만드는 쪽이 실제 스텁인지, 실제 다운스트림에 걸어둔 지연 주입인지에 따라 2000ms 마진의 의미가 달라진다.
- “결과조회”가 조회 전용(read-only inquiry)인지, 보상 트랜잭션(reversal)까지 포함하는지에 따라 그 예산의 성격이 다르다. 후자라면 예산 초과 시의 처리가 예산 값보다 훨씬 중요하다.
- 타임아웃 동작을 검증하는 테스트를 벽시계 없이 결정론적으로 쓰는 방법은 스택마다 다르다. 이 부분은 실제 코드를 놓고 따로 정리할 값어치가 있다.
References
이미지 출처: 본문 상단의 비교표는 필자가 전달받은 원본 캡처다. 표에서 도출한 산술 관계(판정 여유 = 서버 지연 − 읽기 타임아웃, 결과조회 예산 = 읽기 타임아웃)는 이미지에 적힌 값만으로 검산 가능하며, 그 밖의 배경(어떤 코드/환경에서 나온 값인지)은 확인된 바 없어 추정으로 다루지 않았다.
-
Jeffrey Dean, Luiz André Barroso, “The Tail at Scale,” Communications of the ACM, Vol. 56 No. 2 (February 2013), pp. 74–80. https://cacm.acm.org/research/the-tail-at-scale/ (저자 배포본: https://barroso.org/publications/TheTailAtScale.pdf) ↩ ↩2
-
Qingzhou Luo, Farah Hariri, Lamyaa Eloussi, Darko Marinov, “An Empirical Analysis of Flaky Tests,” FSE 2014 (ACM SIGSOFT). https://doi.org/10.1145/2635868.2635920 (전문 PDF: https://mir.cs.illinois.edu/marinov/publications/LuoETAL14FlakyTestsAnalysis.pdf) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Oracle,
java.net.Socket.setSoTimeout(int), Java SE 21 API Documentation. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/Socket.html ↩ ↩2 -
Marc Brooker, “Timeouts, retries, and backoff with jitter,” Amazon Builders’ Library. https://d1.awsstatic.com/builderslibrary/pdfs/timeouts-retries-and-backoff-with-jitter.pdf — 벤더 1차 문헌이며 아마존 내부 운영 경험에 근거한 서술이다(외부 재현 데이터는 제시되지 않는다). ↩ ↩2 ↩3 ↩4 ↩5
-
Mike Ulrich, “Addressing Cascading Failures,” in Site Reliability Engineering (O’Reilly, 2016), Chapter 22 — “Latency and Deadlines” 절. https://sre.google/sre-book/addressing-cascading-failures/ ↩ ↩2 ↩3 ↩4
-
gRPC Authors, “Deadlines,” gRPC Documentation (2025-07-07). https://grpc.io/docs/guides/deadlines/ ↩ ↩2