본문 바로가기

서블릿 응답 출력

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

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

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

표현 준비·metadata·writer/stream 선택·content 쓰기 또는 생략·commit에서 응답 상태가 확정된다.

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와 주입된 JsonMapper, text/html은 출력 문맥에 맞는 인코더나 template escaping을 선택합니다. 본문 응답은 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() 하나만 사용합니다.

응답 표현 준비와 본문 유무에 따른 출력실패 가능한 처리와 제한된 표현 준비 뒤 메타데이터를 설정한다. 본문을 보내는 경로에서는 작성기 또는 출력 스트림 중 하나를 선택해 쓰고, HEAD·204·304 경로에서는 message content를 생략한다. 두 선택 모두 커밋 경계로 이어진다. 각 출력 API와 본문 없는 상태의 다른 길이 규칙은 아래 비교에 있다.본문 있음본문 없음조회·검증·표현 준비실패 가능한 일 먼저상태·type·charset최종 바이트 길이출력 API 하나writer 또는 stream표현 본문 쓰기HEAD · 204 · 304message content 없음길이 규칙 각각 확인커밋 경계상태·헤더 확정
응답 표현 준비와 본문 유무에 따른 출력실패 가능한 처리와 제한된 표현 준비 뒤 메타데이터를 설정한다. 본문을 보내는 경로에서는 작성기 또는 출력 스트림 중 하나를 선택해 쓰고, HEAD·204·304 경로에서는 message content를 생략한다. 두 선택 모두 커밋 경계로 이어진다. 각 출력 API와 본문 없는 상태의 다른 길이 규칙은 아래 비교에 있다.본문 있음본문 없음본문 쓴 뒤조회·검증·표현 준비실패 가능한 일 먼저상태·type·charset최종 바이트 길이출력 API 하나writer 또는 stream표현 본문 쓰기HEAD · 204 · 304message content 없음길이 규칙 각각 확인커밋 경계상태·헤더 확정

화살표는 가능한 출력 경로입니다. 본문 있음과 없음 중 한 경로만 택하며, 아래로 놓인 두 출력 노드를 연달아 실행하지 않습니다. writer와 stream의 배타성은 본문을 쓰는 노드 안의 선택입니다.

01 · BEFORE OUTPUT

실패 가능한 처리와 표현을 먼저 완성한다

조회·검증을 마친 뒤 공개 DTO를 만듭니다. 제한된 크기의 JSON은 주입된 Jackson 3 JsonMapper.writeValueAsBytes()로 먼저 직렬화합니다. status·Content-Type·길이는 완성된 표현에 맞춥니다.

02A · TEXT

UTF-8 → getWriter

text/plain과 인코딩을 작성기를 얻기 전에 설정합니다. charset을 나중에 바꿔도 이미 얻은 writer에는 반영되지 않습니다.

02B · JSON / BINARY

바이트 → getOutputStream

JSON은 DTO와 공유 JsonMapper로 만듭니다. 최종 바이트 길이를 사용하며 application/json에 charset 매개변수를 추가하지 않습니다.

02C · HTML

출력 문맥에 맞게 escape

fixture는 HTML text에 HtmlUtils.htmlEscape를 사용합니다. 실제 view의 template 기본 escaping도 URL·JavaScript 등 다른 문맥의 인코딩을 대신하지 않습니다.

NO CONTENT

HEAD·204·304의 길이 규칙은 다르다

세 응답 모두 message content가 없습니다. 204에는 Content-Length가 금지됩니다. HEAD는 대응 GET 길이,304는 무조건 대응200 표현 길이와 정확히 같을 때만 해당 헤더가 허용됩니다.

03 · COMMIT

출력 API 하나로 쓰고 커밋한다

한 응답에서 writer와 output stream을 함께 얻지 않습니다. 버퍼 포화·flush·요청 종료는 커밋을 일으킬 수 있습니다. 커밋 뒤에는 status와 headers를 교체할 수 없습니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 실패 가능한 처리와 제한된 표현 준비 뒤 메타데이터를 설정한다. 본문을 보내는 경로에서는 작성기 또는 출력 스트림 중 하나를 선택해 쓰고, HEAD·204·304 경로에서는 message content를 생략한다. 두 선택 모두 커밋 경계로 이어진다. 각 출력 API와 본문 없는 상태의 다른 길이 규칙은 아래 비교에 있다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
55에는 Content-Length가 금지됩니다. HEAD는 대응 GET 길이, 304는 무조건 대응 200 표현 길이와 정확히 같을 때만 해당 헤더가 허용됩니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
표현 준비

실패 가능한 처리·제한된 표현 준비를 먼저 끝냅니다.

상태: 미커밋 · 자료: 제한된 표현오류 번역 가능한 상태에서 자료를 준비합니다.
metadata와 출력 선택

metadata 뒤 writer 또는 stream 하나를 고릅니다.

본문 있음: writer 또는 stream · HEAD·204·304: content 생략응답 계약에 맞는 한 경로가 열립니다.
쓰기·생략 뒤 commit

각 선택이 응답 commit 경계로 이어집니다.

본문: 쓰기 또는 생략 · 상태·헤더: commit 때 확정출력 경로가 확정된 응답으로 끝납니다.

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

표현 준비·metadata·writer/stream 선택·content 쓰기 또는 생략·commit에서 응답 상태가 확정된다.

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와 주입된 JsonMapper, text/html은 출력 문맥에 맞는 인코더나 template escaping을 선택합니다. 본문 응답은 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() 하나만 사용합니다.

응답 표현 준비와 본문 유무에 따른 출력실패 가능한 처리와 제한된 표현 준비 뒤 메타데이터를 설정한다. 본문을 보내는 경로에서는 작성기 또는 출력 스트림 중 하나를 선택해 쓰고, HEAD·204·304 경로에서는 message content를 생략한다. 두 선택 모두 커밋 경계로 이어진다. 각 출력 API와 본문 없는 상태의 다른 길이 규칙은 아래 비교에 있다.본문 있음본문 없음조회·검증·표현 준비실패 가능한 일 먼저상태·type·charset최종 바이트 길이출력 API 하나writer 또는 stream표현 본문 쓰기HEAD · 204 · 304message content 없음길이 규칙 각각 확인커밋 경계상태·헤더 확정
응답 표현 준비와 본문 유무에 따른 출력실패 가능한 처리와 제한된 표현 준비 뒤 메타데이터를 설정한다. 본문을 보내는 경로에서는 작성기 또는 출력 스트림 중 하나를 선택해 쓰고, HEAD·204·304 경로에서는 message content를 생략한다. 두 선택 모두 커밋 경계로 이어진다. 각 출력 API와 본문 없는 상태의 다른 길이 규칙은 아래 비교에 있다.본문 있음본문 없음본문 쓴 뒤조회·검증·표현 준비실패 가능한 일 먼저상태·type·charset최종 바이트 길이출력 API 하나writer 또는 stream표현 본문 쓰기HEAD · 204 · 304message content 없음길이 규칙 각각 확인커밋 경계상태·헤더 확정

화살표는 가능한 출력 경로입니다. 본문 있음과 없음 중 한 경로만 택하며, 아래로 놓인 두 출력 노드를 연달아 실행하지 않습니다. writer와 stream의 배타성은 본문을 쓰는 노드 안의 선택입니다.

01 · BEFORE OUTPUT

실패 가능한 처리와 표현을 먼저 완성한다

조회·검증을 마친 뒤 공개 DTO를 만듭니다. 제한된 크기의 JSON은 주입된 Jackson 3 JsonMapper.writeValueAsBytes()로 먼저 직렬화합니다. status·Content-Type·길이는 완성된 표현에 맞춥니다.

02A · TEXT

UTF-8 → getWriter

text/plain과 인코딩을 작성기를 얻기 전에 설정합니다. charset을 나중에 바꿔도 이미 얻은 writer에는 반영되지 않습니다.

02B · JSON / BINARY

바이트 → getOutputStream

JSON은 DTO와 공유 JsonMapper로 만듭니다. 최종 바이트 길이를 사용하며 application/json에 charset 매개변수를 추가하지 않습니다.

02C · HTML

출력 문맥에 맞게 escape

fixture는 HTML text에 HtmlUtils.htmlEscape를 사용합니다. 실제 view의 template 기본 escaping도 URL·JavaScript 등 다른 문맥의 인코딩을 대신하지 않습니다.

NO CONTENT

HEAD·204·304의 길이 규칙은 다르다

세 응답 모두 message content가 없습니다. 204에는 Content-Length가 금지됩니다. HEAD는 대응 GET 길이,304는 무조건 대응200 표현 길이와 정확히 같을 때만 해당 헤더가 허용됩니다.

03 · COMMIT

출력 API 하나로 쓰고 커밋한다

한 응답에서 writer와 output stream을 함께 얻지 않습니다. 버퍼 포화·flush·요청 종료는 커밋을 일으킬 수 있습니다. 커밋 뒤에는 status와 headers를 교체할 수 없습니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 실패 가능한 처리와 제한된 표현 준비 뒤 메타데이터를 설정한다. 본문을 보내는 경로에서는 작성기 또는 출력 스트림 중 하나를 선택해 쓰고, HEAD·204·304 경로에서는 message content를 생략한다. 두 선택 모두 커밋 경계로 이어진다. 각 출력 API와 본문 없는 상태의 다른 길이 규칙은 아래 비교에 있다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
55에는 Content-Length가 금지됩니다. HEAD는 대응 GET 길이, 304는 무조건 대응 200 표현 길이와 정확히 같을 때만 해당 헤더가 허용됩니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
표현 준비

실패 가능한 처리·제한된 표현 준비를 먼저 끝냅니다.

상태: 미커밋 · 자료: 제한된 표현오류 번역 가능한 상태에서 자료를 준비합니다.
metadata와 출력 선택

metadata 뒤 writer 또는 stream 하나를 고릅니다.

본문 있음: writer 또는 stream · HEAD·204·304: content 생략응답 계약에 맞는 한 경로가 열립니다.
쓰기·생략 뒤 commit

각 선택이 응답 commit 경계로 이어집니다.

본문: 쓰기 또는 생략 · 상태·헤더: commit 때 확정출력 경로가 확정된 응답으로 끝납니다.

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

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

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

문자 응답은 setCharacterEncoding과 setContentType을 getWriter()보다 먼저 호출합니다. 명시하지 않았을 때 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의 정제를 대신하지 않습니다.

작성기의 인코딩과 문자열 길이는 별도 값입니다. Java String.length()는 UTF-16 코드 단위 수라 보충 문자를 2로 셀 수 있고, UTF-8 전송 길이는 최종 바이트 배열로 구합니다. 예를 들어 A응답😀는 length=5, 코드 포인트 4개, UTF-8 11바이트입니다. ISO-8859-1 기본값 설명은 다른 설정이 인코딩을 정하지 않은 경우이며, 실제 애플리케이션에서는 컨텍스트 기본 설정이나 필터가 먼저 인코딩을 정할 수도 있습니다. 실험의 기본값을 모든 배포에 일반화하지 말고 응답 계약의 UTF-8을 작성기 전에 명시합니다.

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

아래 테스트 파일은 조각 모음이 아닙니다. 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 속성 순서를 문자열 전체 비교로 고정하지 않습니다.

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

기반 응답을 flush한 뒤 sendError가 거절되는 선택한 fixture 경로를 추적합니다. 미커밋 오류 처리와 캐시 wrapper 복사는 원표의 별도 계약입니다.

Servlet 응답이 준비, 메타데이터 구성, 버퍼 작성, 커밋, 완료 상태로 이동합니다. CONFIGURE와 BUFFERING을 포함한 커밋 전 상태의 sendError는 버퍼를 지우고 container error-page dispatch를 거쳐 응답을 커밋합니다. COMMITTED 상태의 sendError는 IllegalStateException을 던지고 같은 상태에 머뭅니다. 실패를 처음 의미 있게 번역하는 한 경계만 최종 오류 응답을 소유하며, 커밋 뒤에는 스트림 종료와 추적, 복구 계약만 수행합니다.

PREPARE → BUFFER → COMMIT · ONE ERROR OWNER

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

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

기반 응답 커밋 전후의 오류 처리 조건아직 커밋되지 않은 기반 응답에서는 sendError가 버퍼를 지우고 컨테이너 오류 처리를 선택한다. 기반 응답 플러시 등으로 커밋하면 상태와 헤더가 확정되고, 그 뒤 sendError 호출은 IllegalStateException이다. 아래 초기화 비교와 캐시 래퍼 비교는 별도 계약이다. 래퍼 flushBuffer를 기반 응답 플러시와 같은 전이로 읽지 않는다.sendError기반 flush 등호출기반 응답 미커밋상태·헤더 변경 가능버퍼에 본문 가능커밋 전 오류버퍼 비움컨테이너 오류 처리기반 응답 커밋상태·헤더 확정커밋 뒤 sendErrorIllegalStateException새 오류 응답 불가
기반 응답 커밋 전후의 오류 처리 조건아직 커밋되지 않은 기반 응답에서는 sendError가 버퍼를 지우고 컨테이너 오류 처리를 선택한다. 기반 응답 플러시 등으로 커밋하면 상태와 헤더가 확정되고, 그 뒤 sendError 호출은 IllegalStateException이다. 아래 초기화 비교와 캐시 래퍼 비교는 별도 계약이다. 래퍼 flushBuffer를 기반 응답 플러시와 같은 전이로 읽지 않는다.기반flush 등sendError커밋 뒤 호출기반 응답 미커밋상태·헤더 변경 가능버퍼에 본문 가능기반 응답 커밋상태·헤더 확정커밋 전 오류버퍼 비움컨테이너 오류 처리커밋 뒤 sendErrorIllegalStateException새 오류 응답 불가

오른쪽·왼쪽으로 우회한 선은 각각 해당 출발 상태에서 고른 경로입니다. 커밋 전 오류 처리와 커밋 뒤 호출 거절을 순서대로 실행하는 흐름이 아닙니다. resetBuffer와 reset의 차이, 래퍼의 수동 복사는 아래에서 따로 비교합니다.

01 · PREPARE / CONFIGURE

커밋 전: 실패를 온전한 응답으로 번역한다

조회·검증·제한된 크기의 직렬화를 먼저 완료하고 status·type·charset을 설정합니다. isCommitted() == false이면 최종 오류 응답을 선택할 수 있습니다.

02 · BUFFER

본문을 썼어도 커밋 전일 수 있다

resetBuffer()는 본문만 비우고 status·headers·출력 API 선택을 유지합니다. 커밋 전 reset()은 이 모두를 초기화하여 출력 API도 다시 선택할 수 있습니다.

PRE-COMMIT ERROR

sendError → 버퍼 제거 → 컨테이너 오류 처리

미커밋 sendError는 stale body를 지우고 error-page 메커니즘에 맡깁니다. 안정적인 JSON 오류를 직접 소유하려면 setStatus와 명시적 body 또는 MVC 중앙 예외 처리 계약을 사용합니다.

03 · COMMIT / LATE FAILURE

커밋 뒤: status·headers 교체 불가

flushBuffer()·출력 flush·버퍼 포화·요청 종료가 커밋을 일으킬 수 있습니다. 이후 sendError와 reset은 IllegalStateException입니다. 스트림 종료·추적·체크섬·재개 같은 별도 복구 계약을 적용합니다.

RESPONSE CACHE · COPY RESPONSIBILITY

ContentCachingResponseWrapper는 복사까지 수동 버퍼다

downstream body를 캐시한 뒤 래퍼 flushBuffer()만으로 기반 응답은 커밋되지 않습니다. copyBodyToResponse()로 같은 바이트를 전송해야 합니다. 큰 응답의 메모리 비용과 예외 시 복사 책임을 따로 설계합니다.

ONE ERROR OWNER

실패 시점별 최종 본문 소유자를 하나로 정한다

HTTP 문법·연결은 컨테이너, 인증·인가는 보안 체인, 인자·본문 변환은 MVC resolver, 업무 예외는 controller advice 같은 경계가 담당할 수 있습니다. 400·401·403·404·409·415는 경로별 예시 매핑이며 커밋 이후에는 두 번째 오류 응답으로 교체하지 않습니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 아직 커밋되지 않은 기반 응답에서는 sendError가 버퍼를 지우고 컨테이너 오류 처리를 선택한다. 기반 응답 플러시 등으로 커밋하면 상태와 헤더가 확정되고, 그 뒤 sendError 호출은 IllegalStateException이다. 아래 초기화 비교와 캐시 래퍼 비교는 별도 계약이다. 래퍼 flushBuffer를 기반 응답 플러시와 같은 전이로 읽지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
출력 후 미커밋

커밋 뒤 오류 fixture에서 200 상태와 partial 본문을 준비하고 기반 응답은 아직 미커밋입니다.

선택 경로: committedError의 기반 응답 · 상태·버퍼: 200·partial, flush 전이 사례는 기반 응답 flush 뒤의 오류 호출만 추적합니다. 미커밋 sendError 대안은 원표에 보존합니다.
기반 응답 커밋

기반 HttpServletResponse.flushBuffer()가 준비한 출력과 상태·헤더를 커밋합니다.

호출: 기반 응답 flushBuffer · 결과: 커밋됨·200과 기존 출력 확정 · 캐시 wrapper flush: 이 호출과 다른 계약기반 응답이 커밋되어 새 status·오류 본문으로 교체할 수 없게 됩니다.
커밋 뒤 sendError

같은 기반 응답에 sendError(500)를 호출하면 IllegalStateException으로 거절됩니다.

호출: 커밋 뒤 sendError(500) · 결과: IllegalStateException · 기반 응답: 기존 커밋 상태·출력 유지거절된 오류 호출은 두 번째 오류 응답을 만들지 않으며 추적과 스트림 복구는 별도 책임입니다.

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

기반 응답을 flush한 뒤 sendError가 거절되는 선택한 fixture 경로를 추적합니다. 미커밋 오류 처리와 캐시 wrapper 복사는 원표의 별도 계약입니다.

Servlet 응답이 준비, 메타데이터 구성, 버퍼 작성, 커밋, 완료 상태로 이동합니다. CONFIGURE와 BUFFERING을 포함한 커밋 전 상태의 sendError는 버퍼를 지우고 container error-page dispatch를 거쳐 응답을 커밋합니다. COMMITTED 상태의 sendError는 IllegalStateException을 던지고 같은 상태에 머뭅니다. 실패를 처음 의미 있게 번역하는 한 경계만 최종 오류 응답을 소유하며, 커밋 뒤에는 스트림 종료와 추적, 복구 계약만 수행합니다.

PREPARE → BUFFER → COMMIT · ONE ERROR OWNER

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

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

기반 응답 커밋 전후의 오류 처리 조건아직 커밋되지 않은 기반 응답에서는 sendError가 버퍼를 지우고 컨테이너 오류 처리를 선택한다. 기반 응답 플러시 등으로 커밋하면 상태와 헤더가 확정되고, 그 뒤 sendError 호출은 IllegalStateException이다. 아래 초기화 비교와 캐시 래퍼 비교는 별도 계약이다. 래퍼 flushBuffer를 기반 응답 플러시와 같은 전이로 읽지 않는다.sendError기반 flush 등호출기반 응답 미커밋상태·헤더 변경 가능버퍼에 본문 가능커밋 전 오류버퍼 비움컨테이너 오류 처리기반 응답 커밋상태·헤더 확정커밋 뒤 sendErrorIllegalStateException새 오류 응답 불가
기반 응답 커밋 전후의 오류 처리 조건아직 커밋되지 않은 기반 응답에서는 sendError가 버퍼를 지우고 컨테이너 오류 처리를 선택한다. 기반 응답 플러시 등으로 커밋하면 상태와 헤더가 확정되고, 그 뒤 sendError 호출은 IllegalStateException이다. 아래 초기화 비교와 캐시 래퍼 비교는 별도 계약이다. 래퍼 flushBuffer를 기반 응답 플러시와 같은 전이로 읽지 않는다.기반flush 등sendError커밋 뒤 호출기반 응답 미커밋상태·헤더 변경 가능버퍼에 본문 가능기반 응답 커밋상태·헤더 확정커밋 전 오류버퍼 비움컨테이너 오류 처리커밋 뒤 sendErrorIllegalStateException새 오류 응답 불가

오른쪽·왼쪽으로 우회한 선은 각각 해당 출발 상태에서 고른 경로입니다. 커밋 전 오류 처리와 커밋 뒤 호출 거절을 순서대로 실행하는 흐름이 아닙니다. resetBuffer와 reset의 차이, 래퍼의 수동 복사는 아래에서 따로 비교합니다.

01 · PREPARE / CONFIGURE

커밋 전: 실패를 온전한 응답으로 번역한다

조회·검증·제한된 크기의 직렬화를 먼저 완료하고 status·type·charset을 설정합니다. isCommitted() == false이면 최종 오류 응답을 선택할 수 있습니다.

02 · BUFFER

본문을 썼어도 커밋 전일 수 있다

resetBuffer()는 본문만 비우고 status·headers·출력 API 선택을 유지합니다. 커밋 전 reset()은 이 모두를 초기화하여 출력 API도 다시 선택할 수 있습니다.

PRE-COMMIT ERROR

sendError → 버퍼 제거 → 컨테이너 오류 처리

미커밋 sendError는 stale body를 지우고 error-page 메커니즘에 맡깁니다. 안정적인 JSON 오류를 직접 소유하려면 setStatus와 명시적 body 또는 MVC 중앙 예외 처리 계약을 사용합니다.

03 · COMMIT / LATE FAILURE

커밋 뒤: status·headers 교체 불가

flushBuffer()·출력 flush·버퍼 포화·요청 종료가 커밋을 일으킬 수 있습니다. 이후 sendError와 reset은 IllegalStateException입니다. 스트림 종료·추적·체크섬·재개 같은 별도 복구 계약을 적용합니다.

RESPONSE CACHE · COPY RESPONSIBILITY

ContentCachingResponseWrapper는 복사까지 수동 버퍼다

downstream body를 캐시한 뒤 래퍼 flushBuffer()만으로 기반 응답은 커밋되지 않습니다. copyBodyToResponse()로 같은 바이트를 전송해야 합니다. 큰 응답의 메모리 비용과 예외 시 복사 책임을 따로 설계합니다.

ONE ERROR OWNER

실패 시점별 최종 본문 소유자를 하나로 정한다

HTTP 문법·연결은 컨테이너, 인증·인가는 보안 체인, 인자·본문 변환은 MVC resolver, 업무 예외는 controller advice 같은 경계가 담당할 수 있습니다. 400·401·403·404·409·415는 경로별 예시 매핑이며 커밋 이후에는 두 번째 오류 응답으로 교체하지 않습니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 아직 커밋되지 않은 기반 응답에서는 sendError가 버퍼를 지우고 컨테이너 오류 처리를 선택한다. 기반 응답 플러시 등으로 커밋하면 상태와 헤더가 확정되고, 그 뒤 sendError 호출은 IllegalStateException이다. 아래 초기화 비교와 캐시 래퍼 비교는 별도 계약이다. 래퍼 flushBuffer를 기반 응답 플러시와 같은 전이로 읽지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
출력 후 미커밋

커밋 뒤 오류 fixture에서 200 상태와 partial 본문을 준비하고 기반 응답은 아직 미커밋입니다.

선택 경로: committedError의 기반 응답 · 상태·버퍼: 200·partial, flush 전이 사례는 기반 응답 flush 뒤의 오류 호출만 추적합니다. 미커밋 sendError 대안은 원표에 보존합니다.
기반 응답 커밋

기반 HttpServletResponse.flushBuffer()가 준비한 출력과 상태·헤더를 커밋합니다.

호출: 기반 응답 flushBuffer · 결과: 커밋됨·200과 기존 출력 확정 · 캐시 wrapper flush: 이 호출과 다른 계약기반 응답이 커밋되어 새 status·오류 본문으로 교체할 수 없게 됩니다.
커밋 뒤 sendError

같은 기반 응답에 sendError(500)를 호출하면 IllegalStateException으로 거절됩니다.

호출: 커밋 뒤 sendError(500) · 결과: IllegalStateException · 기반 응답: 기존 커밋 상태·출력 유지거절된 오류 호출은 두 번째 오류 응답을 만들지 않으며 추적과 스트림 복구는 별도 책임입니다.

버퍼와 커밋의 경계를 구분합니다

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

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

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

이미 커밋된 큰 다운로드나 스트리밍 응답에서는 중간에 실패해도 500 응답으로 교체할 수 없습니다.

여기의 커밋은 HTTP 응답 메타데이터가 확정되는 사건입니다. 데이터베이스 트랜잭션의 커밋이나 클라이언트가 전체 본문을 받았다는 확인과 다릅니다. 상태 200을 보낸 뒤 연결이 끊겨 본문 일부만 전달될 수 있습니다. 성공 상태만 보고 다운로드 완료를 확정하지 말고 길이·체크섬 등 해당 표현의 완료 조건을 별도로 확인해야 합니다. 체크섬, 범위 요청, 재시도 가능한 작업 리소스, 스트림 내부 오류 신호처럼 프로토콜 차원의 복구 방식을 별도로 설계합니다.

예시 아직 커밋되지 않은 응답에서 상태를 201로 정하고 UTF-8 작성기를 얻어 응답을 썼다고 가정합니다. 이 두 문자는 UTF-8에서는 6바이트입니다. 이어 resetBuffer()를 호출하면 버퍼의 그 6바이트만 사라집니다. 상태 201, 헤더와 작성기 선택은 남으므로 다음 출력도 같은 작성기를 사용해야 합니다. 이 시점에 getOutputStream()을 얻어 JSON으로 바꾸는 것은 허용되지 않습니다.

본문뿐 아니라 출력 API도 바꾸려면 커밋 전에 전체 reset()을 수행한 다음 상태·헤더와 API를 새로 정합니다. 앞서 얻은 작성기는 더 이상 유효한 사용 대상으로 취급하지 않습니다. 전체 초기화 뒤 옛 작성기로 다시 쓰는 동작은 Servlet 계약에서 정의되지 않으므로, 이를 복구 방법이나 이식 가능한 실험 결과로 삼지 않습니다.

독립 확인 다음 두 경우는 서로 다른 새 응답입니다. 각 경우에서 isCommitted()와 최종 본문 후보를 따로 판단하세요. 버퍼 크기는 내용보다 충분히 크며, 자동 플러시나 요청 종료는 아직 일어나지 않았다고 가정합니다.

  1. 상태 201·UTF-8 작성기 선택 → 응답 쓰기 → resetBuffer() → 같은 작성기로 OK 쓰기. 이후 출력 스트림을 얻을 수 있나요?
  2. 상태 201·UTF-8 작성기 선택 → 응답 쓰기 → 기반 응답의 flushBuffer() → resetBuffer(). 마지막 호출이 성공해서 비어 있는 500 응답으로 바뀌나요?
버퍼 초기화와 커밋의 풀이

첫 경우는 아직 커밋되지 않았습니다. resetBuffer()는 최초 본문만 지우므로 최종 본문 후보는 UTF-8의 OK 2바이트이고 상태는 여전히 201입니다. 작성기를 선택한 상태가 남아 있으므로 출력 스트림을 추가로 얻으면 IllegalStateException입니다. OK를 썼다는 사실만으로 커밋됐다고 판단하지 않고, 문제에서 정한 충분한 버퍼와 플러시 없음 조건을 함께 사용합니다.

둘째 경우는 기반 응답의 플러시에서 커밋됩니다. 따라서 마지막 resetBuffer()가 IllegalStateException을 던지며, 이미 보낸 201·헤더를 500으로 교체할 수 없습니다. 실패한 초기화가 원래 출력까지 지우지는 않습니다. 래퍼의 플러시가 기반 응답을 커밋하지 않는 뒤의 캐시 예제와 구분해야 합니다.

문자·바이트 계산은 UTF-8 인코딩으로 확인할 수 있지만, 위 상태 추적은 공식 API 계약을 적용한 예상입니다. 이 보완의 검수에서 Spring·Tomcat loopback 실행을 새로 수행했다는 뜻은 아닙니다.

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

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를 보낸다면 조건 없는 대응 GET의 200 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에서 복사 책임을 설계하되, 예외와 이미 커밋된 응답의 정책도 명시해야 합니다.

예외 경로에서는 finally에서 무조건 캐시를 복사하지 않습니다. downstream이 성공 본문 일부를 캐시에 쓴 뒤 예외를 상위 오류 처리기로 넘긴다면, 먼저 그 일부를 복사·플러시하면 오류 상태로 교체할 기회를 잃을 수 있습니다. 정상 반환은 복사를 책임지고, 예외 경로는 아직 미커밋인지와 누가 최종 오류를 만드는지 확인하여 부분 본문을 폐기할지 선택합니다. 로그용 캐시·getContentAsByteArray()로 얻은 사본도 동시 응답 수만큼 메모리를 차지하므로, 본문을 이미 쓴 뒤 검사하는 로그 상한만으로 출력 캐시의 총 크기까지 제한했다고 판단하지 않습니다.

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

점검표

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

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

추가 연습 — 바이트 길이와 커밋, 캐시 비용을 계산합니다. (1) UTF-8 일반 GET 본문이 A응답😀이며, 압축·변환 없이 최종 바이트를 그대로 출력합니다. Java length와 전송 바이트 수, 대응 HEAD에 Content-Length를 보낼 경우의 값, 같은 리소스의 304에서 허용되는 값과 204의 헤더 허용 여부를 구하세요. (2) 미커밋 캐시 래퍼에 성공 본문 일부가 있고 downstream이 예외를 던져 중앙 오류 처리기가 500 JSON을 만들려고 합니다. finally에서 먼저 copyBodyToResponse를 호출하는 선택은 왜 위험한가요? 이미 기반 응답이 커밋된 경우에는 어떤 보장까지 가능한가요? (3) 한 응답의 캐시가 128 KiB이고 동시에 80개가 유지됩니다. 바이트 배열 사본을 하나씩 더 유지한다고 가정할 때 캐시만의 합과 두 벌의 합을 MiB로 계산하세요. 배열 여유 용량·객체·DTO·기반 버퍼 비용은 이번 계산에서 제외합니다.

추가 연습의 풀이 확인
  1. Java length는 A 1 + 응 1 + 답 1 + 보충 문자 2 = 5이고, UTF-8은 1+3+3+4=11바이트입니다. 대응 GET의 본문 길이가 11이므로 HEAD의 선택적 Content-Length는 11이며 wire 본문은 없습니다. 조건 없는 대응 200의 표현도 동일하다는 조건에서 304의 선택적 길이 역시 11입니다. 204에는 Content-Length 자체를 보내지 않습니다. 세 응답이 본문을 보내지 않는다고 모두 길이 0 헤더를 사용하는 것은 아닙니다.
  2. 캐시 복사가 기반 응답을 플러시·커밋하면 부분 성공 본문과 원 상태가 확정되어 중앙 처리기의 500 교체를 막을 수 있습니다. 미커밋이면 오류 소유자의 정책에 따라 부분 본문을 버리고 완전한 오류 표현을 만들 수 있어야 합니다. 이미 커밋됐으면 새 상태·헤더로 되돌릴 수 없고 연결 종료·추적·표현별 재시도 정책만 남습니다. 어느 경우도 클라이언트가 전체 본문을 받았다는 보장은 아닙니다.
  3. 128×1024×80=10,485,760바이트=10 MiB입니다. 동일 크기 사본을 더 보관하면 20 MiB이며, 제외한 여유 용량과 다른 버퍼 때문에 실제 전체 힙 비용은 이 계산보다 클 수 있습니다. 작은 로그 샘플만 남기는 정책과 응답 전체 캐싱 여부를 따로 결정합니다.