빌드노트
URL의 # 뒤 값이 서버 요청에 없는 이유
주소의 # 뒤 값은 브라우저 fragment로 남고 페이지를 가져오는 요청 대상에서는 빠진다. 새 브라우저와 loopback 서버에서 location 값과 요청 줄을 대조하고, 서버에 필요한 값을 query나 JSON 본문으로 명시하는 기준을 설명한다.
브라우저 주소가 /page?q=alpha#part-beta인데 서버 로그에는 /page?q=alpha만 남고, query 파라미터에서도 part-beta를 찾을 수 없다면 URL의 fragment를 확인한다. #part-beta는 브라우저가 처리할 fragment이고, 그 페이지를 가져오는 HTTP 요청 대상에는 포함되지 않는다. 서버가 이 값을 알아야 한다면 query나 요청 본문으로 따로 보내야 한다.
RFC 3986의 fragment 정의 (새 탭에서 열림)는 자원을 가져오기 전에 fragment를 나머지 주소에서 분리하도록 설명한다. HTTP/1.1로 원본 서버에 직접 보내는 일반 GET 요청의 대상도 경로와 query로 구성된다. RFC 9112의 origin-form (새 탭에서 열림)에 fragment 자리는 없다.
브라우저에는 남았지만 요청 줄에는 없었다
이를 확인하려고 127.0.0.1에 작은 서버를 열고, 새 headless Chrome 세션에서 두 주소를 차례로 열었다. Windows 11·Python 3.12.10·HeadlessChrome 153에서 alpha, part-beta라는 가짜 값만 사용한 실험이다. 서버는 HTTP/1.1 요청 줄을 기록하고, query 값은 Python의 parse_qs로 따로 읽었다.
아래의 href, search, hash는 페이지의 location에서 읽은 값이다. 요청 줄은 서버가 받은 원문이며, q는 서버에서 파싱한 값이다. 주소창의 표시 방식 대신 이 문자열들을 비교했다.
href: http://127.0.0.1:62399/page?q=alpha#part-beta
search: ?q=alpha
hash: #part-beta
서버: GET /page?q=alpha HTTP/1.1
q: "alpha"
href: http://127.0.0.1:62399/page?q=alpha%23part-beta
search: ?q=alpha%23part-beta
hash: ""
서버: GET /page?q=alpha%23part-beta HTTP/1.1
q: "alpha#part-beta"
첫 주소의 query는 q=alpha에서 끝난다. #part-beta는 브라우저의 location.hash에만 남았다. 서버는 그 값을 전달받지 않았으므로 디코딩을 한 번 더 해도 복구할 수 없다.
두 번째 주소의 %23은 query 값 안의 문자 #를 표현한다. hash는 비어 있고 서버에는 %23을 포함한 query가 도착했다. 서버가 query를 한 번 파싱하자 q가 alpha#part-beta로 읽혔다. #가 들어 있는 데이터를 query에 넣은 결과다. 구분자와 충돌하는 데이터를 퍼센트 인코딩한다는 RFC 3986의 예약 문자 규칙 (새 탭에서 열림)과 맞는다.
내 페이지에서는 search와 hash를 나눠 본다
확인할 준비물은 자신의 loopback 테스트 페이지와 브라우저 개발자 도구다. query와 fragment가 없는 페이지 주소에 ?q=alpha#part-beta를 붙여 새로 연다. Console에서 다음 표현식을 실행한다.
JSON.stringify({href: location.href, search: location.search, hash: location.hash})
위 표현식도 두 조건에서 직접 실행했다. 첫 조건의 search는 "?q=alpha", hash는 "#part-beta"였다. 주소의 query와 fragment 부분을 ?q=alpha%23part-beta로 바꿔 새로 열자 search는 "?q=alpha%23part-beta", hash는 빈 문자열이었다. 실험의 62399는 이미 종료한 서버의 임시 포트다. 자신의 테스트 페이지 주소를 사용한다.
Network에서 해당 문서 요청을 찾고, 서버에서도 같은 요청의 경로와 query 원문을 대조한다. 이미 해석된 파라미터 목록만 보면 %23으로 도착했는지 알 수 없다. 값이 hash에만 있고 서버 수신 원문에는 없다면 보내는 코드를 바꾼다. query 원문에 %23이 도착했는데 파라미터가 예상과 다르다면 중간 계층이나 서버의 파싱을 조사한다.
같은 문서에서 hash만 #part-beta에서 #part-gamma로 바꿨을 때는 주소가 바뀌어도 문서 GET 수가 1회 그대로였다. 이 실험 페이지에는 hash 변경에 반응해 요청을 보내는 코드가 없었다. 앱이 그런 코드를 실행하는 경우에는 추가 요청을 따로 확인해야 한다.
서버에 필요한 값을 URL을 만들 때 정한다
검색어 자체가 alpha#part-beta라면 query 값으로 넣는다. 위 테스트 페이지의 Console에서 다음 코드는 주소를 계산해 반환할 뿐, 이동이나 요청을 실행하지 않는다. /page는 자신의 처리 경로로 바꿀 수 있다.
(() => {
const target = new URL('/page', location.origin);
target.searchParams.set('q', 'alpha#part-beta');
return target.href;
})()
실행 결과는 http://127.0.0.1:62399/page?q=alpha%23part-beta였다. 실험에서는 반환 주소를 실제로 열어 앞서 본 두 번째 요청 줄을 받았다. searchParams.set에는 인코딩 전 원문을 넘긴다. WHATWG URL의 URLSearchParams 정의 (새 탭에서 열림)는 값을 저장한 뒤 query로 직렬화할 때 필요한 인코딩을 적용하도록 정한다. 여기서는 원문의 #가 %23으로 표현됐다.
part-beta가 서버도 알아야 할 별도 선택 항목이라면 section처럼 이름을 따로 준다. 실험에서는 페이지 코드가 fragment의 가짜 ASCII 값을 읽어 query로 명시적으로 보냈다. 서버는 GET /echo?q=alpha§ion=part-beta HTTP/1.1을 받았고 section 값도 part-beta였다. 이는 페이지가 로드된 뒤 보낸 별도 요청이다. 초기 페이지 응답부터 서버가 그 값을 써야 한다면 최초 링크를 만들 때부터 query에 넣어야 한다.
본문으로 받는 API라면 서버와 맞춘 본문 형식을 사용한다. 비교 요청에서는 Content-Type: application/json으로 POST /echo에 {"section":"part-beta"}를 보냈고 서버가 같은 본문을 받았다. 화면 내 위치를 가리키는 값은 fragment로 두고, 서버 입력은 처리 경로에 맞는 query나 본문에 명시한다.
직접 확인한 범위는 새 Chrome 세션과 loopback 서버 사이의 두 문서 GET, 명시적인 query GET·JSON POST, 그리고 hash만 바꾸는 동작이다. 다른 브라우저, HTTPS·프록시 경로, 특정 서버 프레임워크, SPA의 hash 라우터는 실행하지 않았다.