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

안동민 개발노트

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

서블릿 응답 출력

Servlet 응답의 상태·헤더·인코딩·본문 순서와 커밋 경계를 텍스트, HTML, JSON 예제로 검증하고 오류·이스케이프·스트림 소유권을 다룹니다.

Servlet 응답은 빈 출력 문서가 아니라 아직 커밋되지 않은 HTTP 응답입니다.

상태와 헤더를 먼저 정하고 본문 작성기나 출력 스트림으로 표현을 쓴 뒤 컨테이너가 전송합니다.

버퍼가 플러시되거나 가득 차면 헤더가 커밋되어 이후 변경이 늦을 수 있습니다.


응답 상태·인코딩 순서

평문 텍스트 응답은 다음 순서가 안전합니다.

src/main/java/board/servlet/PlainPostServlet.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 PlainPostServlet extends HttpServlet {
    private final PostQuery query;

    public PlainPostServlet(PostQuery query) {
        this.query = query;
    }

    @Override
    protected void doGet(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException {
        var post = query.required(
                Long.parseLong(
                        request.getParameter("id")));

        response.setStatus(
                HttpServletResponse.SC_OK);
        response.setCharacterEncoding(
                StandardCharsets.UTF_8.name());
        response.setContentType("text/plain");
        response.getWriter().write(
                post.title() + ":"
                        + post.contentLength());
    }
}

getWriter를 얻으면 현재 문자 인코딩이 작성기에 반영됩니다.

그 뒤 인코딩을 바꾸지 않습니다.

바이너리 파일은 getOutputStream을 사용하며 한 응답에서 작성기와 출력 스트림을 섞지 않습니다.

본문이 없으면 작성기를 열 필요가 없습니다.

204 응답과 304 응답에 실수로 JSON 오류 객체를 쓰지 않습니다.

HEAD 응답 본문도 클라이언트에 전송되지 않지만 GET과 일치하는 표현 메타데이터를 계산해야 합니다.


JSON 직렬화 책임

JSON 문자열을 직접 이어 붙이면 따옴표, 역슬래시, 줄바꿈을 이스케이프하지 못합니다.

src/main/java/board/servlet/JsonPostResponseServlet.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;
import java.nio.charset.StandardCharsets;

public final class JsonPostResponseServlet
        extends HttpServlet {
    private final PostQuery query;
    private final ObjectMapper objectMapper;

    public JsonPostResponseServlet(
            PostQuery query,
            ObjectMapper objectMapper) {
        this.query = query;
        this.objectMapper = objectMapper;
    }

    @Override
    protected void doGet(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException {
        var post = query.required(
                Long.parseLong(
                        request.getParameter("id")));
        var representation =
                PostResponse.from(post);

        response.setStatus(200);
        response.setCharacterEncoding(
                StandardCharsets.UTF_8.name());
        response.setContentType("application/json");
        objectMapper.writeValue(
                response.getOutputStream(),
                representation);
    }
}

도메인 엔티티를 바로 직렬화하면 내부 필드와 양방향 관계가 API에 새어 나갈 수 있습니다.

응답 DTO가 공개 스키마와 날짜·null 정책을 소유합니다.

ObjectMapper 설정도 애플리케이션 전체 계약이므로 Servlet마다 new ObjectMapper()로 서로 다른 모듈을 만들지 않고 Boot가 구성한 인스턴스를 주입합니다.


HTML 이스케이프

제목이 <script>...</script>인데 HTML에 그대로 넣으면 XSS가 됩니다.

피해야 할 HTML 연결
response.getWriter().write(
        "<h1>" + post.title() + "</h1>");

템플릿 엔진은 텍스트 노드와 속성 컨텍스트에 맞는 이스케이프를 제공합니다.

원시 Servlet 실험에서 HTML을 써야 한다면 검증된 인코더를 사용하지만 실제 뷰는 Thymeleaf 템플릿으로 옮깁니다.

URL, JavaScript, CSS 컨텍스트는 HTML 텍스트 이스케이프와 규칙이 다르므로 하나의 범용 치환 함수로 해결하지 않습니다.

Content-Security-Policy는 피해를 줄이는 추가 방어이며 출력 인코딩을 대체하지 않습니다.

사용자 입력을 HTML로 허용해야 한다면 허용 목록 정제기와 저장·표시 정책을 별도로 둡니다.


JSON 응답 규칙 검증

MockHttpServletResponse는 상태와 헤더, 본문 바이트를 분리해 검사할 수 있습니다.

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

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

import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

class JsonServletResponseTest {
    @Test
    void status_content_type_encoding_json을_함께_쓴다()
            throws Exception {
        var query = new FixedPostQuery(
                post(42L, "HTTP \\"response\\"", 50));
        var servlet = new JsonPostResponseServlet(
                query, new ObjectMapper());
        var request = new MockHttpServletRequest(
                "GET", "/raw/post");
        request.addParameter("id", "42");
        var response = new MockHttpServletResponse();

        servlet.service(request, response);

        assertThat(response.getStatus()).isEqualTo(200);
        assertThat(response.getContentType())
                .isEqualTo(
                        "application/json;charset=UTF-8");
        assertThat(response.getContentAsString())
                .contains(
                        "\\"title\\":\\"HTTP \\\\\\"response\\\\\\"\\"")
                .contains("\\"contentLength\\":50");
    }
}

응답 JSON 필드 순서를 문자열 전체로 고정하기보다 파서나 JSON 경로로 의미를 검증합니다.

contains는 이스케이프 예시에 집중한 검증입니다.

실행 결과
JsonServletResponseTest
  > status_content_type_encoding_json을_함께_쓴다() PASSED
status = 200
Content-Type = application/json;charset=UTF-8
body escaping = valid
BUILD SUCCESSFUL

응답 커밋 이후 제약

컨테이너는 응답 버퍼가 차거나 flushBuffer, 스트림 플러시, 요청 종료가 일어날 때 헤더와 상태를 커밋합니다.

commit을 너무 일찍 만드는 실패
response.setStatus(200);
response.setContentType("text/csv");
response.getWriter().println("id,title,contentLength");
response.flushBuffer();

// 이후 DB 조회가 실패
response.setStatus(500);
response.getWriter().println(
        "{\\"error\\":\\"database unavailable\\"}");

클라이언트는 이미 200과 CSV 헤더를 받았는데 뒤에 JSON 오류 조각이 붙을 수 있습니다.

스트리밍이 목적이 아니라면 실패 가능한 조회와 검증을 먼저 끝내고 표현을 완성한 뒤 응답에 씁니다.

큰 스트리밍 응답은 중간 실패를 상태로 되돌릴 수 없다는 계약을 받아들여야 합니다.

레코드별 체크섬, 이어받기 다운로드, 별도 작업 상태 리소스 같은 복구 방식을 둡니다.


sendError와 오류 응답

response.sendError(404, message)는 컨테이너 오류 디스패치를 일으키고 컨테이너가 HTML 오류 페이지를 만들 수 있습니다.

JSON API가 일관된 ProblemDetail을 요구한다면 예외를 중앙 리졸버에서 변환하거나 직접 미디어 타입과 본문을 작성합니다.

응답이 이미 커밋된 뒤 sendError를 호출하면 IllegalStateException이 나거나 기대와 다르게 처리됩니다.

필터와 Servlet이 누가 최종 오류 본문을 소유하는지 정합니다.

실패 시점응답 작성 주체
HTTP 문법 파싱컨테이너400·연결 종료
필터 인증필터/보안 체인401·403
인자·본문 변환프레임워크 리졸버400·415
업무 예외컨트롤러 어드바이스404·409
본문 커밋 이후복구 제한스트림 종료·트레이스 로그

연습 문제

같은 게시글을 text/plain, text/html, application/json으로 반환하는 세 Servlet을 작성하세요.

제목에 따옴표, 앰퍼샌드, 꺾쇠괄호, 한글을 넣고 각 표현의 인코딩과 이스케이프를 검증합니다.

이어 응답을 먼저 플러시한 뒤 상태를 바꾸는 실패를 관찰합니다.

해설 보기

텍스트는 UTF-8 작성기, JSON은 공유 ObjectMapper, HTML은 템플릿 또는 컨텍스트 인식 인코더를 사용합니다.

세 응답 모두 작성기나 출력 스트림을 얻기 전에 상태, 콘텐츠 타입, 인코딩을 정합니다.

response.setStatus(200);
response.setCharacterEncoding(
        StandardCharsets.UTF_8.name());
response.setContentType("text/html");
templateRenderer.render(
        "post-detail",
        Map.of("post", post),
        response.getWriter());

커밋 테스트에서는 response.isCommitted()을 플러시 전후로 확인합니다.

모의 동작과 실제 Tomcat 버퍼링이 완전히 같다고 단정하지 말고 임의 포트 테스트에서 헤더와 잘린 본문도 관찰합니다.

다음 문서에서는 Servlet이 HTML 마크업까지 직접 쓰고 JSP가 SQL·업무 규칙까지 실행할 때 입력·제어·표현 책임이 뒤섞이는 이유를 변경 비용으로 분석합니다.