본문 바로가기
빌드노트 검색

빌드노트

Content-Location을 보냈는데 브라우저 주소가 바뀌지 않는다

200 응답에 Content-Location을 보내도 목적지 요청이나 주소 변경은 일어나지 않는다. 같은 /target을 가리키는 200과 302를 실제 브라우저·curl로 비교하고, 표현의 주소를 알리는 응답과 이동 응답을 구분한다.

200 OK 응답에 Content-Location: /target을 넣었는데 브라우저가 원래 주소에 머문다면, 헤더가 누락됐는지 확인하기 전에 그 역할부터 구분해야 한다. Content-Location은 응답 본문이 나타내는 표현의 주소를 알려 주는 메타데이터다. 목적지로 다시 요청하라는 리다이렉트 지시가 아니다. 페이지를 다른 주소로 이동시키려면 이동의 의미에 맞는 상태 코드와 Location을 함께 반환한다.

RFC 9110 §8.7 (새 탭에서 열림)은 Content-Location이 요청 대상 URI를 대체하지 않는다고 설명한다. 이름에 Location이 들어 있다고 두 헤더를 같은 용도로 쓸 수는 없다.

헤더 수신과 목적지 요청을 따로 확인한다

주소창에 URL을 입력해 여는 페이지에서 문제가 생겼다면 Chrome 개발자 도구의 Network를 열고 Preserve log를 켠 뒤 다시 접근한다. 최초 요청을 선택해 Headers의 상태 코드와 Response Headers를 함께 읽는다. Chrome의 Network 문서 (새 탭에서 열림)에서 이 기록 보존 방법을 확인할 수 있다.

최초 응답이 200이고 Content-Location: /target이 있다면, 헤더는 도착한 것이다. 다음으로 /target에 대한 문서 요청이 실제로 생겼는지와 브라우저의 최종 URL을 확인한다. 최종 화면의 본문이나 마지막 응답의 200만으로 이동 여부를 판단하지 않는다.

반대로 최초 응답이 이미 302와 Location을 보내고 있다면 이번 헤더 혼동과는 다른 상황이다. 후속 요청이 실패했는지, 중간 이동이 더 있는지부터 이어서 본다.

같은 /target을 적었지만 요청 횟수는 달랐다

이를 비교하려고 외부 자원을 불러오지 않는 loopback 서버를 만들었다. /content는 200과 Content-Location: /target을, /redirect는 302와 Location: /target을 반환한다. /target은 200으로 receipt=42 한 줄을 보낸다. /content의 본문도 이와 같게 했다. Content-Location이 가리키는 주소에서 같은 표현을 받을 수 있게 한 것이다.

Windows 11, Python 3.12.10 서버와 headless Chrome 153.0.8010.36에서 각각의 URL로 문서 탐색을 실행했다. 다음은 서버의 실제 수신 경로와 브라우저 최종 값을 정리한 결과다. 요청 수는 아래 문서 경로만 세었고, 별도로 발생한 favicon 요청은 제외했다.

/content 방문
  GET /content  -> 200, Content-Location: /target
  문서 요청: 1회
  최종 URL: http://127.0.0.1:13427/content
  표시 본문: receipt=42

/redirect 방문
  GET /redirect -> 302, Location: /target
  GET /target   -> 200
  문서 요청: 2회
  최종 URL: http://127.0.0.1:13427/target
  표시 본문: receipt=42

브라우저가 받은 /content 응답에 Content-Location이 있는 것도 별도로 확인했다. 헤더를 받았지만 /target을 추가 요청하지 않았고, 최종 URL도 /content였다. /redirect에서는 실제로 두 번째 GET이 발생했고 최종 URL이 /target으로 바뀌었다. 두 화면의 본문은 같았으므로, 본문만 비교했다면 이 차이를 놓쳤을 것이다.

이동이 목적이면 상태 코드와 Location을 함께 바꾼다

현재 본문을 그대로 제공하면서 그 표현에 대응하는 주소를 알리고 싶다면 200 + Content-Location을 쓸 수 있다. 이때 클라이언트가 그 주소를 자동으로 방문할 것이라고 기대해서는 안 된다. 예제의 /content가 이 계약이다.

GET으로 연 페이지를 임시로 다른 주소에서 받게 하려는 경우라면 예제의 /redirect처럼 302 + Location을 반환한다. 아래는 두 응답에서 판단에 필요한 상태 줄과 헤더만 남긴 것이다.

HTTP/1.1 200 OK
Content-Location: /target

HTTP/1.1 302 Found
Location: /target

200을 그대로 두고 헤더 이름만 Location으로 바꾸는 식으로 해결하지 않는다. RFC 9110 §10.2.2 (새 탭에서 열림)는 Location의 의미를 요청 메서드와 상태 코드에 연결한다. 예를 들어 201 Created의 Location은 생성된 리소스의 주소다. Location이 있다는 사실만으로 자동 이동을 요구하는 응답이 되지는 않는다.

RFC 9110의 302 정의 (새 탭에서 열림)는 임시 목적지를 Location으로 제공하고 클라이언트가 이를 따라갈 수 있게 한다. 위 실험에서는 브라우저 문서 탐색이 실제로 따라갔다. 이 결과를 POST의 처리 방식이나 모든 HTTP 클라이언트의 자동 추적 보장으로 넓히지는 않는다.

curl에서는 첫 응답까지 읽는다

자신의 테스트 서버를 진단할 때도 같은 비교를 할 수 있다. Windows PowerShell에서 curl.exe를 사용하며, $base에는 실행 중인 테스트 서버의 origin을 넣는다. 아래 포트 13427은 이번 측정값이다. 독자의 서버 포트와 /content, /redirect 경로로 바꿔 실행한다. 이 글의 임시 서버는 측정 후 종료했다.

다음 명령은 응답 헤더를 본문과 함께 출력하고 리다이렉트를 한 번까지 따라가게 한다.

$base = 'http://127.0.0.1:13427'
curl.exe --noproxy "*" --max-time 5 -i -L --max-redirs 1 "$base/content"
curl.exe --noproxy "*" --max-time 5 -i -L --max-redirs 1 "$base/redirect"

--noproxy "*"는 이 loopback 비교에서 프록시를 우회하고 --max-time 5는 요청 대기를 제한한다. curl의 -L 설명 (새 탭에서 열림)처럼 -L은 이동 응답을 따라가며, -i를 함께 쓰면 중간 응답의 헤더도 볼 수 있다. curl 8.21.0에서 실행한 출력 중 상태 줄·목적지 헤더·최종 본문만 추리면 다음과 같다.

/content 요청의 출력
HTTP/1.1 200 OK
Content-Location: /target
receipt=42

/redirect 요청의 출력
HTTP/1.1 302 Found
Location: /target
HTTP/1.1 200 OK
receipt=42

-L을 켰어도 첫 요청에서는 응답이 하나뿐이었다. 서버 수신 기록도 /content 한 번이었다. 두 번째 명령에서는 302 다음에 목적지의 200이 보였다. 자신의 출력이 302 + Location에서 멈춘다면 curl의 오류 메시지와 목적지 연결을 확인한다. 한 번보다 긴 리다이렉트 체인은 이 명령의 추적 한도를 넘는다.

이번 확인 범위는 쿠키가 없는 새 브라우저의 문서 탐색과 curl의 GET이다. 응답에 Cache-Control: no-store를 설정했으며, Content-Location에 따른 캐시 저장·재사용 전체 동작은 검증하지 않았다. JavaScript로 주소를 바꾸는 동작, API 호출을 통한 화면 전환, 다른 브라우저와 HTTPS 프록시도 이 결과에 포함하지 않는다.

← 전체 글 보기