환경  curl 8.7.1 (macOS 26.5.2 기본 curl) · 근거 문서는 curl 최신 man page 와 RFC 9110 · 2026-08-10 확인

배포 훅을 호출하는 한 줄이 있습니다. 몇 달 동안 잘 돌던 줄인데, 어느 날부터 배포가 되지 않습니다. 이상한 것은 스크립트가 멀쩡히 성공으로 끝난다는 점입니다. 종료 코드는 0 이고, 실패를 잡으라고 붙여 둔 옵션도 아무 말이 없습니다. 서버 쪽 로그를 열어 보면 요청은 분명히 도착해 있습니다. 다만 그 요청에는 보냈어야 할 본문이 없거나, 아예 다른 메서드로 찍혀 있습니다. 중간에 리다이렉트가 하나 끼어들었고, 그 지점에서 요청의 성격이 조용히 바뀌었습니다.

실패가 실패로 보이지 않는 데서 시작합니다

이런 사고가 오래 살아남는 이유는 어느 쪽에서도 붉은 글씨가 뜨지 않기 때문입니다. 호출하는 쪽은 성공했다고 믿고, 받는 쪽은 요청이 왔으니 문제가 없다고 봅니다. 사이에 낀 리다이렉트는 양쪽 로그 어디에도 눈에 띄게 남지 않습니다.

먼저 확인해 둘 것은 curl 이 3xx 응답을 실패로 보지 않는다는 사실입니다. 실패에 민감하게 반응하라고 붙이는 --fail 옵션의 공식 설명은 400 이상인 응답에 대해 오류 코드 22 로 끝낸다는 것입니다. 같은 문서에 기본적으로 curl 은 HTTP 응답 코드를 실패의 근거로 보지 않는다는 문장이 함께 적혀 있습니다. 301 은 400 보다 작으니 --fail 의 사정권 밖입니다.

그래서 아래 호출은 301 을 받고도 종료 코드 0 으로 끝납니다. set -e 를 걸어 둔 스크립트라도 여기서 멈추지 않습니다. 배포 요청이 목적지에 닿지 않았는데 다음 줄로 넘어가고, 마지막에 완료 메시지까지 찍고 끝납니다.

curl --fail -s -o /dev/null -w "code=%{http_code}\n" \
  -H 'Content-Type: application/json' -d '{"ref":"main"}' \
  http://127.0.0.1:8942/deploy-301
# code=301
echo $?
# 0        ← --fail 도 set -e 도 여기서는 걸리지 않는다

3xx 는 curl 이 실패로 세지 않는 구간입니다. 조용히 다음 줄로 넘어갑니다.

리다이렉트는 오류가 아니라 정상 응답입니다. 스크립트를 멈춰 세울 근거가 애초에 없습니다.

몇 줄짜리 서버를 세워 두면 눈으로 볼 수 있습니다

이 현상은 남의 서버로 확인하기가 어렵습니다. 요청이 어떤 모습으로 도착했는지 보여 주는 곳이 드물기 때문입니다. 받은 메서드와 본문 길이를 그대로 되돌려 주는 서버를 하나 띄워 두면 그다음부터는 전부 눈에 보입니다.

아래 서버에는 두 종류의 경로가 있습니다. /deploy-30x 는 상태 코드별로 /hook 으로 보내는 리다이렉트고, /hook 은 받은 요청을 그대로 되읽어 주는 자리입니다. 리다이렉트를 돌려줄 때도 요청 본문은 반드시 끝까지 읽어야 합니다. 읽지 않고 응답만 보내면 연결에 남은 본문이 다음 요청의 첫 줄에 붙어, 서버가 엉뚱한 메서드 이름을 봤다고 보고합니다. 재현 과정에서 실제로 걸렸던 지점이라 적어 둡니다.

이제 아무 리다이렉트도 끼지 않은 직행 호출을 한 번 보냅니다. 메서드는 POST, 본문 길이는 14 바이트로 도착합니다. 여기까지가 정상이고, 이 줄이 이후 비교의 기준이 됩니다.

# server.py — 받은 요청을 그대로 되돌려 주는 확인용 서버
import http.server, socketserver
PORT = 8942

class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _drain(self):                      # 본문을 끝까지 읽어 둔다
        n = int(self.headers.get("Content-Length") or 0)
        return self.rfile.read(n) if n else b""

    def _redir(self, code):
        self._drain()                      # 이걸 빠뜨리면 다음 요청이 깨진다
        self.send_response(code)
        self.send_header("Location", "/hook")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _route(self):
        p = self.path
        if p.startswith("/deploy-"):
            self._redir(int(p.rsplit("-", 1)[1]))
        else:
            body = self._drain()
            out = f"method={self.command} len={len(body)}\n".encode()
            self.send_response(200)
            self.send_header("Content-Length", str(len(out)))
            self.end_headers()
            self.wfile.write(out)

    do_GET = do_POST = _route

class TS(socketserver.ThreadingTCPServer):
    allow_reuse_address = True

TS(("127.0.0.1", PORT), H).serve_forever()

리다이렉트 응답에서도 본문을 비워 주는 _drain 이 있어야 뒤 요청이 온전히 들어옵니다.

curl 의 -v 는 이 순간을 한 줄로 적어 둡니다

리다이렉트를 따라가라고 -L 을 붙인 뒤 같은 호출을 보내면 결과가 달라집니다. 도착한 요청은 POST 가 아니라 GET 이고, 본문 길이는 0 입니다. 보냈다고 생각한 14 바이트는 어디에도 남지 않습니다.

무슨 일이 있었는지는 curl 자신이 알려 줍니다. -v 를 붙이면 응답과 요청 사이에 별표로 시작하는 줄이 하나 끼어 있습니다. POST 에서 GET 으로 바꾼다는 한 문장입니다. 숨겨진 동작이 아니라 명시적으로 알리는 동작인데, 평소 -v 없이 쓰다 보니 보이지 않았을 뿐입니다.

원인을 좁힐 때 이 한 줄만큼 확실한 근거가 없습니다. 서버 로그와 클라이언트 로그가 서로 다른 이야기를 할 때, 둘 사이에서 무엇이 바뀌었는지 말해 주는 유일한 자리이기 때문입니다.

curl -s -L -H 'Content-Type: application/json' -d '{"ref":"main"}' \
  http://127.0.0.1:8942/deploy-301
# method=GET len=0        ← 본문이 사라졌다

curl -s -L -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-301 -o /dev/null -v
# > POST /deploy-301 HTTP/1.1
# > Content-Length: 14
# < HTTP/1.1 301 Moved Permanently
# < Location: /hook
# * Switch from POST to GET
# > GET /hook HTTP/1.1

별표 줄이 사고 지점입니다. curl 은 바꾸고 있다고 분명히 말합니다.

* Switch from POST to GET — 이 한 줄을 찾으면 조사할 것이 더 남지 않습니다.

301 과 302 와 303 은 바뀌고, 307 과 308 은 그대로입니다

상태 코드를 바꿔 가며 같은 호출을 반복하면 경계가 또렷하게 갈립니다. 301·302·303 에서는 메서드가 GET 이 되고 본문이 사라지며, 307·308 에서는 POST 와 본문이 그대로 넘어갑니다. curl 의 -L 설명도 같은 내용을 적어 두었습니다. POST 요청이 301·302·303 을 만나면 다음 요청을 GET 으로 보내고, 그 밖의 3xx 에서는 메서드를 바꾸지 않고 다시 보낸다는 것입니다.

왜 이런 예외가 생겼는지는 규격 쪽에 답이 있습니다. RFC 9110 은 301 과 302 설명에 각각 주석을 달아, 역사적인 이유로 사용자 에이전트가 이어지는 요청의 메서드를 POST 에서 GET 으로 바꿀 수 있다고 적고, 그 동작을 원하지 않으면 307 을 쓰라고 안내합니다. 바꾸지 말라고 금지한 것이 아니라 바꿀 수 있다고 허용해 둔 자리입니다.

반대쪽은 표현이 훨씬 강합니다. 307 항목은 자동으로 리다이렉션을 수행한다면 사용자 에이전트가 요청 메서드를 바꾸어서는 안 된다고 적고 있습니다. 즉 메서드를 지키고 싶다면 애초에 그렇게 하도록 정해진 상태 코드가 따로 있는 셈입니다.

curl 이 규격을 어기는 쪽을 기본값으로 삼은 이유도 문서에 나와 있습니다. --post301 설명은 이 옵션을 붙여야 RFC 를 존중해 301 에서 POST 를 GET 으로 바꾸지 않는다고 하면서, 규격을 벗어난 그 동작이 웹 브라우저에 널리 퍼져 있어 일관성을 위해 기본적으로 변환한다고 밝힙니다. 브라우저와 같게 굴기 위한 선택이지, 실수가 아닙니다.

for c in 301 302 303 307 308; do
  printf "%s → " $c
  curl -s -L -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-$c
done
# 301 → method=GET  len=0
# 302 → method=GET  len=0
# 303 → method=GET  len=0
# 307 → method=POST len=14
# 308 → method=POST len=14

# 클라이언트에서 되돌리는 전용 옵션도 있다
curl -s -L --post301 -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-301
# method=POST len=14
curl -s -L --post302 -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-302
# method=POST len=14
curl -s -L --post303 -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-303
# method=POST len=14

경계는 303 과 307 사이에 있습니다. curl 8.7.1 에서 확인한 결과입니다.

-X POST 를 붙이면 메서드는 남고 본문만 사라집니다

여기서부터가 이 함정의 고약한 부분입니다. 메서드가 GET 으로 바뀐다는 것을 알아챈 사람이 가장 먼저 손대는 곳은 대개 -X POST 입니다. 메서드를 강제로 지정해 두면 되겠다는 생각인데, 결과가 절반만 맞습니다.

-X POST 를 붙이고 301 을 지나가면 서버에 도착하는 메서드는 POST 입니다. 그런데 본문 길이는 여전히 0 입니다. curl 은 메서드 문자열만 그대로 쓰고, 본문은 다시 싣지 않습니다. 겉으로는 고쳐진 것처럼 보이는데 실제로는 빈 POST 가 계속 날아가는 상태가 됩니다.

이 상태가 특히 나쁜 이유는 조사의 방향을 틀어 버리기 때문입니다. 서버 로그에는 POST 로 찍히니 클라이언트 쪽은 무죄로 보이고, 의심은 본문을 만드는 코드나 직렬화 쪽으로 옮겨 갑니다. 실제 문제는 여전히 리다이렉트 한 줄에 있는데 말입니다.

curl 문서도 이 조합을 경고합니다. --request 설명은 --location 을 함께 쓰면 지정한 메서드 문자열이 모든 요청에 사용되며, curl 이 30x 응답에 따라 메서드를 바꾸지 않게 되어 의도치 않은 부작용을 낳을 수 있다고 적어 두었습니다. -X 는 메서드 이름만 바꿀 뿐 curl 의 동작 자체를 바꾸지 않는다는 문장도 같은 항목에 있습니다.

덧붙일 것이 하나 있습니다. 최신 curl 문서에는 이 조합을 위한 --follow 옵션이 있습니다. -X 로 지정한 메서드를 307·308 에서는 유지하고 301·302·303 에서는 GET 으로 되돌리는, 규격에 맞춘 따라가기입니다. 다만 문서에 8.16.0 에서 추가되었다고 적혀 있고, macOS 26.5.2 에 들어 있는 curl 8.7.1 에서는 알 수 없는 옵션이라며 거부합니다. 손에 있는 curl 버전을 먼저 확인해야 합니다.

# 메서드는 지켜지지만 본문이 비어서 간다
curl -s -L -X POST -H 'Content-Type: application/json' \
  -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-301
# method=POST len=0        ← 로그에는 POST 로 남는다

# 307 이었다면 -X 없이도 본문까지 온전히 간다
curl -s -L -d '{"ref":"main"}' http://127.0.0.1:8942/deploy-307
# method=POST len=14

# 규격에 맞춰 따라가는 --follow 는 버전을 탄다
curl --version | head -1
# curl 8.7.1 (x86_64-apple-darwin25.0) ...
curl --follow -X POST -d 'x=1' http://127.0.0.1:8942/deploy-301
# curl: option --follow: is unknown

메서드만 맞고 본문이 빈 상태가 가장 오래 숨습니다. 로그가 정상으로 보이기 때문입니다.

-X POST 는 증상의 절반만 가립니다. 로그는 정상으로 보이고 본문은 계속 비어 갑니다.

호스트가 달라지면 인증 헤더도 함께 빠집니다

같은 리다이렉트 지점에서 조용히 사라지는 것이 하나 더 있습니다. 인증 정보입니다. curl 의 -L 설명은 명령줄로 인증을 제공했을 때 처음 호스트에만 자격 증명을 보내며, 리다이렉트가 다른 호스트로 데려가면 넘겨주지 않는다고 적어 두었습니다. 호스트 이름과 프로토콜, 포트 중 하나라도 달라지면 다른 호스트로 봅니다.

확인해 보면 경계가 생각보다 촘촘합니다. 같은 기계라도 127.0.0.1 에서 localhost 로 넘어가면 다른 호스트로 취급되어 Authorization 헤더가 사라집니다. -u 로 준 것뿐 아니라 -H 로 손수 적어 넣은 헤더도 마찬가지로 제거됩니다. 본문은 그대로 가는 308 리다이렉트에서도 인증만 따로 빠집니다.

그래서 증상이 앞의 것과 다르게 나타납니다. 본문은 멀쩡히 도착했는데 401 이나 403 이 돌아옵니다. 키를 의심하고 새로 발급받아 넣어도 같은 응답이 돌아오니, 자격 증명 자체가 문제라고 결론이 나기 쉽습니다.

꼭 넘겨야 한다면 --location-trusted 가 있습니다. 다만 이름 그대로 신뢰를 전제하는 옵션이라 문서도 리다이렉트된 곳이 어디냐에 따라 보안 문제가 될 수 있다고 밝혀 둡니다. 리다이렉트 목적지를 우리가 통제하지 못한다면, 비밀을 따라 보내는 대신 애초에 리다이렉트되지 않는 주소를 쓰는 편이 맞습니다.

# 앞의 서버에 경로 두 개를 더 둔다
#   /same-308  → Location: http://127.0.0.1:8942/hook  (같은 호스트)
#   /cross-308 → Location: http://localhost:8942/hook  (다른 호스트)

# 같은 호스트 안에서 옮겨 갈 때는 그대로 간다
curl -s -L -u api:secret -d '{"ref":"main"}' http://127.0.0.1:8942/same-308
# method=POST len=14 Authorization=Basic YXBpOnNlY3JldA==

# 127.0.0.1 → localhost 도 다른 호스트다
curl -s -L -u api:secret -d '{"ref":"main"}' http://127.0.0.1:8942/cross-308
# method=POST len=14 Authorization=(없음)

# -H 로 직접 적은 헤더도 함께 제거된다
curl -s -L -H 'Authorization: Bearer tkn123' \
  -d '{"ref":"main"}' http://127.0.0.1:8942/cross-308
# method=POST len=14 Authorization=(없음)

# 넘겨야 한다면 신뢰를 명시한다
curl -s --location-trusted -u api:secret \
  -d '{"ref":"main"}' http://127.0.0.1:8942/cross-308
# method=POST len=14 Authorization=Basic YXBpOnNlY3JldA==

본문은 갔는데 401 이 돌아온다면 리다이렉트로 호스트가 바뀌지 않았는지부터 봅니다.

고칠 자리는 셋 중 하나입니다

가장 깨끗한 해결은 리다이렉트를 없애는 것입니다. 훅 주소가 왜 옮겨졌는지 확인하고, 최종 주소를 스크립트에 그대로 적습니다. 대부분의 경우 원인은 사소합니다. http 로 적어 둔 주소가 https 로 넘어가거나, 끝에 슬래시가 있고 없고의 차이로 서버가 정규화 리다이렉트를 돌려주는 식입니다. 어느 쪽이든 한 번만 고치면 사라집니다.

리다이렉트를 없앨 수 없고 서버를 우리가 쥐고 있다면, 그 응답의 상태 코드를 308 로 바꾸는 것이 다음 선택입니다. 규격이 메서드를 바꾸지 말라고 못 박아 둔 코드라 클라이언트가 curl 이든 다른 무엇이든 같은 결과를 얻습니다. 클라이언트마다 옵션을 챙기는 것보다 응답 한 곳을 고치는 편이 언제나 안전합니다.

서버가 남의 것이고 상태 코드도 바꿀 수 없다면 클라이언트에서 막습니다. 만나는 코드에 맞춰 --post301, --post302, --post303 을 붙이면 됩니다. 세 옵션 모두 -L 과 함께 쓸 때만 의미가 있다고 문서에 적혀 있습니다. -X POST 로 대신하려는 생각은 접는 편이 좋습니다. 앞에서 본 대로 본문이 따라가지 않습니다.

쓰는 도구가 curl 이 아니라면 같은 질문을 그 도구에 다시 해야 합니다. 리다이렉트를 따라갈 때 메서드와 본문을 어떻게 다루는지는 구현마다 다르고, 기본값이 켜져 있는지도 다릅니다. 확인하는 방법은 같습니다. 받은 요청을 그대로 보여 주는 서버를 하나 띄우고 한 번 쏴 보면 됩니다.

# 1) 최종 주소를 먼저 확인한다 — 리다이렉트를 없앨 수 있는지부터
curl -s -o /dev/null -L -d 'x=1' \
  -w 'redirects=%{num_redirects} final=%{url_effective}\n' \
  http://127.0.0.1:8942/deploy-302
# redirects=1 final=http://127.0.0.1:8942/hook

# 2) 서버를 쥐고 있다면 그 응답을 308 로 바꾼다
#    → 클라이언트가 무엇이든 메서드가 유지된다

# 3) 그럴 수 없다면 클라이언트에서 되돌린다
curl -s -L --post301 --post302 --post303 \
  -H 'Content-Type: application/json' -d '{"ref":"main"}' \
  http://127.0.0.1:8942/deploy-302
# method=POST len=14

순서가 있습니다. 없애기 → 서버 상태 코드 고치기 → 클라이언트 옵션으로 막기.

옵션으로 덮는 것은 마지막 수단입니다. 리다이렉트 자체를 없애는 편이 오래 갑니다.

다음에는 조용히 지나가지 않게 만들어 둡니다

고친 뒤에 남는 일은 같은 일이 다시 생겼을 때 소리가 나게 해 두는 것입니다. 이번 사고의 본질은 메서드가 바뀐 것이 아니라, 바뀌었는데 아무도 몰랐다는 데 있습니다.

API 호출에는 --max-redirs 0 을 붙여 둘 만합니다. 리다이렉트를 하나라도 만나면 curl 이 종료 코드 47 로 끝냅니다. 확인해 보면 메서드가 유지되는 308 에서도 똑같이 47 로 멈추고, 리다이렉트가 없는 정상 경로에서는 0 으로 통과합니다. 즉 주소가 옮겨졌다는 사실 자체를 실패로 다루겠다는 선언입니다. 페이지를 내려받는 용도가 아니라 정해진 종단점에 요청을 보내는 용도라면 이쪽이 맞습니다.

따라가야만 하는 사정이 있다면 최소한 결과를 남깁니다. -w 로 num_redirects 와 method 를 함께 찍어 두면, 몇 번 옮겨 갔고 마지막에 어떤 메서드로 도착했는지가 로그에 남습니다. 나중에 같은 증상이 나왔을 때 이 두 값만 보면 조사할 범위가 한 줄로 좁혀집니다.

마지막으로 --fail 에 기대고 있었다면 그 기대를 조정해 둡니다. 400 이상만 잡아 주는 옵션이라 3xx 는 지나갑니다. 배포나 결제처럼 한 번의 요청이 결과를 만드는 호출이라면, 200 을 받았는지가 아니라 우리가 기대한 응답을 받았는지까지 확인하는 편이 안전합니다.

# 리다이렉트를 만나면 실패로 끝낸다
curl -s -L --max-redirs 0 -o /dev/null -d 'x=1' http://127.0.0.1:8942/deploy-301
echo $?
# 47

# 메서드가 유지되는 308 에서도 똑같이 멈춘다
curl -s -L --max-redirs 0 -o /dev/null -d 'x=1' http://127.0.0.1:8942/deploy-308
echo $?
# 47

# 리다이렉트가 없는 정상 경로는 그대로 통과한다
curl -s -L --max-redirs 0 -o /dev/null -d 'x=1' http://127.0.0.1:8942/hook
echo $?
# 0

# 따라가야 한다면 흔적을 남긴다
curl -s -L -o /dev/null -d 'x=1' \
  -w 'code=%{http_code} method=%{method} redirects=%{num_redirects}\n' \
  http://127.0.0.1:8942/deploy-302
# code=200 method=GET redirects=1

%{method} 는 마지막 요청에 실제로 쓰인 메서드입니다. 이 값이 로그에 있으면 다음은 빠릅니다.

메서드가 바뀐 것이 사고가 아니라, 바뀐 줄 몰랐던 것이 사고였습니다.

조치 후 확인할 것

  • curl -v 를 붙여 요청과 응답 사이에 Switch from POST to GET 줄이 있는지 확인합니다. 이 줄이 있으면 원인은 리다이렉트로 확정됩니다.
  • -w 로 num_redirects 와 method 를 찍어, 요청이 몇 번 옮겨 갔고 마지막에 어떤 메서드로 도착했는지 확인합니다.
  • -X POST 로 덮어 둔 곳이 있는지 확인합니다. 메서드는 POST 로 남고 본문만 비어 가는 상태일 수 있습니다.
  • 리다이렉트로 호스트가 바뀌었다면 Authorization 헤더가 빠지지 않았는지 확인합니다. 본문은 갔는데 401 이 돌아오는 경우가 여기에 해당합니다.
  • 정해진 종단점을 호출하는 자리라면 --max-redirs 0 을 붙여, 주소가 옮겨진 사실 자체가 종료 코드 47 로 드러나게 해 둡니다.

확인한 문서

정리하면 확인할 것은 세 가지입니다. 리다이렉트가 끼어 있는지, 그 상태 코드가 303 이하인지 307 이상인지, 그리고 우리 스크립트가 그 사실을 알아챌 수 있는지입니다. 앞의 둘은 curl -v 한 번으로 끝나고, 마지막 하나만 손을 대면 됩니다. 리다이렉트는 오류가 아니라 정상 응답이라 아무도 대신 알려 주지 않습니다. 알려 달라고 적어 두는 일은 우리 몫입니다.