문제 상황
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 기본값)
핵심 문제:
- B서버가 데이터(483바이트)는 1초 만에 전송 완료
- 하지만 Connection: keep-alive로 인해 연결을 유지
- A서버의 readLine()은 EOF를 기다림
- 약 20초 후 Read Timeout 발생하면서 루프 종료
- 그제서야 다음 요청 시작
결정적 증거 발견!
응답 헤더를 자세히 확인한 결과:
Connection: keep-alive
Keep-Alive: timeout=20 ← 바로 이것이 20초의 정체!
Content-Length: 483
핵심 문제:
- B서버가 데이터(483바이트)는 1초 만에 전송 완료
- Connection: keep-alive로 인해 연결을 유지
- Keep-Alive: timeout=20 → 서버가 20초 동안 다음 요청을 기다림
- A서버의 readLine()은 EOF를 기다리며 블로킹
- 정확히 20초 후 서버가 타임아웃으로 연결 종료 → EOF 발생
- 그제서야 다음 요청 시작
이제 왜 정확히 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가 사용된다.
// 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가 사용된다.
// 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)에서만 문제가 발생한 이유:
- 다른 API들은 Connection: close 헤더를 명시적으로 보냄
- 또는 서버가 응답 후 즉시 연결을 끊음
- 또는 Chunked Transfer Encoding으로 명확한 종료 신호 전송
NCP API만 Keep-Alive를 유지하면서 String 방식의 문제가 드러난 것!
결론
네트워크나 서버 문제로 보였던 20초 타임아웃은 실제로는 RestTemplate의 HttpMessageConverter 선택이 원인이었다.
String.class 대신 Map<String, Object>로 받도록 변경하여 Content-Length 기반의 효율적인 읽기 방식을 사용함으로써, 5분 걸리던 작업을 1초로 단축할 수 있었다.
항상 문제의 근본 원인을 찾기 위해 로그를 꼼꼼히 확인하고, 다양한 가설을 세워 하나씩 검증하는 과정이 중요하다는 것을 다시 한번 깨달았다.
'IT' 카테고리의 다른 글
| 같은 서버에서 톰캣 별도 인스턴스 띄우기 (0) | 2025.12.11 |
|---|---|
| JSON 동적 필드 처리 – Jackson @JsonAnySetter 완전 이해하기 (0) | 2025.12.10 |
| 스프링 의존성 주입: @Autowired vs @RequiredArgsConstructor + 단위 테스트 예제 (0) | 2025.09.24 |
| [Java] 특정 문자 포함 여부 확인하기 String.contains() vs StringUtils.contains() (0) | 2025.07.23 |
| [Vue3] composables 역할 (0) | 2025.07.03 |
댓글