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.?뒤에 쿼리 문자열이 붙기도 합니다.
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번 요청한 누적 시간의 중앙값입니다.
합계 약 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 이었습니다.
다음 글
- 다음: HTTP 강좌 2/6: 상태 코드와 리다이렉트
- 이후: 헤더, 쿠키, 세션, ETag 와 Cache-Control 캐시, HTTPS, TLS, 인증서, keep-alive 와 압축
- POST 를 받을 수 있는 PHP 서버: PHP 함수, 폼, 내장 서버
- curl 출력을 grep 등으로 넘기기: 셸 파이프, 리다이렉션, 텍스트 도구
- 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
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 가 나오므로 스크립트에서는 이쪽을 씁니다.