URI와 브라우저 요청
RFC URI 구성 요소와 HTTP 요청 대상을 구분하고, JDK 17과 Spring Web 6.2.11로 인코딩 및 안전한 반환 경로 경계를 검증합니다.
앞 글에서는 이름 해석과 전송 프로토콜, 공개 리스너에서 애플리케이션 업스트림까지의 경로를 다루었습니다. 이번 글은 그 연결 위에서 https://board.example:8443/api/posts/42?view=compact#notes를 어떻게 나누어 읽고 HTTP 요청으로 옮기는지 다룹니다. URI를 문자열 하나로 다루면 경로 데이터의 슬래시가 구분자로 바뀌거나, 쿼리 값의 &가 다음 파라미터를 시작하거나, 바깥 주소가 리다이렉트 대상이 되는 오류를 만들기 쉽습니다.
RFC 3986의 계층형 URI는 스킴(scheme), 권한(authority), 경로(path), 쿼리(query), 조각(fragment)으로 나뉩니다. 이 구분을 먼저 지킨 뒤, 각 구성 요소에 맞는 방식으로 값을 넣어야 합니다.
COMPONENT · ENCODE · REQUEST TARGET
URI 구성 요소는 연결과 요청 대상을 나누고 raw 값은 전송 표현을 보여 준다
구성 요소마다 연결과 전송에서 맡는 경계가 다릅니다. 스킴·권한 부분은 연결과 가상 호스트 선택에, 경로·쿼리는 일반적인 origin-form 요청 대상에 쓰이고 조각은 브라우저에 남습니다. 값은 놓일 구성 요소에 맞게 인코딩하고 raw 접근자로 percent-encoded 표현을 확인합니다.
COMPONENTS · BOUNDARIES
구성 요소는 서로 다른 연결·전송 경계에 참여한다
scheme과 port가 연결 방식을 정한다
https와 명시 포트는 연결 정책의 입력입니다. 포트가 생략되면URI.getPort()는-1이고 클라이언트가 유효 기본값을 적용합니다.authority의 host가 종단을 고른다
host는 이름 해석, TLS SNI·인증서 확인, HTTP
Host·가상 호스트 선택에 쓰입니다. authority에는 선택적인 userinfo와 port도 포함될 수 있습니다.path·query와 fragment를 나눈다
path와 query는 리소스와 조회 입력을 나타냅니다. fragment는 브라우저에 남으며 클라이언트가 별도로 옮기지 않는 한 HTTP 요청 대상으로 전송되지 않습니다.
COMPONENT API · ENCODING
Builder는 값이 놓일 문법 위치를 먼저 구분한다
값을 component API에 맡긴다
path variable과 query value를 각 위치의 API에 전달합니다. 문자열 연결이나 완성된 URI 전체의 일괄 인코딩으로 대체하지 않습니다.
문법 구분자와 데이터 문자를 나눈다
query 값의 공백과 데이터
&는%20·%26으로 바뀌지만, 파라미터 사이의&는 문법 구분자로 유지됩니다.변수는 확장 전에 엄격하게 encode한다
하나의 path variable인
network/http는network%2Fhttp가 됩니다. 이미 인코딩한 값을 다시 넘기면%252F처럼 이중 인코딩될 수 있습니다.
REQUEST TARGET · RAW VIEW
Raw 요청 대상은 표시 문자열과 다른 질문에 답한다
origin-form에는 path와 query가 들어간다
HTTP/1.1 요청선에는
/api/posts/42?view=compact만 들어갑니다. authority는Host에 반영되고#notes는 전송되지 않습니다.raw와 decoded 접근자를 함께 본다
getRawPath()와getRawQuery()는 percent escape를 보존합니다. decoded 접근자는 애플리케이션이 해석한 값 관점에 답합니다.encoding과 보안 검증을 분리한다
인코딩은 URI 문법 경계를 보존할 뿐 인가나 SQL 주입 방어가 아닙니다. 민감 query를 가린 raw 정보와 framework 해석값을 함께 추적합니다.
검증 순서: 구성 요소 배치 → 위치별 encode → raw request-target → framework decoded 값
그림은 URI의 구성 요소가 맡는 세 가지 역할을 보여 줍니다. 스킴과 권한은 연결과 가상 호스트를 고르는 데, 경로와 쿼리는 일반적인 origin-form 요청 대상에 쓰이고, 조각은 브라우저에 남아 요청에 실리지 않습니다. 세 상자는 차례로 지나가는 단계가 아니며, 아래쪽 세 영역은 구성 요소마다 맡는 일, 위치에 맞게 값을 인코딩하는 방법, 전송되는 원래 표현(raw)을 확인하는 방법을 차례로 정리합니다.
파일과 의존성 준비
앞 글의 Java 17 별도 테스트 단위에 이 글의 네 파일을 제목의 경로대로 추가합니다. UriPartsTest, BoardUriBuilderTest, SafeReturnPathTest는 src/test/java 아래에, SafeReturnPath는 src/main/java 아래에 둡니다. 앞의 두 테스트는 board.http, 뒤의 둘은 board.web 패키지이므로 디렉터리와 패키지를 각각 맞춥니다. JUnit Jupiter에 더해 org.springframework:spring-web:6.2.11과 그 의존성이 필요합니다. 게시판 프로젝트의 의존성 묶음에 Spring 6.2.11 JAR 일부만 끼워 넣는 식으로 맞추지 않습니다.
먼저 URI를 나누는 세 테스트를 읽고, 빌더의 두 테스트에서 encode()를 호출하는 위치를 비교한 뒤, 반환 경로의 테스트를 확인합니다. 실제 브라우저나 HTTP 서버를 띄우지 않으므로, URI의 결과와 허용 여부를 먼저 예상하고 테스트 결과와 비교합니다.
JDK URI로 구성 요소를 분리합니다
java.net.URI는 URI 참조의 문법을 해석하는 클래스이며, 브라우저가 사용자 입력을 처리하는 WHATWG URL 파서와는 다른 구현입니다. 아래 테스트는 JDK 17의 API만 씁니다. 명시한 포트 8443은 그대로 돌려주지만, 포트를 생략한 HTTPS URI에서 getPort()는 기본값 443을 채우지 않고 -1을 돌려줍니다. 실제로 쓸 포트는 스킴을 아는 클라이언트가 따로 정합니다.
getRawPath()와 getRawQuery()는 퍼센트 이스케이프를 그대로 보존하고, getPath()와 getQuery()는 한 번 디코딩한 문자열을 돌려주므로 %2F와 %26이 각각 /와 &로 보입니다. 전송되는 형태와 구분자를 검사할 때는 raw API를 씁니다.
package board.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.net.URI;
import org.junit.jupiter.api.Test;
class UriPartsTest {
@Test
void 게시글_URI를_구성_요소로_분해한다() {
var uri = URI.create(
"https://board.example:8443"
+ "/api/posts/42"
+ "?view=compact#notes");
assertEquals("https", uri.getScheme());
assertEquals("board.example", uri.getHost());
assertEquals(8443, uri.getPort());
assertEquals("/api/posts/42", uri.getPath());
assertEquals("view=compact", uri.getQuery());
assertEquals("notes", uri.getFragment());
}
@Test
void raw와_decoded_구성_요소를_구분한다() {
var uri = URI.create(
"https://board.example/api/titles/network%2Fhttp"
+ "?title=Spring%20MVC%20%26%20HTTP");
assertEquals(
"/api/titles/network%2Fhttp",
uri.getRawPath());
assertEquals(
"/api/titles/network/http",
uri.getPath());
assertEquals(
"title=Spring%20MVC%20%26%20HTTP",
uri.getRawQuery());
assertEquals(
"title=Spring MVC & HTTP",
uri.getQuery());
assertEquals(-1, uri.getPort());
}
@Test
void 상대_URI를_base_URI에_해석한다() {
var base = URI.create(
"https://board.example/members/7/");
assertEquals(
URI.create(
"https://board.example/members/"
+ "posts?limit=20"),
base.resolve("../posts?limit=20"));
}
}권한 부분에는 선택적인 사용자 정보와 호스트, 포트가 들어갈 수 있습니다. HTTP가 라우팅에 쓰는 권한 정보는 호스트와 포트이며, 사용자 정보를 인증 수단으로 쓰지 않습니다.
경로와 쿼리는 역할이 다릅니다
게시판 URI는 리소스의 관계와 조회 조건을 나누어 표현합니다. /api/posts/42는 식별자가 42인 게시글이고, /api/members/7/posts는 회원 7의 게시글 컬렉션, /api/posts?from=2026-07-01&limit=20은 컬렉션의 조회 조건, /api/posts/42/exports는 게시글 42에 속한 내보내기 리소스입니다. 경로 변수는 주로 리소스가 무엇이고 어디에 속하는지를, 쿼리 파라미터는 걸러 내기와 정렬, 페이지 나누기, 표현 선택을 나타냅니다. 이것은 절대적인 법칙이 아니라 링크와 라우팅을 예측하기 쉽게 만드는 설계 기준입니다.
URI 변수는 확장 전에 엄격하게 인코딩합니다
이 예제의 기준은 JDK 17과 Spring Web 6.2.11입니다. UriComponentsBuilder에서 변수를 채우기 전에 encode()를 호출하면, 템플릿 자체는 구성 요소의 문법에 맞게 인코딩하고 템플릿 변수는 더 엄격하게 불투명한 데이터로 인코딩합니다. 그래서 경로 변수의 /와 쿼리 변수의 &, =, ?도 구분자가 되지 않습니다.
반대로 buildAndExpand(...).encode()는 먼저 변수를 URI 구조에 넣은 뒤 구성 요소 전체를 인코딩합니다. 이때 /는 경로에 쓸 수 있는 예약 문자이고 ?는 쿼리 값 안에서도 쓸 수 있으므로 그대로 남을 수 있습니다. 예약 문자를 문법으로 쓰려는 경우가 아니라면, 사용자 값을 넣을 때 이 방식을 기본으로 삼지 않습니다.
package board.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.web.util.UriComponentsBuilder;
class BoardUriBuilderTest {
@Test
void encode_후_expand하면_변수를_불투명한_데이터로_보존한다() {
var uri = UriComponentsBuilder
.fromPath("/api/titles/{title}/posts")
.queryParam("q", "{query}")
.queryParam("limit", "{limit}")
.encode()
.buildAndExpand(Map.<String, Object>of(
"title", "network/http",
"query", "a/b?c+d&e=f",
"limit", 20))
.toUri();
assertEquals(
"/api/titles/network%2Fhttp/posts",
uri.getRawPath());
assertEquals(
"q=a%2Fb%3Fc%2Bd%26e%3Df&limit=20",
uri.getRawQuery());
}
@Test
void expand_후_component_encode하면_path의_slash를_보존한다() {
var uri = UriComponentsBuilder
.fromPath("/api/titles/{title}/posts")
.buildAndExpand("network/http")
.encode()
.toUri();
assertEquals(
"/api/titles/network/http/posts",
uri.getRawPath());
}
}이미 인코딩한 %2F를 템플릿 변수에 넣으면 엄격한 인코딩은 %도 데이터로 보아 %252F로 만듭니다. 이중 인코딩을 자동으로 고쳐 주지 않는 올바른 결과입니다. 그러니 빌더에는 미리 인코딩한 문자열이 아니라 디코딩된 원래 값을 넘기고, 완성한 URI 전체에 범용 인코더나 디코더를 다시 적용하지 않습니다. 또 URI 빌더는 문법상의 구분을 지켜 줄 뿐 SQL 주입이나 권한 검사를 대신하지 않습니다. 회원 7의 경로를 만든 사용자가 회원 7의 정보를 읽을 권한이 있는지는 서버가 따로 검사합니다.
브라우저 URL과 HTTP 요청 대상을 구분합니다
브라우저는 사용자 입력을 URL로 해석해 탐색을 시작합니다. HTTP와 HTTPS 같은 특별한 스킴에서 WHATWG URL 파서는 역슬래시를 슬래시처럼 처리할 수 있고, .과 ..뿐 아니라 %2e, .%2e, %2e., %2e%2e도 점 경로 조각으로 다룹니다. JDK의 URI.normalize()는 이 브라우저 규칙을 대신하지 않습니다.
Spring Web 6.2는 문자열을 해석할 때 RFC 파서와 WHATWG 파서를 구분합니다. 서버가 만드는 엄격한 URI 템플릿에는 기본값인 RFC 파서가 맞고, 사용자가 입력해 브라우저로 돌려보낼 전체 URL을 다뤄야 한다면 ParserType.WHAT_WG를 골라 같은 해석 방식으로 검증해야 합니다. 두 파서의 결과를 섞거나, 한쪽 결과로 다른 쪽도 안전하다고 판단하지 않습니다. 브라우저가 처음의 URI를 HTTP/1.1 요청으로 보낼 때 일반적인 origin-form 요청은 다음과 같습니다.
GET /api/posts/42?view=compact HTTP/1.1
Host: board.example:8443
Accept: application/json명시한 포트 8443은 권한 정보의 일부이므로 Host에도 들어갑니다. HTTP/2와 HTTP/3에서는 이 정보가 요청 제어 데이터의 :authority 의사 헤더로 전달될 수 있습니다. 조각 #notes는 요청 대상에서 빠지고 클라이언트가 처리합니다. 서버가 조각 값이 필요하다면 클라이언트가 별도의 쿼리나 본문 필드로 보내야 합니다. 또 리다이렉트의 Location에 조각이 없으면 브라우저가 원래 URI의 조각을 이어받을 수 있는데, 이것은 서버의 라우팅과 별개인 클라이언트 쪽 규칙입니다.
브라우저 탐색은 로컬 판단·문서 응답·하위 요청으로 갈라진다
로컬 처리 여부·document 응답·redirect/304/HTML·병렬 하위 요청의 진행 상태를 선택 적용할 수 있다.
LOCAL · DOCUMENT · SUBRESOURCE
브라우저 탐색은 로컬 판단·문서 응답·하위 요청으로 갈라진다
주소 입력 하나가 항상 새 HTTP 요청 하나를 만들지는 않습니다. 캐시와 제어 중인 범위의 service worker가 응답할 수 있고, document 응답은 재검증·redirect로 분기하며, HTML과 script는 별도 요청을 추가합니다.
NAVIGATION · LOCAL DECISION
URL을 해석한 뒤 로컬 응답 가능성을 먼저 판정한다
같은 document의 fragment 이동을 구분한다
fragment만 바뀌는 같은 문서 탐색은 새 document HTTP 요청 없이 위치만 바꿀 수 있으며 fragment 자체는 전송되지 않습니다.
HSTS와 service worker 범위를 확인한다
알려진 HSTS 정책은 HTTP URL을 HTTPS로 바꿀 수 있습니다. 현재 탐색을 제어하고 scope가 일치하는 service worker만 요청에 응답하거나 전달할 수 있습니다.
cache 재사용과 재검증을 나눈다
재사용 가능한 fresh 응답은 네트워크 없이 제공될 수 있습니다. 검증이 필요한 저장 응답은 조건부 요청으로 이어지고, 사용할 응답이 없으면 네트워크로 갑니다.
DOCUMENT · RESPONSE BRANCH
Document 요청은 전송과 응답 상태에서 다시 분기한다
요청에 맞는 HTTP 경로를 준비한다
브라우저는 목적지와 협상된 HTTP 버전에 맞는 연결을 사용하고 method·request-target·headers를 담은 document 요청을 보냅니다.
proxy 또는 origin 응답을 받는다
응답의 status·headers·body를 받아 탐색 정책과 콘텐츠 처리 경계를 결정합니다. HTTP 오류 응답도 네트워크 오류와는 다른 응답입니다.
redirect와 304를 다른 분기로 처리한다
3xxredirect는 새 URL 탐색을 만들 수 있고, 재검증의304는 저장된 representation의 메타데이터를 갱신해 재사용하게 합니다.
SUBRESOURCE · FAILURE SCOPE
HTML과 script는 독립적인 요청과 실패 범위를 만든다
파서가 하위 리소스를 발견한다
HTML은 CSS·script·image 같은 하위 리소스를 발견하고 CSS도 font·image를 더 발견할 수 있습니다. 이 요청들은 엄격한 직렬 단계가 아니라 겹쳐 실행될 수 있습니다.
script가 API 요청을 추가한다
script는
fetch같은 API 호출을 만들며 각 요청마다 cache·service worker·network 판정과 별도 응답 처리가 적용됩니다.요청별 실패를 따로 추적한다
document
200과 JS·CSS404, API401이 동시에 존재할 수 있습니다. Network의 status·initiator·waterfall을 요청별로 확인합니다.
탐색의 분기: 로컬 응답 여부 → document 응답 → redirect·cache reuse → subresource·API fan-out. 하위 리소스와 API는 서로 겹쳐 실행될 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 같은 문서의 조각 이동이나 사용할 로컬 응답이면 새 document 네트워크 요청 없이 처리할 수 있다. 그렇지 않으면 document 응답을 처리하며, 여기에는 이동 응답·304·HTML 본문인 경우만 펼쳤다. 다른 오류 응답과 정책은 아래 해설에서 확인한다. HTML에서 만든 하위·API 요청은 한 개의 직렬 호출이 아니라 fan-out이다.
- 같은 9노드와 8분기를 다시 배치했다. 응답 뒤의 HTML, 304, redirect는 선택하는 분기이며 위에서 아래로 모두 실행하지 않는다. HTML의 하위·API 요청은 각각 독립적이다. 이 부분 흐름 밖의 오류 응답은 아래 해설을 따른다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 37 | 같은 9노드와 8분기를 다시 배치했다. 응답 뒤의 HTML, 304, redirect는 선택하는 분기이며 위에서 아래로 모두 실행하지 않는다. HTML의 하위·API 요청은 각각 독립적이다. 이 부분 흐름 밖의 오류 응답은 아래 해설을 따른다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 로컬 처리 판정 조각 이동·로컬 응답 사용 여부를 확인합니다. | 로컬 가능: 새 document 요청 없음 · 나머지: 네트워크 | 탐색 문맥이 요청 발생 여부를 정합니다. |
| document 응답 처리 redirect·304·HTML 분기를 처리합니다. | 이동: 새 목적지 · 304: 저장 표현 · HTML: 새 문서 | 문서 응답 종류별로 다음 상태가 갈립니다. |
| HTML 이후 fan-out HTML의 하위·API 요청은 여러 요청으로 퍼집니다. | 하위 요청: fan-out · 직렬 한 요청: 아님 | 문서 결과가 독립 하위 요청을 만듭니다. |
브라우저 탐색은 로컬 판단·문서 응답·하위 요청으로 갈라진다
로컬 처리 여부·document 응답·redirect/304/HTML·병렬 하위 요청의 진행 상태를 선택 적용할 수 있다.
LOCAL · DOCUMENT · SUBRESOURCE
브라우저 탐색은 로컬 판단·문서 응답·하위 요청으로 갈라진다
주소 입력 하나가 항상 새 HTTP 요청 하나를 만들지는 않습니다. 캐시와 제어 중인 범위의 service worker가 응답할 수 있고, document 응답은 재검증·redirect로 분기하며, HTML과 script는 별도 요청을 추가합니다.
NAVIGATION · LOCAL DECISION
URL을 해석한 뒤 로컬 응답 가능성을 먼저 판정한다
같은 document의 fragment 이동을 구분한다
fragment만 바뀌는 같은 문서 탐색은 새 document HTTP 요청 없이 위치만 바꿀 수 있으며 fragment 자체는 전송되지 않습니다.
HSTS와 service worker 범위를 확인한다
알려진 HSTS 정책은 HTTP URL을 HTTPS로 바꿀 수 있습니다. 현재 탐색을 제어하고 scope가 일치하는 service worker만 요청에 응답하거나 전달할 수 있습니다.
cache 재사용과 재검증을 나눈다
재사용 가능한 fresh 응답은 네트워크 없이 제공될 수 있습니다. 검증이 필요한 저장 응답은 조건부 요청으로 이어지고, 사용할 응답이 없으면 네트워크로 갑니다.
DOCUMENT · RESPONSE BRANCH
Document 요청은 전송과 응답 상태에서 다시 분기한다
요청에 맞는 HTTP 경로를 준비한다
브라우저는 목적지와 협상된 HTTP 버전에 맞는 연결을 사용하고 method·request-target·headers를 담은 document 요청을 보냅니다.
proxy 또는 origin 응답을 받는다
응답의 status·headers·body를 받아 탐색 정책과 콘텐츠 처리 경계를 결정합니다. HTTP 오류 응답도 네트워크 오류와는 다른 응답입니다.
redirect와 304를 다른 분기로 처리한다
3xxredirect는 새 URL 탐색을 만들 수 있고, 재검증의304는 저장된 representation의 메타데이터를 갱신해 재사용하게 합니다.
SUBRESOURCE · FAILURE SCOPE
HTML과 script는 독립적인 요청과 실패 범위를 만든다
파서가 하위 리소스를 발견한다
HTML은 CSS·script·image 같은 하위 리소스를 발견하고 CSS도 font·image를 더 발견할 수 있습니다. 이 요청들은 엄격한 직렬 단계가 아니라 겹쳐 실행될 수 있습니다.
script가 API 요청을 추가한다
script는
fetch같은 API 호출을 만들며 각 요청마다 cache·service worker·network 판정과 별도 응답 처리가 적용됩니다.요청별 실패를 따로 추적한다
document
200과 JS·CSS404, API401이 동시에 존재할 수 있습니다. Network의 status·initiator·waterfall을 요청별로 확인합니다.
탐색의 분기: 로컬 응답 여부 → document 응답 → redirect·cache reuse → subresource·API fan-out. 하위 리소스와 API는 서로 겹쳐 실행될 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 같은 문서의 조각 이동이나 사용할 로컬 응답이면 새 document 네트워크 요청 없이 처리할 수 있다. 그렇지 않으면 document 응답을 처리하며, 여기에는 이동 응답·304·HTML 본문인 경우만 펼쳤다. 다른 오류 응답과 정책은 아래 해설에서 확인한다. HTML에서 만든 하위·API 요청은 한 개의 직렬 호출이 아니라 fan-out이다.
- 같은 9노드와 8분기를 다시 배치했다. 응답 뒤의 HTML, 304, redirect는 선택하는 분기이며 위에서 아래로 모두 실행하지 않는다. HTML의 하위·API 요청은 각각 독립적이다. 이 부분 흐름 밖의 오류 응답은 아래 해설을 따른다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 37 | 같은 9노드와 8분기를 다시 배치했다. 응답 뒤의 HTML, 304, redirect는 선택하는 분기이며 위에서 아래로 모두 실행하지 않는다. HTML의 하위·API 요청은 각각 독립적이다. 이 부분 흐름 밖의 오류 응답은 아래 해설을 따른다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 로컬 처리 판정 조각 이동·로컬 응답 사용 여부를 확인합니다. | 로컬 가능: 새 document 요청 없음 · 나머지: 네트워크 | 탐색 문맥이 요청 발생 여부를 정합니다. |
| document 응답 처리 redirect·304·HTML 분기를 처리합니다. | 이동: 새 목적지 · 304: 저장 표현 · HTML: 새 문서 | 문서 응답 종류별로 다음 상태가 갈립니다. |
| HTML 이후 fan-out HTML의 하위·API 요청은 여러 요청으로 퍼집니다. | 하위 요청: fan-out · 직렬 한 요청: 아님 | 문서 결과가 독립 하위 요청을 만듭니다. |
그림은 주소 입력 하나가 언제나 새 HTTP 요청 하나가 되지는 않는다는 것을 보여 줍니다. 같은 문서 안의 조각 이동이나 쓸 수 있는 로컬 응답이 있으면 새 문서 요청 없이 처리하고, 그렇지 않으면 문서 응답을 받아 리다이렉트, 304 재검증, HTML 본문의 세 갈래로 나뉩니다. HTML을 읽으면 CSS와 스크립트, 이미지, API 요청이 각각 따로, 겹쳐서 실행될 수 있습니다. 그림의 HSTS는 브라우저가 알고 있는 HTTPS 사용 정책이고, 서비스 워커(service worker)는 허용된 범위의 요청을 대신 처리할 수 있는 브라우저 쪽 작업자입니다. 둘 다 이 JDK 단위 테스트가 설치하거나 실행하는 기능은 아니며, 저장한 응답을 언제 다시 쓰는지는 HTTP 캐시와 조건부 요청에서 따로 다룹니다.
반환 경로는 작은 문법으로 제한합니다
로그인 뒤 next 같은 값을 검증 없이 Location에 넣으면 https://evil.example이나 //evil.example로 보내는 열린 리다이렉트(open redirect)가 생길 수 있습니다. URI.normalize()만 호출하는 것으로도 부족합니다. 그 메서드는 글자 그대로의 점 경로만 정리하고, 브라우저가 점 경로로 보는 퍼센트 인코딩이나 역슬래시의 해석 차이는 그대로 두기 때문입니다.
그래서 아래 검증기는 일부러 작은 규칙만 허용합니다. 호출하는 쪽은 HTTP 파라미터 계층에서 정확히 한 번 디코딩한 URI 참조 문자열을 넘깁니다. 허용하는 것은 ASCII로 된 루트 기준 경로 /posts, /members와 그 아래 경로, 그리고 선택적인 쿼리뿐입니다. 스킴, 불투명 URI, 권한 부분, //로 시작하는 네트워크 경로 참조, 조각은 거부합니다. ///posts/42처럼 빗금이 셋이면 JDK의 URI는 앞의 두 빗금 뒤를 비어 있는 권한으로 보고 getRawAuthority()에 null을, 경로에 /posts/42를 돌려주지만, 브라우저는 앞의 빗금을 모두 건너뛰고 posts를 호스트로 읽습니다. 그래서 검증기는 raw 경로가 아니라 입력 문자열이 //로 시작하는지를 봅니다. raw 경로의 %, \, 비ASCII 문자도 거부해 JDK와 브라우저의 경로 해석 차이를 처음부터 허용 범위 밖으로 내보냅니다. 그다음 글자 그대로의 점 경로를 정리하고, 경로 조각 단위로 허용 목록과 비교합니다.
검증기 안에서는 URLDecoder를 호출하지 않으며, 돌려주는 값도 다시 디코딩하지 않습니다. 특히 폼 디코딩의 + 처리나 두 번째 퍼센트 디코딩을 URI 경로 검증에 섞지 않습니다. 국제화된 경로나 퍼센트 인코딩된 경로가 실제로 필요하다면, 이 엄격한 규칙을 느슨하게 풀지 말고 WHATWG 파서와 신뢰할 수 있는 기준 URL을 쓰는 별도의 규칙을 설계해야 합니다.
package board.web;
import java.net.URI;
import java.util.List;
public final class SafeReturnPath {
private static final List<String> ALLOWED_ROOTS =
List.of("/posts", "/members");
public static String requireLocal(String candidate) {
if (candidate == null) {
throw invalidReturnPath();
}
final URI uri;
try {
uri = URI.create(candidate);
} catch (IllegalArgumentException exception) {
throw invalidReturnPath(exception);
}
var rawPath = uri.getRawPath();
if (uri.isAbsolute()
|| uri.isOpaque()
|| uri.getRawAuthority() != null
|| rawPath == null
|| !rawPath.startsWith("/")
|| candidate.startsWith("//")
|| uri.getRawFragment() != null
|| rawPath.indexOf('%') >= 0
|| rawPath.indexOf('\\') >= 0
|| rawPath.chars()
.anyMatch(character -> character > 0x7f)) {
throw invalidReturnPath();
}
var normalized = uri.normalize();
var normalizedPath = normalized.getRawPath();
var allowed = ALLOWED_ROOTS.stream()
.anyMatch(root -> normalizedPath.equals(root)
|| normalizedPath.startsWith(root + "/"));
if (!allowed) {
throw invalidReturnPath();
}
return normalized.toASCIIString();
}
private static IllegalArgumentException invalidReturnPath() {
return new IllegalArgumentException(
"return path must match an allowed local route");
}
private static IllegalArgumentException invalidReturnPath(
IllegalArgumentException cause) {
return new IllegalArgumentException(
"return path must match an allowed local route",
cause);
}
private SafeReturnPath() {
}
}package board.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
import org.junit.jupiter.api.Test;
class SafeReturnPathTest {
@Test
void 허용한_경로와_query를_보존하고_dot_segment를_정규화한다() {
assertEquals(
"/posts/42?view=compact",
SafeReturnPath.requireLocal(
"/posts/42?view=compact"));
assertEquals(
"/members/7/posts?filter=a%26b",
SafeReturnPath.requireLocal(
"/members/7/posts?filter=a%26b"));
assertEquals(
"/posts/42",
SafeReturnPath.requireLocal("/posts/./42"));
}
@Test
void 출처를_바꿀_수_있는_참조를_거부한다() {
var candidates = List.of(
"https://evil.example/posts",
"//evil.example/posts",
"///evil.example/posts",
"///posts/42");
for (var candidate : candidates) {
assertThrows(
IllegalArgumentException.class,
() -> SafeReturnPath.requireLocal(candidate),
candidate);
}
}
@Test
void 허용_목록_밖과_traversal과_prefix_collision을_거부한다() {
var candidates = List.of(
"/admin",
"/postscript",
"/posts/../admin",
"/members/../../admin");
for (var candidate : candidates) {
assertThrows(
IllegalArgumentException.class,
() -> SafeReturnPath.requireLocal(candidate),
candidate);
}
}
@Test
void encoded_path와_backslash와_fragment를_거부한다() {
var candidates = List.of(
"/posts/%2e%2e/admin",
"/posts/%2Fadmin",
"/posts/%5Cadmin",
"/posts/%252e%252e/admin",
"/posts/42#notes",
"/posts/한글",
"\\\\evil.example\\posts");
for (var candidate : candidates) {
assertThrows(
IllegalArgumentException.class,
() -> SafeReturnPath.requireLocal(candidate),
candidate);
}
}
}이 파일들은 네트워크나 운영체제의 오류 문구에 의존하지 않으므로 언제 실행해도 결과가 같습니다. Spring Web 6.2.11과 그 의존성, JUnit Jupiter로 --release 17 컴파일과 아홉 테스트를 실행해 두면, 인코딩 순서나 허용 범위가 바뀌는 회귀를 바로 발견할 수 있습니다.
진단할 때 표현 계층을 섞지 않습니다
| 증상 | 먼저 확인할 값 | 다음에 확인할 곳 |
|---|---|---|
| 다른 호스트나 포트로 이동함 | 스킴, raw 권한, Location | 허용 목록과 프록시 라우팅 |
| 404 | 메서드와 raw 경로 | 핸들러 매핑과 컨텍스트 경로 |
| 쿼리 값이 잘림 | raw 쿼리의 %26, %3D | 파라미터 바인딩 |
| 경로 조각 수가 달라짐 | 확장 전에 엄격하게 인코딩했는지 | 라우트 변수 규칙 |
| 브라우저에서만 경로가 달라짐 | %2e 변형과 역슬래시 | RFC와 WHATWG 파서 선택 |
| 조각이 서버 로그에 없음 | 원래 URI의 # 뒤 | 클라이언트 라우터 |
raw URI에는 민감한 쿼리가 들어 있을 수 있으므로, 운영 로그에는 프레임워크가 주는 안전한 요청 정보와 추적 식별자를 남기고 쿼리를 가리는 정책을 적용합니다. 디코딩한 값만 기록하면 원래 어디서 값이 나뉘었는지를 잃어버리므로 주의합니다.
정리
- URI는 스킴, 권한, 경로, 쿼리, 조각으로 나뉘며, 스킴과 권한은 연결에, 경로와 쿼리는 요청 대상에 쓰이고 조각은 요청에 실리지 않습니다.
getRawPath()와getRawQuery()는 전송되는 퍼센트 인코딩을 보여 주고, 디코딩된 접근자는 해석된 값을 보여 줍니다.- 사용자 값은
UriComponentsBuilder에서 변수를 채우기 전에encode()해 경로의/나 쿼리의&가 구분자로 바뀌지 않게 하고, 이미 인코딩한 값을 다시 넘기지 않습니다. - 브라우저의 WHATWG 파서와 JDK, Spring의 RFC 파서는 점 경로와 역슬래시를 다르게 해석할 수 있으므로 한쪽 결과로 다른 쪽의 안전을 판단하지 않습니다.
- 로그인 뒤 돌아갈 경로는 허용 목록과 작은 문법으로 제한해 열린 리다이렉트를 막습니다.
연습 문제
1. 인코딩할 값부터 판단하기
빌더 테스트의 title 값을 network/http에서 a&b로 바꾸고, query는 a/b?c+d&e=f, limit은 20을 그대로 둔다고 해 봅시다. 확장 전에 엄격하게 인코딩하면 raw 경로는 무엇이 되고, 쿼리 파라미터 사이의 &와 query 값 안의 &는 어떻게 구분될까요? title에 이미 인코딩한 a%26b를 넣으면 같은 결과가 되는지도 적어 보세요.
해설 보기
디코딩된 원래 값 a&b를 넣으면 raw 경로는 /api/titles/a%26b/posts입니다. raw 쿼리는 원래 예제와 같은 q=a%2Fb%3Fc%2Bd%26e%3Df&limit=20이며, q 값 안의 데이터 &는 %26이 되고 q와 limit을 나누는 문법상의 &만 그대로 남습니다. 이미 인코딩한 a%26b를 넘기면 %도 데이터로 보므로 a%2526b가 되어 결과가 달라집니다. 어떤 문자열이 원래의 값이고 어떤 문자열이 전송용 표현인지 먼저 구분해야 합니다.
2. 요청 수와 반환 경로
같은 문서가 이미 열려 있을 때 #notes를 #answers로만 바꾸는 이동과, 새 HTML이 CSS와 JS를 읽고 JS가 API를 호출하는 경우를 비교해 보세요. 주소 이동 한 번을 언제나 서버 요청 한 번으로 셀 수 있을까요? 이어서 SafeReturnPath에 /posts/42?view=a%26b와 /posts/%2Fadmin을 각각 넘길 때의 결과를 설명하세요. 반환 경로의 입력은 HTTP 파라미터 디코딩이 한 번 끝난 문자열이라는 조건은 그대로입니다.
해설 보기
같은 문서 안에서 조각만 바꾸는 이동은 새 문서 요청 없이 처리될 수 있습니다. 새 문서를 받는 경우에도 하위 리소스와 API 호출은 각각 별도의 요청이고 겹쳐 실행될 수 있으므로, 주소를 입력한 횟수로 요청 수를 정할 수 없습니다. 캐시와 서비스 워커도 실제로 네트워크 요청이 일어나는지에 영향을 줍니다.
첫 반환 경로는 허용된 /posts 아래의 경로이고, raw 쿼리의 %26을 그대로 보존하므로 허용됩니다. 두 번째는 이 예제의 엄격한 규칙이 경로의 % 자체를 거부하므로 허용되지 않습니다. 쿼리의 퍼센트 이스케이프는 보존하고 경로의 퍼센트 이스케이프는 거부하는 두 규칙을 하나로 섞지 않습니다.
다음 글 HTTP 무상태성과 연결에서는 같은 요청을 여러 서버 인스턴스가 처리할 때 클라이언트와 서버가 각자 무엇을 맡는지, 그리고 무상태 요청과 연결 재사용이 서로 다른 개념이라는 점을 다룹니다.