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

안동민 개발노트

본문 시작
29장 : 애노테이션 HTTP 실행기

폼 본문과 구성 경계

Content-Length와 미디어 타입을 기준으로 요청 본문을 제한적으로 읽어 폼 값의 출처를 보존하고, 구성 루트에서 저장소·서비스·컨트롤러를 한 번 조립합니다.

HTML 폼의 기본 전송 형식인 application/x-www-form-urlencodedid=min-1&name=%EC%A7%84&age=27 같은 바이트 본문을 보냅니다. 모양은 URL 쿼리와 비슷하지만 위치와 HTTP 의미는 다릅니다. 요청 줄의 쿼리는 대상에 포함되고, 폼 본문은 헤더가 정한 길이와 미디어 타입에 따라 읽습니다. 두 입력을 곧바로 하나의 Map에 합치면 어느 쪽 값이 선택됐는지 추적하기 어렵습니다.

본문 파서는 네트워크 프레이밍과 값 디코딩을 순서대로 처리합니다. 먼저 제한을 검사하고 정확한 바이트 수를 읽습니다. 다음으로 Content-Type의 미디어 타입과 문자 집합 매개변수를 해석합니다. 마지막으로 폼 쌍을 퍼센트 디코딩합니다. 컨트롤러는 완성된 요청 모델만 받고 소켓 read나 헤더 대소문자 규칙을 반복하지 않습니다.

Content-Length의 단위

다음 코드는 Content-Length만큼 char[]를 만듭니다. UTF-8 한국어 한 글자는 여러 바이트로 전송되지만 Reader는 디코딩 뒤 Java char를 제공합니다. 바이트 길이와 char 수를 같은 단위로 보면 다음 요청 일부를 당겨 읽거나 본문을 덜 읽어 keep-alive 프레이밍을 깨뜨릴 수 있습니다. Reader.read() 한 번이 요청한 길이를 모두 채운다는 보장도 없습니다.

bad/CharacterCountFormReader.java
import java.io.IOException;
import java.io.Reader;

public final class CharacterCountFormReader {
    static String readBody(Reader reader, int contentLength) throws IOException {
        char[] characters = new char[contentLength];
        int read = reader.read(characters);
        if (read != contentLength) {
            throw new IOException("short character read");
        }
        return new String(characters);
    }

    public static void main(String[] args) {
        System.out.println("compile-only byte-character mismatch counterexample");
    }
}

HTTP/1.1에서 메시지 본문 프레이밍은 바이트 스트림에서 수행합니다. 최대 허용 크기를 Content-Length와 비교한 뒤 InputStream을 반복해서 읽습니다. EOF가 먼저 오면 400으로 처리할 잘린 요청입니다. 숫자가 음수거나 너무 크면 읽기 전에 거절해야 메모리 할당 공격을 줄일 수 있습니다. Transfer-Encoding을 지원하지 않는 작은 서버라면 해당 헤더를 조용히 무시하지 말고 명시적으로 거절합니다.

미디어 타입은 매개변수를 가질 수 있습니다. application/x-www-form-urlencoded; charset=UTF-8을 전체 문자열 동등성으로 비교하면 올바른 요청을 놓칩니다. 세미콜론 앞의 타입/하위 타입은 대소문자를 정규화하고, 문자 집합은 지원 목록과 대조합니다. 폼 인코딩의 표준 동작과 서버 정책이 다를 수 있으므로 지원 문자 집합을 한 곳에 선언합니다.

바이트 판독과 폼 디코딩

아래 예제는 헤더에서 길이와 Content-Type을 받은 뒤 본문을 정확히 읽습니다. 쌍은 첫 번째 =에서만 나누므로 값 안에 인코딩된 등호가 있어도 보존됩니다. 같은 키가 반복될 수 있어 Map<String, List<String>>를 사용합니다. URLDecoder는 폼 규칙에 따라 더하기 기호를 공백으로 바꾸고 퍼센트 순서를 바이트로 복원합니다.

src/FormRequestDecoder.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public final class FormRequestDecoder {
    static final int MAX_BODY_BYTES = 8 * 1024;

    record MediaType(String value, Charset charset) {
        static MediaType parse(String header) {
            if (header == null || header.isBlank()) {
                throw new BadRequest("Content-Type is required");
            }
            String[] parts = header.split(";");
            String value = parts[0].trim().toLowerCase(Locale.ROOT);
            Charset charset = StandardCharsets.UTF_8;
            for (int index = 1; index < parts.length; index += 1) {
                String parameter = parts[index].trim();
                int equals = parameter.indexOf('=');
                if (equals > 0 && parameter.substring(0, equals).trim().equalsIgnoreCase("charset")) {
                    String name = parameter.substring(equals + 1).trim().replace("\"", "");
                    charset = Charset.forName(name);
                }
            }
            return new MediaType(value, charset);
        }
    }

    static final class BadRequest extends RuntimeException {
        BadRequest(String message) {
            super(message);
        }

        BadRequest(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static byte[] readExactly(InputStream input, int contentLength) throws IOException {
        if (contentLength < 0 || contentLength > MAX_BODY_BYTES) {
            throw new BadRequest("body length outside 0.." + MAX_BODY_BYTES);
        }
        ByteArrayOutputStream output = new ByteArrayOutputStream(contentLength);
        byte[] buffer = new byte[Math.min(1024, Math.max(1, contentLength))];
        int remaining = contentLength;
        while (remaining > 0) {
            int read = input.read(buffer, 0, Math.min(buffer.length, remaining));
            if (read < 0) {
                throw new EOFException("body ended with " + remaining + " bytes remaining");
            }
            output.write(buffer, 0, read);
            remaining -= read;
        }
        return output.toByteArray();
    }

    static Map<String, List<String>> decode(
            InputStream input,
            int contentLength,
            String contentType) throws IOException {
        MediaType mediaType = MediaType.parse(contentType);
        if (!mediaType.value().equals("application/x-www-form-urlencoded")) {
            throw new BadRequest("unsupported media type: " + mediaType.value());
        }
        byte[] bytes = readExactly(input, contentLength);
        String body = new String(bytes, mediaType.charset());
        Map<String, List<String>> parameters = new LinkedHashMap<>();
        if (body.isEmpty()) {
            return Map.of();
        }
        for (String pair : body.split("&", -1)) {
            int equals = pair.indexOf('=');
            String encodedKey = equals < 0 ? pair : pair.substring(0, equals);
            String encodedValue = equals < 0 ? "" : pair.substring(equals + 1);
            try {
                String key = URLDecoder.decode(encodedKey, mediaType.charset());
                String value = URLDecoder.decode(encodedValue, mediaType.charset());
                parameters.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
            } catch (IllegalArgumentException exception) {
                throw new BadRequest("invalid percent encoding", exception);
            }
        }
        Map<String, List<String>> result = new LinkedHashMap<>();
        parameters.forEach((key, values) -> result.put(key, List.copyOf(values)));
        return Map.copyOf(result);
    }

    public static void main(String[] args) throws Exception {
        String wire = "id=min-1&name=%EC%A7%84&tag=java&tag=http&note=a%3Db+c";
        byte[] bytes = wire.getBytes(StandardCharsets.UTF_8);
        Map<String, List<String>> form = decode(
                new ByteArrayInputStream(bytes),
                bytes.length,
                "application/x-www-form-urlencoded; charset=UTF-8");
        System.out.println(form);
        System.out.println("wireBytes=" + bytes.length);
    }
}

ByteArrayOutputStream은 설명을 위해 사용했습니다. 길이를 이미 검증했다면 정확한 크기의 바이트 배열과 offset 반복문도 가능합니다. 핵심은 짧은 read를 정상 상황으로 취급해 remaining이 0이 될 때까지 반복하는 것입니다. 네트워크 InputStream은 패킷 경계에 따라 작은 조각을 반환할 수 있습니다.

디코더는 빈 키를 허용할지, 빈 쌍을 무시할지, 키 개수와 각 값 길이를 얼마로 제한할지 정책이 더 필요합니다. 본문 전체가 작아도 a=&a=&a=를 매우 많이 만들면 객체가 늘어납니다. 입력 제한은 바이트 수뿐 아니라 매개변수 개수, 이름 길이, 값 길이에도 적용합니다.

값의 출처 보존

원래 학습 구현처럼 두 입력을 같은 Map에 순서대로 넣으면 본문이 쿼리를 덮어쓸 수 있습니다. /members?id=query와 본문 id=form이 함께 왔을 때 어떤 값이 서비스로 전달됐는지 로그만으로 알기 어렵습니다. RequestParameters(query, form)처럼 분리해 저장하고 컨트롤러가 엔드포인트별 우선순위를 명시하는 편이 안전합니다.

검색 GET은 쿼리만 읽고, 등록 POST는 폼만 읽는 규칙을 둘 수 있습니다. 두 출처를 모두 허용해야 한다면 충돌을 거절하거나 우선순위를 문서화합니다. 다중 값을 단일 String으로 줄일 때도 첫 값, 마지막 값, 중복 거절 가운데 하나를 선택해야 합니다. 보안 검사기는 첫 값을 보고 업무 코드는 마지막 값을 보는 매개변수 오염을 막으려면 전체 계층에서 같은 선택 규칙을 써야 합니다.

폼 필드가 없다는 것과 빈 문자열을 보냈다는 것은 다를 수 있습니다. 체크박스나 선택적 입력은 브라우저 전송 규칙도 확인해야 합니다. 파서가 둘을 모두 null로 합치면 컨트롤러가 정확한 오류를 만들 수 없습니다. 원시 요청 모델은 정보 손실을 최소화하고, 유스케이스 명령을 만드는 바인딩 단계가 필요한 의미로 축소합니다.

구성 루트

구성 역할은 객체를 선택하고 수명과 연결 순서를 결정합니다. 사용 역할은 주입받은 계약을 호출해 기능을 수행합니다. 다음 예제에서 ApplicationAssembly만 저장소 구현을 압니다. 컨트롤러는 서비스 인터페이스에, 서비스는 저장소 인터페이스에 의존합니다. 예제 구성과 운영 구성을 바꿔도 처리기 코드는 수정되지 않습니다.

src/ApplicationAssembly.java
import java.util.ArrayList;
import java.util.List;

public final class ApplicationAssembly {
    record Member(String id, String name) {
    }

    interface MemberRepository {
        void save(Member member);

        List<Member> findAll();
    }

    static final class MemoryMemberRepository implements MemberRepository {
        private final List<Member> members = new ArrayList<>();

        @Override
        public void save(Member member) {
            members.add(member);
        }

        @Override
        public List<Member> findAll() {
            return List.copyOf(members);
        }
    }

    static final class LoggingMemberRepository implements MemberRepository {
        private final MemberRepository delegate;
        private final List<String> events = new ArrayList<>();

        LoggingMemberRepository(MemberRepository delegate) {
            this.delegate = delegate;
        }

        @Override
        public void save(Member member) {
            events.add("save:" + member.id());
            delegate.save(member);
        }

        @Override
        public List<Member> findAll() {
            events.add("findAll");
            return delegate.findAll();
        }

        List<String> events() {
            return List.copyOf(events);
        }
    }

    static final class MemberService {
        private final MemberRepository repository;

        MemberService(MemberRepository repository) {
            this.repository = repository;
        }

        void register(String id, String name) {
            repository.save(new Member(id, name));
        }

        List<Member> list() {
            return repository.findAll();
        }
    }

    static final class MemberController {
        private final MemberService service;

        MemberController(MemberService service) {
            this.service = service;
        }

        String create(String id, String name) {
            service.register(id, name);
            return "created:" + id;
        }

        String index() {
            return service.list().toString();
        }
    }

    record Application(MemberController controller, LoggingMemberRepository repository) {
    }

    static Application assemble() {
        MemoryMemberRepository storage = new MemoryMemberRepository();
        LoggingMemberRepository observed = new LoggingMemberRepository(storage);
        MemberService service = new MemberService(observed);
        MemberController controller = new MemberController(service);
        return new Application(controller, observed);
    }

    public static void main(String[] args) {
        Application application = assemble();
        System.out.println(application.controller().create("min-1", "Min"));
        System.out.println(application.controller().index());
        System.out.println(application.repository().events());
    }
}

구성 루트는 new를 금지하는 곳이 아니라 구체 구현 생성을 모으는 곳입니다. 요청마다 새 저장소를 만들면 인메모리 상태가 사라지고 연결 풀도 낭비됩니다. 반대로 요청별로 격리해야 하는 가변 문맥을 싱글턴에 보관하면 동시 요청이 섞입니다. 객체 수명은 인터페이스만으로 표현되지 않으므로 조립 코드가 책임지고 문서화해야 합니다.

구성 완료 뒤 라우트 표를 컴파일하고 HTTP 서버를 시작하는 순서도 중요합니다. 저장소 연결 확인, 컨트롤러 생성, 애노테이션 검증 가운데 하나라도 실패하면 포트를 열지 않습니다. 종료 때는 새 요청 수락을 멈추고 진행 중 작업을 기다린 뒤 저장소와 실행기를 역순으로 닫습니다. 작은 main 클래스라도 애플리케이션 생명 주기의 출발점입니다.

연습 문제

내용 타입을 키로 디코더를 고르는 레지스트리를 작성합니다. 폼과 text/plain만 지원하고 다른 형식은 415를 반환합니다. 본문이 32바이트를 넘으면 413, 형식 오류 폼은 400으로 구분합니다. 쿼리와 본문은 별도 Map으로 결과에 남깁니다.

해답 보기
src/MediaTypeDispatchSolution.java
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

public final class MediaTypeDispatchSolution {
    sealed interface DecodeResult permits Decoded, Rejected {
    }

    record Decoded(Map<String, String> query, Map<String, String> body) implements DecodeResult {
        Decoded {
            query = Map.copyOf(query);
            body = Map.copyOf(body);
        }
    }

    record Rejected(int status, String reason) implements DecodeResult {
    }

    interface BodyDecoder {
        Map<String, String> decode(byte[] body);
    }

    static final class FormDecoder implements BodyDecoder {
        @Override
        public Map<String, String> decode(byte[] body) {
            String text = new String(body, StandardCharsets.UTF_8);
            Map<String, String> values = new LinkedHashMap<>();
            if (text.isEmpty()) {
                return values;
            }
            for (String pair : text.split("&", -1)) {
                int equals = pair.indexOf('=');
                String key = equals < 0 ? pair : pair.substring(0, equals);
                String value = equals < 0 ? "" : pair.substring(equals + 1);
                String decodedKey = URLDecoder.decode(key, StandardCharsets.UTF_8);
                String decodedValue = URLDecoder.decode(value, StandardCharsets.UTF_8);
                if (values.putIfAbsent(decodedKey, decodedValue) != null) {
                    throw new IllegalArgumentException("duplicate form key: " + decodedKey);
                }
            }
            return values;
        }
    }

    static final class PlainTextDecoder implements BodyDecoder {
        @Override
        public Map<String, String> decode(byte[] body) {
            return Map.of("text", new String(body, StandardCharsets.UTF_8));
        }
    }

    static final class Dispatcher {
        private static final int MAX_BYTES = 32;
        private final Map<String, BodyDecoder> decoders;

        Dispatcher() {
            decoders = Map.of(
                    "application/x-www-form-urlencoded", new FormDecoder(),
                    "text/plain", new PlainTextDecoder());
        }

        DecodeResult decode(Map<String, String> query, String contentType, byte[] body) {
            if (body.length > MAX_BYTES) {
                return new Rejected(413, "body exceeds " + MAX_BYTES + " bytes");
            }
            String mediaType = contentType.split(";", 2)[0]
                    .trim()
                    .toLowerCase(Locale.ROOT);
            BodyDecoder decoder = decoders.get(mediaType);
            if (decoder == null) {
                return new Rejected(415, "unsupported media type: " + mediaType);
            }
            try {
                return new Decoded(query, decoder.decode(body));
            } catch (IllegalArgumentException exception) {
                return new Rejected(400, exception.getMessage());
            }
        }
    }

    public static void main(String[] args) {
        Dispatcher dispatcher = new Dispatcher();
        byte[] form = "name=%EC%A7%84&age=27".getBytes(StandardCharsets.UTF_8);
        System.out.println(dispatcher.decode(Map.of("source", "web"),
                "application/x-www-form-urlencoded; charset=UTF-8", form));
        System.out.println(dispatcher.decode(Map.of(), "application/json", new byte[] { 1, 2 }));
        System.out.println(dispatcher.decode(Map.of(), "text/plain", new byte[40]));
    }
}

해답은 레지스트리 조회 전에 크기를 제한하고, 미디어 타입 매개변수를 떼어 낸 뒤 디코더를 선택합니다. 쿼리와 본문을 끝까지 분리했으므로 컨트롤러가 어느 출처를 허용할지 명확히 결정할 수 있습니다. HTTP 스트림 단계에서는 선언한 Content-Length와 실제 바이트 수 일치 검사도 선행해야 합니다.