본문 바로가기
삵
Development

curl로 REST API 디버깅하기

포포스트맨·2026년 9월 30일·조회 1

API 연동을 붙이다 보면 코드는 멀쩡한데 응답이 이상한 순간이 온다. Postman으로는 되는데 서버에서 curl로 때리면 401이 나거나, 로컬은 빠른데 운영에서만 첫 응답이 늦거나 하는 식이다. 저는 이럴 때 브라우저나 GUI 도구를 먼저 열지 않고 curl 한 줄로 요청과 응답을 통째로 들여다본다. 원인이 헤더에 있는지, 네트워크 구간에 있는지, TLS에 있는지를 가장 빨리 갈라낼 수 있어서다.

결론부터 말하자면, curl 디버깅의 세 축은 -v(주고받은 헤더 전체를 본다), -w(DNS부터 첫 바이트까지 구간별 시간을 잰다), 그리고 상태 코드 해석이다. 이 세 가지면 401/403 인증 문제, 301 리다이렉트 사슬, TLS 핸드셰이크 실패를 눈으로 확인하고 어느 구간이 범인인지 특정할 수 있다.

-v가 실제로 보여주는 것

-v(verbose)는 curl이 서버와 주고받은 내용을 표시선과 함께 찍어준다. 줄 앞의 기호를 구분하는 게 핵심이다. *로 시작하면 curl 자신의 진단 메시지(연결, TLS, 인증서), >는 curl이 보낸 요청 헤더, <는 서버가 돌려준 응답 헤더다.

$ curl -v https://api.example.com/v1/users/1
*   Trying 93.184.216.34:443...
* Connected to api.example.com (93.184.216.34) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* Server certificate:
*  subject: CN=api.example.com
*  start date: ...
*  issuer: C=US; O=Let's Encrypt; CN=R3
* using HTTP/2
> GET /v1/users/1 HTTP/2
> Host: api.example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< content-type: application/json
< content-length: 128
<
{"id":1,"name":"kim"}

여기서 자주 걸리는 건 헤더 대소문자나 오타다. 내가 보낸 Authorization 헤더가 > 줄에 제대로 찍혔는지, Content-Type을 application/json으로 보냈는지 눈으로 확인하면 된다. 응답 본문에 색이 지저분하게 섞이는 게 싫으면 -o /dev/null로 본문을 버리고 헤더만 봐도 된다.

헤더만 확인할 목적이면 -I(대문자 i)로 HEAD 요청을 던지는 방법도 있다. 다만 서버가 HEAD를 GET과 다르게 처리하는 경우가 있어서, 실제 응답을 재현하려면 -I 대신 -i(소문자, 본문과 헤더를 함께 출력)를 쓰는 편이 오해가 적다.

-w로 DNS와 TTFB, 전체 시간을 나눠 재기

"응답이 느리다"는 신고가 들어오면 어느 구간이 느린지부터 갈라야 한다. curl의 -w(write-out)는 전송이 끝난 뒤 미리 정한 변수들을 출력해준다. 타이밍 변수는 모두 요청 시작 시점부터의 누적 초다. 즉 각 값 자체가 아니라 값들의 차이가 구간 소요 시간이 된다.

자주 쓰는 타이밍 변수는 다음과 같다.

  • time_namelookup: DNS 이름 조회가 끝난 시각
  • time_connect: TCP 연결(3-way handshake)이 끝난 시각
  • time_appconnect: TLS 핸드셰이크까지 끝난 시각(HTTPS일 때만 채워진다)
  • time_pretransfer: 요청을 보내기 직전
  • time_starttransfer: 첫 응답 바이트를 받은 시각. 이게 흔히 말하는 TTFB(Time To First Byte)다
  • time_total: 응답 수신까지 전체

매번 긴 포맷 문자열을 치기 귀찮으니 템플릿 파일을 하나 만들어 둔다.

$ cat curl-format.txt
    time_namelookup:  %{time_namelookup}s\n
       time_connect:  %{time_connect}s\n
    time_appconnect:  %{time_appconnect}s\n
   time_pretransfer:  %{time_pretransfer}s\n
 time_starttransfer:  %{time_starttransfer}s\n
                      ----------\n
         time_total:  %{time_total}s\n

본문은 버리고 시간만 보게 -o /dev/null -s를 붙여 호출한다.

$ curl -w "@curl-format.txt" -o /dev/null -s https://api.example.com/v1/users/1
    time_namelookup:  0.021s
       time_connect:  0.058s
    time_appconnect:  0.132s
   time_pretransfer:  0.132s
 time_starttransfer:  0.281s
                      ----------
         time_total:  0.283s

숫자는 환경에 따라 다르니 절대값보다 구간 차이를 봐야 한다. 위 예에서 DNS는 21ms, TCP 연결은 그다음 37ms(0.058 - 0.021), TLS 핸드셰이크는 74ms(0.132 - 0.058)가 걸렸다. 눈에 띄는 건 time_pretransfer가 132ms인데 time_starttransfer가 281ms라는 점이다. 요청을 다 보낸 뒤 첫 바이트까지 149ms가 비어 있으면, 네트워크가 아니라 서버가 응답을 만드는 시간이 느린 것이다. DB 쿼리나 백엔드 처리를 의심하면 된다.

반대로 time_namelookup만 유독 크면 DNS 해석이 느린 것이고, time_appconnect 구간이 크면 TLS 협상이 무거운 것이다. 한 번 걸렸던 함정 하나. time_appconnect가 계속 0으로 찍혀서 TLS가 순간이동하나 싶었는데, 알고 보니 http://로 요청하고 있었다. 평문 HTTP는 이 변수가 0이다.

상태 코드와 리다이렉트 개수만 빠르게 보려면 인라인으로도 충분하다.

$ curl -s -o /dev/null -w "code=%{http_code} redirects=%{num_redirects} ip=%{remote_ip} total=%{time_total}s\n" https://api.example.com/health
code=200 redirects=0 ip=93.184.216.34 total=0.194s

401과 403 가려내기

둘 다 접근이 거부됐다는 뜻이지만 원인이 다르다. 401 Unauthorized는 인증 자체가 안 된 상태다. 토큰을 안 보냈거나, 만료됐거나, 형식이 틀렸다. 403 Forbidden은 인증은 됐는데 그 사용자에게 권한이 없다는 뜻이다. 401이면 토큰을 고치고, 403이면 서버 쪽 권한(role, scope)을 봐야 한다.

401 응답에는 대개 WWW-Authenticate 헤더가 붙어서 어떤 인증을 기대하는지 알려준다. -v로 확인한다.

$ curl -v https://api.example.com/v1/orders
...
< HTTP/2 401
< www-authenticate: Bearer realm="api", error="invalid_token"
< content-type: application/json
<
{"error":"token expired"}

토큰을 붙여 다시 던진다. 셸 변수에 담아두면 헤더에 실수로 공백이 섞이는 걸 줄일 수 있다.

$ TOKEN="eyJhbGciOi..."
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/orders
200

여기서 흔한 함정 두 가지. 첫째, Bearer 뒤에는 공백이 하나만 있어야 하고 토큰 앞뒤에 개행이나 따옴표가 붙으면 안 된다. 토큰을 파일에서 복사할 때 끝에 \n이 딸려오면 그대로 401이 난다. 둘째, 여전히 401이면 -v의 > 줄에서 Authorization 헤더가 실제로 나갔는지 확인한다. 리다이렉트를 따라가는 경우 curl은 보안상 다른 호스트로는 Authorization 헤더를 넘기지 않는다. 이때는 뒤에 나오는 리다이렉트 문제와 겹친다.

301 리다이렉트 사슬 추적하기

기본적으로 curl은 3xx 응답을 받아도 따라가지 않고 그 응답을 그대로 보여준다. 그래서 -L 없이 요청하면 본문 대신 리다이렉트 안내만 나온다. 어디로 튕기는지는 Location 헤더에 있다.

$ curl -sI http://api.example.com/v1/users/1
HTTP/1.1 301 Moved Permanently
Location: https://api.example.com/v1/users/1
content-length: 0

리다이렉트를 실제로 따라가려면 -L(location)을 붙인다. 몇 번을 거쳐 최종 어디에 도착했는지는 -w의 num_redirects와 url_effective로 본다.

$ curl -sL -o /dev/null -w "redirects=%{num_redirects} final=%{url_effective} code=%{http_code}\n" http://api.example.com/v1/users/1
redirects=1 final=https://api.example.com/v1/users/1 code=200

주의할 점이 있다. POST 요청이 301이나 302를 만나면 curl은 관례에 따라 GET으로 바꿔 재요청한다. 데이터를 보냈는데 서버 로그에 GET으로 찍혀 당황하는 경우가 이거다. 원래 메서드와 본문을 유지한 채 따라가려면 -L에 --post301, --post302, 또는 전부 유지하는 --post303을 함께 쓰거나, 애초에 응답의 정확한 최종 주소를 Location에서 확인해 그 주소로 직접 요청한다. 리다이렉트가 무한 반복될까 걱정되면 --max-redirs 10으로 상한을 건다.

TLS 핸드셰이크가 깨질 때

HTTPS 요청이 응답도 못 받고 죽으면 TLS 구간을 의심한다. curl은 인증서 검증에 실패하면 전송 자체를 중단한다. 가장 흔한 메시지가 이것이다.

$ curl -v https://internal.example.com/health
*   Trying 10.0.3.12:443...
* Connected to internal.example.com (10.0.3.12) port 443
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* SSL certificate problem: unable to get local issuer certificate
* Closing connection
curl: (60) SSL certificate problem: unable to get local issuer certificate

이건 서버 인증서를 신뢰할 루트/중간 CA를 curl이 못 찾았다는 뜻이다. 사설 CA로 발급한 인증서라면 해당 CA 번들을 --cacert로 지정한다.

$ curl --cacert /etc/ssl/certs/internal-ca.pem https://internal.example.com/health

진단 단계에서 검증을 잠시 끄고 싶으면 -k(insecure)를 쓴다. 다만 이건 원인 확인용이지 운영 스크립트에 남겨두는 옵션이 아니다. -k로 되면 문제는 인증서 신뢰 사슬에 있는 것이고, 제대로 된 CA 번들을 붙이는 게 진짜 해결이다.

검증 결과만 숫자로 확인하려면 -w의 ssl_verify_result를 본다. 0이면 검증 성공이다.

$ curl -sI -o /dev/null -w "verify=%{ssl_verify_result}\n" https://api.example.com
verify=0

또 다른 함정은 SNI와 호스트 불일치다. 로드밸런서 IP로 직접 붙어야 하는데 도메인 인증서를 검증해야 하는 상황이면 --resolve로 이름은 유지하되 목적지 IP만 바꿔치기한다. 이렇게 하면 인증서의 CN/SAN 검증은 도메인 기준으로 하면서 특정 백엔드로 보낼 수 있다.

$ curl -v --resolve api.example.com:443:10.0.3.12 https://api.example.com/health

프로토콜 협상 자체가 의심되면 --tlsv1.2나 --tlsv1.3으로 버전을 고정해 어느 버전에서 깨지는지 좁힌다. curl 선에서 더 파고들 게 있으면 openssl s_client -connect api.example.com:443 -servername api.example.com으로 인증서 사슬 전체를 뜯어보는 단계로 넘어가면 된다.

어떤 순서로 볼지

정리하면 이렇게 접근한다. 응답이 오긴 오는데 내용이 틀리면 -v로 요청/응답 헤더를 대조하고, 느리면 -w로 구간을 나눠 서버 처리 시간과 네트워크를 분리한다. 상태 코드가 401/403이면 인증(토큰)과 권한(role)을 나눠 보고, 3xx면 -L과 num_redirects로 사슬을 따라간다. 연결 자체가 안 되면 -v의 * 줄에서 TCP까지 갔는지 TLS에서 깨졌는지를 확인한다. GUI 도구로 넘어가기 전에 이 순서만 돌려도 대부분의 API 장애는 어느 구간이 범인인지 특정된다.

자주 묻는 질문

curl -w의 시간 변수는 각 단계에 걸린 시간인가?

아니다. time_namelookup, time_connect, time_appconnect, time_starttransfer, time_total 같은 값은 모두 요청 시작 시점부터의 누적 초다. 특정 구간에 걸린 시간을 알려면 두 값의 차이를 계산해야 한다. 예를 들어 TLS 핸드셰이크 소요 시간은 time_appconnect에서 time_connect를 뺀 값이다.

TTFB는 curl로 어떻게 재나?

time_starttransfer가 첫 응답 바이트를 받은 시각이라 TTFB에 해당한다. curl -s -o /dev/null -w "%{time_starttransfer}s\n" URL 로 확인한다. 이 값이 time_pretransfer보다 크게 벌어져 있으면 요청을 다 보낸 뒤 서버가 응답을 만드는 시간이 오래 걸리는 것이므로 백엔드 처리를 봐야 한다.

401과 403은 무엇이 다른가?

401 Unauthorized는 인증 자체가 안 된 상태로, 토큰을 안 보냈거나 만료됐거나 형식이 틀린 경우다. 403 Forbidden은 인증은 됐지만 그 사용자에게 해당 리소스 권한이 없는 경우다. 401은 토큰을 고치고, 403은 서버의 권한 설정(role, scope)을 확인한다.

curl -k는 운영에서 써도 되나?

안 된다. -k(--insecure)는 TLS 인증서 검증을 끄기 때문에 중간자 공격에 노출된다. 원인이 인증서 신뢰 사슬에 있는지 확인하는 진단 용도로만 쓰고, 실제 해결은 --cacert로 올바른 CA 번들을 지정하는 것이다.

리다이렉트를 따라갔더니 Authorization 헤더가 사라진다.

curl은 보안상 리다이렉트가 다른 호스트를 가리키면 Authorization 헤더를 넘기지 않는다. 같은 호스트 안에서 계속 넘기고 싶으면 신뢰하는 범위에서 --location-trusted를 쓸 수 있지만, 기본은 최종 주소를 Location에서 확인해 그 주소로 직접 인증 헤더를 붙여 요청하는 것이 안전하다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.