본문 바로가기
IT

RestTemplate 응답 타입에 따른 20초 타임아웃 트러블슈팅

by urosie 2025. 12. 4.
반응형

문제 상황

A서버 → B중계서버 → 외부 API로 총 15번의 API 호출을 하는데, 모든 요청이 정확히 20초씩 걸리는 현상이 발생했다.

  • 전체 소요 시간: 15회 × 20초 = 약 5분
  • 외부 API 응답 시간: 1초 (정상)
  • A서버 DB 처리: 1초 (정상)

초기 가설들

가설 1: 타임아웃 설정 문제

정확히 20초라는 패턴 때문에 어딘가에 20초 타임아웃이 설정되어 있다고 의심했다.

  • B서버의 RestTemplate 타임아웃 설정 확인
  • 네트워크/프록시 타임아웃 확인
  • 결과: 명시적인 타임아웃 설정은 없었음

가설 2: 외부 API의 Rate Limiting

외부 API가 요청을 지연시키거나 Rate Limit을 걸고 있을 가능성을 검토했다.

  • 결과: 외부 API는 실제로 1초 만에 응답을 주고 있었음

가설 3: B서버의 병목

B서버에서 순차 처리나 Connection Pool 고갈로 인한 대기 시간이 발생했을 가능성을 확인했다.

public ResponseEntity<Map<String, Object>> getData(ReqDto request) {
    // RestTemplate로 외부 API 호출
    RestTemplate restTemplate = new RestTemplate();
    ResponseEntity<Map<String, Object>> response = 
      restTemplate.exchange(url, HttpMethod.GET, requestEntity, ...);
    
    return response;
}
  • 코드에 명시적인 sleep이나 delay는 없었음
  • Rate limiter 라이브러리 사용도 없었음

핵심 로그 분석

B서버의 로그를 자세히 확인한 결과:

2025-12-03 14:42:00,816  INFO 첫번째 api 호출
2025-12-03 14:42:01,558  INFO 첫번째 api 응답받음
2025-12-03 14:42:21,605  INFO 두번째 api 호출

중요한 발견: 외부 API로부터 응답을 받은 시점(14:42:01)과 다음 API 호출 시작(14:42:21) 사이에 정확히 20초의 간격이 있었다.

이는 B서버가 아니라 A서버에서 응답을 기다리는 동안 20초가 소요되고 있다는 의미였다!

A서버 코드 분석

기존 코드 (문제의 원인)

// Connection 방식으로 B서버 호출
HttpURLConnection con = (HttpURLConnection) url.openConnection();

// 거래 송신
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(con.getOutputStream()));
bw.write(json.toString());
bw.flush();

// 거래응답처리 - 문제 지점!
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));

String line = StringUtils.EMPTY;
while((line = in.readLine()) != null) {  // ← 여기서 블로킹!
    sb.append(line);
}

문제점: readLine()은 라인의 끝(\n) 또는 **스트림의 끝(EOF)**을 만날 때까지 블로킹된다.

근본 원인 파악

HTTP Response 구조 확인

B서버로부터 받는 응답 헤더:

HTTP/1.1 200 OK
server: nginx
content-type: application/json
content-length: 483
x-request-id: 08e48806e5f69aeaa491eb40f041dbeb
(Connection 헤더 없음 = keep-alive 기본값)

핵심 문제:

  1. B서버가 데이터(483바이트)는 1초 만에 전송 완료
  2. 하지만 Connection: keep-alive로 인해 연결을 유지
  3. A서버의 readLine()은 EOF를 기다림
  4. 약 20초 후 Read Timeout 발생하면서 루프 종료
  5. 그제서야 다음 요청 시작

결정적 증거 발견!

응답 헤더를 자세히 확인한 결과:

 
 
http
Connection: keep-alive
Keep-Alive: timeout=20    ← 바로 이것이 20초의 정체!
Content-Length: 483

핵심 문제:

  1. B서버가 데이터(483바이트)는 1초 만에 전송 완료
  2. Connection: keep-alive로 인해 연결을 유지
  3. Keep-Alive: timeout=20 → 서버가 20초 동안 다음 요청을 기다림
  4. A서버의 readLine()은 EOF를 기다리며 블로킹
  5. 정확히 20초 후 서버가 타임아웃으로 연결 종료 → EOF 발생
  6. 그제서야 다음 요청 시작

이제 왜 정확히 20초였는지 완벽하게 설명된다!

 

시도한 해결 방법들

시도 1: B서버에 Connection: close 헤더 추가

@RequestMapping(value = {"/search"}, method = {RequestMethod.POST})
public ResponseEntity<Map<String, Object>> search(
    @RequestBody ReqDto request,
    HttpServletResponse httpResponse) {
    
    httpResponse.setHeader("Connection", "close");
    
    ResponseEntity<Map<String, Object>> response = 
        this.hService.getData(request);
    return response;
}

결과: 404 에러 발생 (HttpServletResponse와 ResponseEntity의 충돌)

시도 2: A서버에서 RestTemplate 사용 + 타임아웃 설정

SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(5000);

RestTemplate template = new RestTemplate(factory);
String resultStr = template.postForObject(url, request, String.class);

결과: SocketTimeoutException: Read timed out 발생

에러 메시지:

while extracting response for type String and content type application/json; 
nested exception is java.net.SocketTimeoutException: read timed out

최종 해결책

응답 타입을 String에서 Map으로 변경

// AS-IS: String으로 받기 (문제 발생)
String resultStr = template.postForObject(url, request, String.class);

// TO-BE: Map으로 받기 (해결!)
ResponseEntity<Map<String, Object>> result = template.exchange(
    url,
    HttpMethod.POST,
    new HttpEntity<>(request),
    new ParameterizedTypeReference<Map<String, Object>>() {}
);

Map<String, Object> resultMap = result.getBody();

기존 JSONObject 처리 로직 수정

// 기존 코드 (String 기반)
JSONObject resultJb = new JSONObject(resultStr);
JSONArray list = resultJb.getJSONArray("DATA");

// 수정 코드 (Map 기반)
JSONObject resultJb = new JSONObject(resultMap);
JSONArray list = resultJb.getJSONArray("DATA");

결과

성능 개선: 5분 → 1초 (300배 향상! 🎉)

  • 15개 API 호출 전체가 1초 만에 완료
  • 각 API 호출당 대기 시간 제거

왜 해결되었는가?

RestTemplate의 HttpMessageConverter 동작 차이

1. String.class로 받을 때 - StringHttpMessageConverter

RestTemplate이 String.class로 응답을 받을 때는 StringHttpMessageConverter가 사용된다.

 
 
java
// StringHttpMessageConverter의 내부 동작 (단순화)
public String read(Class<String> clazz, HttpInputMessage inputMessage) {
    InputStream body = inputMessage.getBody();
    
    StringBuilder sb = new StringBuilder();
    int ch;
    while ((ch = body.read()) != -1) {  // EOF를 만날 때까지 블로킹!
        sb.append((char) ch);
    }
    return sb.toString();
}

문제점:

  • 스트림을 끝까지 읽으려고 시도
  • EOF(End of File)를 만날 때까지 read() 블로킹
  • 하지만 Connection: keep-alive로 인해 연결이 유지됨
  • EOF가 오지 않고 서버의 Keep-Alive: timeout=20이 만료될 때까지 대기
  • 정확히 20초 후 서버가 연결을 끊으면서 EOF 발생
  • 그제서야 루프 종료

타임라인:

 
 
00:00 - 데이터 수신 완료 (483바이트)
00:01 - StringConverter: "EOF 올 때까지 기다려야지..." (블로킹)
00:02 - ...
...
00:20 - 서버의 Keep-Alive timeout 만료, 연결 종료, EOF 발생!
00:20 - 루프 종료, 다음 요청 가능

2. Map<String, Object>로 받을 때 - MappingJackson2HttpMessageConverter

RestTemplate이 Map<String, Object>나 구조화된 타입으로 응답을 받을 때는 MappingJackson2HttpMessageConverter가 사용된다.

 
 
java
// MappingJackson2HttpMessageConverter의 내부 동작 (단순화)
public Map read(Type type, HttpInputMessage inputMessage) {
    InputStream body = inputMessage.getBody();
    
    // HTTP 헤더에서 Content-Length 확인
    long contentLength = inputMessage.getHeaders().getContentLength();
    
    if (contentLength > 0) {
        // Content-Length만큼만 정확히 읽음!
        byte[] buffer = new byte[(int) contentLength];
        body.read(buffer, 0, (int) contentLength);
        
        // JSON 파싱
        return objectMapper.readValue(buffer, type);
    }
}

해결 원리:

  • HTTP 헤더의 Content-Length: 483을 확인
  • 정확히 483바이트만 읽음
  • EOF를 기다릴 필요 없음!
  • Keep-Alive timeout과 무관하게 즉시 완료
  • 읽은 바이트를 바로 JSON으로 파싱

타임라인:

 
 
00:00 - Content-Length 확인: 483바이트
00:01 - 483바이트 정확히 읽기 완료
00:01 - JSON 파싱 완료, 즉시 반환!
00:01 - 바로 다음 요청 가능

핵심 차이:

  • StringConverter: EOF 기반 읽기 → Keep-Alive 환경에서 비효율적
  • JacksonConverter: Content-Length 기반 읽기 → 정확하고 효율적

동작 흐름 비교

 
 
[String.class 사용 시]
1. B서버가 483바이트 전송 (1초) ✓
2. A서버가 데이터 수신 완료 ✓
3. StringConverter: "EOF 올 때까지 기다려야지..."
4. Connection은 Keep-Alive로 계속 열려있음
5. 20초 후 Read Timeout 발생
6. 다음 요청 시작

[Map.class 사용 시]
1. B서버가 483바이트 전송 (1초) ✓
2. A서버가 데이터 수신 완료 ✓
3. JacksonConverter: "Content-Length가 483이네? 다 받았으니 끝!"
4. 즉시 JSON 파싱 후 완료
5. 바로 다음 요청 시작

교훈

1. RestTemplate 사용 시 응답 타입 선택의 중요성

  • String.class는 단순해 보이지만 스트림 처리 방식이 비효율적
  • 구조화된 데이터(Map, DTO)로 받는 것이 더 효율적이고 안전함

2. HTTP Keep-Alive와 Content-Length의 관계

  • Keep-Alive 연결에서는 Content-Length가 데이터 경계를 구분하는 핵심
  • EOF에 의존하는 읽기 방식은 Keep-Alive 환경에서 문제 발생

3. 타임아웃 = 항상 네트워크 문제는 아니다

  • 처음엔 네트워크 지연이나 서버 문제로 의심
  • 실제로는 클라이언트의 응답 파싱 방식이 원인

4. 로그의 중요성

  • B서버의 상세한 타임스탬프 로그가 문제 지점을 정확히 찾아냄
  • "응답받음"과 "다음 호출" 사이의 간격이 핵심 단서

다른 API들은 왜 괜찮았을까?

이 API(NCP)에서만 문제가 발생한 이유:

  1. 다른 API들은 Connection: close 헤더를 명시적으로 보냄
  2. 또는 서버가 응답 후 즉시 연결을 끊음
  3. 또는 Chunked Transfer Encoding으로 명확한 종료 신호 전송

NCP API만 Keep-Alive를 유지하면서 String 방식의 문제가 드러난 것!

결론

네트워크나 서버 문제로 보였던 20초 타임아웃은 실제로는 RestTemplate의 HttpMessageConverter 선택이 원인이었다.

String.class 대신 Map<String, Object>로 받도록 변경하여 Content-Length 기반의 효율적인 읽기 방식을 사용함으로써, 5분 걸리던 작업을 1초로 단축할 수 있었다.

항상 문제의 근본 원인을 찾기 위해 로그를 꼼꼼히 확인하고, 다양한 가설을 세워 하나씩 검증하는 과정이 중요하다는 것을 다시 한번 깨달았다.

반응형

댓글