공공기관 파일 다운로드가 curl에서만 실패한다 — 307 리다이렉트 루프의 진짜 원인은 User-Agent

·

공공 임대주택 공고문 PDF를 자동으로 받아 수치를 대조하는 스크립트를 만들다가, 브라우저에서는 멀쩡히 받아지는 파일이 curl에서만 실패하는 상황을 만났다. 더 나쁜 건 실패 방식이 두 가지였고 둘 다 원인을 엉뚱한 곳으로 가리켰다는 것이다.

증상 1 — 성공한 것처럼 보이는 실패

SH서울주택도시개발공사의 첨부파일 엔드포인트를 그대로 호출했다.

$ curl -s "https://www.i-sh.co.kr/main/com/file/innoFD.do?brdId=GS0401&seq=309073&fileTp=A&fileSeq=1" -o out.pdf
$ echo $?
0
$ wc -c < out.pdf
152
$ file -b out.pdf
HTML document text, ASCII text, with CRLF line terminators

종료 코드는 0이고 파일도 생겼다. 하지만 내용은 PDF가 아니라 152바이트짜리 HTML이었다.

<html>
<head><title>307 Temporary Redirect</title></head>
<body bgcolor="white">
<center><h1>307 Temporary Redirect</h1></center>
</body>
</html>

이게 위험한 이유는 명확하다. 다운로드 스크립트가 종료 코드만 검사하면 이 실패를 통과시킨다. 나중에 PDF 파서가 깨지고 나서야 문제를 알게 되고, 그 시점엔 원인이 다운로드 단계라는 것조차 보이지 않는다.

증상 2 — -L을 붙이자 리다이렉트 루프

307이 보였으니 -L을 붙이는 게 자연스러운 대응이다.

$ curl -sL "https://www.i-sh.co.kr/main/com/file/innoFD.do?brdId=GS0401&seq=309073&fileTp=A&fileSeq=1" -o out.pdf
$ echo $?
47

exit 47CURLE_TOO_MANY_REDIRECTS다. 파일은 아예 생기지 않는다. 리다이렉트 횟수를 세어 보면 이렇다.

$ curl -sL -o /dev/null -w '%{num_redirects} redirects, 최종 %{http_code}\n' "https://www.i-sh.co.kr/main/com/file/innoFD.do?brdId=GS0401&seq=309073&fileTp=A&fileSeq=1"
50 redirects, 최종 307

여기서 흔한 오진이 시작된다. 증상만 보면 “리다이렉트 처리 문제”이고, --max-redirs를 올리거나 쿠키 저장(-c/-b)을 붙이거나 세션을 흉내 내는 쪽으로 시간을 쓰게 된다. 전부 소용없다.

왜 루프가 되는가 — 에러 페이지도 307을 던진다

리다이렉트 목적지를 직접 열어 보면 원인 구조가 드러난다.

$ curl -s -D- -o /dev/null "https://www.i-sh.co.kr/main/com/file/innoFD.do?brdId=GS0401&seq=309073&fileTp=A&fileSeq=1"
HTTP/1.1 307 Temporary Redirect
Content-Type: text/html
Content-Length: 152
Location: https://www.i-sh.co.kr/error/error.html

$ curl -s -D- -o /dev/null "https://www.i-sh.co.kr/error/error.html"
HTTP/1.1 307 Temporary Redirect
Content-Type: text/html
Content-Length: 152

파일 요청은 /error/error.html로 넘기는데, 그 에러 페이지 자체가 같은 조건에서 다시 307을 반환한다. 그래서 무한 루프가 되고 curl의 기본 상한 50회에서 끊긴다.

즉 리다이렉트는 결과지 원인이 아니다. 첫 응답이 이미 “이 요청은 거부한다”는 뜻이었고, 거부 표현이 403이나 404가 아니라 에러 페이지로의 307이었을 뿐이다.

진짜 원인 — curl 기본 User-Agent

요청에서 바꿀 수 있는 것을 하나씩 지웠다. URL·쿼리스트링·쿠키를 전부 그대로 두고 헤더만 추가했다.

$ curl -sL -H 'User-Agent: Mozilla/5.0' \
  -o /dev/null -w '%{num_redirects} redirects, 최종 %{http_code}, %{content_type}, %{size_download}B\n' \
  "https://www.i-sh.co.kr/main/com/file/innoFD.do?brdId=GS0401&seq=309073&fileTp=A&fileSeq=1"

0 redirects, 최종 200, application/octet-stream;charset=UTF-8, 661376B

리다이렉트가 50에서 0으로 떨어졌다. 리다이렉트를 따라간 게 아니라 애초에 발생하지 않았다. 응답은 200이고 661KB짜리 PDF다.

다른 변수를 바꾸지 않았으므로 분기 조건은 User-Agent 하나로 확정된다. 서버 앞단이 curl/8.x 같은 기본 UA를 보고 요청을 에러 페이지로 흘려보내고 있었다.

정리 — 세 가지 결과 대조

요청리다이렉트종료 코드받은 것
curl -s따라가지 않음0152B HTML
curl -sL50회47없음
curl -sL -H 'User-Agent: Mozilla/5.0'00661KB PDF

Referer는 필요 없었다. UA만 붙이면 통과한다.

스크립트에 넣어야 할 방어

이 사고에서 배운 건 원인 자체보다 실패가 성공처럼 보였다는 점이다. 종료 코드는 신뢰할 수 없다. 받은 파일의 실제 형식을 검사해야 한다.

fetch_pdf() {
  local url="$1" out="$2"
  curl -sL -H 'User-Agent: Mozilla/5.0' --max-time 30 "$url" -o "$out" || {
    echo "다운로드 실패 (curl exit $?)"; return 1
  }
  # 종료 코드만으로는 부족하다 — 실제 형식을 본다
  case "$(file -b --mime-type "$out")" in
    application/pdf) return 0 ;;
    *) echo "PDF가 아니다: $(file -b "$out") / $(wc -c < "$out")B"; return 1 ;;
  esac
}

크기 하한도 같이 두면 좋다. 152바이트짜리 에러 스텁은 어떤 정상 PDF보다도 작다.

다른 기관에도 적용되나

같은 날 네 기관의 첨부파일 엔드포인트를 훑으면서 확인한 범위는 이렇다.

  • SH(i-sh.co.kr) — UA 필터링. 기본 UA면 307 루프.
  • 서울주거포털(housing.seoul.go.kr)-L과 UA가 모두 필요했다. 해시 경로(/site/main/file/download/uu/<hash>)로 한 번 더 넘긴다.
  • LH(apply.lh.or.kr) — 첨부 메타를 POST로 먼저 받아 파일 식별자를 얻어야 한다. UA 요구는 없었다.
  • iH(ih.co.kr) — 경로가 개편돼 옛 URL은 404 HTML을 200으로 준다. 이것도 종료 코드로는 안 잡힌다.

공통점은 하나다. HTTP 상태 코드와 종료 코드가 실패를 정직하게 알려주지 않는다. 공공기관 파일을 자동으로 받는다면 받은 바이트를 직접 확인하는 단계를 빼지 말아야 한다.

자주 묻는 질문

exit code가 0인데 왜 실패인가요?

curl은 -L 없이 실행하면 3xx 응답을 '정상 응답'으로 처리하고 본문을 그대로 저장한 뒤 종료 코드 0을 반환합니다. 서버가 307과 함께 152바이트짜리 리다이렉트 안내 HTML을 보냈으므로, 파일은 만들어지고 종료 코드도 0입니다. 다운로드 스크립트가 종료 코드만 검사하면 이 실패를 잡지 못합니다. file 명령으로 실제 형식을 확인하거나 최소 크기를 검사해야 합니다.

-L을 붙였는데 왜 exit 47이 나나요?

curl exit 47은 CURLE_TOO_MANY_REDIRECTS입니다. 이 서버는 파일 요청을 /error/error.html로 307 리다이렉트하는데, 그 error.html 자체도 같은 조건에서 다시 307을 반환합니다. 결과적으로 무한 루프가 되고 curl의 기본 상한(50회)에서 끊깁니다. 리다이렉트 설정을 아무리 만져도 해결되지 않는 이유입니다.

왜 User-Agent가 원인이라고 확정했나요?

URL·쿼리스트링·쿠키를 전부 그대로 두고 -H 'User-Agent: Mozilla/5.0' 한 줄만 추가해 다시 호출했습니다. 리다이렉트 횟수가 50에서 0으로 떨어지고 응답이 200에 661,376바이트 PDF가 됐습니다. 다른 변수를 바꾸지 않았으므로 User-Agent가 분기 조건이라는 것이 확정됩니다.

Referer도 필요한가요?

이 서버에서는 필요하지 않았습니다. User-Agent만 있으면 Referer 없이도 200이 돌아옵니다. 다만 기관마다 다르므로, User-Agent만으로 안 되면 원본 게시글 URL을 Referer로 붙여 한 번 더 시도해 볼 가치는 있습니다.

#curl#HTTP#자동화#에러#크롤링