서블릿·JSP 책임
Servlet controller와 내부 view의 typed model 경계, forward·303 요청 수명, JSP·Thymeleaf escaping을 embedded Tomcat wire test로 검증합니다.
Servlet이 request parsing, use case, HTML 문자열까지 모두 소유하면 제어와 표현이 결합합니다. 반대로 JSP scriptlet이 request, repository, 업무 규칙을 실행하면 template가 애플리케이션 흐름까지 소유합니다. 둘 다 처음에는 빠르지만 입력·업무·화면 가운데 하나만 바뀌어도 다른 층을 함께 수정하고 실행해야 합니다.
INPUT → USE CASE → TYPED MODEL → INTERNAL VIEW
표현과 제어를 분리하고 typed model로만 연결한다
두 혼합 극단을 피합니다. controller는 입력과 use case, model, 고정된 view 선택까지만 소유하고 template는 전달받은 화면 값의 안전한 markup만 소유합니다.
01 · TWO MIXED EXTREMES
Servlet 문자열 HTML과 JSP scriptlet은 반대 방향으로 책임을 섞는다
전자는 제어와 markup을 Java에, 후자는 표현과 business·DB를 JSP에 둡니다. escaping·markup 변경 결합과 render 중 예외·테스트 난이도가 생깁니다.
02 · CONTROLLER → USE CASE
controller는 입력을 해석하고 PostQuery use case를 호출한다
request parameter 파싱과 경로 선택은 controller가 맡고, 조회와 업무 규칙은 application 경계에 위임합니다.
03 · TYPED MODEL CONTRACT
화면에 필요한 값만 PostPageModel과 request attribute로 전달한다
entity나 repository가 아니라 typed model을
request.setAttribute("post", model)로 전달해 오타와
우연한 내부 필드 노출을 줄입니다.
04 · FIXED INTERNAL VIEW
고정된 /WEB-INF view는 markup과 이스케이프만 소유한다
request가 view 경로를 고르게 하지 않습니다. JSP는
<c:out>, Thymeleaf는 th:text처럼
이스케이프되는 출력을 사용하고 repository를 다시 호출하지
않습니다.
/WEB-INF는 외부 직접 요청을 막지만 서버 내부 dispatch는
허용합니다. 따라서 보안 경계만이 아니라 controller가 선택한 fixed
view와 typed model 계약으로 사용합니다.
두 혼합 극단을 제거합니다
Servlet에서 "<h1>" + post.title() + "</h1>"처럼 HTML을 조립하면 Java 문자열, 출력 문맥별 이스케이프, CSS class와 화면 구조가 한 변경 단위가 됩니다. 사용자 제목을 그대로 연결하면 XSS가 되고, HTML을 고치는 작업이 controller 컴파일과 배포에 묶입니다.
JSP 자체가 문제인 것은 아닙니다. Jakarta Server Pages 4.0은 서버에서 동적 markup을 만드는 view 기술입니다. 문제는 scriptlet 안에서 parameter를 파싱하고 repository를 찾고 업무 분기를 실행하는 구조입니다. 데이터 조회나 권한 확인이 render 중 실패하면 일부 응답이 이미 작성되었을 수 있고, view 테스트도 Servlet context와 DB에 의존합니다.
책임을 다음처럼 좁힙니다.
| 경계 | 소유하는 일 | 소유하지 않는 일 |
|---|---|---|
| Servlet controller | HTTP 입력 검증, use case 호출, typed model과 고정 view 선택 | HTML 문자열, SQL, 업무 상태 계산 |
| use case·domain | 조회, 권한, 상태 전이, 추천 자격 같은 업무 의미 | HTML tag와 locale 표시 문자열 |
| presenter·view-model mapper | 화면 필드 선택, 날짜·숫자·label 포매팅, 표현 상태 | request dispatch와 DB 접근 |
| JSP·Thymeleaf view | element 배치와 출력 문맥에 맞는 escaping | repository, request parsing, 복잡한 업무 expression |
PostPageModel 같은 작은 타입은 화면에 필요한 값만 공개합니다. JPA entity, JDBC row, 내부 식별자와 비밀 필드를 template에 그대로 노출하지 않습니다. 범용 Map<String, Object>만 사용해 key 오타가 실행 시점까지 숨는 구조도 피합니다.
내부 view와 escaping은 별개의 경계입니다
/WEB-INF 아래 리소스는 클라이언트가 직접 요청하면 404이지만 서버 코드는 RequestDispatcher로 접근할 수 있습니다. 따라서 /WEB-INF를 authorization 경계로 과장하지 않습니다. controller가 상수로 고정한 view 경로만 선택하고, request parameter로 JSP 경로를 받지 않는 navigation 경계로 사용합니다.
JSP의 ${post.title}은 값을 평가해 출력할 뿐 보편적인 HTML escaping 기능이 아닙니다. HTML text 위치에서는 Jakarta Tags 3.0 c:out의 기본 escapeXml="true" 같은 명시적 출력 기능을 사용합니다. Thymeleaf에서는 th:text이 text를 escape하고 th:utext는 raw markup을 출력합니다. URL, JavaScript, CSS, HTML attribute에는 각 문맥의 encoding 규칙이 따로 있으므로 text escaping 하나를 모든 문맥의 해답으로 재사용하지 않습니다.
다음 독립 테스트 파일은 Servlet controller, typed model, 실제 Jasper JSP, Thymeleaf renderer와 HTTP client를 한 컴파일 단위에 담습니다. 손으로 꾸민 성공 transcript 대신 JUnit 결과와 wire 응답을 실행 영수증으로 남깁니다.
package board.servlet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
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.assertTrue;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.NumberFormat;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
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.tomcat.servlet.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.server.servlet.context
.ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.util.FileSystemUtils;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.StringTemplateResolver;
public class EmbeddedServletViewBoundaryTest {
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 UNSAFE =
"<script>alert('x')</script>&게시글";
@Test
void GET은_typed_model을_fixed_WEB_INF_view로_forward하고_escape한다()
throws Exception {
try (var server = RunningServer.start()) {
var response = get(server, "/posts/42");
var body = response.body();
assertAll(
() -> assertEquals(200, response.statusCode()),
() -> assertTrue(body.contains("<script>")),
() -> assertFalse(body.contains("<script>alert")),
() -> assertTrue(body.contains("1,200자")),
() -> assertTrue(body.contains("긴 글")),
() -> assertTrue(body.contains("추천")),
() -> assertTrue(body.contains(
"<p id=\"forward-uri\">/posts/42</p>")),
() -> assertTrue(body.contains(
"<p id=\"repository\"></p>")),
() -> assertEquals(1, server.store().queryCount.get()),
() -> assertEquals(
PostPageModel.class,
server.probe().lastModelType.get()));
}
}
@Test
void WEB_INF는_직접_요청하면_404이고_view_parameter로_바뀌지_않는다()
throws Exception {
try (var server = RunningServer.start()) {
var direct = get(server, "/WEB-INF/views/post-detail.jsp");
var routed = get(
server,
"/posts/42?view=/WEB-INF/views/escaping-probe.jsp");
assertAll(
() -> assertEquals(404, direct.statusCode()),
() -> assertEquals(200, routed.statusCode()),
() -> assertTrue(routed.body().contains(
"<main id=\"post-detail\">")),
() -> assertFalse(routed.body().contains(
"<p id=\"raw\">")),
() -> assertEquals(1, server.store().queryCount.get()));
}
}
@Test
void 검증_실패_POST는_같은_request의_입력과_error를_forward한다()
throws Exception {
try (var server = RunningServer.start()) {
var title = "<x";
var response = post(
server,
"/posts",
"title=" + URLEncoder.encode(title, UTF_8));
assertAll(
() -> assertEquals(422, response.statusCode()),
() -> assertTrue(response.body().contains(
"value=\"<x\"")),
() -> assertTrue(response.body().contains(
"제목은 3자 이상이어야 합니다")),
() -> assertTrue(response.body().contains(
"<p id=\"forward-uri\">/posts</p>")),
() -> assertTrue(response.body().contains(
"<p id=\"marker\">"
+ server.probe().lastPostMarker.get()
+ "</p>")),
() -> assertEquals(0, server.store().commandCount.get()));
}
}
@Test
void 성공_POST는_303_Location_뒤_new_GET에서_model을_다시_만든다()
throws Exception {
try (var server = RunningServer.start()) {
var created = post(
server,
"/posts",
"title=" + URLEncoder.encode("새 게시글", UTF_8));
var location = created.headers()
.firstValue("Location")
.orElseThrow();
var redirected = get(created.request().uri().resolve(location));
assertAll(
() -> assertEquals(303, created.statusCode()),
() -> assertTrue(location.endsWith("/posts/43")),
() -> assertEquals(200, redirected.statusCode()),
() -> assertTrue(redirected.body().contains("새 게시글")),
() -> assertTrue(redirected.body().contains(
"<p id=\"post-only\"></p>")),
() -> assertEquals("GET", server.probe().lastGetMethod.get()),
() -> assertNotEquals(
server.probe().lastPostMarker.get(),
server.probe().lastGetMarker.get()),
() -> assertEquals(1, server.store().commandCount.get()),
() -> assertEquals(1, server.store().queryCount.get()));
}
}
@Test
void forward는_미커밋_buffer를_지우고_commit_뒤에는_거부된다()
throws Exception {
try (var server = RunningServer.start()) {
var beforeCommit = get(
server, "/diagnostics/forward-before-commit");
var afterCommit = get(
server, "/diagnostics/forward-after-commit");
assertAll(
() -> assertEquals(200, beforeCommit.statusCode()),
() -> assertFalse(beforeCommit.body().contains("STALE")),
() -> assertTrue(beforeCommit.body().contains(
"<p id=\"escaped\"><script>")),
() -> assertEquals(200, afterCommit.statusCode()),
() -> assertEquals("PARTIAL", afterCommit.body()),
() -> assertTrue(
server.probe().committedForwardRejected.get()));
}
}
@Test
void JSP_raw_EL과_c_out의_escaped_text를_실제_Jasper로_구분한다()
throws Exception {
try (var server = RunningServer.start()) {
var response = get(server, "/diagnostics/jsp-escaping");
var body = response.body();
assertAll(
() -> assertEquals(200, response.statusCode()),
() -> assertTrue(body.contains(
"<p id=\"raw\"><script>alert('x')</script>")),
() -> assertTrue(body.contains(
"<p id=\"escaped\"><script>")),
() -> assertTrue(body.contains("&게시글</p>")),
() -> assertEquals(3,
body.split("id=\"(?:raw|escaped)\"", -1).length));
}
}
@Test
void Thymeleaf_th_text와_th_utext의_출력_경계를_구분한다() {
var resolver = new StringTemplateResolver();
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCacheable(false);
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
var context = new Context(Locale.KOREA);
context.setVariable("unsafe", "<em>model</em>&");
var html = engine.process(
"<p id=\"escaped\" th:text=\"${unsafe}\"></p>"
+ "<p id=\"raw\" th:utext=\"${unsafe}\"></p>",
context);
assertAll(
() -> assertTrue(html.contains(
"<p id=\"escaped\"><em>model</em>&</p>")),
() -> assertTrue(html.contains(
"<p id=\"raw\"><em>model</em>&</p>")));
}
private static HttpResponse<String> get(
RunningServer server,
String path)
throws IOException, InterruptedException {
return get(URI.create(server.origin() + path));
}
private static HttpResponse<String> get(URI uri)
throws IOException, InterruptedException {
return send(uri, "GET", null);
}
private static HttpResponse<String> post(
RunningServer server,
String path,
String formBody)
throws IOException, InterruptedException {
return send(URI.create(server.origin() + path), "POST", formBody);
}
private static HttpResponse<String> send(
URI uri,
String method,
String body)
throws IOException, InterruptedException {
var builder = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(10));
if (body == null) {
builder.method(method, HttpRequest.BodyPublishers.noBody());
} else {
builder.header(
"Content-Type",
"application/x-www-form-urlencoded;charset=UTF-8");
builder.method(method, HttpRequest.BodyPublishers.ofString(
body, UTF_8));
}
return CLIENT.send(
builder.build(),
HttpResponse.BodyHandlers.ofString(UTF_8));
}
public record RunningServer(
ServletWebServerApplicationContext context,
String origin,
BoardStore store,
ViewProbe probe,
ViewFiles files)
implements AutoCloseable {
static RunningServer start() throws IOException {
var files = ViewFiles.create();
var store = new BoardStore();
var probe = new ViewProbe();
var mapper = new PostPageMapper();
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",
"server.tomcat.basedir",
files.root().resolve("tomcat").toString(),
"spring.main.banner-mode", "off",
"logging.level.root", "OFF"));
application.addInitializers(context -> {
var beans = context.getBeanFactory();
beans.registerSingleton("viewFiles", files);
beans.registerSingleton("boardStore", store);
beans.registerSingleton("viewProbe", probe);
beans.registerSingleton("postPageMapper", mapper);
});
try {
var context = (ServletWebServerApplicationContext)
application.run();
return new RunningServer(
context,
"http://127.0.0.1:"
+ context.getWebServer().getPort(),
store,
probe,
files);
} catch (RuntimeException failure) {
files.close();
throw failure;
}
}
@Override
public void close() throws IOException {
context.close();
files.close();
}
}
@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
public static class TestApplication {
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory>
documentRoot(ViewFiles files) {
return factory -> {
factory.setDocumentRoot(files.root().toFile());
factory.setRegisterDefaultServlet(true);
};
}
@Bean
ServletRegistrationBean<PostPageServlet> postPageServlet(
BoardStore store,
PostPageMapper mapper,
ViewProbe probe) {
var registration = new ServletRegistrationBean<>(
new PostPageServlet(store, store, mapper, probe),
"/posts",
"/posts/*");
registration.setName("postPageServlet");
return registration;
}
@Bean
ServletRegistrationBean<DispatchProbeServlet> dispatchProbeServlet(
ViewProbe probe) {
var registration = new ServletRegistrationBean<>(
new DispatchProbeServlet(probe),
"/diagnostics/*");
registration.setName("dispatchProbeServlet");
return registration;
}
}
public static final class PostPageServlet extends HttpServlet {
private static final String DETAIL_VIEW =
"/WEB-INF/views/post-detail.jsp";
private static final String FORM_VIEW =
"/WEB-INF/views/post-form.jsp";
private final PostQuery query;
private final PostCommand command;
private final PostPageMapper mapper;
private final ViewProbe probe;
PostPageServlet(
PostQuery query,
PostCommand command,
PostPageMapper mapper,
ViewProbe probe) {
this.query = query;
this.command = command;
this.mapper = mapper;
this.probe = probe;
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
var path = request.getPathInfo();
if (path == null || path.length() < 2) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
final long id;
try {
id = Long.parseLong(path.substring(1));
} catch (NumberFormatException invalidId) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
var post = query.find(id);
if (post.isEmpty()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
var model = mapper.toModel(post.orElseThrow());
var marker = requestMarker("GET", request);
request.setAttribute("post", model);
request.setAttribute("requestMarker", marker);
probe.lastModelType.set(model.getClass());
probe.lastGetMethod.set(request.getMethod());
probe.lastGetMarker.set(marker);
request.getRequestDispatcher(DETAIL_VIEW)
.forward(request, response);
}
@Override
protected void doPost(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
var title = Optional.ofNullable(request.getParameter("title"))
.orElse("");
var marker = requestMarker("POST", request);
probe.lastPostMarker.set(marker);
request.setAttribute("requestMarker", marker);
if (title.strip().length() < 3) {
response.setStatus(422);
request.setAttribute(
"form",
new PostFormModel(
title,
"제목은 3자 이상이어야 합니다"));
request.getRequestDispatcher(FORM_VIEW)
.forward(request, response);
return;
}
var id = command.create(title.strip());
request.setAttribute("postRequestOnly", "must-not-cross");
response.sendRedirect(
response.encodeRedirectURL(
request.getContextPath() + "/posts/" + id),
HttpServletResponse.SC_SEE_OTHER);
}
private static String requestMarker(
String method,
HttpServletRequest request) {
return method + ":" + System.identityHashCode(request);
}
}
public static final class DispatchProbeServlet extends HttpServlet {
private static final String ESCAPING_VIEW =
"/WEB-INF/views/escaping-probe.jsp";
private final ViewProbe probe;
DispatchProbeServlet(ViewProbe probe) {
this.probe = probe;
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
response.setCharacterEncoding(UTF_8.name());
response.setContentType("text/html");
switch (request.getPathInfo()) {
case "/forward-before-commit" -> {
response.getWriter().write("STALE");
request.setAttribute("unsafe", UNSAFE);
request.getRequestDispatcher(ESCAPING_VIEW)
.forward(request, response);
}
case "/forward-after-commit" -> {
response.getWriter().write("PARTIAL");
response.flushBuffer();
try {
request.getRequestDispatcher(ESCAPING_VIEW)
.forward(request, response);
} catch (IllegalStateException committed) {
probe.committedForwardRejected.set(true);
}
}
case "/jsp-escaping" -> {
request.setAttribute("unsafe", UNSAFE);
request.getRequestDispatcher(ESCAPING_VIEW)
.forward(request, response);
}
default -> response.sendError(
HttpServletResponse.SC_NOT_FOUND);
}
}
}
public interface PostQuery {
Optional<PostProjection> find(long id);
}
public interface PostCommand {
long create(String title);
}
public static final class BoardStore implements PostQuery, PostCommand {
private final Map<Long, StoredPost> posts = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(43);
final AtomicInteger queryCount = new AtomicInteger();
final AtomicInteger commandCount = new AtomicInteger();
BoardStore() {
posts.put(42L, new StoredPost(
42L,
UNSAFE,
1_200,
true,
"server-only-secret"));
}
@Override
public Optional<PostProjection> find(long id) {
queryCount.incrementAndGet();
return Optional.ofNullable(posts.get(id))
.map(post -> new PostProjection(
post.id(),
post.title(),
post.contentLength(),
post.recommended()));
}
@Override
public long create(String title) {
commandCount.incrementAndGet();
var id = nextId.getAndIncrement();
posts.put(id, new StoredPost(
id,
title,
title.length(),
false,
"created-secret"));
return id;
}
}
public static final class PostPageMapper {
PostPageModel toModel(PostProjection post) {
var formattedLength = NumberFormat
.getIntegerInstance(Locale.KOREA)
.format(post.contentLength())
+ "자";
return new PostPageModel(
post.id(),
post.title(),
formattedLength,
post.contentLength() >= 60,
post.recommended());
}
}
public record PostPageModel(
long id,
String title,
String displayLength,
boolean longPost,
boolean recommended) {
public long getId() {
return id;
}
public String getTitle() {
return title;
}
public String getDisplayLength() {
return displayLength;
}
public boolean isLongPost() {
return longPost;
}
public boolean isRecommended() {
return recommended;
}
}
public record PostFormModel(String title, String error) {
public String getTitle() {
return title;
}
public String getError() {
return error;
}
}
private record StoredPost(
long id,
String title,
int contentLength,
boolean recommended,
String internalSecret) {}
public record PostProjection(
long id,
String title,
int contentLength,
boolean recommended) {}
public static final class ViewProbe {
final AtomicReference<Class<?>> lastModelType =
new AtomicReference<>();
final AtomicReference<String> lastPostMarker =
new AtomicReference<>();
final AtomicReference<String> lastGetMarker =
new AtomicReference<>();
final AtomicReference<String> lastGetMethod =
new AtomicReference<>();
final AtomicBoolean committedForwardRejected =
new AtomicBoolean(false);
}
public record ViewFiles(Path root) implements AutoCloseable {
private static final String DETAIL_JSP = """
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>게시글 상세</title></head>
<body>
<main id="post-detail">
<h1><c:out value="${post.title}" /></h1>
<p><c:out value="${post.displayLength}" /></p>
<c:if test="${post.longPost}"><strong>긴 글</strong></c:if>
<c:if test="${post.recommended}"><strong>추천</strong></c:if>
<p id="forward-uri"><c:out value="${requestScope['jakarta.servlet.forward.request_uri']}" /></p>
<p id="post-only"><c:out value="${postRequestOnly}" /></p>
<p id="repository"><c:out value="${repository}" /></p>
</main>
</body>
</html>
""";
private static final String FORM_JSP = """
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>게시글 등록</title></head>
<body>
<form method="post" action="/posts">
<label>제목 <input name="title" value="<c:out value="${form.title}" />"></label>
<p role="alert"><c:out value="${form.error}" /></p>
</form>
<p id="forward-uri"><c:out value="${requestScope['jakarta.servlet.forward.request_uri']}" /></p>
<p id="marker"><c:out value="${requestMarker}" /></p>
</body>
</html>
""";
private static final String ESCAPING_JSP = """
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>escaping probe</title></head>
<body>
<p id="raw">${unsafe}</p>
<p id="escaped"><c:out value="${unsafe}" /></p>
</body>
</html>
""";
static ViewFiles create() throws IOException {
var root = Files.createTempDirectory("b77-views-")
.toRealPath();
var views = Files.createDirectories(
root.resolve("WEB-INF/views"));
Files.writeString(
views.resolve("post-detail.jsp"), DETAIL_JSP, UTF_8);
Files.writeString(
views.resolve("post-form.jsp"), FORM_JSP, UTF_8);
Files.writeString(
views.resolve("escaping-probe.jsp"),
ESCAPING_JSP,
UTF_8);
return new ViewFiles(root);
}
@Override
public void close() throws IOException {
if (!root.getFileName().toString().startsWith("b77-views-")) {
throw new IOException("unexpected temporary root: " + root);
}
FileSystemUtils.deleteRecursively(root);
}
}
}테스트는 다음 경계를 서로 다른 관찰로 잠급니다.
- GET controller가
PostQuery를 호출하고 내부 비밀값을 제외한PostPageModel만 request에 둔 뒤 상수/WEB-INF/views/post-detail.jsp로 forward합니다. /WEB-INFJSP 직접 HTTP 요청은404이고,?view=parameter는 controller의 fixed view를 바꾸지 못합니다.- 검증 실패 POST는
422상태와 입력·field error를 같은 request attribute에 둔 뒤 form view로 forward합니다. - 성공 POST는 Servlet 6.1의 두 인자
sendRedirect(location, SC_SEE_OTHER)로303과Location을 보냅니다. client가 명시적으로 새 GET을 수행하면 이전 request attribute 없이 model을 다시 만듭니다. forward()는 미커밋 body buffer를 지우지만flushBuffer()뒤 호출하면IllegalStateException입니다.- 실제 Jasper가 raw JSP EL과 기본
escapeXml=true인<c:out>을 다르게 렌더링합니다. - 실제 Thymeleaf가
th:text와th:utext의 escaped·raw text 경계를 다르게 렌더링합니다.
th:utext와 raw JSP EL 테스트는 위험 차이를 증명하기 위한 진단 전용입니다. 사용자 값을 raw로 내보내는 구현을 권장하지 않습니다.
SAME REQUEST FORWARD · NEW REQUEST 303
포워드와 303은 요청 수명을 섞지 않고 계산 소유자를 지킨다
조회와 검증 실패는 same-request forward, 성공한 POST는 303과 새 GET을 사용합니다. 어느 경로에서도 template가 업무 규칙이나 데이터 접근을 되가져가지 않습니다.
01 · SAME-REQUEST FORWARD
조회와 검증 실패는 같은 request의 model·errors로 view를 렌더링한다
controller가 request attributes를 완성하고 fixed
/WEB-INF view로 forward합니다. 같은 request object와
attributes가 유지되고 browser URL도 유지됩니다.
02 · POST → 303 PRG → NEW GET
POST success는 Location을 보내고 browser가 new request를 만든다
303 See Other 뒤 new GET에는 이전 attributes가
없습니다. GET controller가 화면 model을 다시 구성합니다.
03 · BUSINESS + FORMATTING
업무 값은 use case/domain, 화면 포매팅은 presenter/typed view model이 소유한다
권한·상태 전이 같은 업무 의미와 locale 날짜·숫자·레이블 같은 표현 준비를 서로 다른 변경 이유에 맞춰 분리합니다.
04 · MARKUP BOUNDARY
template는 배치와 이스케이프만 하고 복잡한 expression을 만들지 않는다
repository 호출, request parsing, business 계산은 금지합니다. raw
JSP EL 및 Thymeleaf th:utext는 기본 출력 경계가
아닙니다.
303은 POST 처리 결과를 다른 resource의 retrieval로 안내하는 HTTP 의미입니다. 중복 명령 방지는 서버의 idempotency·transaction 계약과 함께 설계해야 하며, 상태 코드 하나가 자동으로 보장하지 않습니다.
forward와 303은 request 수명이 다릅니다
Servlet 6.1 RequestDispatcher.forward는 커밋 전에만 호출할 수 있습니다. 미커밋 body buffer를 지우고 같은 request·response를 target에 전달합니다. target 경로가 현재 dispatch view에 반영되고 원래 request 정보는 jakarta.servlet.forward.* attribute로 보존됩니다. 정상 반환 전에 async가 시작되지 않았다면 container는 응답을 보내고 커밋·종료합니다.
반면 RFC 9110의 303 See Other는 Location이 가리키는 다른 resource를 GET 또는 HEAD로 조회해 원래 요청의 간접 응답으로 제시하도록 안내합니다. 성공한 POST의 결과 화면을 별도 GET resource로 만들 때 적합합니다. Servlet 6.1의 한 인자 sendRedirect(location)은 302이므로 PRG 의도를 정확히 표현하려면 status overload를 사용합니다.
| 요구 | forward | 303 redirect |
|---|---|---|
| 대표 사용 | GET 결과·POST 검증 실패 view | 성공 POST 뒤 결과 resource |
| request | 같은 request | browser가 새 GET request 생성 |
| request attributes | view까지 유지 | 전달되지 않음 |
| browser 주소 | 원래 URI 유지 | Location URI로 변경 |
| response 경계 | 내부 dispatch가 render 완료 | 303 응답 후 별도 왕복 |
PRG는 정상적으로 redirect를 따라간 뒤 새로고침할 때 POST 재전송을 피하는 application contract입니다. 이중 클릭, network retry, 여러 client의 동시 명령에 대해 exactly-once를 보장하지 않습니다. 중요한 command에는 idempotency key, unique constraint, transaction 같은 서버 계약을 별도로 둡니다.
계산은 변경 이유가 있는 층에 둡니다
“추천 자격”처럼 업무 의미가 있는 값은 domain·use case가 계산합니다. “1,200자”, locale 날짜, 화면 label과 같은 표현 준비는 presenter나 typed view-model mapper가 담당합니다. element 배치와 context-aware escaping은 template가 담당합니다.
contentLength >= 60을 긴 글 badge로 보이는 정도는 화면 표현 상태일 수 있지만, “길면 추천한다”가 서비스 정책이라면 template boolean expression이 아니라 업무 경계로 옮깁니다. 모든 boolean을 무조건 domain 또는 view에 몰지 않고 그 값이 바뀌는 이유로 소유자를 정합니다.
공식 문서로 경계를 확인합니다
- Jakarta Servlet 6.1 사양: dispatch,
/WEB-INF, buffer·commit의 규범적 전체 문맥 - Jakarta Servlet 6.1
RequestDispatcher: forward의 pre-commit, buffer와 request 정보 계약 - Jakarta Servlet 6.1
HttpServletResponse:sendRedirectstatus overload와 commit 계약 - Jakarta Server Pages 4.0: JSP translation·실행과 expression language
- Jakarta Tags 3.0
c:out:escapeXml기본값과 text 출력 - Thymeleaf 3.1 tutorial:
th:text,th:utext, escaped·raw inlining - RFC 9110 §15.4.4: 303과
Locationresource retrieval 의미 - Spring Boot Servlet web reference: embedded Servlet container와 programmatic Servlet 등록
점검표
- controller가 HTTP 입력·use case 호출·typed model·fixed view만 소유하는가?
- view 경로를 request parameter나 사용자 입력에서 만들지 않는가?
- JPA entity, JDBC row, repository와 내부 비밀값이 template에 노출되지 않는가?
- JSP text는 raw
${...}가 아니라<c:out>, Thymeleaf text는th:text처럼 escape되는 기능을 기본으로 쓰는가? - validation failure는 같은 request의 입력·errors로 forward하는가?
- 성공 POST는 의도한 303과 context-aware
Location을 보내고 GET이 model을 다시 만드는가? - business, formatting, markup 계산이 변경 이유에 맞는 owner에 있는가?
- PRG와 멱등성·exactly-once를 같은 보장으로 오해하지 않는가?
다음 문서에서는 이 controller·model·view 경계를 여러 요청에 반복 적용할 때 생기는 mapping과 dispatch 중복을 front controller와 작은 MVC framework로 옮깁니다.