어떤 서비스의 API가 평소엔 수십 ms로 정상 응답하다가, 가끔씩 6~12초씩 느려지는 현상이 있었다. 인프라를 깊게 아는 편이 아니라 “어디서부터 봐야 하나”가 막막했는데, 구간을 하나씩 잘라가며 의심 범위를 좁히는 절차로 접근하니 결국 원인에 도달할 수 있었다.
문제 상황
- 현상: 동일한 API를, 동일한 사용자가, 동일한 조건으로 호출하는데 어떤 요청은 정상(수십 ms), 어떤 요청은 6~12초 걸린 뒤 응답함
- 특이점: 에러가 아니라 느린 채로 결국 성공(200) 함
- 빈도: 항상이 아니라 간헐적으로만 발생
간헐적이라는 점, 그리고 결국 성공한다는 점 때문에 처음엔 원인을 잡기가 까다로웠다. “느리다”는 막연한 증상을 그대로 두면 영원히 헤매게 되므로, 요청이 거쳐가는 경로를 구간으로 나눠 하나씩 배제하기로 했다.
기본적인 요청 흐름은 아래와 같다.
사용자 → 웹서버(Apache httpd) → 애플리케이션 서버(Tomcat) → DB
이 흐름의 각 구간이 범인인지 아닌지를 순서대로 확인해 나갔다.
1단계 — 네트워크 구간부터 자르기 (curl)
가장 먼저 “느린 게 네트워크 때문인가, 서버 때문인가”를 갈랐다.
curl은 한 번의 요청을 DNS 조회 → TCP 연결 → TLS 핸드셰이크 → 첫 응답(TTFB) 단계별로 시간을 찍어볼 수 있다.
curl -w "dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
-o /dev/null -s \
-X POST https://example.com/api/endpoint \
-d "param=value"
결과를 보니 DNS·TCP·TLS는 모두 수십 ms 이내로 정상이었고, TTFB(첫 응답까지)만 12초가 찍혔다.
판단: 외부 네트워크 구간은 정상. 지연은 요청을 보낸 이후 서버 쪽에서 발생하고 있다.
이 한 번의 명령으로 “네트워크 탓”이라는 가능성을 깔끔하게 지웠다.
2단계 — 그 12초 동안 무슨 일이 있었나 (tcpdump)
서버 쪽이라는 건 알았지만, 정확히 어느 시점부터 멈추는지를 보고 싶었다. 패킷 흐름을 타임스탬프 단위로 떠서 확인했다.
tcpdump -i any -s 0 -nn 'host example.com and port 443' -w /tmp/dump.pcap
tcpdump -r /tmp/dump.pcap -nn -tt
흐름을 요약하면 이랬다.
T +0.000초 요청 전송 (SYN)
T +0.025초 TLS 핸드셰이크 완료
T +0.026초 요청 본문 전송
T +0.033초 서버가 요청 수신 확인(ACK)
────── 이후 약 12초간 양방향 패킷 0건 ──────
T+12.451초 서버가 응답 첫 바이트 전송
서버는 요청을 33ms 만에 멀쩡히 받았고, 그 뒤로 12초간 아무 패킷도 오가지 않다가 갑자기 응답했다.
판단: 서버 내부 어딘가에서 무언가를 기다리며 멈춰 있다. 데이터를 주고받느라 느린 게 아니다.
3단계 — 지연 시간의 ‘모양’ 관찰 (로그 분포)
여기서 의외로 결정적인 단서가 나왔다. 접속 로그에서 응답 시간만 뽑아 분포를 세어봤다.
grep 'POST /api/endpoint' access_log \
| awk '{print $NF}' | sort -n | uniq -c
| 응답 시간 | 비율 |
|---|---|
| 0초 (정상) | 84% |
| 6초 | 8% |
| 9~11초 | 3% |
| 12초 | 6% |
지연이 1초, 2초, 3초처럼 연속적으로 퍼져 있지 않고 0초 / 6초 / 12초처럼 특정 값에 뭉쳐 있었다.
판단: 부하나 데이터 양 때문에 느려지면 시간이 연속적으로 분포해야 한다. 이렇게 계단처럼 뚝뚝 끊긴 분포는 어딘가에 고정된 시간의 타임아웃(시간제한)이 끼어 있다는 신호다.
이 “6의 배수” 같은 패턴이 나중에 원인을 확정하는 열쇠가 된다.
4단계 — 흔한 용의자부터 소거하기
본격적으로 범위를 좁히기 전에, 누구나 먼저 의심할 만한 것들을 데이터로 지웠다.
- 동시 접속 과다 때문 아닌가? → 트래픽을 세어보니 분당 1건도 안 됐다. 게다가 나 혼자
curl한 방 날려도 12초가 재현됐다. → 부하 문제 아님 - 코드나 SQL 쿼리가 느린 것 아닌가? → 해당 API 로직은 정상일 때 5ms면 끝나는 단순한 흐름이었다. 같은 코드·같은 쿼리가 어떨 땐 5ms, 어떨 땐 12초. → 코드/쿼리 성능 문제 아님
같은 입력에 같은 코드인데 결과 시간이 100배 넘게 차이 난다면, 원인은 코드 자체가 아니라 그 코드가 실행되기까지의 경로 어딘가에 있다.
5단계 — 틀린 가설도 기록한다 (DB 연결 의심)
처음 세운 가설은 “DB 연결 풀에 죽은 연결이 남아 있어서 그걸 쓰다가 멈춘다”였다. 그럴듯해서 DB 연결 검증 옵션을 강화해봤지만 증상은 그대로였다.
이때 가설을 버린 근거가 중요했다.
- 죽은 DB 연결을 붙들고 기다린 거라면 12초 뒤에 성공(200)이 아니라 에러로 끝나야 한다 → 실제론 전부 성공
- 죽은 연결이 원인이라면 첫 실패 후 연결이 교체되어 다음부턴 정상이어야 하는데, 연속으로 계속 느렸다
- 결정적으로, 이 지연은 이 API 한 곳이 아니라 거의 모든 동적 API에서 똑같이 나타났다
판단: 특정 DB 연결의 문제가 아니라, 모든 요청이 공통으로 거치는 길목의 문제다. (틀린 가설이었지만, 이걸 반증하는 과정에서 “공통 길목”이라는 방향이 잡혔다.)
6단계 — 정적/동적 요청 비교로 길목 특정
“공통 길목”을 더 좁히기 위해 요청을 두 종류로 나눠 비교했다.
| 요청 종류 | 지연 발생 |
|---|---|
| 애플리케이션 서버(Tomcat)로 넘어가는 동적 API | 발생 |
| 웹서버가 직접 응답하는 정적 파일 (이미지 등) | 0건 |
웹서버가 혼자 처리하는 요청은 멀쩡하고, 웹서버가 뒤쪽 애플리케이션 서버로 넘기는 요청만 느렸다.
판단: 범인은 “웹서버 → 애플리케이션 서버로 요청을 넘기는 구간” 으로 좁혀졌다.
7단계 — 양쪽 로그를 같은 요청으로 대조
마지막으로 같은 한 건의 느린 요청을 웹서버 로그와 애플리케이션 서버 로그 양쪽에서 찾아 시간을 비교했다. (애플리케이션 서버에 처리 시간을 ms 단위로 남기도록 로그 설정을 켰다.)
| 측정 위치 | 같은 요청의 소요 시간 |
|---|---|
| 웹서버 입장에서 본 시간 | 12초 |
| 애플리케이션 서버 입장에서 본 시간 | 수 ms |
애플리케이션 서버는 자기에게 도착한 요청을 수 ms 만에 처리하고 있었다. 즉 12초는 전부 웹서버가 애플리케이션 서버로 요청을 “전달하려고 시도하는” 구간에서 사라지고 있었다.
원인
웹서버는 들어온 요청을 여러 대의 애플리케이션 서버에 나눠 보내는 로드밸런서 역할을 한다. 그런데 이 분배 목록에 이미 꺼진(셧다운된) 서버가 그대로 남아 있었다.
동작은 이랬다.
- 웹서버가 요청을 분배 대상 중 한 대로 보낸다
- 하필 꺼진 서버로 배정되면, 연결을 기다리다 3초(연결 제한시간)를 다 쓰고 실패한다
- 다음 서버로 다시 시도하는데, 또 꺼진 서버에 걸리면 3초가 추가된다
- 결국 살아있는 서버에 닿으면 수 ms 만에 처리되어 정상 응답(200) 으로 끝난다
3초가 시도 횟수만큼 쌓이는 구조라, 지연이 3의 배수(6초·9초·12초) 로 뭉쳐서 나타났던 것이다. 3단계에서 본 “계단 모양 분포”가 정확히 이걸 가리키고 있었다.
처음 의심했던 코드·쿼리·DB·네트워크가 모두 멀쩡했던 것도, 꺼진 서버에 걸린 운 나쁜 요청만 간헐적으로 느렸던 것도 전부 이 구조로 설명됐다.
조치
- 로드밸런서 분배 목록에서 꺼진 서버들을 제거
- 웹서버를 무중단으로 설정 반영
# 설정 문법 검사 후 무중단 반영
apachectl -t && apachectl graceful
반영 후 6초 이상 걸리는 느린 응답은 완전히 사라졌다.
돌아보며 — 절차로 남은 것
인프라를 잘 몰라도, 결국 한 일은 단순했다.
- 요청이 지나가는 경로를 구간으로 나눈다 (네트워크 / 웹서버 / 애플리케이션 / DB)
- 구간마다 측정해서 정상인 곳을 하나씩 지운다 (curl → tcpdump → 로그)
- 증상의 ‘모양’을 본다 — 계단 모양 분포는 타임아웃을 의심한다
- 틀린 가설도 반증 근거와 함께 기록한다 — 그 과정에서 진짜 방향이 잡힌다
- 같은 요청을 양쪽 로그에서 대조한다 — 시간이 어디서 사라지는지 한 줄로 드러난다
재발 방지로는 이런 것들을 남겼다.
- 서버를 폐기할 때 로드밸런서 목록에서 제거하는 절차를 함께 포함
- 느린 응답(예: 3초 초과)이 생기면 알림이 오도록 모니터링 설정
- 다른 서비스의 분배 목록에도 죽은 서버가 남아 있지 않은지 일괄 점검
