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

안동민 개발노트

본문 시작
5장 : 서블릿 MVC 구조

서블릿 요청 입력

Servlet 요청의 파라미터 API와 본문 스트림을 구분해 쿼리·폼·JSON 입력을 읽고 인코딩, 일회성 본문, 프록시 정보, 크기 제한의 실패를 재현합니다.

쿼리 문자열, HTML 폼, JSON은 모두 클라이언트 입력이지만 Servlet API에서 같은 방식으로 읽히지 않습니다.

쿼리와 URL 인코딩 폼은 컨테이너가 파라미터 맵으로 해석할 수 있고 JSON은 본문 스트림을 애플리케이션이 파서로 읽어야 합니다.

getParameter()null이라고 JSON 필드가 없다는 뜻은 아닙니다.


쿼리·폼 파라미터

request.getParameter("title")는 쿼리 파라미터와 application/x-www-form-urlencoded 폼 파라미터를 조회합니다.

같은 이름이 여러 번 오면 getParameterValuesgetParameterMap으로 전체 값을 확인합니다.

src/main/java/board/servlet/ParameterPostServlet.java
package board.servlet;

import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public final class ParameterPostServlet
        extends HttpServlet {
    @Override
    protected void doGet(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException {
        writeInput(request, response);
    }

    @Override
    protected void doPost(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException {
        writeInput(request, response);
    }

    private void writeInput(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException {
        var title = request.getParameter("title");
        var content = request.getParameter("content");
        if (title == null || content == null) {
            response.sendError(400, "missing parameter");
            return;
        }
        response.setCharacterEncoding(
                StandardCharsets.UTF_8.name());
        response.setContentType("text/plain");
        response.getWriter().write(
                title + ":" + content);
    }
}

GET /raw/post?title=HTTP&content=요약과 폼 본문 title=HTTP&content=본문이 같은 파라미터 API로 보일 수 있지만 URI 노출과 캐시·북마크 의미가 다릅니다.

조회 조건은 쿼리에, 상태 변경 폼은 POST 본문에 둡니다.

파라미터 이름이 쿼리와 폼 양쪽에 중복되면 컨테이너 결합 순서에 의존하지 않습니다.

입력 소스를 분리해야 하는 계약이면 명시적으로 쿼리 문자열과 본문을 다루거나 엔드포인트를 다시 설계합니다.


JSON 본문 입력 스트림

application/json 본문은 컨테이너가 키-값 파라미터로 자동 변환하지 않습니다.

본문 바이트를 JSON 파서에 전달합니다.

src/main/java/board/servlet/JsonPostServlet.java
package board.servlet;

import tools.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public final class JsonPostServlet extends HttpServlet {
    private final ObjectMapper objectMapper;
    private final PostService service;

    public JsonPostServlet(
            ObjectMapper objectMapper,
            PostService service) {
        this.objectMapper = objectMapper;
        this.service = service;
    }

    @Override
    protected void doPost(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException {
        if (!"application/json".equals(
                request.getContentType())) {
            response.sendError(415);
            return;
        }
        CreatePostRequest input;
        try {
            input = objectMapper.readValue(
                    request.getInputStream(),
                    CreatePostRequest.class);
        } catch (IOException malformedBody) {
            response.sendError(400, "invalid JSON");
            return;
        }
        var saved = service.register(input.toCommand());
        response.setStatus(201);
        response.setHeader(
                "Location",
                "/api/posts/" + saved.id());
    }
}

Content-Type은 파라미터를 포함할 수 있으므로 운영 코드에서는 문자열을 그대로 비교하지 말고 파싱된 미디어 타입을 사용합니다.

application/json;charset=UTF-8application/problem+json 지원 범위를 정책으로 정합니다.

Spring MVC의 메시지 컨버터가 이 판정과 파서 호출을 대신합니다.


파라미터·JSON 요청 비교

MockHttpServletRequest에 컨테이너가 해석한 파라미터와 원시 JSON 본문을 각각 넣어 Servlet API의 관찰 결과를 확인합니다.

src/test/java/board/servlet/RequestInputTest.java
package board.servlet;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;

class RequestInputTest {
    @Test
    void query와_form은_parameter_map으로_읽는다() {
        var request = new MockHttpServletRequest();
        request.addParameter("title", "HTTP");
        request.addParameter("tag", "network", "spring");

        assertThat(request.getParameter("title"))
                .isEqualTo("HTTP");
        assertThat(request.getParameterValues("tag"))
                .containsExactly("network", "spring");
    }

    @Test
    void JSON은_parameter가_아니라_body_stream이다()
            throws Exception {
        var request = new MockHttpServletRequest();
        request.setContentType("application/json");
        request.setContent(
                "{\\"title\\":\\"HTTP\\",\\"content\\":\\"HTTP 본문\\"}"
                        .getBytes(UTF_8));

        assertThat(request.getParameter("title")).isNull();
        assertThat(request.getContentLength())
                .isEqualTo(31);
        assertThat(new String(
                request.getInputStream().readAllBytes(),
                UTF_8))
                .isEqualTo(
                        "{\\"title\\":\\"HTTP\\",\\"content\\":\\"HTTP 본문\\"}");
    }
}

모의에 addParameter를 한 것은 실제 컨테이너의 쿼리·폼 파싱 결과를 미리 채운 것입니다.

퍼센트 인코딩과 잘못된 형식의 폼까지 검증하려면 임의 포트의 실제 Tomcat 테스트를 추가합니다.

단위 테스트와 컨테이너 파서 테스트의 보장 범위를 구분합니다.

실행 결과
RequestInputTest
  > query와_form은_parameter_map으로_읽는다() PASSED
RequestInputTest
  > JSON은_parameter가_아니라_body_stream이다() PASSED
BUILD SUCCESSFUL

일회성 요청 본문

로깅 필터가 getInputStream().readAllBytes()로 본문을 먼저 소비하면 Servlet이나 컨버터는 빈 스트림을 볼 수 있습니다.

본문 로깅이 필요하면 캐싱 래퍼를 필터 체인 전에 적용하고, 읽을 바이트 상한·콘텐츠 타입·민감 필드 마스킹을 둡니다.

큰 업로드를 무조건 메모리에 복사하면 요청 몇 개로 힙을 소진할 수 있습니다.

멀티파트는 파일 크기, 요청 크기, 임시 저장소, 스트리밍 정책을 별도로 정합니다.

JSON도 프록시와 컨테이너의 최대 본문 크기를 일관되게 제한합니다.

getReadergetInputStream을 같은 요청에서 섞어 호출하지 않습니다.

문자 디코딩이 필요하면 콘텐츠 타입의 문자 집합과 프레임워크 정책에 따라 리더 또는 파서를 하나 고릅니다.


문자 인코딩 시점

URL 인코딩과 본문 문자 인코딩은 적용 위치가 다릅니다.

폼 본문을 읽기 전에 요청 문자 인코딩이 정해져야 합니다.

이미 파라미터 파싱을 시작한 뒤 인코딩을 바꾸면 늦을 수 있습니다.

Spring Boot와 현대 브라우저는 UTF-8 중심으로 동작하지만 레거시 클라이언트와 프록시가 다른 문자 집합을 선언할 수 있습니다.

서버가 지원하지 않는 문자 집합을 조용히 플랫폼 기본값으로 읽지 않고 415 또는 400으로 거부하는 정책이 안전합니다.

응답 인코딩도 작성기를 얻기 전에 설정합니다.

setContentType("text/plain;charset=UTF-8") 또는 setCharacterEncoding("UTF-8") 순서를 지킵니다.


요청 메타데이터 신뢰

getRemoteAddr는 직접 연결 피어 주소이며 리버스 프록시 뒤에서는 프록시 주소일 수 있습니다.

X-Forwarded-For를 클라이언트가 직접 보낼 수도 있으므로 신뢰 가능한 프록시가 지우고 다시 쓴 헤더만 해석합니다.

요청률 제한과 보안 감사를 원시 사용자 헤더에 맡기지 않습니다.

다음 메타데이터를 트레이스에 유용하게 쓸 수 있습니다.

  • 메서드, 요청 URI, 쿼리 존재 여부
  • 콘텐츠 타입과 콘텐츠 길이
  • 요청 ID·트레이스 ID
  • 인증된 인증 주체 ID
  • 핸들러와 최종 상태

비밀번호, 인가 헤더, 원시 쿠키, 전체 JSON 본문은 기본 로그에서 제외하거나 마스킹합니다.


연습 문제

하나의 원시 Servlet에 GET 쿼리, 폼 POST, JSON POST 세 입력 경로를 구현하세요.

같은 titlecontent가 들어와도 소스별 파서가 올바르게 선택돼야 하며, JSON을 getParameter로 읽는 실패와 본문 로깅 필터가 스트림을 먼저 소비하는 실패를 각각 재현합니다.

해설 보기

메서드와 파싱된 미디어 타입을 기준으로 입력 어댑터를 나눕니다.

JSON 파서 결과와 폼 파라미터 결과를 공통 CreatePostCommand로 바꾼 뒤 서비스를 호출합니다.

로깅 필터는 ContentCachingRequestWrapper 같은 래퍼를 사용하되 체인 이후 실제 소비된 본문을 제한 길이만 읽는 방식을 검토합니다.

래퍼를 만들었다고 모든 본문이 자동으로 무제한 안전 저장되는 것은 아닙니다.

var wrapped = new ContentCachingRequestWrapper(
        request, 8 * 1024);
filterChain.doFilter(wrapped, response);
var captured = wrapped.getContentAsByteArray();

테스트에는 8KB를 넘는 본문, 비밀번호 필드, 지원하지 않는 콘텐츠 타입을 포함해 로깅과 응답이 모두 정책대로인지 확인합니다.

다음 문서에서는 요청을 읽은 뒤 HttpServletResponse에 상태와 헤더를 커밋하기 전에 텍스트·HTML·JSON 본문을 쓰는 순서와 실패를 다룹니다.