서블릿 응답 출력
Servlet 응답의 표현 메타데이터, 출력 API 소유권, 버퍼·커밋 경계와 오류·캐시 래퍼 동작을 실제 embedded Tomcat으로 검증합니다.
Servlet 응답 출력은 문자열을 소켓에 적는 일이 아니라 상태 코드, 표현 메타데이터, 본문 바이트를 하나의 HTTP 응답으로 확정하는 일입니다. 실패 가능한 조회와 직렬화를 먼저 끝내고, 아직 커밋되지 않은 응답에 상태와 헤더를 설정한 다음, 정확히 하나의 출력 API로 본문을 씁니다.
UNCOMMITTED → CONTRACT → OUTPUT API → COMMIT
응답 계약을 먼저 고정하고 표현별 출력 경로 하나만 연다
status·Content-Type·charset은 출력 API보다 먼저 정합니다.
HEAD·204·304에는 content가 없지만 Content-Length 규칙은
서로 다릅니다. 본문 응답은 text·JSON·HTML에 맞는
getWriter() 또는 getOutputStream() 하나만
사용합니다.
01 · REPRESENTATION CONTRACT
status·Content-Type·charset을 출력 API보다 먼저 확정한다
getWriter()를 얻는 순간 현재 문자 인코딩이 writer에
반영됩니다. metadata를 먼저 정하고 한 응답에서
getWriter()와 getOutputStream()을 섞지
않습니다.
02 · NO CONTENT
HEAD·204·304는 content가 없지만 Content-Length 규칙은 다르다
204에는 Content-Length가 금지됩니다.
HEAD에는 대응 GET이 보냈을 octet 수,
304에는 같은 요청의 무조건 200 응답이 보냈을 selected
representation octet 수와 정확히 일치할 때만 허용됩니다. 세 응답
모두 content bytes를 전송하지 않습니다.
03 · REPRESENTATION-SPECIFIC OUTPUT
text·JSON·HTML은 같은 문자열 연결 방식으로 만들지 않는다
text/plain은 UTF-8 writer, JSON은 response DTO와
주입된 ObjectMapper, HTML은 template engine의
context-aware escaping을 사용합니다.
04 · COMMIT BOUNDARY
출력 API 하나로 쓴 뒤 status와 headers가 잠긴다
full buffer, flush, request end가 commit을 만들 수 있습니다. commit 뒤에는 status와 headers를 되돌릴 수 없습니다.
이 도표는 정상 representation 작성과 commit 진입까지만 다룹니다. 실패
가능한 조회·검증의 선행, commit 이후 복구,
sendError와 오류 본문 소유권은 다음 canonical diagram에서
다룹니다.
응답을 하나의 표현 계약으로 봅니다
응답에는 세 층이 있습니다.
- 의미: 성공·실패를 나타내는 상태 코드
- 표현 메타데이터:
Content-Type, 문자 인코딩,Content-Length, 캐시 검증자 같은 헤더 - 표현 바이트: 텍스트, HTML, JSON, 파일 등 실제 본문
문자 응답은 setCharacterEncoding과 setContentType을 getWriter()보다 먼저 호출합니다. 명시하지 않았을 때 Servlet 응답의 기본 문자 인코딩은 ISO-8859-1이며, 작성기를 얻은 뒤 바꾼 인코딩은 이미 만들어진 작성기에 소급 적용되지 않습니다. 반대로 바이트 표현은 getOutputStream()으로 씁니다. 한 응답에서 작성기와 출력 스트림을 함께 얻으면 IllegalStateException이며, 전체 reset()을 성공적으로 수행한 경우에만 선택을 다시 할 수 있습니다. resetBuffer()는 본문 버퍼만 비우므로 선택한 출력 API와 상태·헤더는 유지합니다.
| 표현 | 출력 소유자 | 먼저 확정할 메타데이터 | 주의점 |
|---|---|---|---|
text/plain | getWriter() | 미디어 타입·문자 인코딩 | 문자 수와 UTF-8 바이트 수는 다를 수 있음 |
text/html | 템플릿 또는 컨텍스트 인식 인코더 | text/html;charset=UTF-8 | 텍스트·속성·URL·JavaScript 컨텍스트의 이스케이프 규칙이 서로 다름 |
application/json | 공유 JSON 직렬화기 | application/json | RFC 8259의 JSON은 네트워크에서 UTF-8이며 이 미디어 타입에는 선택적 charset 매개변수가 정의되지 않음 |
| 바이너리 | getOutputStream() | 정확한 미디어 타입·가능하면 바이트 길이 | 문자 작성기를 거치지 않음 |
JSON은 문자열 연결로 만들지 않습니다. 응답 DTO가 공개 필드와 null·날짜 정책을 소유하고, Spring Boot가 구성한 Jackson 3 JsonMapper를 주입받아 같은 모듈과 설정을 공유합니다. 크기가 제한된 일반 JSON 응답이라면 먼저 writeValueAsBytes로 직렬화를 끝낸 뒤 상태와 길이를 확정해 쓰면, 직렬화 중간 실패가 부분 응답으로 커밋되는 위험도 줄일 수 있습니다. 큰 스트리밍 JSON은 같은 방식으로 전부 메모리에 올리지 말고, 중간 실패를 HTTP 상태로 되돌릴 수 없다는 별도 스트리밍 계약과 크기 제한을 설계해야 합니다.
HTML에서는 사용자 값을 마크업에 그대로 연결하지 않습니다. 실제 화면은 기본 이스케이프가 있는 Thymeleaf 같은 템플릿으로 만들고, 원시 Servlet 실험의 HTML 텍스트 노드에는 HtmlUtils.htmlEscape처럼 검증된 인코더를 사용할 수 있습니다. HTML 텍스트 이스케이프는 URL·CSS·JavaScript 문맥의 인코딩이나 신뢰하지 않는 HTML의 정제를 대신하지 않습니다.
실제 컨테이너에서 계약을 검증합니다
아래 테스트 파일은 조각 모음이 아닙니다. Spring Boot 4.1.1이 구성한 Jackson 3 JsonMapper, embedded Tomcat 11.0.24, Servlet 6.1, JUnit 6.0.3과 JDK loopback HTTP 클라이언트로 응답의 실제 wire 상태를 검증하는 독립 컴파일 단위입니다.
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 속성 순서를 문자열 전체 비교로 고정하지 않습니다.
PREPARE → BUFFER → COMMIT · ONE ERROR OWNER
커밋 전에는 오류를 번역하고, 이후에는 스트림만 복구한다
실패 가능한 작업을 본문 작성보다 먼저 끝냅니다. 응답 메타데이터를 정한 뒤 작성기나 출력 스트림 하나로 버퍼를 채우고, 커밋 전 오류는 한 경계만 최종 응답으로 번역합니다. 커밋 뒤에는 status를 되돌리거나 두 번째 오류 본문을 쓰지 않습니다.
01 · PREPARE FIRST
조회·검증·표현 완성을 본문 작성보다 먼저 끝낸다
스트리밍이 목적이 아니라면 실패 가능한 조회와 검증을 끝내고 응답
표현을 완성한 뒤 body를 씁니다. 이때는 아직
isCommitted() == false이고 오류 응답을 온전히 선택할
수 있습니다.
02 · CONFIGURE & BUFFER
status·Content-Type·charset를 정한 뒤 출력 API 하나만 고른다
문자 본문은 getWriter(), 바이트 본문은
getOutputStream()을 사용하며 한 응답에서 섞지
않습니다. 버퍼가 아직 flush되지도 가득 차지도 않았다면 body를
썼더라도 응답은 커밋 전일 수 있습니다.
03 · COMMIT BOUNDARY
커밋 뒤에는 status와 header를 되돌릴 수 없다
flushBuffer(), stream flush, buffer full, 요청 처리
종료가 응답을 커밋할 수 있습니다. 커밋 전 sendError는
버퍼를 지우고 container error-page 경로로 전환하며 응답은 커밋된
것으로 봅니다. 이미 커밋됐다면
IllegalStateException입니다.
04 · ONE ERROR OWNER
실패를 처음 의미 있게 번역하는 한 경계만 최종 body를 쓴다
-
Servlet container — HTTP 문법·연결:
400또는 연결 종료 -
보안 체인 — 인증·인가:
401·403 -
Spring MVC resolver — 인자·본문 변환:
400·415 -
애플리케이션 계층 —
ControllerAdvice가 업무 예외를404·409등으로 번역 - 커밋 뒤 — 새 오류 body 없이 stream 종료, trace와 checksum·resume·별도 작업 상태 같은 복구 계약 적용
rail의 status는 예시 매핑입니다. Servlet 표준이 Spring MVC
Resolver나 ControllerAdvice를 제공하는 것이
아니며, JSON API는 container 기본 오류 페이지 대신 중앙
resolver/handler가 일관된 미디어 타입과 오류 body를 소유하도록
계약합니다.
버퍼와 커밋은 되돌릴 수 있는 경계입니다
컨테이너 응답 버퍼가 가득 차거나 flushBuffer(), 스트림·작성기 플러시, 요청 종료가 전송을 시작하면 응답은 커밋됩니다. setBufferSize()는 본문을 쓰기 전에만 호출할 수 있습니다. 커밋 전에는 resetBuffer()로 본문만 다시 만들거나 reset()으로 상태·헤더·본문과 출력 API 선택을 함께 초기화할 수 있지만, 커밋 뒤에는 이미 보낸 상태 코드와 헤더를 바꿀 수 없습니다.
따라서 일반 응답은 다음 순서를 지킵니다.
- 입력 검증, 권한 확인, 조회처럼 실패 가능한 일을 마칩니다.
- DTO와 표현을 만들고, 제한된 크기의 JSON이라면 직렬화도 먼저 마칩니다.
- 상태, 미디어 타입, 인코딩, 길이와 캐시 헤더를 정합니다.
- 작성기 또는 출력 스트림 하나로 본문을 씁니다.
- 프레임워크와 컨테이너가 종료 시점에 플러시하도록 두고, 조기 플러시는 스트리밍 계약일 때만 선택합니다.
큰 다운로드나 스트리밍은 중간 실패가 500 응답으로 교체되지 않습니다. 체크섬, 범위 요청, 재시도 가능한 작업 리소스, 스트림 내부 오류 신호처럼 프로토콜 차원의 복구 방식을 별도로 설계합니다.
본문이 없는 응답의 규칙은 같지 않습니다
HEAD, 204, 304는 wire에 message content를 보내지 않지만 Content-Length 규칙까지 같지는 않습니다.
HEAD의 응답 헤더는 같은 요청을GET으로 처리했을 때의 헤더와 같아야 하며,Content-Length를 보낸다면 그GET표현의 실제 octet 수여야 합니다.204 No Content는 message content뿐 아니라Content-Length헤더도 보낼 수 없습니다.304 Not Modified는 message content를 보내지 않습니다. 다만Content-Length를 보낸다면 조건 없는 대응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에서 복사 책임을 설계하되, 예외와 이미 커밋된 응답의 정책도 명시해야 합니다.
공식 문서로 경계를 확인합니다
- Jakarta Servlet 6.1
ServletResponse: 인코딩 순서, 작성기·출력 스트림 배타성, 버퍼·커밋·reset 계약 - Jakarta Servlet 6.1
HttpServletResponse: 상태, 헤더,sendError,Content-LengthAPI - Jakarta Servlet 6.1
HttpServlet: GET과 HEAD 처리 계약 - Jakarta Servlet 6.1 사양: 응답 버퍼링과 오류 처리의 규범적 전체 문맥
- Spring Boot JSON 지원: 자동 구성된 Jackson 매퍼와 커스터마이징
- Spring Boot Servlet 웹 애플리케이션: embedded Servlet 컨테이너와 등록 방식
- Spring Framework 7
ContentCachingResponseWrapper: 캐시 조회,flushBuffer,copyBodyToResponse계약 - Spring Framework 7
HtmlUtils와 Thymeleaf 통합: HTML 이스케이프와 뷰 책임 - Spring MVC 오류 응답:
ProblemDetail과 중앙 예외 처리 - RFC 9110: message content, HEAD, 204, 304와
Content-Length규칙 - RFC 8259: JSON 미디어 타입의 UTF-8·매개변수 규칙
- OWASP XSS Prevention Cheat Sheet: 출력 문맥별 인코딩과 추가 방어
점검표
- 상태·미디어 타입·인코딩을 출력 API보다 먼저 정했는가?
- 응답 하나에서 작성기와 출력 스트림 중 하나만 소유하는가?
Content-Length가 문자가 아닌 최종 바이트 길이인가?- Boot가 구성한
JsonMapper와 공개 응답 DTO를 사용하는가? - HTML 값이 실제 출력 문맥에 맞게 이스케이프되는가?
- 커밋 전에 실패 가능한 작업을 끝냈는가?
- HEAD·204·304의 서로 다른 본문·길이 규칙을 지켰는가?
- 컨테이너, 필터, MVC 가운데 오류 본문 소유자가 하나인가?
- 캐시 래퍼 본문을 클라이언트에 복사하고 로깅 크기·민감정보를 제한했는가?
다음 문서에서는 Servlet이 HTML 마크업까지 직접 쓰고 JSP가 SQL·업무 규칙까지 실행할 때 입력·제어·표현 책임이 뒤섞이는 이유를 변경 비용으로 분석합니다.