406 Not Acceptable은 차단이 아니라 잘못된 URL이었다
정부 사이트에서 자료를 받다가 406 Not Acceptable을 만났다. 봇 차단이라고 판단하고 한참을 우회하려 했다. 전부 헛수고였고, 원인은 내가 만들어 낸 URL이었다.
처음 본 것
주택도시기금 사이트의 상품 페이지를 받으려고 했다.
curl -s -o /dev/null -w '%{http_code}\n' \
'https://nhuf.molit.go.kr/FP/FP05/FP0503/FP050301.jsp'
# 406
응답 본문은 1,370바이트짜리 안내 페이지였다.
The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
문장이 아주 친절하게 Accept 헤더를 가리킨다. 그래서 헤더를 채웠다.
curl -s -L 'https://nhuf.molit.go.kr/FP/FP05/FP0503/FP050301.jsp' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36' \
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \
-H 'Accept-Language: ko-KR,ko;q=0.9' --compressed
# 여전히 406
여기서 봇 차단이라고 확신했다. 그리고 시간을 버리기 시작했다.
내가 시도한 우회들
순서대로 이렇다.
- User-Agent 위조 — 실패
- Accept·Accept-Language 전부 채우기 — 실패
- 마크다운 변환 fetch 도구 — 406 그대로 반환
- 헤드리스 브라우저로 접속 — 406
- 루트를 먼저 방문한 뒤 딥링크로 이동 — 406
- 모바일 호스트 탐색 — 200이 떨어졌지만 본문이 1,627바이트였다
6번이 특히 그럴듯해 보였다. 본문을 열어 보니 봇 검사 스크립트만 들어 있었다.
<link href="https://cdn2.devy.kr/1811/css/webgate.css?v=1" rel="stylesheet" />
<script src="https://cdn2.devy.kr/1811/js/webgate.js?v=1"></script>
<script>
function startWebGate() {
WG_StartWebGate(1651, /* ... */);
모바일 호스트에 진짜로 봇 게이트가 있었다. 이게 결정적인 오해를 만들었다. “역시 이 기관은 자동 접근을 막는구나”라는 가설이 강화됐고, 나는 메인 호스트도 같은 정책일 거라고 넘겨짚었다.
실제로는 이랬다
한참 뒤에야 당연한 검사를 했다. 아래 표의 응답 코드는 전부 2026년 8월 26일에 직접 돌려서 찍은 값이고, 명령을 그대로 붙여 넣으면 재현된다. 페이지 링크에서 긁어낸 실제 경로들을 같은 방식으로 때려 본 것이다.
UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36'
for u in \
"/" \
"/FP/FP05/FP0501/FP0501.jsp" \
"/FP/FP05/FP0502/FP05020301.jsp" \
"/FP/FP05/FP0502/FP05020603.jsp" \
"/FP/FP05/FP0503/FP050301.jsp" ; do
code=$(curl -s -o /tmp/r.html -w '%{http_code}' -L "https://nhuf.molit.go.kr$u" \
-H "User-Agent: $UA" --compressed -m 20)
size=$(wc -c < /tmp/r.html | tr -d ' ')
title=$(grep -o '<title>[^<]*' /tmp/r.html | head -1 | sed 's/<title>//')
printf '%-42s %s %7s %s\n' "$u" "$code" "$size" "$title"
done
결과가 이렇게 나왔다.
/ 200 103873 주택도시기금
/FP/FP05/FP0501/FP0501.jsp 200 111832 개인상품 전체보기
/FP/FP05/FP0502/FP05020301.jsp 200 109988 대출안내
/FP/FP05/FP0502/FP05020603.jsp 200 1732 404에러 | 주택도시기금포털
/FP/FP05/FP0503/FP050301.jsp 406 1370 406 Not Acceptable
실제 경로는 전부 200이다. 그것도 -H 'Accept: ...' 없이, 평범한 User-Agent 하나만 준 curl로 111KB가 그대로 떨어진다. 차단은 처음부터 없었다.
406이 뜬 것은 마지막 한 줄, FP050301.jsp뿐이다. 이건 내가 지어낸 경로다. 이 사이트의 하위 페이지는 FP05020301.jsp처럼 카테고리 코드 뒤에 두 자리가 더 붙는데, 나는 한 단계 짧게 만들었다. 사람 눈에는 같은 패턴으로 보이지만 서버에게는 그냥 다른 문자열이다.
세 가지 상태가 각각 다른 의미다
이 표가 이 글의 요점이다.
| 요청한 경로 | HTTP | 본문 | 뜻 |
|---|---|---|---|
| 실제 존재하는 경로 | 200 | 정상 페이지 | 잘 된다 |
| 존재했다가 삭제된 경로 | 200 | ”404에러” 안내 페이지 | 페이지가 없어졌다 |
| 문법에 안 맞는 경로 | 406 | Not Acceptable 안내 | 내 URL이 틀렸다 |
두 번째 줄을 특히 조심해야 한다. HTTP 상태는 200인데 내용은 404 안내다. 상태 코드만 보고 성공으로 처리하는 스크래퍼는 이걸 정상 페이지로 착각한다. 반대로 사람이 브라우저로 보면 404 페이지가 보이니 “404가 떴다”고 말하게 된다. 둘 다 같은 응답을 다르게 부르는 것이다.
세 번째 줄이 이 글의 함정이다. 406의 표준 의미와 이 서버가 쓰는 의미가 다르다. RFC 9110 15.5.7에서 406은 콘텐츠 협상 실패다. 그런데 여기서는 경로 패턴 위반에 붙어 나온다. 응답 본문이 Accept 헤더를 명시적으로 지목하기 때문에, 코드를 그대로 믿으면 반드시 헤더를 고치러 간다. 나는 그 오답을 다섯 단계나 따라갔다.
다음부터 할 것
우회를 시작하기 전에 한 줄을 먼저 돌린다.
# 목록 페이지에서 진짜 링크를 하나 긁어서, 그걸 먼저 때려 본다
curl -s -o /dev/null -w '%{http_code}\n' "https://호스트/실제로/링크에서/긁은/경로"
이게 200이면 차단이 아니다. 문제는 내 URL이다.
루트만 확인하는 것으로는 부족하다. 나는 실제로 루트가 200인 것을 초반에 봤는데도 차단 가설을 버리지 않았다. 루트는 보통 정적이고 가장 관대하게 열려 있어서, 루트 200과 하위 경로 실패는 “루트만 열어 두고 나머지는 막는다”로도 읽히기 때문이다. 링크에서 실제로 긁어낸 하위 경로여야 판별력이 생긴다.
그리고 URL은 추측하지 않는다. 목록 페이지를 받아서 href를 파싱하면 5초면 끝난다.
curl -s 'https://nhuf.molit.go.kr/' -H "User-Agent: $UA" --compressed \
| grep -o 'href="[^"]*FP05[^"]*"' | sort -u | head
내가 우회에 쓴 시간보다 이게 훨씬 짧았다.
남는 교훈
이번 건에서 나를 오래 붙잡은 것은 부분적으로 맞는 증거였다. 모바일 호스트에는 정말로 봇 게이트가 있었다. 그 사실이 틀린 가설에 힘을 실어 줬고, 이후의 실패를 전부 “역시 막혀 있다”로 해석하게 만들었다.
가설을 강화하는 증거를 찾았을 때가 오히려 반증을 한 번 시도할 시점이다. 여기서는 그 반증이 curl 한 줄이었다.
자주 묻는 질문
406은 원래 무슨 뜻인가요?
RFC 9110 기준으로 406 Not Acceptable은 서버가 가진 표현이 요청의 콘텐츠 협상 헤더(Accept, Accept-Language, Accept-Encoding 등)를 만족시키지 못할 때 쓰는 코드입니다. 즉 원래 의미는 '이 리소스는 있는데 네가 받겠다고 한 형식으로는 줄 수 없다'입니다. 그래서 406을 보면 Accept 헤더를 의심하게 되는데, 실무에서는 WAF나 라우팅 계층이 이 코드를 전혀 다른 이유로 재사용하는 경우가 있습니다.
404가 아니라 406이 나온 이유는 뭔가요?
확실한 것은 관측된 동작뿐입니다. 이 사이트에서 존재했다가 삭제된 경로는 HTTP 200에 본문이 '404에러' 안내 페이지였고, 사이트의 경로 문법에 맞지 않는 경로는 406이었습니다. 애플리케이션까지 도달한 요청은 앱이 404 안내 페이지를 그리고, 그 앞단에서 경로 패턴에 걸러진 요청은 406으로 끊긴다고 보면 관측과 맞습니다. 다만 이건 추정이고, 서버 설정을 본 것은 아닙니다.
봇 차단인지 아닌지 어떻게 빨리 구분하나요?
같은 호스트에서 확실히 존재하는 URL 하나를 같은 방식으로 요청해 보면 됩니다. 그게 200이면 차단이 아니라 그 URL의 문제입니다. 루트 경로는 좋은 후보지만 충분하지 않습니다. 루트만 200이고 하위 경로가 전부 실패하면 여전히 차단이 의심되기 때문입니다. 그래서 루트가 아니라 링크에서 실제로 긁어낸 하위 경로를 써야 합니다.
URL을 추측해서 만드는 게 왜 위험한가요?
경로 규칙이 눈에 보이는 것보다 엄격한 경우가 많기 때문입니다. 이 사이트는 하위 페이지가 FP05020301.jsp처럼 카테고리 코드 뒤에 두 자리가 더 붙는 형태인데, 저는 FP050301.jsp처럼 한 단계 짧게 지어냈습니다. 사람 눈에는 같은 패턴으로 보이지만 서버에게는 다른 문자열입니다. 목록 페이지의 링크를 파싱해서 실제 경로를 가져오는 편이 항상 빠릅니다.