본문으로 건너뛰기

안동민 개발노트

본문 시작

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는 스킴, 권한 부분, 경로, 쿼리, 조각으로 나뉩니다. 이 경계를 먼저 보존한 뒤 각 구성 요소에 맞게 값을 넣어야 합니다.

URI의 스킴·권한 부분·경로·쿼리·조각이 연결과 요청 대상에서 맡는 역할, 구성 요소별 percent encoding, Java URI의 raw 접근자로 실제 전송 표현을 검증하는 기준을 설명합니다.

COMPONENT · ENCODE · REQUEST TARGET

URI 구성 요소는 연결과 요청 대상을 나누고 raw 값은 전송 표현을 보여 준다

구성 요소마다 연결과 전송에서 맡는 경계가 다릅니다. 스킴·권한 부분은 연결과 가상 호스트 선택에, 경로·쿼리는 일반적인 origin-form 요청 대상에 쓰이고 조각은 브라우저에 남습니다. 값은 놓일 구성 요소에 맞게 인코딩하고 raw 접근자로 percent-encoded 표현을 확인합니다.

COMPONENTS · BOUNDARIES

구성 요소는 서로 다른 연결·전송 경계에 참여한다

  1. scheme과 port가 연결 방식을 정한다

    https와 명시 포트는 연결 정책의 입력입니다. 포트가 생략되면 URI.getPort()-1이고 클라이언트가 유효 기본값을 적용합니다.

  2. authority의 host가 종단을 고른다

    host는 이름 해석, TLS SNI·인증서 확인, HTTP Host·가상 호스트 선택에 쓰입니다. authority에는 선택적인 userinfo와 port도 포함될 수 있습니다.

  3. path·query와 fragment를 나눈다

    path와 query는 리소스와 조회 입력을 나타냅니다. fragment는 브라우저에 남으며 클라이언트가 별도로 옮기지 않는 한 HTTP 요청 대상으로 전송되지 않습니다.

COMPONENT API · ENCODING

Builder는 값이 놓일 문법 위치를 먼저 구분한다

  1. 값을 component API에 맡긴다

    path variable과 query value를 각 위치의 API에 전달합니다. 문자열 연결이나 완성된 URI 전체의 일괄 인코딩으로 대체하지 않습니다.

  2. 문법 구분자와 데이터 문자를 나눈다

    query 값의 공백과 데이터 &%20·%26으로 바뀌지만, 파라미터 사이의 &는 문법 구분자로 유지됩니다.

  3. 변수는 확장 전에 엄격하게 encode한다

    하나의 path variable인 network/httpnetwork%2Fhttp가 됩니다. 이미 인코딩한 값을 다시 넘기면 %252F처럼 이중 인코딩될 수 있습니다.

REQUEST TARGET · RAW VIEW

Raw 요청 대상은 표시 문자열과 다른 질문에 답한다

  1. origin-form에는 path와 query가 들어간다

    HTTP/1.1 요청선에는 /api/posts/42?view=compact만 들어갑니다. authority는 Host에 반영되고 #notes는 전송되지 않습니다.

  2. raw와 decoded 접근자를 함께 본다

    getRawPath()getRawQuery()는 percent escape를 보존합니다. decoded 접근자는 애플리케이션이 해석한 값 관점에 답합니다.

  3. encoding과 보안 검증을 분리한다

    인코딩은 URI 문법 경계를 보존할 뿐 인가나 SQL 주입 방어가 아닙니다. 민감 query를 가린 raw 정보와 framework 해석값을 함께 추적합니다.

검증 순서: 구성 요소 배치 → 위치별 encode → raw request-target → framework decoded 값


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를 사용합니다.

src/test/java/board/http/UriPartsTest.java
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에 속한 내보내기 리소스

경로 변수는 리소스의 동일성과 계층을, 쿼리 파라미터는 필터링, 정렬, 페이지네이션과 표현 선택을 주로 나타냅니다. 이는 절대 법칙이 아니라 링크와 라우팅 계약을 예측 가능하게 만드는 설계 기준입니다.

브라우저가 주소 입력을 캐시·service worker·네트워크 경로로 분기하고, document 응답의 redirect·재검증·본문 처리 뒤 HTML과 script가 독립적인 하위 리소스와 API 요청을 만드는 과정을 설명합니다.

LOCAL · DOCUMENT · SUBRESOURCE

브라우저 탐색은 로컬 판단·문서 응답·하위 요청으로 갈라진다

주소 입력 하나가 항상 새 HTTP 요청 하나를 만들지는 않습니다. 캐시와 제어 중인 범위의 service worker가 응답할 수 있고, document 응답은 재검증·redirect로 분기하며, HTML과 script는 별도 요청을 추가합니다.

NAVIGATION · LOCAL DECISION

URL을 해석한 뒤 로컬 응답 가능성을 먼저 판정한다

  1. 같은 document의 fragment 이동을 구분한다

    fragment만 바뀌는 같은 문서 탐색은 새 document HTTP 요청 없이 위치만 바꿀 수 있으며 fragment 자체는 전송되지 않습니다.

  2. HSTS와 service worker 범위를 확인한다

    알려진 HSTS 정책은 HTTP URL을 HTTPS로 바꿀 수 있습니다. 현재 탐색을 제어하고 scope가 일치하는 service worker만 요청에 응답하거나 전달할 수 있습니다.

  3. cache 재사용과 재검증을 나눈다

    재사용 가능한 fresh 응답은 네트워크 없이 제공될 수 있습니다. 검증이 필요한 저장 응답은 조건부 요청으로 이어지고, 사용할 응답이 없으면 네트워크로 갑니다.

DOCUMENT · RESPONSE BRANCH

Document 요청은 전송과 응답 상태에서 다시 분기한다

  1. 요청에 맞는 HTTP 경로를 준비한다

    브라우저는 목적지와 협상된 HTTP 버전에 맞는 연결을 사용하고 method·request-target·headers를 담은 document 요청을 보냅니다.

  2. proxy 또는 origin 응답을 받는다

    응답의 status·headers·body를 받아 탐색 정책과 콘텐츠 처리 경계를 결정합니다. HTTP 오류 응답도 네트워크 오류와는 다른 응답입니다.

  3. redirect와 304를 다른 분기로 처리한다

    3xx redirect는 새 URL 탐색을 만들 수 있고, 재검증의 304는 저장된 representation의 메타데이터를 갱신해 재사용하게 합니다.

SUBRESOURCE · FAILURE SCOPE

HTML과 script는 독립적인 요청과 실패 범위를 만든다

  1. 파서가 하위 리소스를 발견한다

    HTML은 CSS·script·image 같은 하위 리소스를 발견하고 CSS도 font·image를 더 발견할 수 있습니다. 이 요청들은 엄격한 직렬 단계가 아니라 겹쳐 실행될 수 있습니다.

  2. script가 API 요청을 추가한다

    script는 fetch 같은 API 호출을 만들며 각 요청마다 cache·service worker·network 판정과 별도 응답 처리가 적용됩니다.

  3. 요청별 실패를 따로 추적한다

    document 200과 JS·CSS 404, API 401이 동시에 존재할 수 있습니다. Network의 status·initiator·waterfall을 요청별로 확인합니다.

분기 순서: 로컬 응답 여부 → document 응답 → redirect·cache reuse → subresource·API fan-out


URI 변수는 확장 전에 엄격하게 인코딩합니다

이 예제의 기준은 JDK 17과 Spring Web 6.2.11입니다. UriComponentsBuilder#encode()확장 전에 호출하면 템플릿 자체는 구성 요소 문법에 맞게 인코딩하고, 템플릿 변수는 불투명한 데이터로 더 엄격하게 인코딩합니다. 따라서 경로 변수의 /와 쿼리 변수의 &, =, ?도 구분자가 되지 않습니다.

반대로 buildAndExpand(...).encode()는 먼저 변수를 URI 구조에 넣은 뒤 구성 요소 전체를 인코딩합니다. 이 단계에서 /는 경로에 합법적인 예약 문자이고 ?는 쿼리 값 안에서도 합법이므로 그대로 남을 수 있습니다. 예약 문자를 문법으로 쓰려는 경우가 아니라면 사용자 값을 넣는 기본 패턴으로 삼지 않습니다.

src/test/java/board/http/BoardUriBuilderTest.java
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 요청은 다음과 같습니다.

브라우저가 보내는 HTTP/1.1 요청
GET /api/posts/42?view=compact HTTP/1.1
Host: board.example:8443
Accept: application/json

명시 포트 8443은 권한 정보의 일부이므로 Host에도 포함됩니다. HTTP/2와 HTTP/3에서는 이 정보가 요청 제어 데이터의 :authority 의사 헤더 board.example:8443으로 전달될 수 있습니다.

조각 #notes는 target URI와 요청 대상에서 제외되어 클라이언트가 처리합니다. 서버가 조각 값을 필요로 한다면 클라이언트가 별도의 쿼리나 본문 필드로 명시적으로 보내야 합니다. 또한 리다이렉트 Location에 조각이 없으면 사용자 에이전트가 원래 URI 참조의 조각을 상속할 수 있는데, 이는 서버 라우팅과는 별도의 클라이언트 규칙입니다.

앞 문서에서 다룬 DNS, 전송 프로토콜과 리스너를 통과한 뒤 여기의 요청 대상과 권한 정보가 애플리케이션 라우팅 입력이 됩니다. 연결 재사용과 요청 사이의 상태 보존 여부는 다음 문서에서 분리해 다룹니다.


반환 경로는 작은 문법으로 제한합니다

로그인 후 next 같은 값을 검증 없이 Location에 넣으면 https://evil.example이나 //evil.example로 열린 리다이렉트가 생길 수 있습니다. 단순히 URI.normalize()만 호출해도 충분하지 않습니다. 리터럴 점 경로만 정리하며, 브라우저가 점 경로로 보는 퍼센트 인코딩이나 역슬래시와의 해석 차이는 남기 때문입니다.

아래 검증기는 의도적으로 작은 계약을 사용합니다.

  • 호출자는 HTTP 파라미터 계층에서 정확히 한 번 디코딩한 URI 참조 문자열을 전달합니다.
  • 허용 범위는 ASCII 루트 상대 경로 /posts, /members와 그 하위 경로, 선택적인 쿼리뿐입니다.
  • 스킴, 불투명 URI, 권한 부분, 네트워크 경로 참조, 조각을 거부합니다.
  • raw 경로의 %, \, 비ASCII 문자를 거부해 JDK와 브라우저의 경로 해석 차이를 계약 밖으로 냅니다.
  • 리터럴 점 경로를 정규화한 뒤 조각 경계가 있는 허용 목록을 검사합니다.

검증기 안에서는 URLDecoder를 호출하지 않으며, 반환값도 다시 디코딩하지 않습니다. 특히 form 디코딩의 + 처리나 두 번째 퍼센트 디코딩을 URI 경로 검증에 섞지 않습니다. 국제화 경로나 퍼센트 인코딩 경로가 실제 제품 요구라면 이 엄격한 계약을 느슨하게 우회하지 말고, WHATWG 파서와 신뢰한 base URL을 사용하는 별도 브라우저 URL 계약을 설계해야 합니다.

src/main/java/board/web/SafeReturnPath.java
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("/")
                || rawPath.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() {
    }
}
src/test/java/board/web/SafeReturnPathTest.java
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");
        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파라미터 바인딩
경로 조각 수가 달라짐strict encode-before-expand 여부라우트 변수 계약
브라우저에서만 경로가 달라짐%2e 변형과 역슬래시RFC/WHATWG 파서 선택
조각이 서버 로그에 없음원본 URI의 # 이후클라이언트 라우터

raw URI에는 민감한 쿼리가 있을 수 있으므로 운영 로그에는 프레임워크가 제공하는 안전한 요청 정보와 트레이스 ID를 남기고, 쿼리 마스킹 정책을 적용합니다. 디코딩한 값만 기록해 원래 구분자 경계를 잃지 않도록 합니다.

다음 문서에서는 같은 요청을 여러 서버 인스턴스가 처리할 때 클라이언트와 서버의 책임, 무상태 요청, 연결 재사용을 서로 다른 개념으로 구분합니다.