트러블슈팅 방법론
"접속이 안 된다"는 증상을 정확한 질문으로 바꾸고, 이름 해석, TCP 연결, TLS, HTTP의 단계와 클라이언트, 프록시, 서버의 구간으로 나누어 원인을 좁히는 방법을 정리합니다. 연결 거부와 시간 초과, curl의 종료 코드가 가리키는 단계, 변경 이력을 의심하는 습관과 확인한 것을 기록하는 방법도 함께 봅니다.
"서버에 접속이 안 돼요." 개발자가 가장 자주 듣는 말 가운데 하나입니다. 하지만 접속이 안 되는 이유는 수십 가지입니다. 이름이 풀리지 않는지, TCP 연결이 맺어지지 않는지, 연결은 되는데 TLS에서 막히는지, 응답은 오는데 오류인지에 따라 봐야 할 곳이 완전히 다릅니다. 마지막 장인 이 장에서는 지금까지 배운 것을 바탕으로 네트워크 문제를 체계적으로 좁혀 가는 방법과 도구, 패킷 분석을 다룹니다.
증상을 질문으로 바꾼다
원인을 찾기 전에 증상부터 정확히 해야 합니다. "안 된다"를 다음과 같은 질문으로 바꾸면 그것만으로 범위가 크게 줄어듭니다.
- 누가 안 되는가. 모든 사용자인가, 특정 지역이나 통신사, 특정 앱 버전의 사용자인가.
- 언제부터인가. 그 시각 즈음에 배포, 인증서 갱신, DNS 변경, 방화벽 규칙의 변경이 있었는가.
- 무엇이 안 되는가. 모든 페이지인가, 특정 API인가, 큰 파일을 받을 때만인가.
- 어떻게 실패하는가. 오류 메시지의 원문, 응답 코드, 걸린 시간은 무엇인가.
- 항상 그런가. 매번 실패하는가, 가끔 실패하는가, 특정 시간대에만 실패하는가.
특히 "무엇이 바뀌었나"는 가장 빠른 지름길입니다. 잘 돌던 시스템이 갑자기 멈췄다면 대개 무언가가 바뀐 것이므로, 장애가 시작된 시각과 변경 기록을 나란히 놓고 봅니다. 증상을 재현할 수 있다면 그 재현 방법도 함께 적어 둡니다. 재현할 수 있어야 고친 뒤에 정말 고쳐졌는지 확인할 수 있습니다.
단계로 나눈다
HTTPS 요청 하나는 이름 해석, TCP 연결, TLS 핸드셰이크, HTTP 요청과 응답의 네 단계를 차례로 지납니다. 앞 단계가 성공해야 다음 단계로 가므로, 어느 단계에서 멈췄는지를 찾으면 그 앞의 단계는 정상이라는 것을 알 수 있습니다.
이름 해석·연결·TLS·HTTP를 나누어 증거를 모읍니다
DNS 응답의 주소와 관측 시각을 확인합니다. 성공 경로에서 응답 코드·출처·요청 ID를 모읍니다. 각 단계의 값과 전환 이유를 비교합니다.
진단 · 성공한 경계와 실패한 경계
이름 해석·연결·TLS·HTTP를 나누어 증거를 모읍니다
새 HTTPS/TCP 연결의 단순 조사 순서입니다. 이미 확보한 증거가 있으면 그 경계부터 시작하며, ping 성공 하나로 다른 프로토콜이나 서비스의 정상 상태를 판정하지 않습니다.
DNS · 사용할 주소를 얻었는가
관측: 응답 코드·주소·조회 서버와 시각을 기록합니다.
한계: 주소를 얻어도 포트 연결과 HTTP 성공은 별도입니다.
TCP · 해당 주소·포트에 연결했는가
관측: 연결 성공·거부·시간 초과를 구분합니다.
다음 확인: 리스너·바인딩·정책·양방향 경로를 대조합니다.
TLS · 서버 인증과 협상을 마쳤는가
관측: 접속 이름·인증서 검증·협상 오류를 기록합니다.
한계: TLS 성공도 API의 처리 완료를 보장하지 않습니다.
HTTP · 어떤 응답을 받았는가
관측: 코드·헤더·내용·요청 ID와 첫 바이트 시간을 기록합니다.
대조: 프록시·서버·의존 서비스의 같은 시각 로그를 잇습니다.
관측을 모으는 순서이며 자동 원인 판정기가 아닙니다. 캐시·연결 재사용·프록시·QUIC은 경로를 따로 해석합니다. 무응답이나 로그 부재만으로 특정 장비·방화벽·CPU를 원인으로 단정하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 주소 확보부터 HTTP 관측까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 새 직접 HTTPS/TCP 연결 사례의 다섯 노드와 네 간선입니다. 이미 DNS 주소와 TCP 443 연결을 확인한 뒤 TLS 인증 통과 여부로 분기합니다. 실패하면 HTTP 응답은 미확인이고 성공하면 HTTP 코드/응답 출처/요청 ID를 관측합니다. TLS 통과가 API 성공을 뜻하지 않으며 모든 장애를 이 한 분기로 열거하지 않습니다. 각 단계는 각 경계의 증거와 한계를 유지합니다. 모바일 예 경로는 TLS 실패 노드를 통과하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 주소 확보 DNS 응답의 주소와 관측 시각을 확인합니다. |
|
|
| TCP 연결 해당 주소의 443 포트 연결을 확인합니다. |
|
|
| TLS 확인 서버 인증과 TLS 협상을 판정합니다. |
|
|
| HTTP 관측 성공 경로에서 응답 코드·출처·요청 ID를 모읍니다. |
|
|
이름 해석·연결·TLS·HTTP를 나누어 증거를 모읍니다
DNS 응답의 주소와 관측 시각을 확인합니다. 성공 경로에서 응답 코드·출처·요청 ID를 모읍니다. 각 단계의 값과 전환 이유를 비교합니다.
진단 · 성공한 경계와 실패한 경계
이름 해석·연결·TLS·HTTP를 나누어 증거를 모읍니다
새 HTTPS/TCP 연결의 단순 조사 순서입니다. 이미 확보한 증거가 있으면 그 경계부터 시작하며, ping 성공 하나로 다른 프로토콜이나 서비스의 정상 상태를 판정하지 않습니다.
DNS · 사용할 주소를 얻었는가
관측: 응답 코드·주소·조회 서버와 시각을 기록합니다.
한계: 주소를 얻어도 포트 연결과 HTTP 성공은 별도입니다.
TCP · 해당 주소·포트에 연결했는가
관측: 연결 성공·거부·시간 초과를 구분합니다.
다음 확인: 리스너·바인딩·정책·양방향 경로를 대조합니다.
TLS · 서버 인증과 협상을 마쳤는가
관측: 접속 이름·인증서 검증·협상 오류를 기록합니다.
한계: TLS 성공도 API의 처리 완료를 보장하지 않습니다.
HTTP · 어떤 응답을 받았는가
관측: 코드·헤더·내용·요청 ID와 첫 바이트 시간을 기록합니다.
대조: 프록시·서버·의존 서비스의 같은 시각 로그를 잇습니다.
관측을 모으는 순서이며 자동 원인 판정기가 아닙니다. 캐시·연결 재사용·프록시·QUIC은 경로를 따로 해석합니다. 무응답이나 로그 부재만으로 특정 장비·방화벽·CPU를 원인으로 단정하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 주소 확보부터 HTTP 관측까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 새 직접 HTTPS/TCP 연결 사례의 다섯 노드와 네 간선입니다. 이미 DNS 주소와 TCP 443 연결을 확인한 뒤 TLS 인증 통과 여부로 분기합니다. 실패하면 HTTP 응답은 미확인이고 성공하면 HTTP 코드/응답 출처/요청 ID를 관측합니다. TLS 통과가 API 성공을 뜻하지 않으며 모든 장애를 이 한 분기로 열거하지 않습니다. 각 단계는 각 경계의 증거와 한계를 유지합니다. 모바일 예 경로는 TLS 실패 노드를 통과하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 주소 확보 DNS 응답의 주소와 관측 시각을 확인합니다. |
|
|
| TCP 연결 해당 주소의 443 포트 연결을 확인합니다. |
|
|
| TLS 확인 서버 인증과 TLS 협상을 판정합니다. |
|
|
| HTTP 관측 성공 경로에서 응답 코드·출처·요청 ID를 모읍니다. |
|
|
각 단계는 이 교재의 앞부분과 이어집니다. 이름 해석은 8장의 DNS와 hosts 파일, TCP 연결은 5장의 라우팅과 6장 2편의 핸드셰이크, 방화벽, TLS는 10장 2편의 인증서 검증, HTTP는 9장의 상태 코드와 헤더입니다. 다음 코드는 각 단계에서 모은 결과를 보고 처음 실패한 단계를 찾습니다.
STAGES = ["DNS", "TCP", "TLS", "HTTP"]
def first_failure(results):
for stage in STAGES:
status = results.get(stage)
if status is None:
return f"{stage} 단계는 아직 확인하지 않았다: 여기부터 확인한다"
if status != "ok":
return f"{stage} 단계에서 실패({status}): 그 앞 단계까지는 정상이다"
return "네 단계 모두 정상: 응답의 내용과 애플리케이션의 기록을 본다"
cases = {
"A": {"DNS": "ok", "TCP": "ok", "TLS": "인증서 이름 불일치"},
"B": {"DNS": "ok", "TCP": "ok", "TLS": "ok", "HTTP": "503, 정상 대상 0개"},
"C": {"DNS": "ok", "TCP": "시간 초과"},
"D": {"DNS": "ok"},
}
for name, results in cases.items():
print(name, first_failure(results))A TLS 단계에서 실패(인증서 이름 불일치): 그 앞 단계까지는 정상이다
B HTTP 단계에서 실패(503, 정상 대상 0개): 그 앞 단계까지는 정상이다
C TCP 단계에서 실패(시간 초과): 그 앞 단계까지는 정상이다
D TCP 단계는 아직 확인하지 않았다: 여기부터 확인한다A는 서버에 닿아 TCP 연결까지 맺었으므로 네트워크의 경로는 살아 있고, 인증서의 이름이 접속한 이름과 맞지 않는 것이 문제입니다. B는 HTTP 응답까지 받았으므로 네트워크는 정상이고, 로드 밸런서가 보낼 정상 서버가 없다는 것이 문제입니다. C는 이름은 풀렸지만 TCP 연결에서 아무 답이 없었으므로 경로나 방화벽을 봐야 합니다. D처럼 확인하지 않은 단계가 있다면 짐작하지 말고 그 단계부터 확인합니다.
물리 계층부터 하나씩 올라가는 방법도 있지만, 이미 확인한 결과가 있다면 그 단계부터 시작하면 됩니다. HTTP 응답을 받았다면 케이블과 라우팅은 확인할 필요가 없습니다. 반대로 ping이 된다고 위의 단계가 정상인 것도 아닙니다. ping이 확인하는 것은 ICMP가 오가는 길뿐이어서, 443번 포트가 막혀 있거나 인증서가 틀렸는지는 알려 주지 않습니다.
구간으로 나눈다
단계와 함께 구간도 나눕니다. 요청은 클라이언트에서 CDN, 로드 밸런서, 프록시를 거쳐 서버에 닿고, 서버는 다시 데이터베이스나 다른 서비스를 부릅니다. 같은 요청을 다른 위치에서 보내 보면 어느 구간의 문제인지 좁힐 수 있습니다.
| 어디서 보내 보나 | 결과로 알 수 있는 것 |
|---|---|
| 내 노트북 | 사용자가 겪는 증상 그대로 |
| 다른 망(휴대폰의 LTE 등) | 회사 망이나 특정 통신사만의 문제인지 |
| 로드 밸런서를 거치지 않고 서버의 주소로 직접 | 로드 밸런서와 그 앞의 문제인지 |
서버 안에서 curl localhost | 서버의 애플리케이션 자체가 응답하는지 |
서버 안에서는 되는데 로드 밸런서를 거치면 안 된다면 로드 밸런서의 설정이나 상태 확인을, 회사 망에서만 안 된다면 회사의 프록시나 방화벽을 의심합니다. 되는 경우와 안 되는 경우를 나란히 놓고 무엇이 다른지 찾는 것이 가장 효과적인 방법입니다. 요청마다 붙는 요청 ID를 클라이언트, 로드 밸런서, 서버의 기록에서 함께 찾으면, 요청이 어디까지 갔는지 정확히 알 수 있습니다. 다만 서버의 기록에 요청이 없다고 해서 패킷이 서버에 닿지 않았다고 단정하면 안 됩니다. 애플리케이션이 기록하기 전에 거부되었을 수도 있습니다.
실패의 모양을 읽는다
같은 "연결 실패"라도 모양이 다르면 원인이 다릅니다. 6장 2편에서 본 것처럼, 연결 거부는 누군가 RST로 답했다는 뜻이고 시간 초과는 아무도 답하지 않았다는 뜻입니다.
| 관측 | 뜻 | 먼저 볼 것 |
|---|---|---|
| 이름 해석 실패 | 이름을 주소로 바꾸지 못했다 | 응답 코드(NXDOMAIN인지 SERVFAIL인지), 쓰고 있는 리졸버 |
| 연결 거부 | 그 주소에는 닿았고 RST가 왔다 | 그 포트에서 듣는 프로세스가 있는지(ss -tlnp), RST를 보내는 방화벽 |
| 연결 시간 초과 | 아무 답이 없었다 | 방화벽이 조용히 버리는지, 경로와 돌아오는 길, 보안 그룹 |
| 인증서 오류 | TLS에서 서버를 믿지 못했다 | 인증서의 이름과 만료, 중간 인증서, 클라이언트의 시계 |
| 502 | 프록시가 뒤쪽 서버에서 올바른 응답을 받지 못했다 | 프록시의 기록에 남은 뒤쪽 연결의 실패 이유 |
| 503 | 지금은 처리할 수 없다 | 정상 대상의 수, 과부하, 점검 중인지 |
| 504 | 프록시가 뒤쪽 서버의 응답을 기다리다 포기했다 | 뒤쪽 서버의 처리 시간과 프록시의 시간 제한 |
| 가끔 느리거나 실패 | 손실, 재전송, 큐의 대기 | 같은 시각의 재전송 통계와 서버, 데이터베이스의 지연 |
curl을 쓴다면 종료 코드가 실패한 단계를 바로 알려 줍니다. curl의 설명서에 따르면 6은 이름을 풀지 못함, 7은 연결하지 못함, 28은 시간 초과, 35는 TLS 연결 오류, 60은 인증서를 믿을 수 없음입니다. 다음 스크립트는 단계마다 결과와 종료 코드를 남기며 확인합니다.
#!/bin/bash
TARGET=${1:-example.com}
PORT=${2:-443}
echo "== 이름 해석"
getent ahosts "$TARGET" | head -3
[ "${PIPESTATUS[0]}" -eq 0 ] || echo "이름을 풀지 못했다" # 파이프의 종료 코드는 head의 것이므로 getent의 코드를 따로 본다
echo "== TCP 연결"
nc -z -w 3 "$TARGET" "$PORT"
echo "nc 종료 코드: $?"
echo "== TLS와 HTTP"
curl -sS -o /dev/null --max-time 10 \
-w 'http=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer}\n' \
"https://$TARGET:$PORT/"
echo "curl 종료 코드: $?"이 스크립트는 판단을 대신하지 않고 관측을 모을 뿐입니다. getent는 운영체제의 이름 해석을 그대로 따르므로 hosts 파일까지 반영하고, nc -z는 TCP 연결만 맺어 봅니다. curl이 실패하면 http_code는 000이 되는데, 이는 HTTP의 상태 코드가 아니라 응답을 받지 못했다는 표시이므로 종료 코드와 오류 메시지로 단계를 확인합니다. 시간 항목들의 뜻은 다음 글에서 자세히 봅니다.
가설을 세우고 하나씩 바꾼다
단계와 구간을 좁혔다면, 원인에 대한 가설을 세우고 그 가설이 맞다면 무엇이 보여야 하는지 미리 정한 뒤 확인합니다. "방화벽이 443을 버린다"는 가설이라면, 같은 서버의 다른 포트는 연결되고 443만 시간 초과가 나야 하며, 서버 쪽에서 캡처하면 SYN이 보이지 않아야 합니다. 예측이 맞지 않으면 가설을 버리고 다음 가설로 넘어갑니다.
고칠 때는 한 번에 하나만 바꿉니다. 여러 설정을 한꺼번에 바꾸면 무엇이 고쳤는지 모르게 되고, 오히려 다른 문제를 숨기기도 합니다. 그리고 확인한 것과 그 결과를 시각과 함께 적어 둡니다. "14시 05분, 서버 안에서 curl localhost:8080/health 200, 로드 밸런서를 거치면 503" 같은 기록은 장애가 길어질 때 같은 확인을 되풀이하지 않게 해 주고, 끝난 뒤 원인을 정리하는 바탕이 됩니다.
정리
- "안 된다"는 증상을 누가, 언제부터, 무엇이, 어떻게, 얼마나 자주라는 질문으로 바꾸고, 그 시각의 변경 기록부터 봅니다.
- HTTPS 요청은 이름 해석, TCP 연결, TLS, HTTP의 단계를 차례로 지나므로, 처음 실패한 단계를 찾으면 그 앞은 정상입니다.
- ping의 성공은 ICMP의 길만 보여 주므로 포트와 TLS, HTTP는 따로 확인합니다.
- 같은 요청을 다른 위치에서 보내 구간을 나누고, 요청 ID로 여러 기록을 이어 요청이 어디까지 갔는지 봅니다.
- 연결 거부는 RST를, 시간 초과는 무응답을 뜻하며, curl의 종료 코드 6, 7, 28, 35, 60은 실패한 단계를 알려 줍니다.
- 가설마다 보여야 할 것을 미리 정해 확인하고, 한 번에 하나만 바꾸며, 확인한 것을 시각과 함께 기록합니다.
연습 문제
1. 어디까지 확인되었나
A는 새 HTTPS 연결에서 이름을 풀고 443번 포트의 TCP 연결까지 성공했지만 인증서 검증에서 실패해 HTTP 응답을 받지 못했습니다. B는 프록시에게서 503을 받았고, 그 요청 ID로 찾은 프록시의 기록에는 정상 대상이 0개라고 남아 있습니다. 두 경우 각각 어디까지 확인되었고, 아직 단정할 수 없는 것은 무엇인가요?
해설 보기
A는 그 주소와 포트까지의 경로와 TCP 연결이 정상임을 확인했고, TLS의 서버 인증에서 멈췄습니다. 접속한 이름과 인증서의 SAN, 인증서 사슬과 유효 기간, 클라이언트의 시계를 확인해야 하며, API 자체가 정상인지는 아직 알 수 없습니다. B는 프록시까지는 정상적으로 닿았고 프록시가 보낼 정상 서버가 없다는 것까지 확인했습니다. 하지만 모든 서버의 프로세스가 죽었다고 단정하면 안 됩니다. 서버가 등록 해제되었거나, 상태 확인의 경로나 설정이 틀렸을 수도 있으므로 대상의 등록 상태와 상태 확인의 결과를 함께 봐야 합니다.
2. 종료 코드가 가리키는 곳
같은 API를 부르는 curl이 네 환경에서 각각 종료 코드 6, 7, 28, 60으로 실패했습니다. 각각 어느 단계의 문제이고 무엇부터 확인해야 하나요?
해설 보기
6은 이름을 풀지 못한 것이므로 그 환경의 리졸버 설정과 DNS 응답부터 봅니다. 7은 연결하지 못한 것으로, 대개 RST를 받은 연결 거부이므로 그 포트에서 듣는 프로세스와 거부하는 방화벽을 봅니다. 28은 정해진 시간 안에 끝나지 않은 것이므로, -w의 시간 항목으로 연결에서 멈췄는지 응답을 기다리다 멈췄는지를 나누어 봅니다. 60은 TLS에서 서버의 인증서를 믿지 못한 것이므로, 인증서의 이름과 사슬, 그 환경의 신뢰 저장소에 회사의 루트 인증서가 필요한지를 봅니다.
3. 일부 사용자만 실패할 때
어느 날부터 사용자의 약 5%만 사이트에 접속하지 못한다는 문의가 들어옵니다. 나머지 사용자와 개발팀은 모두 정상입니다. 원인의 후보로 무엇을 생각할 수 있고, 무엇을 물어보거나 확인해야 하나요?
해설 보기
일부 사용자만 다른 길을 탄다는 뜻이므로, 그 사용자들의 공통점을 찾아야 합니다. 특정 통신사나 지역의 사용자라면 GeoDNS가 그들에게 다른 주소를 주거나 그 통신사의 경로에 문제가 있을 수 있고, IPv6를 쓰는 사용자라면 AAAA 레코드나 IPv6 경로만 고장 났을 수 있습니다. 특정 앱 버전이나 오래된 기기라면 인증서 사슬의 변경이나 TLS 설정의 변경을 의심합니다. 로드 밸런서 뒤의 서버 한 대만 고장 났다면 그 서버로 가는 사용자만 실패할 수도 있습니다. 실패한 사용자에게 시각과 통신사, 오류 메시지를 묻고, 그들이 받은 DNS 응답과 접속한 서버를 기록에서 찾아 정상 사용자와 비교합니다.
다음 글 핵심 진단 도구에서는 curl의 시간 항목과 ss, dig, nc, tcpdump처럼 각 단계를 확인하는 도구의 사용법과 출력을 읽는 법을 정리합니다.