HTTP 기초: curl -v 출력을 한 줄씩 읽어 보기

HTTP 강좌 1. python http.server 를 로컬에 띄우고 curl -v 를 한 줄씩 읽습니다. 메서드, 상태 코드, 헤더, curl 종료 코드, 실측한 요청 시간까지.

시리즈: HTTP 강좌 1/6 (초급). API 는 매일 호출하지만 HTTP 메시지 원문은 한 번도 본 적 없는 신입 개발자를 위한 글입니다. 다 읽고 나면 명령 한 줄로 웹 서버를 띄우고, curl -v 출력을 모든 줄까지 읽고, 메서드·상태 코드·헤더가 정확히 무엇인지 설명할 수 있습니다.

신입 때 가장 막막한 순간 중 하나가 "앱에서 서버 연결이 안 돼요"라는 말을 듣고 로딩 스피너만 바라보는 상황입니다. 프런트엔드 담당은 "서버 문제", 서버 담당은 "앱 문제"라고 할 때, 누가 맞는지 가려 주는 것이 바로 curl -v 입니다. 이 글에서는 직접 띄운 서버에 요청을 보내고, 내 컴퓨터에서 나간 요청과 돌아온 응답을 있는 그대로 봅니다. 모든 예제는 Apple Silicon Mac(M1 Pro, macOS 26.6)의 curl 8.7.1, Python 3.9.6 으로 실행했고, 서버는 127.0.0.1 에서 돌렸으며 파일 경로는 ~/demo/ 로 바꿨습니다.

서버 언어를 몰라도 됩니다. 터미널에서 cd 만 칠 줄 알면 끝까지 따라올 수 있습니다.

URL 을 요청하면 무슨 일이 일어나나

HTTP(HyperText Transfer Protocol)는 요청/응답 방식의 프로토콜입니다. 클라이언트(브라우저, 앱, curl)가 서버에 연결해서 텍스트 줄로 된 요청을 보내면, 서버는 텍스트 줄과 본문(body)으로 된 응답을 돌려줍니다. 그다음 연결을 닫거나 다음 요청에 재사용합니다.

http://127.0.0.1:18101/data.json 같은 URL 은 매일 쓰게 될 네 부분으로 나뉩니다.

  • 스킴 http. https 는 같은 HTTP 를 암호화된 TLS 연결 안에서 보내는 것입니다(5강).
  • 호스트 127.0.0.1. "이 컴퓨터 자신"을 뜻하는 루프백 주소입니다.
  • 포트 18101. 생략하면 http 는 80, https 는 443 입니다.
  • 경로 /data.json. ? 뒤에 쿼리 문자열이 붙기도 합니다.
두 줄짜리 흐름도. 윗줄: curl 이 URL 을 받아 호스트 이름을 IP 주소로 바꾸고, 포트로 TCP 연결을 연 뒤, GET / HTTP/1.1 과 Host 같은 > 로 시작하는 요청 줄을 보낸다. 아랫줄: 서버가 요청을 읽고 로그를 한 줄 남긴 뒤, < 로 시작하는 응답 줄(HTTP/1.0 200 OK 같은 상태 줄, 헤더, 빈 줄, 본문)을 보내고, 마지막으로 연결을 닫는다.
curl 이 보내는 HTTP 요청 한 번의 흐름. 상자마다 curl -v 가 줄을 찍는 단계이고, 서버 쪽 동작은 서버 로그에 남는다.

5분 안에 첫 서버 띄우기

폴더에 파일 두 개를 만들고 Python 에 들어 있는 파일 서버를 실행합니다. 비어 있는 포트면 아무 번호나 됩니다. 여기서는 18101 을 썼습니다.

mkdir -p ~/demo && cd ~/demo
printf '<!doctype html>\n<html><head><meta charset="utf-8"><title>Hello</title></head>\n<body><h1>Hello, HTTP</h1></body></html>\n' > index.html
echo '{"name":"demo","ok":true}' > data.json
python3 -m http.server 18101 --bind 127.0.0.1

--bind 127.0.0.1 은 빼지 마세요. 빼면 모든 네트워크 인터페이스에서 대기하므로, 카페나 사무실의 같은 Wi-Fi 에 있는 다른 기기도 폴더 내용을 읽을 수 있습니다. 이 터미널은 그대로 두고(여기에 로그가 찍힙니다) curl 용 터미널을 하나 더 엽니다. 서버는 Ctrl+C 로 끕니다.

curl -v 를 한 줄씩 읽기

curl -sv http://127.0.0.1:18101/

-v(verbose)는 주고받은 내용을 보여 주고, -s(silent)는 파이프로 넘길 때 섞여 나오는 진행률 표시를 숨깁니다. 실제 출력입니다.

*   Trying 127.0.0.1:18101...
* Connected to 127.0.0.1 (127.0.0.1) port 18101
> GET / HTTP/1.1
> Host: 127.0.0.1:18101
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.6
< Date: Thu, 12 Nov 2026 14:33:47 GMT
< Content-type: text/html
< Content-Length: 119
< Last-Modified: Thu, 12 Nov 2026 14:28:18 GMT
< 
{ [119 bytes data]
* Closing connection
<!doctype html>
<html><head><meta charset="utf-8"><title>Hello</title></head>
<body><h1>Hello, HTTP</h1></body></html>

줄 앞의 기호는 세 가지입니다.

  • * 는 curl 이 나에게 하는 말입니다. 어느 주소로 연결을 시도했는지, TCP 연결이 언제 맺어지고 언제 닫혔는지.
  • > 는 curl 이 보낸 요청입니다. 첫 줄이 요청 줄로, 메서드 GET, 경로 /, 프로토콜 버전 HTTP/1.1 이 들어 있습니다. 이어서 이름: 값 형식의 요청 헤더. HTTP/1.1 에서는 Host 가 필수인데, IP 주소 하나로 여러 사이트를 운영하기 때문입니다. 빈 > 줄이 헤더의 끝이고, GET 에는 본문이 없으니 요청은 여기서 끝납니다. 줄바꿈까지 합쳐 정확히 78바이트였습니다.
  • < 는 응답입니다. 상태 줄 HTTP/1.0 200 OK 에 버전, 상태 코드 200, 사람이 읽는 설명이 있습니다. 그다음 응답 헤더, 빈 줄, 본문(119바이트, Content-Length: 119 와 일치)입니다.

버전이 다르다는 점도 보세요. curl 은 HTTP/1.1 로 물었는데 Python 서버는 HTTP/1.0 으로 답했고, curl 은 HTTP 1.0, assume close after body 라고 메모했습니다. 서버는 더 낮은 버전으로 답해도 되고, HTTP/1.0 에서는 응답마다 연결이 닫힙니다. 이 이야기는 6강(keep-alive)에서 다시 나옵니다.

서버 터미널에는 요청마다 로그가 한 줄씩 찍힙니다. GET, HEAD, 없는 파일, POST 네 번의 로그입니다.

127.0.0.1 - - [12/Nov/2026 14:33:47] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Nov/2026 14:33:48] "HEAD /data.json HTTP/1.1" 200 -
127.0.0.1 - - [12/Nov/2026 14:33:48] code 404, message File not found
127.0.0.1 - - [12/Nov/2026 14:33:48] "GET /nope.html HTTP/1.1" 404 -
127.0.0.1 - - [12/Nov/2026 14:33:48] code 501, message Unsupported method ('POST')
127.0.0.1 - - [12/Nov/2026 14:33:48] "POST / HTTP/1.1" 501 -

클라이언트 IP, 시각, 따옴표 안의 요청 줄, 상태 코드. Apache 나 nginx 액세스 로그와 같은 모양입니다. 클라이언트 쪽 출력과 서버 로그를 나란히 놓고 읽는 것이 원인을 가장 빨리 좁히는 방법입니다. (Date 헤더는 항상 GMT 라서 로그 시각과 맞추려고 서버를 TZ=UTC 로 실행했습니다.)

메서드: 서버에 무엇을 해 달라는 건가

메서드는 요청 줄의 동사입니다.

메서드 뜻 본문 여러 번 보내도 안전?
GET 리소스 읽기 없음 예
HEAD GET 과 같지만 헤더만 없음 예
POST 데이터 제출, 무언가 생성 있음 아니오
PUT 리소스 교체 있음 예(멱등)
PATCH 리소스 일부 변경 있음 보장 안 됨
DELETE 리소스 삭제 보통 없음 예(멱등)

"멱등"은 같은 요청을 두 번 보내도 한 번 보낸 것과 결과가 같다는 뜻입니다. 앱이 GET 은 마음 놓고 재시도하지만 POST 재시도는 조심하는 이유(주문이 두 건 생김)가 여기에 있습니다.

우리 서버는 GET 과 HEAD 만 구현합니다. curl -I 는 HEAD 를 보냅니다.

$ curl -sI http://127.0.0.1:18101/data.json
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:25 GMT
Content-type: application/json
Content-Length: 26
Last-Modified: Thu, 12 Nov 2026 14:28:18 GMT

POST 를 보내면(-d 로 본문을 붙이면 메서드가 자동으로 POST 가 됩니다) 이렇게 됩니다.

$ curl -si -d 'a=1' http://127.0.0.1:18101/ | head -1
HTTP/1.0 501 Unsupported method ('POST')

501 은 "그 메서드는 구현하지 않았다"는 뜻으로, 파일 서버로서는 정직한 답입니다. 2강에서 POST 를 받는 작은 php -S 라우터를 만듭니다.

상태 코드와 헤더 한눈에 보기

상태 코드는 첫 자리로 분류합니다. 2xx 성공, 3xx 다른 곳으로, 4xx 클라이언트 쪽 잘못, 5xx 서버 쪽 실패. 2강에서 하나씩 직접 돌려 봅니다. 여기서는 없는 파일에 대한 404 를 읽어 봅니다.

$ curl -si http://127.0.0.1:18101/nope.html | head -6
HTTP/1.0 404 File not found
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:25 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 469

설명 문구는 자유라서 흔히 보는 "Not Found" 대신 "File not found" 였습니다. 프로그램은 숫자만 봐야 합니다.

자주 읽게 될 헤더는 다음과 같습니다.

  • Content-Type: 본문 종류(text/html, application/json). Python 은 확장자로 추측합니다. 200 페이지는 charset 없는 text/html, 에러 페이지는 charset=utf-8 이 붙어 있었습니다. charset 이 없거나 틀리면 한글이 어떻게 깨지는지는 3강에서 EUC-KR 예제로 보여 드립니다.
  • Content-Length: 본문 바이트 수.
  • Date, Last-Modified: 항상 GMT. Last-Modified 는 파일 수정 시각이고 캐시에 쓰입니다(4강).
  • Server: 소프트웨어 이름. 운영 서버는 버전을 숨기는 경우가 많습니다.
  • Location: 다음에 갈 곳. 3xx 와 함께 씁니다.

끝 슬래시 없이 폴더를 요청하면 Location 이 실제로 나옵니다.

$ curl -si http://127.0.0.1:18101/docs | head -5
HTTP/1.0 301 Moved Permanently
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:42 GMT
Location: /docs/
Content-Length: 0

curl 은 -L 을 붙이지 않으면 따라가지 않습니다. -L 과 -w '%{http_code} %{num_redirects} %{url_effective}' 를 붙이자 200 1 http://127.0.0.1:18101/docs/ 가 찍혔습니다.

HTTP 를 손으로 쳐 보기

HTTP/1.x 는 그냥 텍스트라서 curl 없이도 보낼 수 있습니다. macOS 에는 nc(netcat)가 들어 있습니다.

$ printf 'GET /data.json HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n' | nc -w 2 127.0.0.1 18101
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:42 GMT
Content-type: application/json
Content-Length: 26
Last-Modified: Thu, 12 Nov 2026 14:28:18 GMT

{"name":"demo","ok":true}

줄 끝은 \r\n, 빈 줄이 헤더의 끝입니다. 같은 서버에서 의외의 결과가 두 개 나왔습니다.

$ printf 'GET /data.json\r\n\r\n' | nc -w 2 127.0.0.1 18101
{"name":"demo","ok":true}

상태 줄도 헤더도 없습니다. 버전이 없는 요청 줄은 HTTP/0.9(1991년)로 취급되고, 그 시절 응답은 본문뿐이었습니다. 로그는 "GET /data.json" 200 - 였습니다. 아예 HTTP 가 아닌 것을 보내면 이렇습니다.

$ printf 'HELLO\r\n\r\n' | nc -w 2 127.0.0.1 18101 | grep -E "Error code|Message"
        <p>Error code: 400</p>
        <p>Message: Bad request syntax ('HELLO').</p>

흔한 실수와 curl 종료 코드

curl 의 종료 코드(바로 뒤에 echo $?)를 보면 어느 층에서 실패했는지 알 수 있습니다. 실제 실행 결과입니다.

$ curl -sS http://127.0.0.1:18149/
curl: (7) Failed to connect to 127.0.0.1 port 18149 after 0 ms: Couldn't connect to server
$ curl -sS "http://127.0.0.1:18101/my file.txt"
curl: (3) URL rejected: Malformed input to a URL function
$ curl -sS htp://127.0.0.1:18101/
curl: (1) Protocol "htp" not supported
$ curl -fsS http://127.0.0.1:18101/nope.html
curl: (22) The requested URL returned error: 404
$ curl -s http://127.0.0.1:18101/nope.html -o /dev/null; echo "exit=$?"
exit=0

실수: "curl 이 성공했으니 API 는 정상이다". 실제로는 마지막 예처럼 404 여도 종료 코드가 0 입니다. curl 입장에서는 에러 페이지도 "다운로드 성공"입니다. 배포 스크립트의 헬스 체크를 이렇게 짜 두면 서버가 죽어도 배포가 통과합니다. 해결: 스크립트와 CI 에서는 curl -fsS(4xx/5xx 면 종료 코드 22, 조용하지만 에러는 출력)를 쓰거나 -w '%{http_code}' 로 코드를 찍어서 확인합니다.

실수: URL 에 공백 넣기. curl 8.7.1 은 종료 코드 3 으로 거부했습니다. %20 으로 바꾸면 spaced 가 돌아오고 로그에는 GET /my%20file.txt 가 찍혔습니다. 쿼리 값은 curl 에게 인코딩을 맡길 수 있습니다. curl -G --data-urlencode 'q=café & tea' … 는 GET /data.json?q=caf%c3%a9+%26+tea 를 보냈습니다. 한글 검색어도 같은 방식으로 UTF-8 바이트가 %xx 로 바뀝니다.

실수: localhost 와 127.0.0.1 은 같다. 거의 같지만, localhost 는 ::1(IPv6)과 127.0.0.1 둘 다로 해석되고 curl 은 IPv6 를 먼저 시도했습니다.

*   Trying [::1]:18101...
* connect to ::1 port 18101 from ::1 port 53480 failed: Connection refused
*   Trying 127.0.0.1:18101...

서버가 127.0.0.1 에서만 대기했기 때문에 첫 시도는 실패했고 curl 이 IPv4 로 넘어갔습니다. 결과적으로는 동작하지만, 넘어가 주지 않는 클라이언트라면 서버가 떠 있는데도 "연결 거부"가 됩니다. 해결: 양쪽에서 같은 주소를 씁니다.

종료 코드 뜻 흔한 원인
0 전송 완료 -f 가 없으면 404/500 도 0
1 지원하지 않는 프로토콜 스킴 오타
3 잘못된 URL 공백, 범위를 벗어난 포트
6 호스트 이름 해석 실패 DNS 이름 오타(이번엔 실행 안 함)
7 연결 실패 서버 꺼짐, 포트 틀림, IPv4/IPv6 차이
22 -f 와 HTTP 에러 4xx, 5xx
28 시간 초과 --max-time 도달(2강)
47 리다이렉트 너무 많음 리다이렉트 루프(2강)

궁금한 것별 curl 옵션 정리

궁금한 것 명령
무엇을 보내고 받았는지 전부 curl -sv URL
상태와 헤더만 curl -sI URL(HEAD) 또는 curl -si URL(GET, 헤더 포함)
스크립트용 상태 코드만 curl -s -o /dev/null -w '%{http_code}\n' URL
4xx/5xx 면 스크립트 중단 curl -fsS URL
리다이렉트 따라가기 curl -L URL
폼 데이터 보내기 curl -d 'a=1&b=2' URL
시간이 어디서 걸리는지 curl -s -o /dev/null -w '%{time_connect} %{time_starttransfer} %{time_total}\n' URL

실측: 요청 한 번에 얼마나 걸리나

-w 는 curl 이 직접 잰 타이머를 출력합니다. 로컬 Python 서버에 URL 마다 31번 순차 요청하고 첫 번째를 워밍업으로 뺀 중앙값입니다(Apple M1 Pro).

요청 헤더 본문 연결 완료 첫 바이트 합계
/(index.html) 185 B 119 B 0.273 ms 0.733 ms 0.777 ms
/data.json 191 B 26 B 0.276 ms 0.749 ms 0.788 ms
/big.bin(1 MiB, 시드 고정 난수) 204 B 1,048,576 B 0.269 ms 0.730 ms 1.304 ms
localhost 로 / 185 B 119 B 0.331 ms 0.875 ms

로컬에서는 1 MiB 를 보내도 약 0.5 ms 늘었을 뿐입니다. localhost 가 약 0.1 ms 느린 것은 실패한 IPv6 시도 때문입니다.

실제 인터넷은 다릅니다. https://example.com/(헤더 291바이트, 본문 577바이트의 작은 페이지)에 5번 요청한 누적 시간의 중앙값입니다.

https://example.com/ 요청의 누적 시점을 보여 주는 막대그래프. 이름 조회 완료 3.2 ms, TCP 연결 17.3 ms, TLS 핸드셰이크 36.3 ms, 응답 첫 바이트 도착 58.7 ms.
실제 HTTPS 요청에서 시간이 어디에 쓰이는지. Apple M1 Pro Mac 에서 https://example.com/ 으로 curl -w 를 5번 실행한 중앙값. 값은 요청 시작부터의 누적. 127.0.0.1 의 python http.server 에서 비슷하게 작은 페이지를 받으면 합계 0.78 ms 였다.

합계 약 59 ms 중 서버가 답을 만드는 시간은 아주 작은 부분이고, 대부분은 TCP 와 TLS 왕복을 기다리는 시간입니다. 뒤의 강좌에서 연결 재사용과 리다이렉트 줄이기를 신경 쓰는 이유입니다. 숫자는 네트워크마다 다르지만 모양은 대개 비슷합니다.

솔직한 한계와 버전 메모

  • HTTP/1.1 은 지금 RFC 9110(의미: 메서드, 상태 코드)과 RFC 9112(메시지 형식)로 정의됩니다. 둘 다 2022년 6월에 나왔고 RFC 7230~7235 를 대체했습니다. HTTP/2 는 RFC 9113, HTTP/3 은 RFC 9114, PATCH 는 RFC 5789 입니다.
  • python3 -m http.server(3.9.6 에서 확인)는 HTTP/1.0 으로 답하고 GET 과 HEAD 만 구현합니다. 개발용이며 Python 공식 문서도 운영 환경 사용을 권하지 않습니다. 새 Python 버전은 로그와 헤더가 조금 다를 수 있지만 확인하지 않았습니다.
  • HTTP/0.9 응답은 이 Python 버전의 동작입니다. 요즘 서버는 대부분 이런 요청을 거부합니다.
  • 127.0.0.1 측정은 내 컴퓨터의 속도이지 네트워크가 아닙니다. example.com 숫자는 어느 날 저녁 한 회선에서 잰 5번입니다.
  • 브라우저는 실행하지 않았습니다. 브라우저는 curl 의 세 개보다 훨씬 많은 헤더(Accept-Language, 쿠키, Sec-Fetch-* 등)를 보냅니다.

연습 문제

1. 서버를 띄우지 않고 curl -sS http://127.0.0.1:18149/ 를 실행하면 종료 코드는 몇이고, 무엇을 알 수 있나요?

2. http://127.0.0.1:18101/nope.html 의 상태 코드만 출력하는 명령과, 그 결과로 셸 스크립트를 멈추는 명령을 써 보세요.

3. 위 curl -sv 출력에서 서버가 curl 과 다른 HTTP 버전으로 말했다는 것을 보여 주는 줄은 무엇인가요?

정답

1. 종료 코드 7, Failed to connect … Couldn't connect to server. 그 포트에서 아무도 대기하지 않는다(또는 ::1 만처럼 다른 주소에서 대기한다)는 뜻입니다. 연결 문제이지 HTTP 문제가 아니며, 요청은 한 바이트도 나가지 않았습니다.

2. curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18101/nope.html 은 404 를 출력합니다. 스크립트에서는 curl -fsS http://127.0.0.1:18101/nope.html || exit 1. curl 은 종료 코드 22 와 함께 The requested URL returned error: 404 를 출력합니다.

3. 상태 줄 < HTTP/1.0 200 OK 와 curl 메모 * HTTP 1.0, assume close after body 입니다. 요청 줄은 > GET / HTTP/1.1 이었습니다.

다음 글

자주 묻는 질문

curl -v 출력의 >, <, * 는 무슨 뜻인가요?

> 로 시작하는 줄은 curl 이 보낸 요청, < 로 시작하는 줄은 서버가 돌려준 상태 줄과 헤더, * 로 시작하는 줄은 Trying 127.0.0.1:18101, Connected to 같은 curl 자신의 메모입니다. 본문은 기호 없이 출력됩니다.

python -m http.server 에 POST 를 보내면 왜 501 이 나오나요?

이 서버의 핸들러는 GET 과 HEAD 만 구현합니다. POST 나 DELETE 에는 HTTP/1.0 501 Unsupported method ('POST') 가 돌아오고 로그에도 code 501 이 찍혔습니다. 개발용 파일 서버라서, POST 를 받아야 하면 php -S 나 프레임워크를 씁니다.

서버가 404 를 돌려주면 curl 은 실패로 끝나나요?

아닙니다. 옵션이 없으면 404 여도 종료 코드는 0 입니다. HTTP 주고받기 자체는 성공했기 때문입니다. -f(--fail)를 붙이면 종료 코드 22 와 The requested URL returned error: 404 가 나오므로 스크립트에서는 이쪽을 씁니다.

  • #HTTP
  • #초급
  • #curl
  • #http.server
  • #상태코드
  • #헤더

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑