이번에 결제 기능을 추가하며 외부 API를 연동하는 일이 생겼다.
외부 API 연동을 위해 기존에 설정해둔 Connection Timeout과 Read Timeout은 각각 5초/5초였으나, 큰 고민 없이 설정했기에 찝찝함이 남았다.
이에 근거 있는 값을 찾기 위해 조사했다.
Timeout 값 설정에 앞서, 왜 Timeout을 명시적으로 설정해야 할까?
Timeout은 무한대로 두고, 사용자에게 선택권을 맡길 수도 있는 것 아닌가?
오히려 Timeout을 부적절하게 설정하게 되면 서비스 이용에 불편함을 겪을 수 있다. 그럼에도 Timeout을 설정하는 이유는 무엇일까?
여러 이유가 있겠지만, 서버 입장에서 두 가지 이유가 존재한다.
이런 자원은 한정적이다. 만약 적절한 제어 없이 계속 연결을 유지하게 된다면 정작 다른 정상적인 요청을 처리할 때 자원이 고갈되어 이를 처리할 수 없게 된다.
단순히 생각해보면 UX 측면에서도 좋지 않다.
사용자가 응답 없는 화면을 몇 분 동안 기다리는 상황이 발생할 수 있다. 적절한 Timeout을 설정하고 빠르게 오류를 반환하는 것이 훨씬 나은 경험을 제공한다.
따라서 Timeout은 외부 API를 요청할 때 필수 설정이다.
클라이언트가 서버와 TCP 연결을 성립하기 위해 기다리는 최대 시간
connection timeout은 3-way Handshake에 소요되는 시간을 기준으로 설정해야 한다.
TCP 연결 과정에서 발생 가능한 지연 요인을 고려해 값을 정했다.

TCP 연결 과정
1. 클라이언트 → 서버: SYN
2. 서버 → 클라이언트: SYN+ACK
3. 클라이언트 → 서버: ACK
한 번의 요청에 RTT(Round-Trip Time)만큼의 시간이 필요하다. 즉, SYN을 전송해 응답을 받는 데 걸리는 시간이 RTT다.
실제 환경에서는 네트워크 지터나 큐잉 지연으로 RTT가 늘어날 수 있다.
더 큰 문제는 패킷 손실이다. 패킷 손실 시 재요청이 발생하고, 이 과정이 전체 연결 시간에 영향을 준다.
최초 SYN 전송 후 Init RTO(초기 재전송 타임아웃) 기간만큼 대기 후 재전송을 시도한다.
여기서는 재전송 한 번만 고려해 시간을 계산했다.
TCP는 패킷 재전송 여부를 결정하기 위해 RTO(Retransmission Timeout)를 사용한다.
RTO 계산식
RTO = SRTT + max(G, K × RTTVAR)
식 전체를 이해할 필요는 없다.
RTO는 재전송을 결정하기 위한 대기시간이며, 실제 재전송 간격은 RTO 값을 초과하지 않는다.
다만, Init RTO는 이 계산식이 적용되지 않는다. 최초 전송 시 측정된 RTT가 없기 때문에 상수로 대체하기 때문이다.
| Init RTO | Min RTO |
|---|---|
| RTT 측정값이 없는 상태에서 사용하는 기본 RTO | 시스템이 허용하는 최소 RTO |
| RFC 6298은 Init RTO를 1 초(1000 ms)로 권장 | 리눅스 기본값 200 ms |
macOS(XNU)는 Min RTO를 최소 1000 ms 이상으로 보장한다는 글이 있으나, 공식문서나 설정파일에서는 직접 확인하지 못했다.
Connection Timeout은 다음 값을 기준으로 정하는 것이 합리적이라고 결론내렸다.
3-way Handshake(Init RTO) + 패킷 손실 시 재전송 여유(Min RTO)
따라서 Connection Timeout = 1000 ms + 200 ms = 1200 ms.
이 설정은 최초 SYN 전송 후 1초 대기, 응답 불가 시 재전송(200 ms)까지 커버하므로, 패킷 손실 상황에서도 연결 시도를 유지할 수 있다.
결과적으로 이번 프로젝트에서는 Connection Timeout을 1200 ms로 결정했다.
(Init RTO와 Min RTO 값은 RFC 6298에서 참고했다.)
TCP 연결이 성립된 후, 서버가 클라이언트에게 응답(데이터)을 보내주는 데 걸리는 최대 시간
연결 성립 후 클라이언트는 HTTP 요청 본문을 서버로 전송하고, 서버는 이를 처리한 뒤 응답을 반환한다.
이 과정에서 대기하는 시간을 Read Timeout으로 지정한다.
Connection Timeout 단계에서 이미 TCP 연결이 완성되었으므로, Read 과정은 RTO 기반이 아닌 비즈니스 로직 처리 시간과 네트워크 재전송 여유를 고려해야 한다.
Read Timeout은 외부 서버의 비즈니스 로직 처리 시간을 기준으로 설정한다.

연동 대상인 Toss 결제 승인 API는 구체적인 타임아웃 가이드를 제공하지 않는다.
유사한 자동결제 승인 API 문서에서는 최소 30초를 권장했다.
처음에는 권장치인 30000 ms로 설정했으나, 실제 테스트 시 과도하게 긴 값이라는 판단이 들었다. (5 000 ms로도 요청 처리에 문제가 없었다.)
이후 요청 성공 테스트를 반복하며 Read Timeout 값을 조정했다.
결론적으로 Read Timeout을 6000 ms로 설정했다.
비즈니스 로직 응답 시간(3000 ms) + 네트워크 재전송 여유(200 ms) × 2회 + 추가 안전 여유(1600 ms)
@Bean
public RestClient.Builder restClientBuilder() {
var clientFactory = new HttpComponentsClientHttpRequestFactory();
clientFactory.setConnectTimeout(1_200);
clientFactory.setReadTimeout(6_000);
...
return RestClient.builder()
.requestFactory(clientFactory)
.baseUrl(baseUrl);
}
이 코드는 외부 API 연동 시 Timeout 설정 코드이다.
Connection Timeout과 Read Timeout을 RTO, RTT, 외부 API 비즈니스 로직 처리 속도를 모두 고려해 정했다.
아쉬운 점은 macOS 환경의 Init RTO와 Min RTO를 명시한 공식문서를 찾지 못한 것이다. 실제 서버 환경과 달리 리눅스 기반으로 처리한 것에 아쉬움이 남는다.
또한 Read Timeout을 테스트 기반으로 결정했으므로, 운영 환경이 변경될 때마다 Timeout 값도 적절히 변경돼야 하고 그때마다 추가 검증 과정이 필요하다.
그럼에도 이 설정은 소규모 서버 환경에서 TCP 재전송 및 외부 API 처리 시간을 충분히 커버하므로, 현재 상황에서는 안정적인 결제 기능 연동이 가능할 것 같다.
실제 서버를 운영하게 된다면 Timeout의 값도 언제든지 변경할 수 있도록 외부설정으로 관리하는 것은 어떨까?
# application.yml
external-api:
connect-timeout-ms: 1200
read-timeout-ms: 6000 # 개발 환경에서 핏하게 설정
# application-prod.yml
external-api:
read-timeout-ms: 8000 # 운영 환경에서는 약간 여유
잘 보고 가용 👍