공공기관 파일 다운로드가 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 47은 CURLE_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 | 따라가지 않음 | 0 | 152B HTML |
curl -sL | 50회 | 47 | 없음 |
curl -sL -H 'User-Agent: Mozilla/5.0' | 0 | 0 | 661KB 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로 붙여 한 번 더 시도해 볼 가치는 있습니다.