본문 바로가기

서블릿 응답 출력

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

서블릿의 응답을 쓰는 일은 문자열을 소켓에 적는 것이 아니라, 상태 코드와 표현의 메타데이터, 본문 바이트를 하나의 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 때 확정출력 경로가 확정된 응답으로 끝납니다.

그림은 이 순서를 보여 줍니다. 조회와 검증, 표현 준비를 마친 뒤 상태와 타입, 문자 인코딩을 정하고, 본문이 있으면 작성기(writer)나 출력 스트림 중 하나를 골라 쓰며, HEAD와 204, 304처럼 본문이 없으면 쓰지 않고 넘어갑니다. 두 경로는 둘 중 하나를 고르는 것이며, 어느 쪽이든 응답이 커밋되는 지점으로 이어집니다. 아래쪽 상자들은 텍스트와 JSON, HTML이 각각 어떤 출력 방법을 쓰는지와, 본문 없는 세 응답의 Content-Length 규칙이 서로 다르다는 점을 정리합니다.


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

응답에는 세 층이 있습니다. 성공과 실패를 나타내는 상태 코드, Content-Type과 문자 인코딩, Content-Length, 캐시 검증기 같은 표현의 메타데이터, 그리고 텍스트나 HTML, JSON, 파일 같은 실제 본문 바이트입니다.

문자 응답은 setCharacterEncoding과 setContentType을 getWriter()보다 먼저 호출합니다. 따로 정하지 않으면 서블릿 응답의 기본 문자 인코딩은 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 같은 템플릿으로 만들고, 원시 서블릿 실험에서 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.2가 구성한 Jackson 3의 JsonMapper와 내장 Tomcat 11.0.27, 서블릿 6.1, JUnit 6.0.3, JDK의 루프백 HTTP 클라이언트로 실제로 전송된 응답을 확인합니다.

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();
            probe.cache.set(new CacheObservation(
                    committedAfterWrapperFlush, cachedBeforeCopy));
            wrapper.copyBodyToResponse();
        }
    }

    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 문자열의 길이가 아니라 보낼 표현의 바이트 수인지, 작성기와 출력 스트림이 어느 쪽에서든 서로를 막으며 커밋 전의 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 · 기반 응답: 기존 커밋 상태·출력 유지거절된 오류 호출은 두 번째 오류 응답을 만들지 않으며 추적과 스트림 복구는 별도 책임입니다.

그림은 커밋 전과 후의 오류 처리를 나누어 보여 줍니다. 아직 커밋되지 않은 응답에서는 sendError가 버퍼를 지우고 컨테이너의 오류 처리를 고르지만, 바탕 응답의 플러시 등으로 커밋된 뒤에는 상태와 헤더가 확정되어 sendError가 IllegalStateException으로 거절됩니다. 아래쪽 상자들은 resetBuffer와 reset의 차이, 캐싱 래퍼의 수동 복사, 실패한 시점마다 최종 오류 본문을 맡는 곳을 하나로 정하는 원칙을 정리합니다. 래퍼의 flushBuffer를 바탕 응답의 플러시와 같은 것으로 읽지 않습니다.


버퍼와 커밋을 구분합니다

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

그래서 보통의 응답은 입력 검증과 권한 확인, 조회처럼 실패할 수 있는 일을 먼저 끝내고, DTO와 표현을 만들며 크기가 제한된 JSON이라면 직렬화까지 마칩니다. 그다음 상태와 미디어 타입, 인코딩, 길이, 캐시 헤더를 정하고 작성기나 출력 스트림 하나로 본문을 씁니다. 플러시는 프레임워크와 컨테이너가 끝날 때 하도록 두고, 일찍 플러시하는 것은 스트리밍 규칙일 때만 고릅니다. 이미 커밋된 큰 다운로드나 스트리밍 응답은 중간에 실패해도 500 응답으로 바꿀 수 없습니다.

여기서 말하는 커밋은 HTTP 응답의 메타데이터가 확정되는 일이며, 데이터베이스 트랜잭션의 커밋이나 클라이언트가 본문을 모두 받았다는 확인과 다릅니다. 상태 200을 보낸 뒤 연결이 끊겨 본문의 일부만 전달될 수도 있습니다. 성공 상태만 보고 다운로드가 끝났다고 확정하지 말고, 길이나 체크섬처럼 그 표현이 끝났는지를 따로 확인해야 하며, 체크섬과 범위 요청, 다시 시도할 수 있는 작업 리소스, 스트림 안의 오류 신호 같은 복구 방법을 별도로 설계합니다.

예를 들어 아직 커밋되지 않은 응답에서 상태를 201로 정하고 UTF-8 작성기를 얻어 응답을 썼다고 해 봅시다. 두 글자는 UTF-8로 6바이트입니다. 이어서 resetBuffer()를 부르면 버퍼의 그 6바이트만 사라지고, 상태 201과 헤더, 작성기의 선택은 남으므로 다음 출력도 같은 작성기로 해야 합니다. 이때 getOutputStream()을 얻어 JSON으로 바꾸는 것은 허용되지 않습니다. 본문뿐 아니라 출력 API까지 바꾸려면 커밋 전에 전체 reset()을 부르고 상태와 헤더, API를 새로 정합니다. 앞에서 얻은 작성기는 더 이상 쓰지 않으며, 전체 초기화 뒤에 예전 작성기로 다시 쓰는 동작은 서블릿 명세가 정하지 않으므로 복구 방법으로 삼지 않습니다.


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

HEAD와 204, 304는 모두 본문(message content)을 보내지 않지만, Content-Length의 규칙까지 같지는 않습니다. HEAD는 대응하는 GET과 같은 헤더를 보내는 것이 권장되지만, 본문을 실제로 만들어야만 계산할 수 있는 헤더는 생략할 수 있습니다. 다만 Content-Length를 보낸다면 그 GET 표현의 실제 바이트 수여야 합니다. 204 No Content는 본문뿐 아니라 Content-Length 헤더도 보낼 수 없습니다. 304 Not Modified는 본문을 보내지 않지만, Content-Length를 보낸다면 조건 없는 GET의 200 OK에서 보냈을 표현의 바이트 수와 정확히 같아야 합니다.

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


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

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

실패하는 시점대표적으로 맡는 곳응답 방식
HTTP 문법이나 연결 처리서블릿 컨테이너컨테이너의 4xx나 연결 종료
인증과 권한 필터보안 필터 체인일관된 401과 403
MVC의 인자와 본문 변환Spring MVC 예외 리졸버400과 415의 Problem Details
업무 예외컨트롤러 어드바이스404나 409 같은 공개 오류 DTO
본문 커밋 뒤스트림을 맡은 쪽상태 교체 불가, 종료와 관찰, 재시도 정책

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

Spring의 ContentCachingResponseWrapper는 뒤쪽이 쓴 바이트를 캐시에 보관합니다. 래퍼의 flushBuffer()만 불러서는 바탕 응답이 커밋되지 않으며, 필터가 로그나 해시, 수정 같은 후처리를 마친 뒤 반드시 copyBodyToResponse()를 호출해야 클라이언트가 같은 바이트를 받습니다. 이 래퍼는 응답을 직접 버퍼링하므로, 큰 파일 전체를 생각 없이 복제하면 메모리 비용이 커집니다. 비밀 값이 있는 본문은 기록하지 말고, 미디어 타입과 경로, 크기를 허용 목록으로 제한하며, 로그용 표본에는 따로 작은 상한을 둡니다.

예외가 난 경로에서는 finally에서 무조건 캐시를 복사하지 않습니다. 뒤쪽이 성공 본문의 일부를 캐시에 쓴 뒤 예외를 위쪽의 오류 처리기로 넘긴다면, 그 일부를 먼저 복사하고 플러시했을 때 오류 상태로 바꿀 기회를 잃을 수 있습니다. 정상적으로 돌아왔을 때 복사하고, 예외 경로에서는 아직 커밋 전인지와 누가 최종 오류를 만드는지 확인해 일부만 쓴 본문을 버릴지 정합니다. 로그용 캐시나 getContentAsByteArray()로 얻은 사본도 동시 응답의 수만큼 메모리를 차지하므로, 본문을 다 쓴 뒤에 검사하는 로그 상한만으로 출력 캐시의 전체 크기까지 제한했다고 판단하지 않습니다.


참고한 공식 문서

이 글의 내용은 Jakarta Servlet 6.1의 ServletResponse와 HttpServletResponse, HttpServlet, 서블릿 6.1 명세를 기준으로 합니다. Spring 쪽은 Spring Boot의 JSON 지원과 서블릿 웹 애플리케이션, ContentCachingResponseWrapper, HtmlUtils와 Thymeleaf 통합, Spring MVC의 오류 응답을 참고합니다. HTTP 규칙은 RFC 9110과 RFC 8259를, 출력 문맥별 인코딩은 OWASP XSS 방지 치트 시트를 따릅니다.


정리

  • 응답은 실패할 수 있는 일을 먼저 끝낸 뒤, 커밋 전에 상태와 미디어 타입, 인코딩을 정하고 작성기나 출력 스트림 하나로 본문을 씁니다.
  • Content-Length는 Java 문자열의 길이가 아니라 최종 바이트 수이며, JSON은 Boot가 구성한 JsonMapper와 공개 DTO로 만듭니다.
  • resetBuffer()는 본문만 비우고, 커밋 전의 reset()은 출력 API의 선택까지 초기화하며, 커밋 뒤에는 상태와 헤더를 바꿀 수 없습니다.
  • HEAD와 204, 304는 모두 본문이 없지만, 204는 Content-Length를 금지하고 HEAD와 304는 대응하는 표현의 실제 길이일 때만 허용합니다.
  • 오류 본문은 실패 시점마다 맡을 곳을 하나로 정하고, 캐싱 래퍼는 정상 경로에서 copyBodyToResponse()로 복사하되 예외 경로에서는 무조건 복사하지 않습니다.

연습 문제

1. 버퍼 초기화와 커밋

다음 두 경우는 서로 다른 새 응답입니다. 버퍼는 내용보다 충분히 크고, 자동 플러시나 요청 종료는 아직 일어나지 않았다고 가정합니다. ① 상태 201과 UTF-8 작성기를 정하고 응답을 쓴 뒤 resetBuffer()를 부르고, 같은 작성기로 OK를 씁니다. isCommitted()와 최종 본문 후보는 무엇이며, 이후에 출력 스트림을 얻을 수 있나요? ② 상태 201과 UTF-8 작성기를 정하고 응답을 쓴 뒤, 바탕 응답의 flushBuffer()를 부르고 resetBuffer()를 부릅니다. 마지막 호출이 성공해 빈 500 응답으로 바뀌나요?

해설 보기

①은 아직 커밋되지 않았습니다. resetBuffer()는 처음의 본문만 지우므로 최종 본문 후보는 UTF-8의 OK 2바이트이고, 상태는 그대로 201입니다. 작성기를 고른 상태가 남아 있으므로 출력 스트림을 더 얻으면 IllegalStateException이 납니다. OK를 썼다는 것만으로 커밋되었다고 판단하지 않고, 버퍼가 충분하고 플러시가 없다는 조건을 함께 봅니다.

②는 바탕 응답의 플러시에서 커밋됩니다. 그래서 마지막 resetBuffer()가 IllegalStateException을 던지고, 이미 보낸 201과 헤더를 500으로 바꿀 수 없습니다. 실패한 초기화가 원래의 출력까지 지우지는 않습니다. 래퍼의 플러시가 바탕 응답을 커밋하지 않는 캐싱 예제와 구분해야 합니다. 이 상태 추적은 공식 API의 규칙을 적용한 예상이며, 테스트를 실행해 확인해야 합니다.

2. 바이트 길이와 커밋, 캐시 비용

① UTF-8로 된 보통의 GET 본문이 A응답😀이고, 압축이나 변환 없이 최종 바이트를 그대로 보냅니다. Java의 length()와 전송 바이트 수, 대응하는 HEAD에 Content-Length를 보낼 때의 값, 같은 리소스의 304에서 허용되는 값, 204에서 이 헤더를 보낼 수 있는지를 구하세요. ② 아직 커밋되지 않은 캐싱 래퍼에 성공 본문의 일부가 있고, 뒤쪽이 예외를 던져 중앙 오류 처리기가 500 JSON을 만들려고 합니다. finally에서 먼저 copyBodyToResponse를 부르는 것은 왜 위험한가요? 이미 바탕 응답이 커밋된 경우에는 무엇까지 보장할 수 있나요? ③ 응답 하나의 캐시가 128KiB이고 동시에 80개가 유지됩니다. 바이트 배열 사본을 하나씩 더 둔다고 할 때, 캐시만의 합과 두 벌의 합을 MiB로 계산하세요. 배열의 여유 용량이나 객체, DTO, 바탕 버퍼의 비용은 빼고 계산합니다.

해설 보기

① length()는 A 1, 응 1, 답 1, 보충 문자 2를 더해 5이고, UTF-8은 1 + 3 + 3 + 4 = 11바이트입니다. 대응하는 GET의 본문 길이가 11이므로 HEAD의 선택적인 Content-Length는 11이며 실제 본문은 없습니다. 조건 없는 200의 표현도 같다면 304의 선택적인 길이도 11이고, 204에는 Content-Length를 아예 보내지 않습니다. 세 응답이 본문을 보내지 않는다고 모두 길이 0의 헤더를 쓰는 것은 아닙니다.

② 캐시를 복사할 때 바탕 응답이 플러시되어 커밋되면, 일부만 쓴 성공 본문과 원래 상태가 확정되어 중앙 처리기가 500으로 바꿀 수 없게 됩니다. 아직 커밋 전이라면 오류를 맡은 쪽의 정책에 따라 일부 본문을 버리고 완전한 오류 표현을 만들 수 있어야 합니다. 이미 커밋되었다면 새 상태와 헤더로 되돌릴 수 없고, 연결 종료와 추적, 표현별 재시도 정책만 남습니다. 어느 경우에도 클라이언트가 본문을 모두 받았다는 보장은 아닙니다.

③ 128 × 1024 × 80 = 10,485,760바이트 = 10MiB이고, 같은 크기의 사본을 하나씩 더 두면 20MiB입니다. 뺀 여유 용량과 다른 버퍼 때문에 실제 힙 비용은 이보다 클 수 있습니다. 작은 로그 표본만 남기는 정책과, 응답 전체를 캐시할지는 따로 결정합니다.

다음 글 서블릿·JSP 책임에서는 서블릿이 HTML 마크업까지 직접 쓰고 JSP가 SQL과 업무 규칙까지 실행할 때, 입력과 제어, 표현의 역할이 뒤섞이면서 변경 비용이 어떻게 커지는지 분석합니다.