본문 바로가기

요청 경로와 쿼리 문자열 파싱

시작 줄의 요청 대상을 경로와 쿼리로 나누고, 퍼센트 인코딩된 한글과 같은 이름으로 여러 번 온 매개변수를 잃지 않고 복원하는 파서를 만듭니다.

앞 글에서 만든 서버는 요청의 시작 줄을 읽기만 하고 그 내용은 들여다보지 않았습니다. 이제 그 줄에서 클라이언트가 무엇을 요청했는지 꺼낼 차례입니다. 브라우저 주소창에 localhost:8080/search?q=가나다&tag=java를 입력하면 서버에는 다음과 같은 시작 줄이 도착합니다.

GET /search?q=%EA%B0%80%EB%82%98%EB%8B%A4&tag=java HTTP/1.1

주소창에 쓴 가나다가 %EA%B0%80%EB%82%98%EB%8B%A4로 바뀌어 있습니다. 서버는 이 줄에서 경로 /search를 찾아 어떤 기능을 실행할지 정하고, 매개변수 q의 값으로 원래 글자 가나다를 되살려야 합니다. 이 글에서는 그 일을 하는 파서를 만들면서 세 가지를 살펴봅니다. 문자열 검색으로 값을 꺼내면 왜 틀리는지, 퍼센트 인코딩은 언제 풀어야 하는지, 같은 이름의 매개변수가 여러 번 오면 어떻게 보관해야 하는지입니다.

요청 대상은 경로와 쿼리로 이루어진다

시작 줄은 공백 하나로 구분된 세 부분, 즉 메서드, 요청 대상(request-target), HTTP 버전으로 이루어집니다. 두 번째 부분인 요청 대상은 파일 경로처럼 보이지만 실제로는 두 부분이 합쳐진 것입니다. 첫 번째 ? 앞은 경로(path)이고 뒤는 쿼리(query)입니다. 위 예에서 경로는 /search, 쿼리는 q=%EA%B0%80%EB%82%98%EB%8B%A4&tag=java입니다.

클라이언트가 서버에 직접 요청할 때는 이처럼 /로 시작하는 경로와 쿼리만 보냅니다. 이 모양을 원점 형식(origin-form)이라고 하며, HTTP/1.1 명세인 RFC 9112는 이를 absolute-path [ "?" query ]로 정의합니다. 주소창에 있던 http://localhost:8080 부분은 요청 대상에 들어가지 않습니다. 호스트 이름과 포트는 Host 헤더로 따로 전달됩니다.

주소 끝에 #section 같은 프래그먼트(fragment)를 붙여도 그 부분은 서버로 가지 않습니다. 프래그먼트는 받은 문서의 어느 위치로 스크롤할지처럼 브라우저가 혼자 쓰는 정보이기 때문입니다. 원점 형식의 문법에도 #이 들어갈 자리가 없으므로, 요청 대상에 #이 있다면 잘못된 요청으로 보고 거절해도 됩니다.

더 깊이: 원점 형식이 아닌 요청 대상

RFC 9112는 요청 대상의 형식을 네 가지로 정의합니다. 프록시에 보내는 요청은 GET http://example.com/a HTTP/1.1처럼 주소 전체를 쓰는 절대 형식(absolute-form)을 씁니다. CONNECT 요청은 example.com:443처럼 호스트와 포트만 쓰는 권한 형식(authority-form)을, 서버 전체에 대한 OPTIONS 요청은 * 하나만 쓰는 별표 형식(asterisk-form)을 씁니다. 명세는 앞으로의 전환에 대비해 일반 서버도 절대 형식을 받아들여야 한다고 정하지만, 이 글의 파서는 범위를 좁혀 원점 형식만 다룹니다.

한글이 %EA%B0%80이 되는 이유

URI 문법(RFC 3986)이 허용하는 문자는 영문자와 숫자, 그리고 몇 가지 기호뿐입니다. 한글이나 공백처럼 허용되지 않는 문자는 먼저 바이트로 바꾼 다음, 바이트 하나를 %와 16진수 두 자리로 적습니다. 이것을 퍼센트 인코딩(percent-encoding)이라고 합니다. 한글 가는 UTF-8로 EA B0 80 세 바이트이므로 %EA%B0%80이 됩니다. 쿼리에서 구분자로 쓰이는 &나 =가 값 안에 들어 있을 때도 %26, %3D로 적어야 쿼리의 구조가 바뀌지 않습니다.

디코딩은 이 과정을 거꾸로 합니다. % 뒤의 16진수 두 자리를 바이트 하나로 되돌리고, 모은 바이트 전체를 UTF-8로 해석해 문자를 얻습니다. 이때 순서가 중요합니다. %EA, %B0, %80을 바이트마다 곧바로 char 하나로 바꾸면 ê, °, 그리고 보이지 않는 제어 문자 U+0080, 이렇게 세 글자가 됩니다. 한 글자를 이루는 바이트를 모두 모은 뒤에 UTF-8로 해석해야 가 한 글자가 나옵니다.

문자열 검색으로 값을 꺼내면 생기는 일

가장 먼저 떠오르는 방법은 요청에서 q=를 찾아 그 뒤부터 다음 공백 전까지 잘라 내는 것입니다. 다음 코드는 그렇게 만든 query 메서드에 요청 네 개를 넣어 봅니다. 마지막 요청은 쿼리가 없는 대신 브라우저가 흔히 보내는 Accept 헤더가 붙어 있습니다.

bad/SubstringQueryExtractor.java
public final class SubstringQueryExtractor {
    static String query(String wholeRequest) {
        int start = wholeRequest.indexOf("q=") + 2;
        int end = wholeRequest.indexOf(' ', start);
        return wholeRequest.substring(start, end);
    }

    public static void main(String[] args) {
        System.out.println(query("GET /search?q=java HTTP/1.1\r\n\r\n"));
        System.out.println(query("GET /search?q=java&page=2 HTTP/1.1\r\n\r\n"));
        System.out.println(query("GET /search?faq=1&q=java HTTP/1.1\r\n\r\n"));
        System.out.println(query("GET /home HTTP/1.1\r\n"
                + "Accept: text/html;q=0.9, */*;q=0.8\r\n\r\n"));
    }
}

indexOf는 찾는 문자열이 처음 나온 위치만 돌려주고, 그 자리가 쿼리 안인지 헤더 안인지는 따지지 않습니다. 네 줄 가운데 의도한 검색어 java만 정확히 나온 줄이 몇 개인지 세어 보세요.

java
java&page=2
1&q=java
0.9,

맞는 답은 첫 줄뿐입니다. 두 번째 요청에서는 & 뒤의 다른 매개변수 page=2까지 검색어에 딸려 왔습니다. 세 번째는 faq= 안에 들어 있는 q=를 먼저 찾는 바람에 엉뚱한 값을 잘라 냈습니다. 네 번째는 쿼리가 아예 없는 요청인데, Accept 헤더에서 응답 형식의 선호도를 나타내는 q=0.9를 검색어로 착각했습니다.

어느 경우에도 예외가 나지 않는다는 점이 더 위험합니다. 틀린 값이 조용히 다음 코드로 넘어가기 때문입니다. 게다가 이 메서드는 퍼센트 인코딩을 풀지 않으므로, 한글 검색어가 와도 %EA%B0%80 같은 문자열을 그대로 돌려줍니다. 원인은 하나입니다. 요청 대상에는 정해진 구조가 있는데, 이 코드는 구조를 무시하고 글자만 찾았습니다. 올바른 파서는 구조를 바깥에서 안쪽으로 차례대로 나눕니다.

  1. 시작 줄을 공백으로 나눠 요청 대상을 꺼냅니다.
  2. 요청 대상을 첫 번째 ?에서 경로와 쿼리로 나눕니다.
  3. 쿼리를 &에서 나눠 항목들을 얻습니다.
  4. 각 항목을 첫 번째 =에서 키와 값으로 나눕니다.
  5. 마지막으로 키와 값을 하나씩 디코딩합니다.

나눈 다음에 디코딩해야 하는 이유

디코딩을 맨 마지막에 하는 데는 이유가 있습니다. 검색어로 a&b를 보내는 경우를 생각해 봅시다. 값 안의 &는 구분자와 헷갈리지 않도록 %26으로 인코딩되므로 쿼리는 q=a%26b&tag=x가 됩니다. 이 쿼리를 통째로 먼저 디코딩하면 q=a&b&tag=x가 되고, 이것을 &로 나누면 q=a, b, tag=x 세 항목이 생깁니다. 값의 일부였던 b가 새 매개변수로 둔갑한 것입니다. 반대로 디코딩하기 전의 쿼리를 &에서 먼저 나누면 q=a%26b와 tag=x 두 항목이 나오고, 그다음에 값을 디코딩해야 q의 값이 a&b로 올바르게 복원됩니다.

이 글에서는 디코딩하기 전의 원래 문자열을 원시(raw) 문자열이라고 부르겠습니다. 규칙을 한 문장으로 줄이면 '구분자는 원시 문자열에서 찾고, 디코딩은 나뉜 조각에만 한 번 적용한다'입니다. 디코딩해서 생긴 &나 =는 데이터일 뿐 구분자가 아닙니다.

항목의 모양도 몇 가지 더 정해 두어야 합니다. empty=처럼 = 뒤가 비어 있으면 값은 빈 문자열입니다. flag처럼 = 없이 키만 있는 항목은 빈 값으로 볼 수도 있고 잘못된 형식으로 거절할 수도 있는데, 어느 쪽을 택할지는 애플리케이션이 정할 규칙입니다. 이 글의 파서는 HTML 폼을 해석하는 방식과 같이 빈 문자열로 봅니다. 반면 a=1&&b=2처럼 &가 연달아 나와 생긴 빈 항목은 거절합니다. 브라우저의 URLSearchParams는 빈 항목을 조용히 건너뛰므로, 이 파서가 브라우저보다 엄격한 셈입니다.

tag=java&tag=http처럼 같은 키가 여러 번 오는 일도 흔합니다. 체크박스를 여러 개 고른 폼이 이렇게 보냅니다. 이 값들을 Map<String, String>에 넣으면 나중 값이 앞의 값을 덮어써서 java가 사라집니다. 그래서 키마다 값의 목록을 두는 Map<String, List<String>>을 씁니다.

요청 대상 파서 만들기

아래 RequestTargetParser는 앞의 순서를 그대로 코드로 옮긴 것입니다. parse는 요청 대상이 /로 시작하는 원점 형식인지 확인하고 #이 있으면 거절한 뒤, 첫 ?에서 경로와 원시 쿼리를 나눕니다. parseQuery는 원시 쿼리를 &와 첫 =로 나눈 뒤 키와 값을 URLDecoder로 디코딩해 키별 목록에 모읍니다.

URLDecoder를 쓰는 데는 전제가 하나 있습니다. 이 클래스는 HTML 폼이 데이터를 보내는 형식인 application/x-www-form-urlencoded를 해석하는 도구라서, 퍼센트 인코딩을 푸는 것 말고도 +를 공백으로 바꿉니다. 폼은 공백을 +로 보내기 때문입니다. 따라서 이 파서는 쿼리가 폼 형식으로 인코딩되어 있다고 가정합니다. 경로에는 이런 규칙이 없어서 경로까지 같은 디코더로 풀면 실제 더하기 기호가 공백으로 바뀝니다. 그래서 경로는 디코딩하지 않고 원시 형태로 둡니다.

src/RequestTargetParser.java
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class RequestTargetParser {
    record Target(String rawPath, Map<String, List<String>> query) {
        Target {
            query = Map.copyOf(query);
        }

        String first(String key) {
            return query.getOrDefault(key, List.of()).stream()
                    .findFirst().orElse(null);
        }
    }

    static Target parse(String requestTarget) {
        if (requestTarget == null || requestTarget.isEmpty()) {
            throw new IllegalArgumentException("target missing");
        }
        if (!requestTarget.startsWith("/")) {
            throw new IllegalArgumentException("origin-form required");
        }
        if (requestTarget.indexOf('#') >= 0) {
            throw new IllegalArgumentException("fragment is not sent to origin");
        }

        int question = requestTarget.indexOf('?');
        String path = question < 0
                ? requestTarget
                : requestTarget.substring(0, question);
        String rawQuery = question < 0
                ? ""
                : requestTarget.substring(question + 1);
        return new Target(path, parseQuery(rawQuery));
    }

    private static Map<String, List<String>> parseQuery(String rawQuery) {
        Map<String, List<String>> result = new LinkedHashMap<>();
        if (rawQuery.isEmpty()) {
            return result;
        }
        for (String field : rawQuery.split("&", -1)) {
            if (field.isEmpty()) {
                throw new IllegalArgumentException("empty query field");
            }
            String[] pair = field.split("=", 2);
            String key = decodeFormComponent(pair[0]);
            String value = pair.length == 2
                    ? decodeFormComponent(pair[1])
                    : "";
            if (key.isEmpty()) {
                throw new IllegalArgumentException("query key missing");
            }
            result.computeIfAbsent(key, ignored -> new ArrayList<>())
                    .add(value);
        }
        result.replaceAll((key, values) -> List.copyOf(values));
        return result;
    }

    private static String decodeFormComponent(String raw) {
        try {
            return URLDecoder.decode(raw, StandardCharsets.UTF_8);
        } catch (IllegalArgumentException error) {
            throw new IllegalArgumentException(
                    "invalid percent encoding",
                    error);
        }
    }

    public static void main(String[] args) {
        Target target = parse(
                "/search?q=%EA%B0%80%EB%82%98%EB%8B%A4"
                        + "&tag=java&tag=http&empty=");
        System.out.println("path=" + target.rawPath());
        System.out.println("q=" + target.first("q"));
        System.out.println("tags=" + target.query().get("tag"));
        System.out.println("empty='" + target.first("empty") + "'");

        Target fromCurl = parse(
                "/search?q=%ec%9e%90%eb%b0%94+%ec%9e%85%eb%ac%b8&tag=a%26b");
        System.out.println("q=" + fromCurl.first("q")
                + ", tag=" + fromCurl.first("tag"));
    }
}

first는 같은 이름의 값이 여러 개면 첫 번째 값을, 하나도 없으면 null을 돌려줍니다. 그래서 empty=처럼 이름만 있고 값이 빈 매개변수는 출력에서 null과 구별됩니다.

path=/search
q=가나다
tags=[java, http]
empty=''
q=자바 입문, tag=a&b

처음 네 줄은 첫 번째 요청 대상을 파싱한 결과입니다. q는 퍼센트 인코딩이 풀려 가나다가 되었고, 두 번 온 tag는 입력 순서대로 목록에 모두 남았습니다. empty=의 값은 null이 아니라 빈 문자열이라서 작은따옴표 사이가 비어 있습니다.

마지막 줄의 요청 대상은 curl이 실제로 보낸 것을 그대로 옮긴 것입니다. 한 터미널에서 nc -l 8081처럼 받은 바이트를 그대로 화면에 출력하는 프로그램을 띄우고(배포판에 따라 nc -l -p 8081), 다른 터미널에서 다음 명령을 실행했습니다. -G는 데이터를 본문 대신 쿼리에 붙이라는 옵션이고, --data-urlencode는 값을 인코딩해서 붙이라는 옵션입니다.

curl -G --data-urlencode 'q=자바 입문' --data-urlencode 'tag=a&b' http://127.0.0.1:8081/search

nc는 받은 바이트를 해석하지 않고 그대로 찍으므로, curl이 요청 줄과 헤더를 어떻게 썼는지 있는 그대로 볼 수 있습니다. 받는 쪽에는 다음 요청이 도착했습니다.

GET /search?q=%ec%9e%90%eb%b0%94+%ec%9e%85%eb%ac%b8&tag=a%26b HTTP/1.1
Host: 127.0.0.1:8081
User-Agent: curl/8.5.0
Accept: */*

curl 8.5.0은 공백을 폼 방식대로 +로 보냈고, 16진수는 소문자로 적었습니다. RFC 3986은 대문자를 쓰라고 권하면서도 16진수의 대소문자만 다른 퍼센트 인코딩은 같은 바이트를 뜻한다고 정하므로, URLDecoder는 %ec와 %EC를 똑같이 받아들입니다. 값 안의 &는 %26으로 왔고, 파서가 원시 &에서 먼저 나눈 덕분에 tag의 값은 a&b로 복원되었습니다.

아래 그림은 parseQuery가 같은 구조의 쿼리 q=a%26b&tag=x를 처리하는 과정을 단계별로 보여 줍니다. 원시 &로 두 항목을 나누고, 각 항목을 첫 =로 나눈 뒤 값을 디코딩합니다. 복원된 a&b의 &로 다시 나누는 단계는 어디에도 없습니다.

원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다

원시 query를 먼저 &와 첫 =로 분리한 뒤 각 성분을 디코딩해 q=a&b와 tag=x를 만듭니다. 디코딩된 &를 다시 필드 구분자로 쓰지 않는 순서가 핵심입니다.

원시 구분자를 나눈 뒤 값의 퍼센트를 해제한다

RequestTargetParser가 쿼리 구조를 정하는 순서와 반환값을 나타냅니다.

원시 쿼리 · 구조 분리 후 디코딩

원시 구분자를 나눈 뒤 값의 퍼센트를 해제한다

원시 &와 첫 =로 항목을 먼저 나눕니다. a%26b를 a&b로 복원한 뒤에는 &를 새 구분자로 다시 해석하지 않습니다.

원시 항목 구분을 끝낸 뒤 값의 퍼센트를 푼다RequestTargetParser.parseQuery가유효한q=a%26b&tag=x를읽는흐름이다.원시&로항목을나눈뒤각항목의첫=로키와값을나누고URLDecoder로각폼성분을복원해키별목록에추가한다.다음항목이있으면반복하고없으면Map을돌려준다.복원된a&b의&를새항목으로다시나누는간선은없다.빈항목/잘못된입력의오류분기는이정상입력그림에서생략하고원비교에서확인한다.있음없음원시 쿼리q=a%26b&tag=x원시 & 항목 분리q=a%26b / tag=x다음 항목?첫 =로 키·값 분리현재 항목 하나각 폼 성분 복원키별 목록에 추가복원된 &로 안 나눔query Map 반환q=a&b · tag=x두 항목 처리 뒤유효한 두 항목의 흐름 · 오류 분기는 원 비교 참고
원시 항목 구분을 끝낸 뒤 값의 퍼센트를 푼다RequestTargetParser.parseQuery가유효한q=a%26b&tag=x를읽는흐름이다.원시&로항목을나눈뒤각항목의첫=로키와값을나누고URLDecoder로각폼성분을복원해키별목록에추가한다.다음항목이있으면반복하고없으면Map을돌려준다.복원된a&b의&를새항목으로다시나누는간선은없다.빈항목/잘못된입력의오류분기는이정상입력그림에서생략하고원비교에서확인한다.있음원시 쿼리q=a%26b&tag=x원시 & 항목 분리q=a%26b / tag=x다음 항목?첫 =로 키·값 분리현재 항목 하나각 폼 성분 복원키별 목록에 추가복원된 &로 안 나눔query Map 반환q=a&b · tag=x두 항목 처리 뒤왼쪽 선: 다음 항목오른쪽 선: 항목 없음오류는 원 비교 참고

값 안의 구분자: q=a%26b&tag=x

구조 분리
원시 &에서 q와 tag를 분리
구성 요소 해석
q의 값은 a&b · 복원된 &로 다시 나누지 않음

반복 키: tag=java&tag=http

구조 분리
두 항목을 입력 순서대로 처리
구성 요소 해석
tag 목록에 java, http를 보존

빈 값: empty= 또는 flag

구조 분리
첫 = 뒤가 비었거나 =가 없음
구성 요소 해석
각 키의 값은 빈 문자열

빈 항목: a=1&&b=2

구조 분리
가운데 항목이 빈 문자열
구성 요소 해석
예외로 거절

원문 main에는 반복 tag와 empty=가 있습니다. 다른 입력은 코드 분기 설명이며 새 실행 결과가 아닙니다.

이 예의 조건

  • 디코딩된 &를 다시 필드 구분자로 쓰지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
1원시 항목 구분을 끝낸 뒤 값의 퍼센트 인코딩을 해제한다
2RequestTargetParser.parseQuery가 유효한 q=a%26b&tag=x를 읽는 흐름이다. 원시 &로 항목을 나눈 뒤 각 항목의 첫 =로 키와 값을 나누고 URLDecoder로 각 폼 성분을 복원해 키별 목록에 추가한다. 다음 항목이 있으면 반복하고 없으면 Map을 돌려준다. 복원된 a&b의 &를 새 항목으로 다시 나누는 간선은 없다. 빈 항목/잘못된 입력의 오류 분기는 이 정상 입력 그림에서 생략하고 원래 비교에서 확인한다.
8q=a%26b
9tag=x
원 도표의 설명과 주석
구분내용
설명RequestTargetParser.parseQuery가 유효한 q=a%26b&tag=x를 읽는 흐름이다. 원시 &로 항목을 나눈 뒤 각 항목의 첫 =로 키와 값을 나누고 URLDecoder로 각 폼 성분을 복원해 키별 목록에 추가한다. 다음 항목이 있으면 반복하고 없으면 Map을 돌려준다. 복원된 a&b의 &를 새 항목으로 다시 나누는 간선은 없다. 빈 항목/잘못된 입력의 오류 분기는 이 정상 입력 그림에서 생략하고 원래 비교에서 확인한다.
전체 읽기 정보원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다 원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다 RequestTargetParser가 쿼리 구조를 정하는 순서와 반환값을 나타냅니다. 원시 쿼리 · 구조 분리 후 디코딩 원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다 원시 &와 첫 =로 항목을 먼저 나눕니다. a%26b를 a&b로 복원한 뒤에는 &를 새 구분자로 다시 해석하지 않습니다. 값 안의 구분자: q=a%26b&tag=x 구조 분리 원시 &에서 q와 tag를 분리 구성 요소 해석 q의 값은 a&b · 복원된 &로 다시 나누지 않음 반복 키: tag=java&tag=http 구조 분리 두 항목을 입력 순서대로 처리 구성 요소 해석 tag 목록에 java, http를 보존 빈 값: empty= 또는 flag 구조 분리 첫 = 뒤가 비었거나 =가 없음 구성 요소 해석 각 키의 값은 빈 문자열 빈 항목: a=1&&b=2 구조 분리 가운데 항목이 빈 문자열 구성 요소 해석 예외로 거절 원문 main에는 반복 tag와 empty=가 있습니다. 다른 입력은 코드 분기 설명이며 새 실행 결과가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원시 입력

percent escape를 유지한 query를 받습니다.

  • raw: q=a%26b&tag=x
  • 결과 Map: 아직 없음
  • 분리 전에 decode하면 데이터와 구분자가 섞인다.
필드 분리

원시 &로 두 필드를 나눕니다.

  • 필드 0: q=a%26b
  • 필드 1: tag=x
  • wire의 실제 구분자만 필드 경계로 사용한다.
성분 분리와 복원

첫 =로 이름/값을 나누고 각 성분을 decode합니다.

  • 첫 값: a&b
  • 둘째 값: x
  • %26이 값 내부의 &로 복원되어 경계로 재사용되지 않는다.
목록 Map 완성

같은 키의 값을 순서대로 모읍니다.

  • q: [a&b]
  • tag: [x]
  • 분리와 복원이 완료된 값만 저장한다.

원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다

원시 query를 먼저 &와 첫 =로 분리한 뒤 각 성분을 디코딩해 q=a&b와 tag=x를 만듭니다. 디코딩된 &를 다시 필드 구분자로 쓰지 않는 순서가 핵심입니다.

원시 구분자를 나눈 뒤 값의 퍼센트를 해제한다

RequestTargetParser가 쿼리 구조를 정하는 순서와 반환값을 나타냅니다.

원시 쿼리 · 구조 분리 후 디코딩

원시 구분자를 나눈 뒤 값의 퍼센트를 해제한다

원시 &와 첫 =로 항목을 먼저 나눕니다. a%26b를 a&b로 복원한 뒤에는 &를 새 구분자로 다시 해석하지 않습니다.

원시 항목 구분을 끝낸 뒤 값의 퍼센트를 푼다RequestTargetParser.parseQuery가유효한q=a%26b&tag=x를읽는흐름이다.원시&로항목을나눈뒤각항목의첫=로키와값을나누고URLDecoder로각폼성분을복원해키별목록에추가한다.다음항목이있으면반복하고없으면Map을돌려준다.복원된a&b의&를새항목으로다시나누는간선은없다.빈항목/잘못된입력의오류분기는이정상입력그림에서생략하고원비교에서확인한다.있음없음원시 쿼리q=a%26b&tag=x원시 & 항목 분리q=a%26b / tag=x다음 항목?첫 =로 키·값 분리현재 항목 하나각 폼 성분 복원키별 목록에 추가복원된 &로 안 나눔query Map 반환q=a&b · tag=x두 항목 처리 뒤유효한 두 항목의 흐름 · 오류 분기는 원 비교 참고
원시 항목 구분을 끝낸 뒤 값의 퍼센트를 푼다RequestTargetParser.parseQuery가유효한q=a%26b&tag=x를읽는흐름이다.원시&로항목을나눈뒤각항목의첫=로키와값을나누고URLDecoder로각폼성분을복원해키별목록에추가한다.다음항목이있으면반복하고없으면Map을돌려준다.복원된a&b의&를새항목으로다시나누는간선은없다.빈항목/잘못된입력의오류분기는이정상입력그림에서생략하고원비교에서확인한다.있음원시 쿼리q=a%26b&tag=x원시 & 항목 분리q=a%26b / tag=x다음 항목?첫 =로 키·값 분리현재 항목 하나각 폼 성분 복원키별 목록에 추가복원된 &로 안 나눔query Map 반환q=a&b · tag=x두 항목 처리 뒤왼쪽 선: 다음 항목오른쪽 선: 항목 없음오류는 원 비교 참고

값 안의 구분자: q=a%26b&tag=x

구조 분리
원시 &에서 q와 tag를 분리
구성 요소 해석
q의 값은 a&b · 복원된 &로 다시 나누지 않음

반복 키: tag=java&tag=http

구조 분리
두 항목을 입력 순서대로 처리
구성 요소 해석
tag 목록에 java, http를 보존

빈 값: empty= 또는 flag

구조 분리
첫 = 뒤가 비었거나 =가 없음
구성 요소 해석
각 키의 값은 빈 문자열

빈 항목: a=1&&b=2

구조 분리
가운데 항목이 빈 문자열
구성 요소 해석
예외로 거절

원문 main에는 반복 tag와 empty=가 있습니다. 다른 입력은 코드 분기 설명이며 새 실행 결과가 아닙니다.

이 예의 조건

  • 디코딩된 &를 다시 필드 구분자로 쓰지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
1원시 항목 구분을 끝낸 뒤 값의 퍼센트 인코딩을 해제한다
2RequestTargetParser.parseQuery가 유효한 q=a%26b&tag=x를 읽는 흐름이다. 원시 &로 항목을 나눈 뒤 각 항목의 첫 =로 키와 값을 나누고 URLDecoder로 각 폼 성분을 복원해 키별 목록에 추가한다. 다음 항목이 있으면 반복하고 없으면 Map을 돌려준다. 복원된 a&b의 &를 새 항목으로 다시 나누는 간선은 없다. 빈 항목/잘못된 입력의 오류 분기는 이 정상 입력 그림에서 생략하고 원래 비교에서 확인한다.
8q=a%26b
9tag=x
원 도표의 설명과 주석
구분내용
설명RequestTargetParser.parseQuery가 유효한 q=a%26b&tag=x를 읽는 흐름이다. 원시 &로 항목을 나눈 뒤 각 항목의 첫 =로 키와 값을 나누고 URLDecoder로 각 폼 성분을 복원해 키별 목록에 추가한다. 다음 항목이 있으면 반복하고 없으면 Map을 돌려준다. 복원된 a&b의 &를 새 항목으로 다시 나누는 간선은 없다. 빈 항목/잘못된 입력의 오류 분기는 이 정상 입력 그림에서 생략하고 원래 비교에서 확인한다.
전체 읽기 정보원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다 원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다 RequestTargetParser가 쿼리 구조를 정하는 순서와 반환값을 나타냅니다. 원시 쿼리 · 구조 분리 후 디코딩 원시 구분자로 나눈 뒤 값의 퍼센트 인코딩을 해제한다 원시 &와 첫 =로 항목을 먼저 나눕니다. a%26b를 a&b로 복원한 뒤에는 &를 새 구분자로 다시 해석하지 않습니다. 값 안의 구분자: q=a%26b&tag=x 구조 분리 원시 &에서 q와 tag를 분리 구성 요소 해석 q의 값은 a&b · 복원된 &로 다시 나누지 않음 반복 키: tag=java&tag=http 구조 분리 두 항목을 입력 순서대로 처리 구성 요소 해석 tag 목록에 java, http를 보존 빈 값: empty= 또는 flag 구조 분리 첫 = 뒤가 비었거나 =가 없음 구성 요소 해석 각 키의 값은 빈 문자열 빈 항목: a=1&&b=2 구조 분리 가운데 항목이 빈 문자열 구성 요소 해석 예외로 거절 원문 main에는 반복 tag와 empty=가 있습니다. 다른 입력은 코드 분기 설명이며 새 실행 결과가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원시 입력

percent escape를 유지한 query를 받습니다.

  • raw: q=a%26b&tag=x
  • 결과 Map: 아직 없음
  • 분리 전에 decode하면 데이터와 구분자가 섞인다.
필드 분리

원시 &로 두 필드를 나눕니다.

  • 필드 0: q=a%26b
  • 필드 1: tag=x
  • wire의 실제 구분자만 필드 경계로 사용한다.
성분 분리와 복원

첫 =로 이름/값을 나누고 각 성분을 decode합니다.

  • 첫 값: a&b
  • 둘째 값: x
  • %26이 값 내부의 &로 복원되어 경계로 재사용되지 않는다.
목록 Map 완성

같은 키의 값을 순서대로 모읍니다.

  • q: [a&b]
  • tag: [x]
  • 분리와 복원이 완료된 값만 저장한다.

코드에서 눈여겨볼 곳이 몇 군데 있습니다. rawQuery.split("&", -1)의 두 번째 인자 -1은 끝에 생기는 빈 문자열을 버리지 말라는 뜻입니다. 그냥 split("&")을 쓰면 a=1&의 마지막 빈 항목이 조용히 사라져 빈 항목 검사를 피해 갑니다. field.split("=", 2)는 첫 번째 =에서 한 번만 나누므로 expr=1=1의 값은 1=1이 됩니다.

Target의 컴팩트 생성자(compact constructor)는 Map.copyOf로 맵을 수정할 수 없는 복사본으로 바꿉니다. 그런데 Map.copyOf는 맵만 복사할 뿐 그 안의 목록까지 복사하지는 않습니다. 그래서 parseQuery는 반환하기 전에 replaceAll로 각 목록을 List.copyOf의 결과로 바꿔 둡니다. 둘 중 하나라도 빠지면 target.query().get("tag").add("x") 같은 코드로 파싱 결과를 바꿀 수 있습니다.

Map.copyOf가 만든 맵은 키를 꺼내는 순서가 정해져 있지 않고, 실행할 때마다 달라질 수도 있습니다. 한 키 안의 값 순서는 목록이 지켜 주지만, a=1&b=2&a=3처럼 서로 다른 키가 섞인 전체 순서까지 필요하다면 연습 문제 1처럼 (키, 값) 쌍의 목록으로 보관해야 합니다.

URLEncoder로 쿼리 값 만들기

클라이언트 쪽에서 쿼리를 만들 때는 반대로 URLEncoder를 씁니다. 이때 요청 대상 전체가 아니라 값 하나하나를 인코딩해야 합니다. 다음 코드는 값 네 개를 인코딩해 UTF-8 바이트, 인코딩 결과, 인코딩한 뒤의 바이트 수를 출력하고, 다시 디코딩하면 원래 값이 나오는지 확인합니다. 마지막 줄은 요청 대상 전체를 실수로 인코딩한 경우입니다.

src/PercentEncodingProbe.java
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;

public final class PercentEncodingProbe {
    record Observation(String text, String utf8Hex,
                       String encoded, int wireBytes) {
    }

    static Observation observe(String value) {
        byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
        String encoded = URLEncoder.encode(
                value,
                StandardCharsets.UTF_8);
        return new Observation(
                value,
                HexFormat.ofDelimiter(" ").withUpperCase().formatHex(utf8),
                encoded,
                encoded.getBytes(StandardCharsets.US_ASCII).length);
    }

    public static void main(String[] args) {
        for (String value : List.of("java", "가", "hello world", "a+b")) {
            Observation observation = observe(value);
            String decoded = URLDecoder.decode(
                    observation.encoded(),
                    StandardCharsets.UTF_8);
            System.out.println(observation);
            System.out.println("roundTrip=" + decoded.equals(value));
        }
        System.out.println(URLEncoder.encode(
                "/search?q=가",
                StandardCharsets.UTF_8));
    }
}

Observation은 레코드라서 toString()이 네 필드를 이름과 함께 찍습니다. 그래서 값마다 레코드 한 줄과 roundTrip 한 줄이 나오고, 마지막 줄만 인코딩한 문자열 그대로입니다.

Observation[text=java, utf8Hex=6A 61 76 61, encoded=java, wireBytes=4]
roundTrip=true
Observation[text=가, utf8Hex=EA B0 80, encoded=%EA%B0%80, wireBytes=9]
roundTrip=true
Observation[text=hello world, utf8Hex=68 65 6C 6C 6F 20 77 6F 72 6C 64, encoded=hello+world, wireBytes=11]
roundTrip=true
Observation[text=a+b, utf8Hex=61 2B 62, encoded=a%2Bb, wireBytes=5]
roundTrip=true
%2Fsearch%3Fq%3D%EA%B0%80

URLEncoder는 영문자와 숫자, 그리고 ., -, *, _만 그대로 두고 나머지는 모두 인코딩합니다. 그래서 java는 그대로이고, 가는 UTF-8 세 바이트가 각각 %XX 세 글자가 되어 9바이트로 늘었습니다. 공백은 +가 되었고, 원래 있던 +는 공백과 구별되도록 %2B가 되었습니다. 네 값 모두 다시 디코딩하면 원래 값과 같습니다.

한글 한 글자가 요청 대상에서 9바이트를 차지한다는 점은 길이 제한을 생각할 때 중요합니다. RFC 9112는 시작 줄을 적어도 8000바이트까지는 처리할 수 있게 하라고 권장할 뿐이라서, 실제 한도는 서버와 프록시마다 다릅니다. 처리할 수 있는 것보다 긴 요청 대상을 받은 서버는 414(URI Too Long)로 응답합니다. 길거나 구조가 복잡한 데이터라면 쿼리에 억지로 넣기보다 POST 본문에 담고 Content-Type으로 형식을 알리는 편이 낫습니다.

마지막 줄은 /search?q=가 전체를 URLEncoder에 넣은 결과입니다. /, ?, =까지 %2F, %3F, %3D로 바뀌어서, 이것을 받은 서버는 더는 경로와 쿼리를 나눌 수 없습니다. 요청 대상은 값을 하나씩 인코딩한 다음 ?, &, =로 이어 붙이는 순서로 만들어야 합니다.

URLDecoder가 그냥 넘기는 잘못된 입력

URLDecoder가 잘못된 입력을 모두 거절하지는 않습니다. 다음 코드는 네 가지 입력을 디코딩해 봅니다. 첫 입력 %C3%28은 UTF-8로 올바르지 않은 바이트 조합입니다. C3은 두 바이트로 된 문자의 첫 바이트인데, 뒤에 온 28(괄호 ()은 그 문자의 두 번째 바이트가 될 수 없기 때문입니다.

src/LenientDecoderDemo.java
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

public final class LenientDecoderDemo {
    public static void main(String[] args) {
        for (String raw : List.of("%C3%28", "가", "%ZZ", "100%")) {
            try {
                String decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8);
                System.out.printf("%s -> %s (첫 문자 U+%04X)%n",
                        raw, decoded, decoded.codePointAt(0));
            } catch (IllegalArgumentException error) {
                System.out.println(raw + " -> IllegalArgumentException");
            }
        }
    }
}

printf의 U+%04X는 디코딩한 문자열의 첫 코드 포인트를 16진수로 찍습니다. 화면의 글자만으로는 알아보기 어려운 대체 문자도 이 번호로는 분명히 드러납니다.

%C3%28 -> �( (첫 문자 U+FFFD)
가 -> 가 (첫 문자 U+AC00)
%ZZ -> IllegalArgumentException
100% -> IllegalArgumentException

%ZZ처럼 % 뒤가 16진수가 아니거나 100%처럼 끝에 %가 홀로 남은 입력은 예외로 거절합니다. 하지만 잘못된 UTF-8 바이트는 예외 없이 대체 문자(replacement character) U+FFFD로 바꿔 버립니다. 화면에 �로 보이는 그 문자입니다. URLDecoder.decode(String, Charset)의 Javadoc에도 잘못된 바이트는 문자 집합의 대체 값으로 바꾼다고 적혀 있습니다. 퍼센트 인코딩하지 않은 한글 가도 그대로 통과합니다. URI에는 ASCII 밖의 문자가 그대로 올 수 없으니 엄밀히 따지면 이것도 잘못된 입력입니다.

검색어 정도라면 이렇게 너그러운 처리가 오히려 편할 수 있습니다. 하지만 사용자 이름이나 서명할 문자열처럼 바이트가 정확해야 하는 값에서는 문제가 됩니다. %C3%28과 %C4%28이 모두 �(가 되듯이 서로 다른 입력이 같은 문자열로 바뀌고, 그런 일이 있었다는 사실조차 남지 않기 때문입니다. 잘못된 입력을 오류로 알리려면 바이트를 직접 모은 뒤, 오류를 만나면 예외를 던지는 CharsetDecoder로 해석해야 합니다. 이 방법은 연습 문제 1에서 직접 만들어 봅니다.

경로는 언제, 몇 번 디코딩할까

지금까지 경로는 원시 형태로 두었습니다. 라우팅하기 전에 경로를 디코딩하고 정규화(normalization)할 수도 있지만, 이 과정의 몇 가지 결정은 보안과 바로 이어집니다. %2F를 /로 바꾸면 /files/a%2Fb가 구간 하나인지 둘인지가 달라집니다. 중복된 //를 하나로 합칠지, .과 .. 구간을 어떻게 처리할지도 정해야 합니다. 경로를 파일 시스템 경로로 연결한다면 디코딩과 정규화를 모두 마친 뒤에 기준 디렉터리 밖으로 나가지 않는지 다시 검사해야 합니다. %2e%2e는 디코딩하면 ..가 되므로 디코딩하기 전에 검사하면 놓치기 때문입니다.

디코딩 횟수도 정해야 합니다. %252e%252e를 한 번 디코딩하면 %2e%2e가 되고, 한 번 더 디코딩하면 ..가 됩니다. 앞단 프록시의 보안 필터가 한 번 디코딩한 결과에서 ..를 찾고 뒤의 애플리케이션이 한 번 더 디코딩한다면, 필터는 아무 문제도 보지 못한 채 상위 디렉터리로 가는 경로를 통과시킵니다. 그래서 시스템 전체에서 디코딩은 정해진 한 곳에서 정확히 한 번만 하고, 이미 디코딩한 값에 디코더를 다시 적용하지 않습니다.

요청의 각 부분을 언제 나누고 어떻게 디코딩하는지 정리하면 다음과 같습니다.

부분나누는 시점디코딩크기 제한
요청 대상시작 줄에서 꺼낼 때아직 하지 않음전체 길이
경로첫 ? 앞구간별 규칙을 따로 정함구간 수와 길이
쿼리 항목&와 첫 =로 나눈 뒤폼 형식으로 키와 값을 각각항목 수, 키와 값의 길이
헤더첫 :로 나눈 뒤퍼센트 디코딩하지 않음줄 길이와 전체 크기
본문본문 길이를 확정한 뒤Content-Type에 따름본문 바이트 수

헤더 값에는 일반적인 퍼센트 인코딩 규칙이 없으므로 %가 보여도 풀지 않습니다. 본문을 어디까지 읽을지 정하는 방법은 다음 글에서 다룹니다.

디코딩한 값을 다시 내보낼 때

디코딩은 입력을 원래 값으로 되돌릴 뿐, 그 값을 안전하게 만들어 주지는 않습니다. q=%3Cscript%3E를 디코딩하면 <script>가 나오고, 이것을 HTML에 그대로 넣으면 브라우저가 스크립트로 실행합니다. 그래서 값을 내보낼 때는 그 값이 들어가는 곳의 규칙을 따로 적용해야 합니다. HTML에 넣을 때는 HTML 이스케이프를, SQL에는 매개변수 바인딩을, 셸 명령에는 인자 배열을 씁니다.

로그도 마찬가지입니다. %0D%0A는 디코딩하면 줄바꿈이 되므로, 디코딩한 값을 로그에 그대로 쓰면 공격자가 가짜 로그 줄을 끼워 넣을 수 있습니다. 로그에 남길 때는 제어 문자를 이스케이프하고 길이를 제한하며, 개인정보가 담길 수 있는 값은 아예 남기지 않는 편이 좋습니다. 입력을 해석하는 일과 출력을 이스케이프하는 일은 목적이 다르므로 '정제' 함수 하나로 합치지 않습니다.

파서를 시험할 입력

RequestTargetParser를 고치거나 비슷한 파서를 새로 만들었다면 적어도 다음 입력으로 동작을 확인해 보세요. 오른쪽 열은 이 글의 파서가 실제로 내는 결과입니다.

요청 대상결과
/search?q=%EA%B0%80q는 가
/search?q=a+bq는 a b
/search?q=a%2Bbq는 a+b
/search?q=a%26bq는 a&b, 항목은 하나
/search?tag=a&tag=btag는 [a, b]
/search?flag&empty=두 키 모두 빈 문자열
/search?q=%C3%28오류 없이 q의 첫 글자가 U+FFFD
/search?q=%ZZ, /search?q=%예외 invalid percent encoding
/search?a=1&&b=2, /search?a=1&예외 empty query field
/search?=java예외 query key missing
search?q=1예외 origin-form required
/page#top예외 fragment is not sent to origin

잘못된 UTF-8인 %C3%28이 오류 없이 통과하는 줄이 눈에 띌 것입니다. 앞에서 본 URLDecoder의 동작 그대로이고, 이것까지 거절하려면 연습 문제 1의 디코더가 필요합니다.

정리

  • 요청 대상은 첫 ?를 기준으로 경로와 쿼리로 나뉩니다. 서버에 직접 보내는 요청은 /로 시작하는 원점 형식을 쓰고, 프래그먼트는 서버로 오지 않습니다.
  • 퍼센트 인코딩은 문자를 UTF-8 바이트로 바꾼 뒤 바이트마다 %XX로 적는 방식입니다. 디코딩할 때는 바이트를 모두 모은 다음에 UTF-8로 해석합니다.
  • 구분자 ?, &, =는 원시 문자열에서 찾고, 디코딩은 나뉜 조각에만 한 번 적용합니다. 먼저 디코딩하면 값 안의 %26이 구분자로 둔갑합니다.
  • 같은 키가 여러 번 올 수 있으므로 Map<String, List<String>>이나 (키, 값) 쌍의 목록으로 보관합니다.
  • URLDecoder는 +를 공백으로 바꾸는 폼 디코더라서 경로에는 쓰지 않습니다. 잘못된 UTF-8은 예외 없이 U+FFFD로 바꾸므로, 거절해야 한다면 CharsetDecoder를 직접 씁니다.

연습 문제

1. 잘못된 입력을 거절하는 쿼리 디코더

원시 쿼리 tag=java&tag=%ED%95%9C%EA%B8%80&flag&empty=를 읽어 입력 순서를 그대로 지키는 List<Pair>를 반환하는 파서를 만드세요. +는 공백으로, %2B는 더하기 기호로 복원합니다. 잘못된 16진수, 퍼센트 인코딩되지 않은 ASCII 밖의 문자, 잘못된 UTF-8은 대체 문자로 바꾸지 말고 예외로 거절해야 합니다.

풀이: 바이트를 모은 뒤 CharsetDecoder로 해석하기

구조는 RequestTargetParser와 같습니다. 원시 쿼리를 &와 첫 =로 나눈 뒤 조각마다 decode를 부릅니다. 달라지는 곳은 decode입니다. URLDecoder에 맡기지 않고 바이트를 직접 모아서, ASCII 문자는 그대로 쓰고 +는 공백 바이트로, %는 뒤의 16진수 두 자리를 한 바이트로 바꿉니다. 마지막에 오류를 만나면 보고하도록(REPORT) 설정한 UTF-8 디코더로 바이트 전체를 해석합니다.

solution/StrictQueryPairsSolution.java
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public final class StrictQueryPairsSolution {
    record Pair(String key, String value) {
    }

    static List<Pair> parse(String rawQuery) {
        if (rawQuery.isEmpty()) {
            return List.of();
        }
        List<Pair> pairs = new ArrayList<>();
        for (String rawField : rawQuery.split("&", -1)) {
            if (rawField.isEmpty()) {
                throw new IllegalArgumentException("empty field");
            }
            String[] rawPair = rawField.split("=", 2);
            String key = decode(rawPair[0]);
            String value = rawPair.length == 2 ? decode(rawPair[1]) : "";
            if (key.isEmpty()) {
                throw new IllegalArgumentException("empty key");
            }
            pairs.add(new Pair(key, value));
        }
        return List.copyOf(pairs);
    }

    static String decode(String raw) {
        var bytes = new ByteArrayOutputStream();
        for (int index = 0; index < raw.length(); index += 1) {
            char current = raw.charAt(index);
            if (current == '+') {
                bytes.write(' ');
            } else if (current == '%') {
                if (index + 2 >= raw.length()) {
                    throw new IllegalArgumentException("truncated escape");
                }
                int high = hexValue(raw.charAt(index + 1));
                int low = hexValue(raw.charAt(index + 2));
                if (high < 0 || low < 0) {
                    throw new IllegalArgumentException("invalid hex escape");
                }
                bytes.write((high << 4) | low);
                index += 2;
            } else if (current <= 0x7F) {
                bytes.write(current);
            } else {
                throw new IllegalArgumentException("raw non-ASCII character");
            }
        }

        try {
            return StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(bytes.toByteArray()))
                    .toString();
        } catch (CharacterCodingException error) {
            throw new IllegalArgumentException("invalid UTF-8", error);
        }
    }

    private static int hexValue(char digit) {
        if (digit >= '0' && digit <= '9') {
            return digit - '0';
        }
        if (digit >= 'A' && digit <= 'F') {
            return digit - 'A' + 10;
        }
        if (digit >= 'a' && digit <= 'f') {
            return digit - 'a' + 10;
        }
        return -1;
    }

    public static void main(String[] args) {
        List<Pair> pairs = parse(
                "tag=java&tag=%ED%95%9C%EA%B8%80&flag&empty=&symbol=%2B");
        pairs.forEach(System.out::println);
        System.out.println("count=" + pairs.size());
    }
}

실행 결과:

Pair[key=tag, value=java]
Pair[key=tag, value=한글]
Pair[key=flag, value=]
Pair[key=empty, value=]
Pair[key=symbol, value=+]
count=5

입력 순서가 그대로 남았고, 같은 키 tag도 두 쌍으로 따로 보존되었습니다. flag와 empty의 값은 둘 다 빈 문자열이고, main에 덧붙인 symbol=%2B는 더하기 기호로 복원되었습니다.

16진수를 Character.digit 대신 직접 만든 hexValue로 해석한 데는 이유가 있습니다. Character.digit은 유니코드의 숫자와 글자를 폭넓게 받아들여서, 전각 숫자 0(U+FF10)를 0으로, 전각 대문자 A(U+FF21)를 10으로 읽습니다. 그러면 %0A가 줄바꿈 바이트 0x0A로 디코딩되어 버립니다. 퍼센트 인코딩에 쓰는 16진수는 ASCII 숫자와 A부터 F까지의 대소문자뿐이므로 그 범위만 받아야 합니다.

newDecoder()로 새로 만든 CharsetDecoder는 원래 오류를 보고하는 것이 기본 동작이지만, 의도를 드러내려고 REPORT를 명시했습니다. 같은 바이트를 new String(bytes, StandardCharsets.UTF_8)로 바꾸면 잘못된 바이트가 언제나 U+FFFD로 대체되므로, 거절하려면 이처럼 디코더를 직접 써야 합니다.

폼 디코딩과 엄격한 UTF-8 오류 처리는 다른 선택이다

RequestTargetParser와 StrictQueryPairsSolution의 디코딩 범위를 비교합니다.

쿼리 디코딩 · 오류 처리 정책

폼 디코딩과 엄격한 UTF-8 오류 처리는 다른 선택이다

두 구현 모두 +는 공백, %2B는 더하기로 복원합니다. OpenJDK 21 폼 디코더의 대체 문자 처리와 REPORT 풀이의 오류 거절은 다릅니다.

표준 폼 디코더

+ 또는 %2B
각각 공백 또는 +로 복원
%G0 또는 끝의 %
잘못된 퍼센트 형식으로 거절
잘못된 UTF-8: %C3%28
잘못된 바이트를 대체 문자로 처리
이스케이프 없는 한글
입력 문자를 그대로 유지

REPORT 풀이

+ 또는 %2B
각각 공백 또는 +로 복원
%G0 또는 끝의 %
16진수 변환 또는 길이 검사에서 거절
잘못된 UTF-8: %C3%28
UTF-8 디코더가 오류로 거절
이스케이프 없는 한글
원시 비 ASCII 문자로 거절

표는 API와 코드 분기를 비교합니다. 풀이 main은 유효한 입력만 사용하므로 오류 경로를 실행한 결과로 읽지 않습니다.

두 방식 모두 +는 공백으로, %2B는 더하기 기호로 복원합니다. 차이는 잘못된 입력에서 납니다. 표준 폼 디코더는 %G0이나 끝의 %는 거절하지만 잘못된 UTF-8은 대체 문자로 바꾸고, 인코딩되지 않은 한글은 그대로 둡니다. 이 풀이는 네 경우를 모두 예외로 거절합니다. 풀이의 main에는 올바른 입력만 넣었으므로, 오류가 나는 경우는 2번 문제에서 따져 봅니다.

이 풀이에도 없는 것이 있습니다. 쿼리 전체 길이, 항목 수, 키와 값의 길이 제한이 없고, 디코딩한 값에 제어 문자가 들어 있는지도 검사하지 않습니다. 실제 서버라면 이런 제한을 함께 둡니다. 편하게 조회하고 싶다면 검증을 마친 쌍 목록에서 Map을 따로 만들면 됩니다. 파서가 '마지막 값만 남긴다' 같은 규칙을 미리 정해 버리지 않으니, 라우트마다 값을 하나만 받을지 여러 개 받을지 고를 수 있습니다.

2. 원시 구분자와 한 번의 디코딩

1번 풀이의 parse에 x=a%26b&x=c+d&flag를 넣으면 쌍은 몇 개이고 각 값은 무엇일까요? x=%C3와 x=%2541은 어떻게 될까요? 디코딩은 한 번만 하고, 디코딩해서 생긴 &와 %는 구분자로 다루지 않습니다.

해설

첫 입력은 원시 &에서 먼저 나뉘므로 항목은 x=a%26b, x=c+d, flag 세 개입니다. 각 값을 디코딩하면 (x, "a&b"), (x, "c d"), (flag, "") 세 쌍이 되고, 두 x는 입력 순서대로 남습니다. %26에서 복원된 &는 첫 값의 내용일 뿐 새 항목을 만들지 않습니다.

x=%C3의 C3은 두 바이트 문자의 첫 바이트인데 뒤따르는 바이트가 없습니다. 그래서 REPORT 디코더가 오류를 알리고 parse는 invalid UTF-8 예외를 던집니다. URLDecoder였다면 U+FFFD 한 글자로 바뀌었을 입력입니다. x=%2541은 %25가 %로 복원되어 값이 %41이 됩니다. 이것을 한 번 더 디코딩해 A로 만들지 않는 것이 한 번만 디코딩한다는 규칙입니다. 세 입력의 결과는 모두 1번 풀이로 실행해 확인한 것입니다.

3. 경로에 URLDecoder를 쓰면

경로 /files/c++.txt를 URLDecoder.decode로 디코딩하면 무엇이 나올까요? 경로에 이 디코더를 쓰면 안 되는 이유도 설명하세요.

해설

+ 두 개가 모두 공백으로 바뀌어 다음 결과가 나옵니다.

/files/c  .txt

+를 공백으로 읽는 것은 HTML 폼 형식의 규칙이지 URI 경로의 규칙이 아닙니다. 경로에서 +는 그냥 더하기 기호이므로, 이렇게 디코딩하면 c++.txt라는 파일 이름이 c 뒤에 공백 두 개가 붙은 다른 이름으로 바뀌어 엉뚱한 파일을 찾게 됩니다. 경로의 공백은 %20으로 인코딩되어 오므로, 경로를 디코딩하려면 %XX만 바이트로 되돌리고 +는 그대로 두는 별도의 디코더가 필요합니다.

다음 글

HTTP 본문 읽기: Content-Length와 chunked에서는 시작 줄과 헤더 뒤에 오는 본문을 다룹니다. 헤더와 본문을 하나의 바이트 스트림에서 읽어 요청이 끝나는 위치를 정확히 찾고, 여러 조각으로 나뉘어 오는 본문을 풉니다.