본문 바로가기

핵심 진단 도구

이름 해석, 경로, 포트, TLS, HTTP의 단계마다 쓰는 진단 도구를 정리하고 각 출력이 무엇을 말해 주는지 봅니다. dig와 getent의 차이, ss로 보는 리스닝 주소와 연결 상태, nc와 Test-NetConnection으로 하는 포트 확인, curl의 --resolve와 누적 시간 항목을 구간으로 나누어 읽는 방법을 다룹니다.

앞 글에서 문제를 단계와 구간으로 나누어 좁히는 방법을 보았습니다. 이 글에서는 각 단계를 확인하는 도구를 다룹니다. 도구마다 확인해 주는 범위가 정해져 있어서, 그 범위를 넘어서 결과를 해석하면 엉뚱한 결론에 이릅니다. ping이 된다고 웹 서버가 정상인 것이 아니고, curl이 느리다고 서버의 처리가 느린 것도 아닙니다.

단계마다 쓰는 도구

단계도구확인해 주는 것
이름 해석dig, getent, nslookup, Resolve-DnsName그 이름이 어떤 주소로 풀리는지
경로ping, traceroute, mtrICMP나 probe가 오가는 길과 지연
포트nc, ss, Test-NetConnection그 포트에 TCP 연결이 맺어지는지, 누가 듣고 있는지
TLSopenssl s_client협상된 버전과 인증서, 검증의 결과
HTTPcurl상태 코드와 헤더, 단계마다 걸린 시간
패킷tcpdump, Wireshark실제로 오간 패킷 그 자체

ping과 traceroute, mtr은 5장 3편에서, openssl s_client는 10장 2편에서 자세히 보았으므로 여기서는 주의할 점만 짚고, 나머지 도구를 중심으로 봅니다. 패킷 분석은 다음 글에서 다룹니다.

ping과 traceroute를 쓸 때

ping이 답하지 않는다고 서버가 죽은 것은 아닙니다. 많은 서버와 방화벽이 ICMP를 막아 두기 때문입니다. AWS라면 새로 만든 보안 그룹은 들어오는 규칙이 하나도 없어 ICMP도 막히지만, 기본 보안 그룹은 같은 그룹에 속한 자원끼리의 트래픽을 허용하므로, 같은 VPC 안에서도 그룹에 따라 결과가 달라집니다. 반대로 ping이 된다고 웹 서버가 정상인 것도 아닙니다. ping이 확인하는 것은 ICMP가 오가는 길뿐이어서, 443번 포트가 막혔는지는 알려 주지 않습니다.

traceroute의 * * *는 그 홉이 응답하지 않았다는 뜻일 뿐 패킷을 넘기지 못한다는 뜻이 아니고, 한 홉의 지연이나 mtr의 손실률은 그 뒤의 홉과 목적지까지 이어지는지를 함께 봐야 합니다. 둘 다 가는 길만 보여 주므로 돌아오는 길이 의심된다면 반대쪽에서도 확인합니다.

dig와 getent로 이름을 확인한다

dig는 DNS 서버에 직접 질문을 보내 응답을 그대로 보여 줍니다. 8장 2편에서 본 것처럼 status와 flags, TTL까지 볼 수 있어 DNS의 문제를 진단하기에 좋습니다.

dig www.example.com A                     # 기본 리졸버에 묻는다
dig @1.1.1.1 www.example.com A            # 다른 리졸버와 비교한다
dig @ns1.example.com www.example.com A    # 권한 서버의 원본을 본다
dig +trace www.example.com                # 루트부터 위임을 따라간다
getent hosts www.example.com              # 운영체제가 실제로 주는 답(Linux)
Resolve-DnsName www.example.com           # Windows PowerShell

dig와 getent의 결과가 다를 수 있다는 점이 중요합니다. dig는 DNS 서버에만 묻지만, 애플리케이션이 쓰는 getaddrinfo()는 /etc/nsswitch.conf의 순서대로 hosts 파일과 DNS를 보므로, getent가 애플리케이션이 받는 답에 더 가깝습니다. hosts 파일에 예전 주소를 적어 두고 잊었다면 dig로는 정상으로 보이는데 애플리케이션은 계속 예전 서버로 갑니다. dig +trace도 그 순간 루트부터 직접 따라간 결과일 뿐, 리졸버의 캐시에 남은 답을 보여 주지는 않습니다.

더 깊이: getent hosts와 getent ahosts는 다른 함수를 부른다

getent는 데이터베이스 이름에 따라 다른 C 라이브러리 함수를 부릅니다. getent(1)에 따르면 hosts는 오래된 gethostbyname2()로 찾고, ahosts는 애플리케이션이 쓰는 getaddrinfo()를 주소 체계 AF_UNSPEC으로 불러 돌려받은 주소를 하나씩 보여 줍니다. 둘 다 /etc/nsswitch.conf를 따르므로 hosts 파일은 반영되지만, 보여 주는 주소의 목록은 다를 수 있습니다.

glibc의 getent 소스를 보면 getent hosts는 이름을 받으면 먼저 IPv6로 찾고, IPv6 주소가 하나도 없을 때만 IPv4로 다시 찾습니다. 그래서 A와 AAAA 레코드가 모두 있는 이름이라면 IPv6 주소만 보여 줄 수 있고, IPv4 주소가 틀린 문제를 이 명령으로는 놓칠 수 있습니다. 애플리케이션이 받는 주소 목록에 더 가까운 것은 getent ahosts이며, 한쪽 주소만 보고 싶다면 ahostsv4나 ahostsv6를 씁니다. 다만 ahostsv6는 AI_V4MAPPED를 켜고 getaddrinfo()를 부르므로, IPv6 주소가 하나도 없으면 IPv4 주소를 ::ffff:로 시작하는 IPv6 형식으로 바꾸어 보여 줍니다.

ss로 누가 듣고 있는지 본다

ss는 6장 1편에서 본 것처럼 소켓의 상태를 보여 줍니다. 서버가 떠 있는데 바깥에서 접속이 안 된다면, 가장 먼저 그 프로세스가 어느 주소에서 듣고 있는지 확인합니다. 다음은 ss -tlnp의 출력을 필요한 열만 남겨 옮긴 예입니다.

State   Local Address:Port   Process
LISTEN  0.0.0.0:80           nginx
LISTEN  0.0.0.0:443          nginx
LISTEN  127.0.0.1:3000       node
LISTEN  0.0.0.0:5432         postgres

nginx는 0.0.0.0, 곧 모든 주소에서 듣고 있지만, node는 127.0.0.1에서만 듣고 있습니다. 루프백 주소는 같은 컴퓨터 안에서만 닿으므로, 바깥에서 3000번 포트로 접속하면 연결이 거부됩니다. 같은 컴퓨터의 nginx가 이 포트로 프록시하는 구조라면 오히려 바람직한 설정이지만, 바깥에서 직접 받아야 한다면 애플리케이션이 그 인터페이스의 주소나 0.0.0.0에서 듣도록 고치고 방화벽도 열어야 합니다. 반대로 postgres가 0.0.0.0에서 듣고 있다면, 바깥에 열 필요가 없는 데이터베이스가 열려 있는 것은 아닌지 확인해야 합니다.

ss -s                                                          # 상태별 연결 수의 요약
ss -tnp state established                                      # 맺어진 연결과 그 프로세스
ss -Htn state established '( sport = :80 or dport = :80 )' | wc -l   # 80번 포트의 연결 수

마지막 명령은 로컬이나 상대의 포트가 정확히 80인 연결만 셉니다. 출력을 grep :80으로 거르면 8080번 포트의 연결까지 함께 세어지므로, ss의 필터 문법을 쓰는 편이 정확합니다.

nc와 Test-NetConnection으로 포트를 확인한다

nc(netcat)는 TCP 연결을 맺어 보기만 하는 데 쓰기 좋습니다. -z는 데이터를 보내지 않고 연결만 확인하고, -v는 결과를 자세히, -w는 기다릴 시간을 정합니다.

nc -zv -w 3 db.internal.example.com 5432          # 데이터베이스 포트에 연결되는가
nc -l 9000                                        # 서버 쪽: 9000번 포트에서 잠시 듣는다
nc -zv -w 3 server.example.com 9000               # 클라이언트 쪽: 방화벽이 이 포트를 통과시키는가
Test-NetConnection -ComputerName db.internal.example.com -Port 5432   # Windows PowerShell

nc -l로 서버 쪽에 임시로 포트를 열어 두고 반대쪽에서 연결해 보면, 애플리케이션과 상관없이 경로와 방화벽이 그 포트를 통과시키는지만 따로 확인할 수 있습니다. 연결이 바로 실패하면 RST를 받은 연결 거부이고, 정해 둔 시간만큼 기다리다 실패하면 아무 답이 없는 시간 초과입니다. 앞 글에서 본 것처럼 두 결과는 원인이 다릅니다. nc의 옵션은 배포판마다 들어 있는 구현에 따라 조금씩 다르므로, 옵션이 통하지 않으면 그 시스템의 man nc를 확인합니다.

curl로 HTTP를 들여다본다

curl은 HTTP 단계를 확인하는 가장 기본적인 도구입니다. 아래 네 줄은 같은 서버를 서로 다른 쪽에서 들여다봅니다. 연결 과정 전체를 보는 -v, 응답 헤더만 보는 -I, 리다이렉트를 끝까지 따라간(-L) 뒤의 주소와 상태 코드만 찍는 -w, DNS 대신 정한 주소로 접속하는 --resolve입니다. -s는 진행 막대 같은 부가 출력을 꺼서 필요한 결과만 남기고, -o /dev/null은 본문을 버립니다.

curl -v https://www.example.com/                          # 연결, TLS, 요청과 응답의 헤더를 모두 본다
curl -sI https://www.example.com/                         # 응답 헤더만
curl -sL -o /dev/null -w '%{url_effective} %{http_code}\n' https://example.com/   # 리다이렉트를 따라간 끝
curl --resolve www.example.com:443:192.0.2.11 https://www.example.com/            # DNS를 거치지 않고 특정 서버로

-v는 *로 시작하는 줄에 연결과 TLS의 진행을, >로 시작하는 줄에 보낸 요청의 헤더를, <로 시작하는 줄에 받은 응답의 헤더를 보여 주므로 어느 단계에서 멈췄는지 바로 보입니다. --resolve는 이름을 DNS로 풀지 않고 지정한 주소로 연결하면서도, SNI와 Host 헤더는 원래 이름 그대로 보냅니다. 그래서 로드 밸런서 뒤의 서버 한 대나 아직 DNS를 바꾸지 않은 새 서버를, 인증서 검사를 통과하는 그대로 시험해 볼 수 있습니다. 주소로 https://192.0.2.11/에 접속하면 인증서의 이름이 맞지 않아 실패하므로 이와 다릅니다.

느린 요청을 조사할 때는 -w로 단계마다의 시각을 찍습니다. 이 값들은 모두 요청을 시작한 순간부터 그 단계가 끝날 때까지의 누적 시간이라는 점이 중요합니다.

curl -o /dev/null -s -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://www.example.com/

curl 시간 필드가 가리키는 완료 지점

시작부터 DNS 완료까지의 누적값을 읽습니다. 전송 완료값에서 첫 바이트 완료값을 뺍니다. 각 단계의 값과 전환 이유를 비교합니다.

curl · 누적 시간 해석

time_connect는 TCP 단계 시간 자체가 아닙니다

각 필드는 전송 시작부터 완료 지점까지의 누적 초입니다. 인접 값의 차이는 새 단일 HTTPS/TCP 연결·리다이렉트 없음의 단순 조건에서 해석하며, 첫 바이트는 서버 처리만의 시간이 아닙니다.

완료지점의 누적값과 지점 사이의 추가 시간을 구분한다여섯지점노드다섯시간진행간선. 누적0,.02,.08,.20,.35,.50초를지점에쓰고추가구간.02,.06,.12,.15,.15초를간선옆에적었다. 노드간격은순서를위한배치이며시간길이에비례하지않는다. 가정새단일HTTPS/TCP연결/리다이렉트없음이며프록시/재사용/QUIC/재시도는별도다. 첫바이트구간은서버처리시간단독이아니다. 같은누적값과차분이모바일에유지된다.전송 시작0.00초DNS 0.02초time_namelookup0.02초연결 추가 0.06초time_connect0.08초TLS 추가 0.12초time_appconnect0.20초첫 바이트까지 0.15초time_starttransfer0.35초완료까지 0.15초time_total0.50초
완료지점의 누적값과 지점 사이의 추가 시간을 구분한다여섯지점노드다섯시간진행간선. 누적0,.02,.08,.20,.35,.50초를지점에쓰고추가구간.02,.06,.12,.15,.15초를간선옆에적었다. 노드간격은순서를위한배치이며시간길이에비례하지않는다. 가정새단일HTTPS/TCP연결/리다이렉트없음이며프록시/재사용/QUIC/재시도는별도다. 첫바이트구간은서버처리시간단독이아니다. 같은누적값과차분이모바일에유지된다.전송 시작0.00초DNS 0.02초time_namelookup0.02초연결 추가 0.06초time_connect0.08초TLS 추가 0.12초time_appconnect0.20초첫 바이트까지 0.15초time_starttransfer0.35초완료까지 0.15초time_total0.50초
curl 시간 필드가 가리키는 완료 지점
지점누적 시간 필드인접 지점과의 차이
이름 해석time_namelookup시작 → 이름 해석 완료
TCP 연결time_connecttime_connect − time_namelookup
TLS 협상time_appconnecttime_appconnect − time_connect
첫 바이트time_starttransfertime_starttransfer − time_appconnect: 요청 전송·서버 대기·첫 응답 경로 등을 포함
전송 완료time_totaltime_total − time_starttransfer: 첫 바이트 이후 완료까지
이름 해석
누적 시간 필드: time_namelookup
인접 지점과의 차이: 시작 → 이름 해석 완료
TCP 연결
누적 시간 필드: time_connect
인접 지점과의 차이: time_connect − time_namelookup
TLS 협상
누적 시간 필드: time_appconnect
인접 지점과의 차이: time_appconnect − time_connect
첫 바이트
누적 시간 필드: time_starttransfer
인접 지점과의 차이: time_starttransfer − time_appconnect: 요청 전송·서버 대기·첫 응답 경로 등을 포함
전송 완료
누적 시간 필드: time_total
인접 지점과의 차이: time_total − time_starttransfer: 첫 바이트 이후 완료까지

프록시·연결 재사용·QUIC·재시도에서는 추가 해석이 필요합니다. 정상 시간을 고정 숫자로 단정하지 말고 같은 조건의 기준값과 오류·HTTP 버전을 함께 비교합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 DNS 완료부터 전송 완료까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명여섯 지점 노드와 다섯 시간 진행 간선입니다. 누적 0, .02, .08, .20, .35, .50초를 지점에 쓰고 추가 구간 .02, .06, .12, .15, .15초를 간선 옆에 적었습니다. 노드 간격은 순서를 위한 배치이며 시간 길이에 비례하지 않습니다. 새 단일 HTTPS/TCP 연결과 리다이렉트 없음을 가정하며 프록시/재사용/QUIC/재시도는 별도입니다. 첫 바이트 구간은 서버 처리 시간만으로 구성되지 않습니다. 같은 누적값과 차분이 모바일에 유지됩니다.
표의 값이름 해석 — 누적 시간 필드: time_namelookup; 인접 지점과의 차이: 시작 → 이름 해석 완료. TCP 연결 — 누적 시간 필드: time_connect; 인접 지점과의 차이: time_connect − time_namelookup. TLS 협상 — 누적 시간 필드: time_appconnect; 인접 지점과의 차이: time_appconnect − time_connect. 첫 바이트 — 누적 시간 필드: time_starttransfer; 인접 지점과의 차이: time_starttransfer − time_appconnect: 요청 전송·서버 대기·첫 응답 경로 등을 포함. 전송 완료 — 누적 시간 필드: time_total; 인접 지점과의 차이: time_total − time_starttransfer: 첫 바이트 이후 완료까지.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
DNS 완료

시작부터 DNS 완료까지의 누적값을 읽습니다.

  • 누적 시간 필드 1: start0
  • 누적 시간 필드 2: namelookup.02
  • 누적 시간 필드 3: 추가 20ms
  • 이름 해석 완료까지 20ms
TCP 완료

TCP 완료값에서 DNS 완료값을 뺍니다.

  • 누적 시간 필드 1: connect.08
  • 누적 시간 필드 2: .08-.02=.06s
  • 연결 단계 추가 60ms
TLS 완료

TLS 완료값에서 TCP 완료값을 뺍니다.

  • 누적 시간 필드 1: appconnect.20
  • 누적 시간 필드 2: 추가 120ms
  • TLS 협상 단계 추가 120ms
첫 바이트

요청·대기·첫 응답 경로의 누적값을 읽습니다.

  • 누적 시간 필드 1: starttransfer.35
  • 누적 시간 필드 2: 추가 150ms
  • 직전 단계 뒤 추가 150ms; 전부 서버 처리 시간이라고 할 수 없음
전송 완료

전송 완료값에서 첫 바이트 완료값을 뺍니다.

  • 누적 시간 필드 1: total.50
  • 누적 시간 필드 2: 추가 150ms
  • 누적 시간 필드 3: 합 500ms
  • 추가 150ms·합계 500ms; 가상 값이며 도형 간격은 시간 비례가 아님

curl 시간 필드가 가리키는 완료 지점

시작부터 DNS 완료까지의 누적값을 읽습니다. 전송 완료값에서 첫 바이트 완료값을 뺍니다. 각 단계의 값과 전환 이유를 비교합니다.

curl · 누적 시간 해석

time_connect는 TCP 단계 시간 자체가 아닙니다

각 필드는 전송 시작부터 완료 지점까지의 누적 초입니다. 인접 값의 차이는 새 단일 HTTPS/TCP 연결·리다이렉트 없음의 단순 조건에서 해석하며, 첫 바이트는 서버 처리만의 시간이 아닙니다.

완료지점의 누적값과 지점 사이의 추가 시간을 구분한다여섯지점노드다섯시간진행간선. 누적0,.02,.08,.20,.35,.50초를지점에쓰고추가구간.02,.06,.12,.15,.15초를간선옆에적었다. 노드간격은순서를위한배치이며시간길이에비례하지않는다. 가정새단일HTTPS/TCP연결/리다이렉트없음이며프록시/재사용/QUIC/재시도는별도다. 첫바이트구간은서버처리시간단독이아니다. 같은누적값과차분이모바일에유지된다.전송 시작0.00초DNS 0.02초time_namelookup0.02초연결 추가 0.06초time_connect0.08초TLS 추가 0.12초time_appconnect0.20초첫 바이트까지 0.15초time_starttransfer0.35초완료까지 0.15초time_total0.50초
완료지점의 누적값과 지점 사이의 추가 시간을 구분한다여섯지점노드다섯시간진행간선. 누적0,.02,.08,.20,.35,.50초를지점에쓰고추가구간.02,.06,.12,.15,.15초를간선옆에적었다. 노드간격은순서를위한배치이며시간길이에비례하지않는다. 가정새단일HTTPS/TCP연결/리다이렉트없음이며프록시/재사용/QUIC/재시도는별도다. 첫바이트구간은서버처리시간단독이아니다. 같은누적값과차분이모바일에유지된다.전송 시작0.00초DNS 0.02초time_namelookup0.02초연결 추가 0.06초time_connect0.08초TLS 추가 0.12초time_appconnect0.20초첫 바이트까지 0.15초time_starttransfer0.35초완료까지 0.15초time_total0.50초
curl 시간 필드가 가리키는 완료 지점
지점누적 시간 필드인접 지점과의 차이
이름 해석time_namelookup시작 → 이름 해석 완료
TCP 연결time_connecttime_connect − time_namelookup
TLS 협상time_appconnecttime_appconnect − time_connect
첫 바이트time_starttransfertime_starttransfer − time_appconnect: 요청 전송·서버 대기·첫 응답 경로 등을 포함
전송 완료time_totaltime_total − time_starttransfer: 첫 바이트 이후 완료까지
이름 해석
누적 시간 필드: time_namelookup
인접 지점과의 차이: 시작 → 이름 해석 완료
TCP 연결
누적 시간 필드: time_connect
인접 지점과의 차이: time_connect − time_namelookup
TLS 협상
누적 시간 필드: time_appconnect
인접 지점과의 차이: time_appconnect − time_connect
첫 바이트
누적 시간 필드: time_starttransfer
인접 지점과의 차이: time_starttransfer − time_appconnect: 요청 전송·서버 대기·첫 응답 경로 등을 포함
전송 완료
누적 시간 필드: time_total
인접 지점과의 차이: time_total − time_starttransfer: 첫 바이트 이후 완료까지

프록시·연결 재사용·QUIC·재시도에서는 추가 해석이 필요합니다. 정상 시간을 고정 숫자로 단정하지 말고 같은 조건의 기준값과 오류·HTTP 버전을 함께 비교합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 DNS 완료부터 전송 완료까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명여섯 지점 노드와 다섯 시간 진행 간선입니다. 누적 0, .02, .08, .20, .35, .50초를 지점에 쓰고 추가 구간 .02, .06, .12, .15, .15초를 간선 옆에 적었습니다. 노드 간격은 순서를 위한 배치이며 시간 길이에 비례하지 않습니다. 새 단일 HTTPS/TCP 연결과 리다이렉트 없음을 가정하며 프록시/재사용/QUIC/재시도는 별도입니다. 첫 바이트 구간은 서버 처리 시간만으로 구성되지 않습니다. 같은 누적값과 차분이 모바일에 유지됩니다.
표의 값이름 해석 — 누적 시간 필드: time_namelookup; 인접 지점과의 차이: 시작 → 이름 해석 완료. TCP 연결 — 누적 시간 필드: time_connect; 인접 지점과의 차이: time_connect − time_namelookup. TLS 협상 — 누적 시간 필드: time_appconnect; 인접 지점과의 차이: time_appconnect − time_connect. 첫 바이트 — 누적 시간 필드: time_starttransfer; 인접 지점과의 차이: time_starttransfer − time_appconnect: 요청 전송·서버 대기·첫 응답 경로 등을 포함. 전송 완료 — 누적 시간 필드: time_total; 인접 지점과의 차이: time_total − time_starttransfer: 첫 바이트 이후 완료까지.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
DNS 완료

시작부터 DNS 완료까지의 누적값을 읽습니다.

  • 누적 시간 필드 1: start0
  • 누적 시간 필드 2: namelookup.02
  • 누적 시간 필드 3: 추가 20ms
  • 이름 해석 완료까지 20ms
TCP 완료

TCP 완료값에서 DNS 완료값을 뺍니다.

  • 누적 시간 필드 1: connect.08
  • 누적 시간 필드 2: .08-.02=.06s
  • 연결 단계 추가 60ms
TLS 완료

TLS 완료값에서 TCP 완료값을 뺍니다.

  • 누적 시간 필드 1: appconnect.20
  • 누적 시간 필드 2: 추가 120ms
  • TLS 협상 단계 추가 120ms
첫 바이트

요청·대기·첫 응답 경로의 누적값을 읽습니다.

  • 누적 시간 필드 1: starttransfer.35
  • 누적 시간 필드 2: 추가 150ms
  • 직전 단계 뒤 추가 150ms; 전부 서버 처리 시간이라고 할 수 없음
전송 완료

전송 완료값에서 첫 바이트 완료값을 뺍니다.

  • 누적 시간 필드 1: total.50
  • 누적 시간 필드 2: 추가 150ms
  • 누적 시간 필드 3: 합 500ms
  • 추가 150ms·합계 500ms; 가상 값이며 도형 간격은 시간 비례가 아님

그래서 단계마다 걸린 시간은 이웃한 값의 차이로 구해야 합니다. 다음 코드는 그림의 누적 시간을 구간으로 나눕니다.

def phases(t):
    names = ["DNS", "TCP 연결", "TLS", "첫 바이트까지", "본문 전송"]
    marks = [0, t["namelookup"], t["connect"], t["appconnect"], t["starttransfer"], t["total"]]
    return {name: round((b - a) * 1000) for name, a, b in zip(names, marks, marks[1:])}

timing = {"namelookup": 0.02, "connect": 0.08, "appconnect": 0.20, "starttransfer": 0.35, "total": 0.50}
result = phases(timing)
for name, ms in result.items():
    print(f"{name}: {ms}ms")
print(f"합계: {sum(result.values())}ms")
DNS: 20ms
TCP 연결: 60ms
TLS: 120ms
첫 바이트까지: 150ms
본문 전송: 150ms
합계: 500ms

connect 값 0.08초를 TCP 연결에 걸린 시간으로 읽으면 DNS의 20ms까지 더해 잘못 읽게 됩니다. 실제로 TCP 연결에 걸린 시간은 0.08 − 0.02 = 60ms입니다. TCP 연결의 시간은 거의 한 번의 RTT이므로, 이 값으로 서버까지의 RTT를 짐작할 수 있습니다. TLS의 120ms는 그 RTT의 두 배쯤이므로 TLS 1.2처럼 왕복이 두 번 필요한 핸드셰이크였을 수 있습니다.

TLS가 끝난 뒤 첫 바이트가 오기까지의 150ms를 서버의 처리 시간으로 읽으면 안 됩니다. 그 안에는 요청을 보내는 시간, 서버의 처리 시간, 첫 응답이 돌아오는 한 번의 RTT가 함께 들어 있습니다. 서버의 처리 시간만 알고 싶다면 같은 요청의 서버 기록과 대조합니다. 첫 바이트 뒤의 본문 전송 시간은 응답의 크기와 대역폭, 6장 3편의 혼잡 윈도우가 정합니다. 이 해석은 새 연결 하나로 리다이렉트 없이 HTTPS를 받는 경우의 것이며, 연결을 재사용하거나 프록시, HTTP/3를 쓰면 각 값의 뜻이 달라집니다.

정리

  • 도구마다 확인해 주는 범위가 정해져 있으므로, 이름 해석, 경로, 포트, TLS, HTTP, 패킷의 단계마다 맞는 도구를 고르고 그 범위 안에서 해석합니다.
  • ping이 안 된다고 서버가 죽은 것이 아니고, ping이 된다고 웹 서버가 정상인 것도 아닙니다.
  • dig는 DNS 서버의 답을, getent는 hosts 파일까지 반영한 운영체제의 답을 보여 주므로 둘이 다르면 내 컴퓨터의 설정을 의심합니다.
  • ss -tlnp로 서버가 어느 주소에서 듣는지 확인하고, 127.0.0.1에서만 듣는 서버는 바깥에서 닿지 않습니다.
  • nc나 Test-NetConnection으로 포트의 연결만 따로 확인하며, curl --resolve는 DNS를 거치지 않고 특정 서버를 이름 그대로 시험하게 해 줍니다.
  • curl의 -w 시간은 모두 누적 값이므로 이웃한 값의 차이로 구간을 구하고, 첫 바이트까지의 시간에는 RTT와 요청 전송이 섞여 있습니다.

연습 문제

1. 누적 시간을 구간으로

새 HTTPS 연결 하나로 리다이렉트 없이 받은 요청의 curl 결과가 dns=0.01 connect=0.05 tls=0.12 ttfb=0.40 total=0.70입니다. TCP 연결과 TLS, 첫 바이트까지, 본문 전송에 각각 몇 ms가 걸렸나요? 가장 긴 구간만 보고 원인이 된 장비를 정할 수 있나요?

해설 보기

TCP 연결은 0.05 − 0.01 = 40ms, TLS는 0.12 − 0.05 = 70ms, 첫 바이트까지는 0.40 − 0.12 = 280ms, 본문 전송은 0.70 − 0.40 = 300ms이고, DNS의 10ms까지 더하면 700ms입니다. 가장 긴 본문 전송의 300ms는 응답의 크기와 대역폭, 혼잡 윈도우, 클라이언트가 읽는 속도가 함께 정하므로 이것만으로 원인을 정할 수 없습니다. 응답이 몇 바이트였는지를 함께 봐야 합니다. 첫 바이트까지의 280ms도 요청 전송과 서버의 처리, RTT가 섞여 있으므로, 같은 요청의 프록시와 서버 기록에서 처리 시간을 확인해야 합니다.

2. 바깥에서 닿지 않는 서버

ss -tlnp에 LISTEN 127.0.0.1:3000 node가 보이고, 같은 서버에서 curl localhost:3000은 되는데 다른 컴퓨터에서 curl http://서버주소:3000은 연결이 거부됩니다. 왜 그런가요? 어떻게 고칠 수 있나요?

해설 보기

node가 루프백 주소인 127.0.0.1에서만 듣고 있으므로, 같은 컴퓨터 안에서 localhost로 오는 연결만 받고, 바깥의 인터페이스로 온 연결에는 듣는 소켓이 없어 운영체제가 RST로 거부합니다. 바깥에서 직접 받아야 한다면 애플리케이션이 0.0.0.0이나 그 인터페이스의 주소에서 듣도록 설정을 고치고, 방화벽과 보안 그룹에서 3000번 포트를 엽니다. 하지만 같은 서버의 nginx가 앞에서 받아 3000번으로 넘기는 구조라면 지금 설정이 오히려 안전하므로, 바깥의 접속은 nginx의 80번이나 443번으로 하게 하는 편이 낫습니다.

3. 특정 서버만 시험하기

www.example.com의 DNS는 로드 밸런서를 가리키고 있고, 그 뒤에 새로 올린 서버 192.0.2.11이 정상인지 DNS를 바꾸기 전에 확인하고 싶습니다. curl https://192.0.2.11/과 curl --resolve www.example.com:443:192.0.2.11 https://www.example.com/은 무엇이 다른가요?

해설 보기

curl https://192.0.2.11/은 접속할 이름이 주소이므로 SNI를 보내지 않고 Host 헤더도 192.0.2.11이 됩니다. 서버는 어느 사이트의 인증서를 줄지 몰라 기본 인증서를 주거나, 준 인증서의 이름이 192.0.2.11과 맞지 않아 curl이 검증에 실패합니다. --resolve를 쓰면 이름은 www.example.com 그대로 두고 연결만 192.0.2.11로 하므로, SNI와 Host가 실제 사용자의 요청과 같아져 인증서 검사까지 통과하는 그대로 그 서버를 시험할 수 있습니다. 검사를 끄는 -k로 첫 명령을 통과시키는 것은 인증서의 문제를 숨기므로 이 목적에 맞지 않습니다.

다음 글 패킷 분석에서는 tcpdump와 Wireshark로 실제로 오간 패킷을 잡아, 핸드셰이크와 재전송, RST를 직접 읽어 내는 방법을 살펴봅니다.