본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
4장 : HTTP 기본 원리

URI와 브라우저 요청

URI의 스킴·권한 부분·경로·쿼리·조각을 Java로 분해하고 브라우저가 주소 입력부터 게시판 응답을 렌더링하기까지의 실패 지점을 추적합니다.

https://board.example:8443/api/posts/42?view=compact#notes는 한 문자열이지만 모든 부분이 서버로 전달되는 것은 아닙니다.

스킴은 연결과 기본 포트 선택에, 호스트는 DNS와 가상 호스트에, 경로와 쿼리는 서버 라우팅과 입력에 쓰입니다.

조각은 브라우저 안의 위치 표시이므로 HTTP 요청에 포함되지 않습니다.


URI 구성 요소

Java URI로 분해하면 표시 문자열과 서버가 받는 값을 구분할 수 있습니다.

src/test/java/board/http/UriPartsTest.java
package board.http;

import static org.assertj.core.api.Assertions.assertThat;

import java.net.URI;
import org.junit.jupiter.api.Test;

class UriPartsTest {
    @Test
    void posts_URI를_구성_요소로_분해한다() {
        var uri = URI.create(
                "https://board.example:8443"
                        + "/api/posts/42"
                        + "?view=compact#notes");

        assertThat(uri.getScheme()).isEqualTo("https");
        assertThat(uri.getHost()).isEqualTo("board.example");
        assertThat(uri.getPort()).isEqualTo(8443);
        assertThat(uri.getPath())
                .isEqualTo("/api/posts/42");
        assertThat(uri.getQuery()).isEqualTo("view=compact");
        assertThat(uri.getFragment()).isEqualTo("notes");
    }

    @Test
    void 상대_URI를_base_URI에_해석한다() {
        var base = URI.create(
                "https://board.example/members/7/");

        assertThat(base.resolve("../posts?limit=20"))
                .isEqualTo(URI.create(
                        "https://board.example/members/"
                                + "posts?limit=20"));
    }
}

host와 HTTP Host 헤더는 연결됩니다.

리버스 프록시가 여러 도메인을 같은 IP와 포트에서 받으면 호스트를 보고 가상 서비스를 고릅니다.

HTTPS에서는 TLS 핸드셰이크의 SNI와 인증서 검증에도 호스트 이름이 쓰입니다.

명시 포트가 없으면 스킴의 기본값을 사용합니다.

일반적으로 HTTP는 80, HTTPS는 443입니다.

URI.getPort()는 기본 포트를 자동 채우지 않고 명시되지 않았다는 뜻으로 -1을 반환하므로 클라이언트 설정에서 유효 포트를 따로 결정합니다.


경로와 쿼리의 역할

게시판 URI는 리소스 관계를 드러내게 설계합니다.

  • /api/posts/42: 식별자 42인 게시글
  • /api/members/7/posts: 회원 7의 게시글 컬렉션
  • /api/posts?from=2026-07-01&limit=20: 컬렉션의 조회 조건
  • /api/posts/42/exports: 게시글 42에 속한 내보내기 리소스

동사를 모두 경로에 넣은 /api/getPost?id=42도 작동하지만 메서드와 상태 의미를 활용하기 어렵습니다.

반대로 모든 선택 조건을 경로 조각으로 넣으면 선택적 필터 조합이 폭발합니다.

경로 변수는 리소스 동일성과 계층을, 쿼리 파라미터는 필터링·정렬·페이지네이션·표현 선택을 주로 나타냅니다.

이 규칙은 절대 법칙이 아니라 클라이언트가 URI만 보고 안정적으로 링크를 만들 수 있게 하는 설계 기준입니다.


구성 요소별 인코딩

제목 Spring MVC & HTTP를 쿼리에 그대로 붙이면 공백과 &가 구분자로 해석됩니다.

전체 URI를 한 번에 인코딩하면 이미 인코딩된 %2F%252F로 이중 변환할 수도 있습니다.

src/test/java/board/http/BoardUriBuilderTest.java
package board.http;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.web.util.UriComponentsBuilder;

class BoardUriBuilderTest {
    @Test
    void path와_query_value를_각_위치에_맞게_encode한다() {
        var uri = UriComponentsBuilder
                .fromPath("/api/members/{memberId}/posts")
                .queryParam("title", "Spring MVC & HTTP")
                .queryParam("limit", 20)
                .buildAndExpand(7)
                .encode()
                .toUri();

        assertThat(uri.getRawPath())
                .isEqualTo("/api/members/7/posts");
        assertThat(uri.getRawQuery())
                .isEqualTo(
                        "title=Spring%20MVC%20%26%20HTTP&limit=20");
    }

    @Test
    void path_variable에_들어온_slash는_segment로_노출하지_않는다() {
        var uri = UriComponentsBuilder
                .fromPath("/api/titles/{title}/posts")
                .buildAndExpand("network/http")
                .encode()
                .toUri();

        assertThat(uri.getRawPath())
                .isEqualTo(
                        "/api/titles/network%2Fhttp/posts");
    }
}

URI 빌더가 SQL 주입이나 인가를 해결해 주지는 않습니다.

인코딩은 문법 경계를 보존할 뿐입니다.

회원 7 경로를 요청한 사용자가 회원 7을 읽을 권한이 있는지는 서버가 별도로 검사해야 합니다.

실행 결과
UriPartsTest > posts_URI를_구성_요소로_분해한다() PASSED
UriPartsTest > 상대_URI를_base_URI에_해석한다() PASSED
BoardUriBuilderTest > path와_query_value를_각_위치에_맞게_encode한다() PASSED
BoardUriBuilderTest > path_variable에_들어온_slash는_segment로_노출하지_않는다() PASSED
BUILD SUCCESSFUL

브라우저 요청 과정

사용자가 주소를 입력하면 브라우저는 대략 다음 경로를 밟습니다.

  1. 입력을 URL로 해석하고 HSTS·서비스 워커·캐시 적용 가능성을 확인합니다.
  2. DNS 또는 기존 연결 정보를 이용해 목적 주소를 결정합니다.
  3. TCP와 TLS 또는 QUIC 연결을 새로 만들거나 풀에서 재사용합니다.
  4. 메서드, 대상, 헤더를 담은 HTTP 요청을 전송합니다.
  5. 프록시와 서버가 요청을 처리해 상태, 헤더, 본문을 반환합니다.
  6. 리다이렉트면 새 URI로 다시 요청하고, 캐시 조건에 따라 본문을 재사용합니다.
  7. HTML이면 파서가 DOM을 만들고 CSS·스크립트·글꼴 같은 하위 리소스를 추가 요청합니다.

개발자 도구의 네트워크 탭에서 한 “페이지 열기”가 여러 요청으로 보이는 이유입니다.

문서가 200이어도 스크립트가 404면 화면은 깨질 수 있고, API가 401이면 클라이언트 스크립트가 오류 화면을 그릴 수 있습니다.


프래그먼트·리다이렉트 신뢰 경계

#notes는 브라우저가 같은 문서 안의 위치를 찾거나 클라이언트 라우터가 사용하는 값입니다.

네트워크 요청 라인은 보통 다음과 같으며 조각이 없습니다.

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

서버가 조각 값을 필요로 한다면 클라이언트가 쿼리나 본문에 명시적으로 옮겨야 합니다.

접근 로그에 조각이 없는 것은 로깅 오류가 아닙니다.

리다이렉트 URI도 조심해야 합니다.

?next=https://evil.example를 검증 없이 Location으로 쓰면 열린 리다이렉트가 됩니다.

로그인 후 이동 같은 기능은 허용된 동일 출처 상대 경로인지 검사하거나 서버가 발급한 불투명 상태와 연결합니다.

src/main/java/board/web/SafeReturnPath.java
package board.web;

import java.net.URI;

public final class SafeReturnPath {
    public static String requireLocal(String candidate) {
        var uri = URI.create(candidate);
        if (uri.isAbsolute()
                || uri.getRawAuthority() != null
                || !candidate.startsWith("/")
                || candidate.startsWith("//")) {
            throw new IllegalArgumentException(
                    "return path must be local");
        }
        return uri.normalize().toString();
    }

    private SafeReturnPath() {
    }
}

//evil.example는 스킴이 없어 보여도 네트워크 경로 참조로 해석될 수 있어 함께 거부합니다.

경로 순회와 허용 경로 범위까지 필요한 서비스라면 단순 정규화를 넘어 허용 목록을 둡니다.


URI와 상태 문제 분리

증상URI에서 확인다음 계층
다른 호스트로 감스킴·권한 부분·리다이렉트 LocationDNS·TLS
404메서드와 원시 경로·컨텍스트 경로핸들러 매핑
필터가 적용 안 됨실제 경로와 디스패처 타입서블릿 필터
쿼리 값이 잘림원시 쿼리 인코딩인자 바인딩
한글이 깨짐원시 바이트와 문자 집합 적용 위치컨버터
브라우저만 다름조각·캐시·서비스 워커클라이언트 런타임

서버 로그에는 디코딩된 URI만 남기지 말고 프레임워크가 제공하는 안전한 요청 정보와 트레이스 ID를 함께 남깁니다.

원시 URI에는 민감 쿼리가 있을 수 있으므로 접근 로그의 마스킹 정책도 필요합니다.


연습 문제

/api/members/{memberId}/poststitle, from, limit 쿼리를 붙이는 URI 팩토리를 작성하세요.

제목에 공백, &, 슬래시가 포함된 세 사례를 검증하고, 브라우저에만 쓰이는 조각은 팩토리 입력에서 제외합니다.

이어 외부 절대 URL과 //host를 거부하는 반환 경로 검증기를 완성합니다.

해설 보기

경로 변수와 쿼리 값을 문자열로 합치지 않고 UriComponentsBuilder의 각 API에 전달합니다.

URI postsUri(
        long memberId,
        String title,
        LocalDate from,
        int limit) {
    return UriComponentsBuilder
            .fromPath("/api/members/{id}/posts")
            .queryParam("title", title)
            .queryParam("from", from)
            .queryParam("limit", limit)
            .buildAndExpand(memberId)
            .encode()
            .toUri();
}

검증은 디코딩된 getPath()만 보지 말고 getRawPath()getRawQuery()로 실제 전송 형식 표현을 확인합니다.

반환 경로는 절대 URI, 권한 부분 존재, // 시작을 모두 거부하고 허용하는 애플리케이션 경로 접두사까지 제한하면 더 안전합니다.

다음 문서에서는 이 요청을 여러 서버 인스턴스가 처리해도 같은 결과를 내기 위해 클라이언트-서버 분리, 무상태 요청, 연결 재사용을 서로 다른 개념으로 구분합니다.