본문으로 건너뛰기

안동민 개발노트

본문 시작

서블릿 응답 출력

Servlet 응답의 표현 메타데이터, 출력 API 소유권, 버퍼·커밋 경계와 오류·캐시 래퍼 동작을 실제 embedded Tomcat으로 검증합니다.

Servlet 응답 출력은 문자열을 소켓에 적는 일이 아니라 상태 코드, 표현 메타데이터, 본문 바이트를 하나의 HTTP 응답으로 확정하는 일입니다. 실패 가능한 조회와 직렬화를 먼저 끝내고, 아직 커밋되지 않은 응답에 상태와 헤더를 설정한 다음, 정확히 하나의 출력 API로 본문을 씁니다.

Servlet 응답이 커밋되기 전에 status, Content-Type, charset을 정하고 content 여부를 나눕니다. HEAD, 204, 304에는 content가 없지만 204에는 Content-Length가 금지되고, HEAD와 304에는 대응 GET 또는 200 응답의 실제 octet 수와 정확히 일치할 때만 Content-Length를 보낼 수 있습니다. 본문이 있으면 text/plain은 UTF-8 writer, application/json은 응답 DTO와 주입된 ObjectMapper, text/html은 context-aware template engine을 선택합니다. 본문 응답은 writer 또는 output stream 하나만 사용하며 full buffer, flush, request end에서 commit되어 status와 headers가 잠깁니다.

UNCOMMITTED → CONTRACT → OUTPUT API → COMMIT

응답 계약을 먼저 고정하고 표현별 출력 경로 하나만 연다

status·Content-Type·charset은 출력 API보다 먼저 정합니다. HEAD·204·304에는 content가 없지만 Content-Length 규칙은 서로 다릅니다. 본문 응답은 text·JSON·HTML에 맞는 getWriter() 또는 getOutputStream() 하나만 사용합니다.

Servlet 응답 계약과 표현별 단일 출력 흐름 커밋되지 않은 Servlet 응답에서 status, Content-Type, charset을 먼저 정하고 content 여부를 나눕니다. HEAD·204·304의 content와 Content-Length 규칙을 구분하고, 본문 응답은 text/plain, application/json, text/html에 맞는 출력 도구를 선택합니다. 모든 경로는 하나의 commit 경계로 합류합니다. NO WIRE BODY text/plain JSON text/html UNCOMMITTED RESPONSE metadata와 buffer 변경 가능 REPRESENTATION CONTRACT status · Content-Type · charset writer / output stream보다 먼저 WIRE BODY? method · status 규칙 NO CONTENT 204 · Content-Length 금지 HEAD / 304 · 정확한 대응 길이만 REPRESENTATION? text · JSON · HTML TEXT/PLAIN UTF-8 · getWriter() charset 먼저 · 문자열 기록 APPLICATION/JSON Response DTO + ObjectMapper 주입 인스턴스 · getOutputStream() TEXT/HTML Template engine context-aware escaping · CSP는 보조 WRITER XOR OUTPUT STREAM COMMIT BOUNDARY full buffer · flush · request end

01 · REPRESENTATION CONTRACT

status·Content-Type·charset을 출력 API보다 먼저 확정한다

getWriter()를 얻는 순간 현재 문자 인코딩이 writer에 반영됩니다. metadata를 먼저 정하고 한 응답에서 getWriter()getOutputStream()을 섞지 않습니다.

02 · NO CONTENT

HEAD·204·304는 content가 없지만 Content-Length 규칙은 다르다

204에는 Content-Length가 금지됩니다. HEAD에는 대응 GET이 보냈을 octet 수, 304에는 같은 요청의 무조건 200 응답이 보냈을 selected representation octet 수와 정확히 일치할 때만 허용됩니다. 세 응답 모두 content bytes를 전송하지 않습니다.

03 · REPRESENTATION-SPECIFIC OUTPUT

text·JSON·HTML은 같은 문자열 연결 방식으로 만들지 않는다

text/plain은 UTF-8 writer, JSON은 response DTO와 주입된 ObjectMapper, HTML은 template engine의 context-aware escaping을 사용합니다.

04 · COMMIT BOUNDARY

출력 API 하나로 쓴 뒤 status와 headers가 잠긴다

full buffer, flush, request end가 commit을 만들 수 있습니다. commit 뒤에는 status와 headers를 되돌릴 수 없습니다.

이 도표는 정상 representation 작성과 commit 진입까지만 다룹니다. 실패 가능한 조회·검증의 선행, commit 이후 복구, sendError와 오류 본문 소유권은 다음 canonical diagram에서 다룹니다.


응답을 하나의 표현 계약으로 봅니다

응답에는 세 층이 있습니다.

  1. 의미: 성공·실패를 나타내는 상태 코드
  2. 표현 메타데이터: Content-Type, 문자 인코딩, Content-Length, 캐시 검증자 같은 헤더
  3. 표현 바이트: 텍스트, HTML, JSON, 파일 등 실제 본문

문자 응답은 setCharacterEncodingsetContentTypegetWriter()보다 먼저 호출합니다. 명시하지 않았을 때 Servlet 응답의 기본 문자 인코딩은 ISO-8859-1이며, 작성기를 얻은 뒤 바꾼 인코딩은 이미 만들어진 작성기에 소급 적용되지 않습니다. 반대로 바이트 표현은 getOutputStream()으로 씁니다. 한 응답에서 작성기와 출력 스트림을 함께 얻으면 IllegalStateException이며, 전체 reset()을 성공적으로 수행한 경우에만 선택을 다시 할 수 있습니다. resetBuffer()는 본문 버퍼만 비우므로 선택한 출력 API와 상태·헤더는 유지합니다.

표현출력 소유자먼저 확정할 메타데이터주의점
text/plaingetWriter()미디어 타입·문자 인코딩문자 수와 UTF-8 바이트 수는 다를 수 있음
text/html템플릿 또는 컨텍스트 인식 인코더text/html;charset=UTF-8텍스트·속성·URL·JavaScript 컨텍스트의 이스케이프 규칙이 서로 다름
application/json공유 JSON 직렬화기application/jsonRFC 8259의 JSON은 네트워크에서 UTF-8이며 이 미디어 타입에는 선택적 charset 매개변수가 정의되지 않음
바이너리getOutputStream()정확한 미디어 타입·가능하면 바이트 길이문자 작성기를 거치지 않음

JSON은 문자열 연결로 만들지 않습니다. 응답 DTO가 공개 필드와 null·날짜 정책을 소유하고, Spring Boot가 구성한 Jackson 3 JsonMapper를 주입받아 같은 모듈과 설정을 공유합니다. 크기가 제한된 일반 JSON 응답이라면 먼저 writeValueAsBytes로 직렬화를 끝낸 뒤 상태와 길이를 확정해 쓰면, 직렬화 중간 실패가 부분 응답으로 커밋되는 위험도 줄일 수 있습니다. 큰 스트리밍 JSON은 같은 방식으로 전부 메모리에 올리지 말고, 중간 실패를 HTTP 상태로 되돌릴 수 없다는 별도 스트리밍 계약과 크기 제한을 설계해야 합니다.

HTML에서는 사용자 값을 마크업에 그대로 연결하지 않습니다. 실제 화면은 기본 이스케이프가 있는 Thymeleaf 같은 템플릿으로 만들고, 원시 Servlet 실험의 HTML 텍스트 노드에는 HtmlUtils.htmlEscape처럼 검증된 인코더를 사용할 수 있습니다. HTML 텍스트 이스케이프는 URL·CSS·JavaScript 문맥의 인코딩이나 신뢰하지 않는 HTML의 정제를 대신하지 않습니다.

실제 컨테이너에서 계약을 검증합니다

아래 테스트 파일은 조각 모음이 아닙니다. Spring Boot 4.1.1이 구성한 Jackson 3 JsonMapper, embedded Tomcat 11.0.24, Servlet 6.1, JUnit 6.0.3과 JDK loopback HTTP 클라이언트로 응답의 실제 wire 상태를 검증하는 독립 컴파일 단위입니다.

src/test/java/board/servlet/EmbeddedServletResponseOutputTest.java
package board.servlet;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.server.servlet.context
        .ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.HtmlUtils;
import tools.jackson.databind.json.JsonMapper;
class EmbeddedServletResponseOutputTest {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .followRedirects(HttpClient.Redirect.NEVER)
            .version(HttpClient.Version.HTTP_1_1)
            .build();
    private static final String TEXT_BODY = "응답 UTF-8: 세 글자";
    private static final String JSON_TITLE = "HTTP \"응답\"\n\\끝";
    private static final byte[] CACHED_BODY =
            "cache-body:응답".getBytes(UTF_8);
    @Test
    void 인코딩은_writer보다_먼저_정하고_길이는_문자수가_아닌_바이트수다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var utf8 = send(server, "GET", "/raw/response/text");
            var late = send(server, "GET", "/raw/response/late-charset");
            assertAll(
                    () -> assertEquals(200, utf8.statusCode()),
                    () -> assertArrayEquals(
                            TEXT_BODY.getBytes(UTF_8), utf8.body()),
                    () -> assertEquals(
                            UTF_8,
                            mediaType(utf8).getCharset()),
                    () -> assertEquals(
                            TEXT_BODY.getBytes(UTF_8).length,
                            contentLength(utf8)),
                    () -> assertNotEquals(
                            TEXT_BODY.length(), contentLength(utf8)),
                    () -> assertEquals("metadata-before-writer", header(
                            utf8, "X-Phase")),
                    () -> assertEquals(ISO_8859_1, mediaType(late).getCharset()),
                    () -> assertArrayEquals(
                            "café".getBytes(ISO_8859_1), late.body()));
        }
    }
    @Test
    void writer와_outputStream은_상호_배타적이고_reset만_선택을_되돌린다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var writerFirst = send(
                    server, "GET", "/raw/response/writer-first");
            var streamFirst = send(
                    server, "GET", "/raw/response/stream-first");
            var resetSwitch = send(
                    server, "GET", "/raw/response/reset-switch");
            assertAll(
                    () -> assertEquals(
                            "writer->stream:IllegalStateException",
                            text(writerFirst, UTF_8)),
                    () -> assertEquals(
                            "stream->writer:IllegalStateException",
                            text(streamFirst, UTF_8)),
                    () -> assertEquals(200, resetSwitch.statusCode()),
                    () -> assertEquals(
                            "kept:응답", text(resetSwitch, UTF_8)),
                    () -> assertFalse(
                            text(resetSwitch, UTF_8).contains("discarded")));
        }
    }
    @Test
    void resetBuffer와_flushBuffer는_커밋_경계를_구분한다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var response = send(server, "GET", "/raw/response/buffer");
            var observation = server.probe().buffer.get();
            assertAll(
                    () -> assertEquals(200, response.statusCode()),
                    () -> assertEquals("kept", text(response, UTF_8)),
                    () -> assertFalse(observation.committedBeforeFlush()),
                    () -> assertTrue(observation.committedAfterFlush()),
                    () -> assertTrue(observation.resizeAfterWriteRejected()),
                    () -> assertTrue(observation.resetAfterCommitRejected()),
                    () -> assertTrue(observation.errorAfterCommitRejected()),
                    () -> assertEquals(null, response.headers()
                            .firstValue("X-Too-Late").orElse(null)));
        }
    }
    @Test
    void sendError는_커밋_전_버퍼를_비우지만_커밋_뒤에는_오류를_대체하지_못한다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var delegated = send(
                    server, "GET", "/raw/response/send-error");
            var explicit = send(
                    server, "GET", "/raw/response/set-status-error");
            var committed = send(
                    server, "GET", "/raw/response/committed-error");
            assertAll(
                    () -> assertEquals(409, delegated.statusCode()),
                    () -> assertFalse(text(delegated, UTF_8)
                            .contains("STALE-BODY")),
                    () -> assertEquals(409, explicit.statusCode()),
                    () -> assertEquals(
                            "application/problem+json",
                            mediaType(explicit).toString()),
                    () -> assertEquals(
                            "{\"status\":409}", text(explicit, UTF_8)),
                    () -> assertEquals(200, committed.statusCode()),
                    () -> assertEquals("partial", text(committed, UTF_8)),
                    () -> assertTrue(
                            server.probe().committedSendErrorRejected.get()));
        }
    }
    @Test
    void Boot_JsonMapper와_HTML_텍스트_인코더가_표현을_소유한다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var json = send(server, "GET", "/raw/response/json");
            var html = send(server, "GET", "/raw/response/html");
            var tree = server.jsonMapper().readTree(json.body());
            assertAll(
                    () -> assertSame(
                            server.jsonMapper(), server.probe().jsonMapper.get()),
                    () -> assertEquals(200, json.statusCode()),
                    () -> assertEquals(
                            "application/json", mediaType(json).toString()),
                    () -> assertTrue(mediaType(json).getParameters().isEmpty()),
                    () -> assertEquals(JSON_TITLE,
                            tree.get("title").asString()),
                    () -> assertEquals(42, tree.get("id").asInt()),
                    () -> assertFalse(tree.has("internalSecret")),
                    () -> assertEquals(
                            json.body().length, contentLength(json)),
                    () -> assertEquals(200, html.statusCode()),
                    () -> assertTrue(text(html, UTF_8)
                            .contains("<script>")),
                    () -> assertTrue(text(html, UTF_8).contains("&응답")),
                    () -> assertFalse(text(html, UTF_8).contains("<script>")));
        }
    }
    @Test
    void HEAD는_대응_표현_길이를_보내고_204와_304는_wire_본문이_없다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var get = send(server, "GET", "/raw/response/resource");
            var head = send(server, "HEAD", "/raw/response/resource");
            var noContent = send(
                    server, "GET", "/raw/response/no-content");
            var notModified = send(
                    server, "GET", "/raw/response/not-modified");
            assertAll(
                    () -> assertEquals(200, get.statusCode()),
                    () -> assertTrue(get.body().length > 0),
                    () -> assertEquals(get.body().length, contentLength(get)),
                    () -> assertEquals(200, head.statusCode()),
                    () -> assertEquals(0, head.body().length),
                    () -> assertEquals(get.body().length, contentLength(head)),
                    () -> assertEquals(204, noContent.statusCode()),
                    () -> assertEquals(0, noContent.body().length),
                    () -> assertFalse(noContent.headers()
                            .firstValue("Content-Length").isPresent()),
                    () -> assertEquals(304, notModified.statusCode()),
                    () -> assertEquals(0, notModified.body().length),
                    () -> assertOptionalContentLengthEquals(
                            notModified, get.body().length),
                    () -> assertEquals("\"post-42-v1\"", header(
                            notModified, "ETag")));
        }
    }
    @Test
    void ContentCachingResponseWrapper는_복사하기_전까지_수동_캐시다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var response = send(server, "GET", "/raw/response/cached");
            var observation = server.probe().cache.get();
            assertAll(
                    () -> assertEquals(200, response.statusCode()),
                    () -> assertArrayEquals(CACHED_BODY, response.body()),
                    () -> assertFalse(observation.committedAfterWrapperFlush()),
                    () -> assertArrayEquals(
                            CACHED_BODY, observation.cachedBeforeCopy()),
                    () -> assertArrayEquals(
                            observation.cachedBeforeCopy(), response.body()));
        }
    }
    private static HttpResponse<byte[]> send(
            RunningServer server,
            String method,
            String path)
            throws IOException, InterruptedException {
        var request = HttpRequest.newBuilder(
                        URI.create(server.origin() + path))
                .timeout(Duration.ofSeconds(5))
                .method(method, HttpRequest.BodyPublishers.noBody())
                .build();
        return CLIENT.send(
                request, HttpResponse.BodyHandlers.ofByteArray());
    }
    private static String text(
            HttpResponse<byte[]> response,
            java.nio.charset.Charset charset) {
        return new String(response.body(), charset);
    }
    private static String header(
            HttpResponse<byte[]> response,
            String name) {
        return response.headers().firstValue(name).orElseThrow();
    }
    private static long contentLength(HttpResponse<byte[]> response) {
        return Long.parseLong(header(response, "Content-Length"));
    }
    private static void assertOptionalContentLengthEquals(
            HttpResponse<byte[]> response,
            long expected) {
        response.headers()
                .firstValue("Content-Length")
                .ifPresent(value -> assertEquals(
                        expected, Long.parseLong(value)));
    }
    private static MediaType mediaType(HttpResponse<byte[]> response) {
        return MediaType.parseMediaType(header(response, "Content-Type"));
    }
    private record RunningServer(
            ServletWebServerApplicationContext context,
            String origin,
            ResponseProbe probe,
            JsonMapper jsonMapper)
            implements AutoCloseable {
        static RunningServer start() {
            var probe = new ResponseProbe();
            var application = new SpringApplication(TestApplication.class);
            application.setWebApplicationType(WebApplicationType.SERVLET);
            application.setRegisterShutdownHook(false);
            application.setDefaultProperties(Map.of(
                    "server.address", "127.0.0.1",
                    "server.port", "0",
                    "spring.main.banner-mode", "off",
                    "logging.level.root", "OFF"));
            application.addInitializers(context -> context
                    .getBeanFactory()
                    .registerSingleton("responseProbe", probe));
            var context = (ServletWebServerApplicationContext)
                    application.run();
            return new RunningServer(
                    context,
                    "http://127.0.0.1:"
                            + context.getWebServer().getPort(),
                    probe,
                    context.getBean(JsonMapper.class));
        }
        @Override
        public void close() {
            context.close();
        }
    }
    @SpringBootConfiguration(proxyBeanMethods = false)
    @EnableAutoConfiguration
    static class TestApplication {
        @Bean
        ServletRegistrationBean<ResponseOutputServlet> responseOutputServlet(
                JsonMapper jsonMapper,
                ResponseProbe probe) {
            var registration = new ServletRegistrationBean<>(
                    new ResponseOutputServlet(jsonMapper, probe),
                    "/raw/response/*");
            registration.setName("responseOutputServlet");
            return registration;
        }
        @Bean
        FilterRegistrationBean<CopyingCacheFilter> copyingCacheFilter(
                ResponseProbe probe) {
            var registration = new FilterRegistrationBean<>(
                    new CopyingCacheFilter(probe));
            registration.setName("copyingCacheFilter");
            registration.addUrlPatterns("/raw/response/cached");
            registration.setOrder(1);
            return registration;
        }
    }
    static final class ResponseOutputServlet extends HttpServlet {
        private static final byte[] RESOURCE_BODY =
                "resource:응답".getBytes(UTF_8);
        private static final String HTML_INPUT =
                "<script>alert(\"x\")</script>&응답";
        private final JsonMapper jsonMapper;
        private final ResponseProbe probe;
        ResponseOutputServlet(JsonMapper jsonMapper, ResponseProbe probe) {
            this.jsonMapper = jsonMapper;
            this.probe = probe;
            probe.jsonMapper.set(jsonMapper);
        }
        @Override
        protected void doGet(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            switch (request.getRequestURI()) {
                case "/raw/response/text" -> writeText(response);
                case "/raw/response/late-charset" -> lateCharset(response);
                case "/raw/response/writer-first" -> writerFirst(response);
                case "/raw/response/stream-first" -> streamFirst(response);
                case "/raw/response/reset-switch" -> resetSwitch(response);
                case "/raw/response/buffer" -> buffer(response);
                case "/raw/response/send-error" -> sendError(response);
                case "/raw/response/set-status-error" ->
                        setStatusError(response);
                case "/raw/response/committed-error" ->
                        committedError(response);
                case "/raw/response/json" -> writeJson(response);
                case "/raw/response/html" -> writeHtml(response);
                case "/raw/response/resource" -> resource(response);
                case "/raw/response/no-content" -> noContent(response);
                case "/raw/response/not-modified" -> notModified(response);
                case "/raw/response/cached" -> cached(response);
                default -> response.sendError(404);
            }
        }
        private static void writeText(HttpServletResponse response)
                throws IOException {
            var bytes = TEXT_BODY.getBytes(UTF_8);
            response.setStatus(HttpServletResponse.SC_OK);
            response.setCharacterEncoding(UTF_8.name());
            response.setContentType("text/plain");
            response.setContentLength(bytes.length);
            response.setHeader("X-Phase", "metadata-before-writer");
            response.getWriter().write(TEXT_BODY);
        }
        private static void lateCharset(HttpServletResponse response)
                throws IOException {
            response.setContentType("text/plain");
            var writer = response.getWriter();
            response.setCharacterEncoding(UTF_8.name());
            writer.write("café");
        }
        private static void writerFirst(HttpServletResponse response)
                throws IOException {
            prepareUtf8Text(response);
            var writer = response.getWriter();
            try {
                response.getOutputStream();
                writer.write("unexpected");
            } catch (IllegalStateException expected) {
                writer.write("writer->stream:IllegalStateException");
            }
        }
        private static void streamFirst(HttpServletResponse response)
                throws IOException {
            prepareUtf8Text(response);
            var output = response.getOutputStream();
            try {
                response.getWriter();
                output.write("unexpected".getBytes(UTF_8));
            } catch (IllegalStateException expected) {
                output.write(
                        "stream->writer:IllegalStateException".getBytes(UTF_8));
            }
        }
        private static void resetSwitch(HttpServletResponse response)
                throws IOException {
            prepareUtf8Text(response);
            response.getWriter().write("discarded");
            response.reset();
            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType("text/plain;charset=UTF-8");
            response.getOutputStream().write("kept:응답".getBytes(UTF_8));
        }
        private void buffer(HttpServletResponse response) throws IOException {
            response.setBufferSize(64);
            prepareUtf8Text(response);
            var writer = response.getWriter();
            writer.write("discarded");
            var resizeRejected = false;
            try {
                response.setBufferSize(128);
            } catch (IllegalStateException expected) {
                resizeRejected = true;
            }
            var beforeFlush = response.isCommitted();
            response.resetBuffer();
            writer.write("kept");
            response.flushBuffer();
            var afterFlush = response.isCommitted();
            response.setStatus(HttpServletResponse.SC_CREATED);
            response.setHeader("X-Too-Late", "ignored");
            var resetRejected = false;
            try {
                response.resetBuffer();
            } catch (IllegalStateException expected) {
                resetRejected = true;
            }
            var errorRejected = false;
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            } catch (IllegalStateException expected) {
                errorRejected = true;
            }
            probe.buffer.set(new BufferObservation(
                    beforeFlush,
                    afterFlush,
                    resizeRejected,
                    resetRejected,
                    errorRejected));
        }
        private static void sendError(HttpServletResponse response)
                throws IOException {
            response.getOutputStream().write("STALE-BODY".getBytes(UTF_8));
            response.sendError(HttpServletResponse.SC_CONFLICT, "conflict");
        }
        private static void setStatusError(HttpServletResponse response)
                throws IOException {
            var body = "{\"status\":409}".getBytes(UTF_8);
            response.setStatus(HttpServletResponse.SC_CONFLICT);
            response.setContentType("application/problem+json");
            response.setContentLength(body.length);
            response.getOutputStream().write(body);
        }
        private void committedError(HttpServletResponse response)
                throws IOException {
            response.setStatus(HttpServletResponse.SC_OK);
            response.getOutputStream().write("partial".getBytes(UTF_8));
            response.flushBuffer();
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                probe.committedSendErrorRejected.set(false);
            } catch (IllegalStateException expected) {
                probe.committedSendErrorRejected.set(true);
            }
        }
        private void writeJson(HttpServletResponse response)
                throws IOException {
            var post = new DomainPost(42, JSON_TITLE, "server-only");
            var body = jsonMapper.writeValueAsBytes(PostResponse.from(post));
            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType("application/json");
            response.setContentLength(body.length);
            response.getOutputStream().write(body);
        }
        private static void writeHtml(HttpServletResponse response)
                throws IOException {
            var body = ("<!doctype html><h1>"
                            + HtmlUtils.htmlEscape(HTML_INPUT)
                            + "</h1>")
                    .getBytes(UTF_8);
            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType("text/html;charset=UTF-8");
            response.setContentLength(body.length);
            response.getOutputStream().write(body);
        }
        private static void resource(HttpServletResponse response)
                throws IOException {
            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType("text/plain;charset=UTF-8");
            response.setContentLength(RESOURCE_BODY.length);
            response.setHeader("ETag", "\"post-42-v1\"");
            response.getOutputStream().write(RESOURCE_BODY);
        }
        private static void noContent(HttpServletResponse response) {
            response.setStatus(HttpServletResponse.SC_NO_CONTENT);
        }
        private static void notModified(HttpServletResponse response) {
            response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
            response.setHeader("ETag", "\"post-42-v1\"");
            response.setContentLength(RESOURCE_BODY.length);
        }
        private static void cached(HttpServletResponse response)
                throws IOException {
            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType("application/octet-stream");
            response.setContentLength(CACHED_BODY.length);
            response.getOutputStream().write(CACHED_BODY);
        }
        private static void prepareUtf8Text(HttpServletResponse response) {
            response.setStatus(HttpServletResponse.SC_OK);
            response.setCharacterEncoding(UTF_8.name());
            response.setContentType("text/plain");
        }
    }
    static final class CopyingCacheFilter implements Filter {
        private final ResponseProbe probe;
        CopyingCacheFilter(ResponseProbe probe) {
            this.probe = probe;
        }
        @Override
        public void doFilter(
                ServletRequest request,
                ServletResponse response,
                FilterChain chain)
                throws IOException, ServletException {
            var httpResponse = (HttpServletResponse) response;
            var wrapper = new ContentCachingResponseWrapper(httpResponse);
            chain.doFilter(request, wrapper);
            wrapper.flushBuffer();
            var committedAfterWrapperFlush = httpResponse.isCommitted();
            var cachedBeforeCopy = wrapper.getContentAsByteArray().clone();
            wrapper.copyBodyToResponse();
            probe.cache.set(new CacheObservation(
                    committedAfterWrapperFlush, cachedBeforeCopy));
        }
    }
    static final class ResponseProbe {
        final AtomicReference<BufferObservation> buffer =
                new AtomicReference<>();
        final AtomicReference<Boolean> committedSendErrorRejected =
                new AtomicReference<>(false);
        final AtomicReference<JsonMapper> jsonMapper =
                new AtomicReference<>();
        final AtomicReference<CacheObservation> cache =
                new AtomicReference<>();
    }
    private record BufferObservation(
            boolean committedBeforeFlush,
            boolean committedAfterFlush,
            boolean resizeAfterWriteRejected,
            boolean resetAfterCommitRejected,
            boolean errorAfterCommitRejected) {}
    private record CacheObservation(
            boolean committedAfterWrapperFlush,
            byte[] cachedBeforeCopy) {}
    private record DomainPost(
            long id,
            String title,
            String internalSecret) {}
    private record PostResponse(long id, String title) {
        static PostResponse from(DomainPost post) {
            return new PostResponse(post.id(), post.title());
        }
    }
}

테스트는 모의 응답이 대신 판단하기 어려운 지점을 실제 컨테이너와 HTTP 연결에서 관찰합니다. 특히 다음을 구분합니다.

  • Content-Length는 Java 문자열 길이가 아니라 전송할 표현의 octet 수입니다.
  • 작성기와 출력 스트림 선택은 양방향으로 배타적이고, 커밋 전 reset()만 선택까지 초기화합니다.
  • resetBuffer()는 본문을 버리되 상태·헤더와 출력 API 선택을 유지합니다.
  • flushBuffer()는 응답을 커밋하므로 이후 상태·헤더 변경, resetBuffer(), sendError()로 되돌릴 수 없습니다.
  • sendError()는 커밋 전 버퍼를 지우고 컨테이너 오류 처리를 요청합니다. JSON 오류 스키마를 직접 소유하려면 setStatus()와 명시적 본문을 쓰거나 Spring MVC의 중앙 예외 처리로 위임합니다.
  • DTO 직렬화 결과를 파싱해 의미를 확인하며 JSON 속성 순서를 문자열 전체 비교로 고정하지 않습니다.
Servlet 응답이 준비, 메타데이터 구성, 버퍼 작성, 커밋, 완료 상태로 이동합니다. CONFIGURE와 BUFFERING을 포함한 커밋 전 상태의 sendError는 버퍼를 지우고 container error-page dispatch를 거쳐 응답을 커밋합니다. COMMITTED 상태의 sendError는 IllegalStateException을 던지고 같은 상태에 머뭅니다. 실패를 처음 의미 있게 번역하는 한 경계만 최종 오류 응답을 소유하며, 커밋 뒤에는 스트림 종료와 추적, 복구 계약만 수행합니다.

PREPARE → BUFFER → COMMIT · ONE ERROR OWNER

커밋 전에는 오류를 번역하고, 이후에는 스트림만 복구한다

실패 가능한 작업을 본문 작성보다 먼저 끝냅니다. 응답 메타데이터를 정한 뒤 작성기나 출력 스트림 하나로 버퍼를 채우고, 커밋 전 오류는 한 경계만 최종 응답으로 번역합니다. 커밋 뒤에는 status를 되돌리거나 두 번째 오류 본문을 쓰지 않습니다.

Servlet 응답 커밋 상태와 오류 응답 소유권 응답이 준비, 구성, 버퍼 작성, 커밋, 완료 상태로 이동합니다. 커밋 전 sendError는 clear buffer, error-page dispatch, commit을 수행하고, 커밋 뒤 sendError는 IllegalStateException을 던지며 COMMITTED에 머뭅니다. 실패 시점별로 Servlet 컨테이너, 보안 체인, Spring MVC 리졸버, 애플리케이션 계층 중 한 경계만 오류 응답을 소유합니다. isCommitted() = false isCommitted() = true COMMIT BOUNDARY REQUEST READY WRITE FLUSH FULL/END RETURN LATE FAIL CLOSE sendError(sc) [!isCommitted()] / clear buffer; error-page dispatch; commit * PRE-COMMIT sendError(sc) [isCommitted()] / IllegalStateException PREPARE 실패 가능한 작업 조회 · 검증 · 표현 완성 body = empty CONFIGURE 응답 메타데이터 status · type · charset isCommitted() = false BUFFERING 본문 버퍼 작성 Writer XOR OutputStream not flushed · not full COMMITTED 상태·헤더 전송됨 isCommitted() = true COMPLETE RECOVERY ONLY 커밋 뒤 복구 제한 stream close · trace resume · checksum · job ABORTED BEFORE COMMIT · ONE FINAL ERROR BODY AFTER COMMIT · NO SECOND BODY SERVLET Container HTTP 문법 · 연결 400 · connection close SECURITY 보안 체인 인증 · 인가 401 · 403 SPRING MVC Resolver 인자 · 본문 변환 400 · 415 APPLICATION ControllerAdvice 업무 예외 404 · 409 COMMITTED RESPONSE 복구만 가능 새 status · error body 없음 close · trace · recovery contract

01 · PREPARE FIRST

조회·검증·표현 완성을 본문 작성보다 먼저 끝낸다

스트리밍이 목적이 아니라면 실패 가능한 조회와 검증을 끝내고 응답 표현을 완성한 뒤 body를 씁니다. 이때는 아직 isCommitted() == false이고 오류 응답을 온전히 선택할 수 있습니다.

02 · CONFIGURE & BUFFER

status·Content-Type·charset를 정한 뒤 출력 API 하나만 고른다

문자 본문은 getWriter(), 바이트 본문은 getOutputStream()을 사용하며 한 응답에서 섞지 않습니다. 버퍼가 아직 flush되지도 가득 차지도 않았다면 body를 썼더라도 응답은 커밋 전일 수 있습니다.

03 · COMMIT BOUNDARY

커밋 뒤에는 status와 header를 되돌릴 수 없다

flushBuffer(), stream flush, buffer full, 요청 처리 종료가 응답을 커밋할 수 있습니다. 커밋 전 sendError는 버퍼를 지우고 container error-page 경로로 전환하며 응답은 커밋된 것으로 봅니다. 이미 커밋됐다면 IllegalStateException입니다.

04 · ONE ERROR OWNER

실패를 처음 의미 있게 번역하는 한 경계만 최종 body를 쓴다

  • Servlet container — HTTP 문법·연결: 400 또는 연결 종료
  • 보안 체인 — 인증·인가: 401·403
  • Spring MVC resolver — 인자·본문 변환: 400·415
  • 애플리케이션 계층ControllerAdvice가 업무 예외를 404·409 등으로 번역
  • 커밋 뒤 — 새 오류 body 없이 stream 종료, trace와 checksum·resume·별도 작업 상태 같은 복구 계약 적용

rail의 status는 예시 매핑입니다. Servlet 표준이 Spring MVC ResolverControllerAdvice를 제공하는 것이 아니며, JSON API는 container 기본 오류 페이지 대신 중앙 resolver/handler가 일관된 미디어 타입과 오류 body를 소유하도록 계약합니다.


버퍼와 커밋은 되돌릴 수 있는 경계입니다

컨테이너 응답 버퍼가 가득 차거나 flushBuffer(), 스트림·작성기 플러시, 요청 종료가 전송을 시작하면 응답은 커밋됩니다. setBufferSize()는 본문을 쓰기 전에만 호출할 수 있습니다. 커밋 전에는 resetBuffer()로 본문만 다시 만들거나 reset()으로 상태·헤더·본문과 출력 API 선택을 함께 초기화할 수 있지만, 커밋 뒤에는 이미 보낸 상태 코드와 헤더를 바꿀 수 없습니다.

따라서 일반 응답은 다음 순서를 지킵니다.

  1. 입력 검증, 권한 확인, 조회처럼 실패 가능한 일을 마칩니다.
  2. DTO와 표현을 만들고, 제한된 크기의 JSON이라면 직렬화도 먼저 마칩니다.
  3. 상태, 미디어 타입, 인코딩, 길이와 캐시 헤더를 정합니다.
  4. 작성기 또는 출력 스트림 하나로 본문을 씁니다.
  5. 프레임워크와 컨테이너가 종료 시점에 플러시하도록 두고, 조기 플러시는 스트리밍 계약일 때만 선택합니다.

큰 다운로드나 스트리밍은 중간 실패가 500 응답으로 교체되지 않습니다. 체크섬, 범위 요청, 재시도 가능한 작업 리소스, 스트림 내부 오류 신호처럼 프로토콜 차원의 복구 방식을 별도로 설계합니다.

본문이 없는 응답의 규칙은 같지 않습니다

HEAD, 204, 304는 wire에 message content를 보내지 않지만 Content-Length 규칙까지 같지는 않습니다.

  • HEAD의 응답 헤더는 같은 요청을 GET으로 처리했을 때의 헤더와 같아야 하며, Content-Length를 보낸다면 그 GET 표현의 실제 octet 수여야 합니다.
  • 204 No Content는 message content뿐 아니라 Content-Length 헤더도 보낼 수 없습니다.
  • 304 Not Modified는 message content를 보내지 않습니다. 다만 Content-Length를 보낸다면 조건 없는 대응 GET200 OK 응답에서 보냈을 표현의 octet 수와 정확히 같아야 합니다.

즉 “본문이 없으니 길이는 언제나 0”도, “본문 없는 상태에는 Content-Length가 언제나 금지”도 틀립니다. 위 테스트는 대응 GET의 UTF-8 바이트 길이를 HEAD에 사용하고 204에서는 길이 헤더 자체가 없음을 확인합니다. 304 경로에도 허용되는 대응 표현 길이를 설정하지만, 컨테이너가 그 선택적 헤더를 생략할 수 있으므로 wire에 헤더가 남아 있을 때만 대응 GET 길이와 정확히 같은지 검사합니다.

오류 본문 소유자를 하나로 정합니다

sendError(status, message)는 커밋 전 버퍼를 지우고 컨테이너의 오류 페이지 메커니즘을 호출합니다. 메시지가 그대로 JSON이 되거나 안정적인 공개 오류 스키마가 된다고 가정해서는 안 됩니다. Spring MVC API는 예외를 ProblemDetail로 변환하는 중앙 예외 처리기를 둘 수 있으므로, 컨테이너 HTML 오류와 애플리케이션 JSON 오류 가운데 누가 최종 본문을 소유하는지 경로별로 하나만 정합니다.

실패 시점대표 소유자응답 전략
HTTP 문법·연결 처리Servlet 컨테이너컨테이너 4xx 또는 연결 종료
인증·인가 필터보안 필터 체인일관된 401·403 진입점
MVC 인자·본문 변환Spring MVC 예외 리졸버400·415 Problem Detail
업무 예외컨트롤러 어드바이스404·409 등 공개 오류 DTO
본문 커밋 이후스트림 소유자상태 교체 불가, 종료·관찰·재시도 정책

ContentCachingResponseWrapper는 수동 복사가 필요합니다

Spring의 ContentCachingResponseWrapper는 downstream이 쓴 바이트를 캐시에 보관합니다. 래퍼의 flushBuffer()만 호출해도 기반 응답은 커밋되지 않습니다. 필터가 로깅·해시·수정 같은 후처리를 마친 뒤 반드시 copyBodyToResponse()를 호출해야 클라이언트가 같은 바이트를 받습니다.

이 래퍼는 응답을 수동으로 버퍼링하므로 큰 파일 전체를 무심코 복제하면 메모리 비용이 커집니다. 비밀값이 있는 본문을 로깅하지 말고, 미디어 타입·경로·크기를 허용 목록으로 제한하며, 로그 샘플은 별도의 작은 상한을 둡니다. 클라이언트 전송 자체는 생략하지 않도록 try/finally에서 복사 책임을 설계하되, 예외와 이미 커밋된 응답의 정책도 명시해야 합니다.

공식 문서로 경계를 확인합니다

점검표

  • 상태·미디어 타입·인코딩을 출력 API보다 먼저 정했는가?
  • 응답 하나에서 작성기와 출력 스트림 중 하나만 소유하는가?
  • Content-Length가 문자가 아닌 최종 바이트 길이인가?
  • Boot가 구성한 JsonMapper와 공개 응답 DTO를 사용하는가?
  • HTML 값이 실제 출력 문맥에 맞게 이스케이프되는가?
  • 커밋 전에 실패 가능한 작업을 끝냈는가?
  • HEAD·204·304의 서로 다른 본문·길이 규칙을 지켰는가?
  • 컨테이너, 필터, MVC 가운데 오류 본문 소유자가 하나인가?
  • 캐시 래퍼 본문을 클라이언트에 복사하고 로깅 크기·민감정보를 제한했는가?

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