본문 바로가기

서블릿·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이 애플리케이션 흐름까지 소유합니다. 둘 다 처음에는 빠르지만 입력·업무·화면 가운데 하나만 바뀌어도 다른 층을 함께 수정하고 실행해야 합니다.

typed model도 템플릿 문자열까지 컴파일 검사하지는 않습니다. Java에서 PostPageModel의 필드와 생성자는 타입으로 제한되지만 JSP의 ${post.title} 같은 속성명은 렌더링 때 EL이 해석합니다. 이 예제의 getter는 그 접근 계약을 드러내며, 오타가 없는지와 실제 화면 값이 맞는지는 렌더링 assertion으로 확인합니다. model 타입을 정하는 책임과 HTML 문맥에 맞게 escape하는 책임은 각각 필요합니다.

표현과 제어를 분리하고 typed model로만 연결한다

projection 조회·page model·request attribute·고정 WEB-INF view 출력으로 표현 값이 이동한다.

Servlet 문자열 HTML은 제어와 표현을 Java에 섞고, JSP scriptlet은 표현과 업무 규칙과 데이터 접근을 템플릿에 섞습니다. 분리된 구조에서는 GET 요청을 Servlet controller가 받고 PostQuery use case를 호출한 뒤 화면에 필요한 값만 담은 PostPageModel을 request attribute로 저장합니다. 고정된 RequestDispatcher가 WEB-INF 내부 view로 forward하고, view는 repository를 다시 호출하지 않은 채 c:out 또는 Thymeleaf th:text처럼 이스케이프되는 출력 기능으로 markup만 만듭니다.

INPUT → USE CASE → TYPED MODEL → INTERNAL VIEW

표현과 제어를 분리하고 typed model로만 연결한다

두 혼합 극단을 피합니다. controller는 입력과 use case, model, 고정된 view 선택까지만 소유하고 template는 전달받은 화면 값의 안전한 markup만 소유합니다.

Servlet 조회에서 mapper와 내부 view로 이어지는 typed model 경계PostPageServlet이 PostQuery에서 projection을 조회하고 PostPageMapper가 화면 model을 만듭니다. model은 request attribute로 전달되어 고정된 WEB-INF view가 출력합니다. template은 repository나 입력 파싱을 소유하지 않습니다.
GET /posts/42PostPageServlet
연결 관계GET /posts/42 · PostPageServlet → PostQuery · PostProjection
PostQueryPostProjection
연결 관계PostQuery · PostProjection → PostPageMapper · 표시 값 선택
PostPageMapper표시 값 선택
연결 관계PostPageMapper · 표시 값 선택 → PostPageModel · 공개 화면 값만
PostPageModel공개 화면 값만
연결 관계PostPageModel · 공개 화면 값만 → request attribute · post → model
request attributepost → model
연결 관계request attribute · post → model → fixed forward · /WEB-INF view
fixed forward/WEB-INF view

view: c:out / th:text · markup과 text escaping

Servlet 조회와 typed model의 모바일 흐름기존 가로 그림과 같은 여섯 노드·다섯 관계이다. GET controller가 query에서 projection을 받고 mapper가 화면 model을 만든다. model은 request attribute로 전달되고 고정된 내부 view로 forward한다. view는 제공된 값의 markup과 escaping을 맡으며 repository를 다시 호출하지 않는다.GET /posts/42PostPageServletPostQueryPostProjectionPostPageMapper표시 값 선택PostPageModel공개 화면 값만request attributepost → modelfixed forward/WEB-INF view

view는 전달된 model로 markup을 만들고 c:out / th:text로 text를 escape합니다. 이 선은 SQL·권한·추천 정책을 template로 옮기는 경로가 아닙니다.

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 계약으로 사용합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • PostPageServlet이 PostQuery에서 projection을 조회하고 PostPageMapper가 화면 model을 만듭니다. model은 request attribute로 전달되어 고정된 WEB-INF view가 출력합니다. template은 repository나 입력 파싱을 소유하지 않습니다.
  • 기존 가로 그림과 같은 여섯 노드·다섯 관계이다. GET controller가 query에서 projection을 받고 mapper가 화면 model을 만든다. model은 request attribute로 전달되고 고정된 내부 view로 forward한다. view는 제공된 값의 markup과 escaping을 맡으며 repository를 다시 호출하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
4두 혼합 극단을 피합니다. controller는 입력과 use case, model, 고정된 view 선택까지만 소유하고 template은 전달받은 화면 값의 안전한 markup만 소유합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
projection 조회

Servlet이 PostQuery에서 projection을 얻습니다.

조회: PostQuery · 산출물: projection화면 자료의 조회가 완료됩니다.
typed 화면 모델

PostPageMapper가 page model을 만들어 request attribute에 둡니다.

자료: typed page model · 전달: request attribute조회 표현이 화면 계약으로 바뀝니다.
고정 view 출력

고정 WEB-INF view가 model만 출력합니다.

view: 고정 WEB-INF · repository·파싱: template 책임 아님화면 출력이 데이터 조회와 분리됩니다.

표현과 제어를 분리하고 typed model로만 연결한다

projection 조회·page model·request attribute·고정 WEB-INF view 출력으로 표현 값이 이동한다.

Servlet 문자열 HTML은 제어와 표현을 Java에 섞고, JSP scriptlet은 표현과 업무 규칙과 데이터 접근을 템플릿에 섞습니다. 분리된 구조에서는 GET 요청을 Servlet controller가 받고 PostQuery use case를 호출한 뒤 화면에 필요한 값만 담은 PostPageModel을 request attribute로 저장합니다. 고정된 RequestDispatcher가 WEB-INF 내부 view로 forward하고, view는 repository를 다시 호출하지 않은 채 c:out 또는 Thymeleaf th:text처럼 이스케이프되는 출력 기능으로 markup만 만듭니다.

INPUT → USE CASE → TYPED MODEL → INTERNAL VIEW

표현과 제어를 분리하고 typed model로만 연결한다

두 혼합 극단을 피합니다. controller는 입력과 use case, model, 고정된 view 선택까지만 소유하고 template는 전달받은 화면 값의 안전한 markup만 소유합니다.

Servlet 조회에서 mapper와 내부 view로 이어지는 typed model 경계PostPageServlet이 PostQuery에서 projection을 조회하고 PostPageMapper가 화면 model을 만듭니다. model은 request attribute로 전달되어 고정된 WEB-INF view가 출력합니다. template은 repository나 입력 파싱을 소유하지 않습니다.
GET /posts/42PostPageServlet
연결 관계GET /posts/42 · PostPageServlet → PostQuery · PostProjection
PostQueryPostProjection
연결 관계PostQuery · PostProjection → PostPageMapper · 표시 값 선택
PostPageMapper표시 값 선택
연결 관계PostPageMapper · 표시 값 선택 → PostPageModel · 공개 화면 값만
PostPageModel공개 화면 값만
연결 관계PostPageModel · 공개 화면 값만 → request attribute · post → model
request attributepost → model
연결 관계request attribute · post → model → fixed forward · /WEB-INF view
fixed forward/WEB-INF view

view: c:out / th:text · markup과 text escaping

Servlet 조회와 typed model의 모바일 흐름기존 가로 그림과 같은 여섯 노드·다섯 관계이다. GET controller가 query에서 projection을 받고 mapper가 화면 model을 만든다. model은 request attribute로 전달되고 고정된 내부 view로 forward한다. view는 제공된 값의 markup과 escaping을 맡으며 repository를 다시 호출하지 않는다.GET /posts/42PostPageServletPostQueryPostProjectionPostPageMapper표시 값 선택PostPageModel공개 화면 값만request attributepost → modelfixed forward/WEB-INF view

view는 전달된 model로 markup을 만들고 c:out / th:text로 text를 escape합니다. 이 선은 SQL·권한·추천 정책을 template로 옮기는 경로가 아닙니다.

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 계약으로 사용합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • PostPageServlet이 PostQuery에서 projection을 조회하고 PostPageMapper가 화면 model을 만듭니다. model은 request attribute로 전달되어 고정된 WEB-INF view가 출력합니다. template은 repository나 입력 파싱을 소유하지 않습니다.
  • 기존 가로 그림과 같은 여섯 노드·다섯 관계이다. GET controller가 query에서 projection을 받고 mapper가 화면 model을 만든다. model은 request attribute로 전달되고 고정된 내부 view로 forward한다. view는 제공된 값의 markup과 escaping을 맡으며 repository를 다시 호출하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
4두 혼합 극단을 피합니다. controller는 입력과 use case, model, 고정된 view 선택까지만 소유하고 template은 전달받은 화면 값의 안전한 markup만 소유합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
projection 조회

Servlet이 PostQuery에서 projection을 얻습니다.

조회: PostQuery · 산출물: projection화면 자료의 조회가 완료됩니다.
typed 화면 모델

PostPageMapper가 page model을 만들어 request attribute에 둡니다.

자료: typed page model · 전달: request attribute조회 표현이 화면 계약으로 바뀝니다.
고정 view 출력

고정 WEB-INF view가 model만 출력합니다.

view: 고정 WEB-INF · repository·파싱: template 책임 아님화면 출력이 데이터 조회와 분리됩니다.

두 혼합 극단을 제거합니다

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 controllerHTTP 입력 검증, use case 호출, typed model과 고정 view 선택HTML 문자열, SQL, 업무 상태 계산
use case·domain조회, 권한, 상태 전이, 추천 자격 같은 업무 의미HTML tag와 locale 표시 문자열
presenter·view-model mapper화면 필드 선택, 날짜·숫자·label 포매팅, 표현 상태request dispatch와 DB 접근
JSP·Thymeleaf viewelement 배치와 출력 문맥에 맞는 escapingrepository, 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 하나를 모든 문맥의 해답으로 재사용하지 않습니다.

JSP 선택 실습을 준비합니다

시작 장의 빌드는 Spring MVC·Thymeleaf와 테스트 스타터를 갖춥니다. 아래 JSP 비교 실습에는 Jasper와 Jakarta Tags를 더해 JSP 파일과 jakarta.tags.core의 <c:out>, <c:if>를 해석할 수 있어야 합니다. 이 실습을 따라 할 때 기존 build.gradle의 dependencies 블록에 다음 세 줄만 추가합니다.

build.gradle (JSP 선택 실습의 테스트 런타임 의존성 추가)
testRuntimeOnly 'org.apache.tomcat.embed:tomcat-embed-jasper'
testRuntimeOnly 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api'
testRuntimeOnly 'org.glassfish.web:jakarta.servlet.jsp.jstl'

세 항목은 기존 Spring Boot 4.1.1 BOM의 관리 대상이므로 선언에 버전을 다시 붙이지 않습니다. 그 BOM의 기준은 Jasper 11.0.24, Jakarta Tags API 3.0.2, GlassFish 구현 3.0.1입니다. BOM은 버전을 관리하고, 위 선언은 선택 실습의 테스트 런타임에 라이브러리를 추가합니다. 기본 Thymeleaf 경로를 공부하는 동안에는 이 JSP 실습을 뒤로 미뤄도 됩니다.

이 파일은 테스트가 임시 디렉터리에 JSP를 만들어 Tomcat의 document root로 연결하는 독립 예제입니다. src/main/webapp이나 운영 JSP 화면을 추가하지 않으며, 이 구성을 executable JAR의 JSP 배포 지원으로 일반화하지 않습니다. 실습을 수행하면 GET의 escaping, 검증 실패의 같은 요청 forward, 성공 POST의 303과 새 GET을 아래 assertion과 대조하세요. JSP가 해석되지 않거나 taglib를 찾지 못하면 먼저 테스트 런타임의 Jasper·Tags API·구현 구성과 jakarta.tags.core URI를 확인합니다.

다음 독립 테스트 파일은 Servlet controller, typed model, 실제 Jasper JSP, Thymeleaf renderer와 HTTP client를 한 컴파일 단위에 담습니다. JUnit assertion과 HTTP 응답으로 각 경계를 확인합니다.

src/test/java/board/servlet/EmbeddedServletViewBoundaryTest.java
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("&lt;script&gt;")),
                    () -> 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=\"&lt;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\">&lt;script&gt;")),
                    () -> 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\">&lt;script&gt;")),
                    () -> assertTrue(body.contains("&amp;게시글</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\">&lt;em&gt;model&lt;/em&gt;&amp;</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-INF JSP 직접 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로 내보내는 구현을 권장하지 않습니다.

조회와 검증 실패는 같은 request에서 controller가 model 또는 errors를 준비하고 WEB-INF view로 forward하므로 request attributes가 유지되고 브라우저 주소는 바뀌지 않습니다. POST 성공은 303 See Other와 Location을 응답하고 브라우저가 새 GET을 만들어 model을 다시 구성하므로 이전 request attributes는 이어지지 않습니다. 업무 규칙은 service 또는 use case, 화면 포매팅은 typed view-model mapper, markup과 이스케이프는 template가 소유하며 template는 repository, request parsing, 복잡한 업무 계산을 하지 않습니다.

SAME REQUEST FORWARD · NEW REQUEST 303

포워드와 303은 요청 수명을 섞지 않고 계산 소유자를 지킨다

조회와 검증 실패는 same-request forward, 성공한 POST는 303과 새 GET을 사용합니다. 어느 경로에서도 template가 업무 규칙이나 데이터 접근을 되가져가지 않습니다.

같은 request forward와 별도 요청인 303 후 GET의 비교forward는 커밋 전에 같은 request와 attributes를 view로 전달하고 브라우저 주소를 유지합니다. 성공 POST는 303과 Location을 응답하고 client가 새 GET을 수행해 model을 다시 만듭니다. PRG는 중복 명령 방지를 보장하지 않습니다.GET / invalid POST조회 / 입력 검증 실패request attributesmodel / field errorsfixed forward같은 request주소 유지 · 미커밋 버퍼 제거 · attributes 유지POST 성공이 fixture의 명령 실행303 + LocationPOST 응답 종료새 GETmodel 다시 구성새 주소 · 새 request · 이전 attributes 전달 없음
같은 request forward와 새 request GET의 모바일 비교기존 가로 그림과 같은 여섯 노드·네 관계이다. 위 영역은 GET 또는 검증 실패 POST에서 attributes를 만들어 같은 request로 forward하는 경로이다. 아래 영역은 성공 POST에서303과Location을 받은 뒤 client가 별도GET을 보내 model을 새로 만드는 경로이다. 영역 간 연결은 없으며 앞 요청의 attributes가 새 요청으로 복사되지 않는다.같은 request의 경로새 request를 만드는 경로GET 또는검증 실패 POSTrequest attributesmodel / field errorsfixed forward같은 requestPOST 성공이 fixture의 명령 실행303 + LocationPOST 응답 종료새 GETmodel 다시 구성

위쪽은 원래 주소와 attributes를 유지합니다. 아래쪽은 새 주소에서 model을 다시 만듭니다. 두 영역은 별도 경우의 비교이며, 위에서 아래로 계속 실행하는 한 요청이 아닙니다.

01 · SAME-REQUEST FORWARD

조회와 검증 실패는 같은 request의 model·errors로 view를 렌더링한다

controller가 request attributes를 완성하고 fixed /WEB-INF view로 forward합니다. 같은 request object와 attributes가 유지되고 browser URL도 유지됩니다. forward는 커밋 전에만 가능하며 미커밋 body buffer를 지웁니다.

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를 사용합니다.

요구forward303 redirect
대표 사용GET 결과·POST 검증 실패 view성공 POST 뒤 결과 resource
request같은 requestbrowser가 새 GET request 생성
request attributesview까지 유지전달되지 않음
browser 주소원래 URI 유지Location URI로 변경
response 경계내부 dispatch가 render 완료303 응답 후 별도 왕복

PRG는 정상적으로 redirect를 따라간 뒤 새로고침할 때 POST 재전송을 피하는 application contract입니다. 이중 클릭, network retry, 여러 client의 동시 명령에 대해 exactly-once를 보장하지 않습니다. 중요한 command에는 idempotency key, unique constraint, transaction 같은 서버 계약을 별도로 둡니다.

예시 같은 fixture에서 제목이 짧아 검증이 실패하면 저장 명령을 호출하지 않고, 원래 입력과 오류를 request에 넣어 form view로 forward합니다. HTTP 응답은 422이지만 입력값을 포함한 HTML은 정상적으로 렌더링할 수 있습니다. 반대로 저장이 성공한 POST에서는 303만 받은 시점과 그 Location으로 새 GET을 보낸 시점을 나눠 봅니다. 위 HTTP 클라이언트는 redirect를 자동으로 따라가지 않으므로 303 수신만으로 조회 횟수가 늘지는 않습니다.

독립 확인 새 RunningServer 하나에서 다른 요청이나 동시 접근 없이 다음 순서로 확인한다고 가정합니다. 공백도 포함한 제목은 form 인코딩해 보내며, 별도 인증·저장 실패는 없습니다.

  1. 제목 AB 로 POST합니다. status, command 횟수와 form view에 남는 원래 입력은 무엇인가요?
  2. 이어 제목 새 글 로 POST하되 redirect는 아직 따라가지 않습니다. Location의 게시글 번호, command·query 횟수를 판단하세요.
  3. 그 Location으로 명시적인 GET을 보냅니다. command·query 횟수, displayLength, 긴 글·추천 표시, 이전 POST의 postRequestOnly 전달 여부를 판단하세요.
검증·저장·새 조회를 나누는 풀이

첫 입력은 strip()하면 두 글자여서 검증을 통과하지 못합니다. 422 상태로 같은 request의 form을 렌더링하고 command 횟수는 0입니다. PostFormModel에는 strip()한 값이 아니라 원래 제목을 넣으므로 양쪽 공백을 포함한 입력이 남습니다. 입력을 그대로 보존한다는 것은 raw markup으로 출력한다는 뜻이 아니며, form의 <c:out>은 출력 문자를 escape합니다.

둘째 입력은 strip()하면 공백 하나를 포함한 세 글자 새 글입니다. 이 fixture의 기준인 3자 이상을 만족해 초기 nextId 43으로 저장합니다. POST 응답은 303, Location의 번호는 43이고 command는 1, query는 0입니다. 저장 성공과 조회 실행을 같은 단계로 세지 않습니다.

새 GET에서 조회 한 번을 실행하므로 command 1·query 1이 됩니다. 새 게시글의 contentLength는 저장한 제목의 Java 문자열 길이 3이므로 displayLength는 3자, longPost는 false이고 recommended도 false입니다. 이 길이는 앞 절의 HTTP octet 길이와 다른 화면 fixture 값입니다. 새 request에는 이전 POST의 postRequestOnly가 자동 전달되지 않습니다. 결과 페이지는 저장소의 projection으로 model을 새로 구성합니다.

이 풀이는 원 코드의 분기·카운터·request 수명을 추적한 예상입니다. 실제 Jasper·Spring·HTTP 연결을 새로 실행한 검증과는 구분합니다.

여기서 ‘자’는 예제의 Java UTF-16 코드 단위입니다. strip().length() >= 3은 보이는 글자 세 개나 코드 포인트 세 개라는 규칙이 아닙니다. A😀는 코드 포인트 두 개지만 length는 3이어서 이 fixture를 통과하고 3자로 표시됩니다. 실제 서비스가 눈에 보이는 글자 수를 요구한다면 그 단위와 최대 길이를 업무 계약으로 따로 정해야 합니다. 예제의 strip()도 Java가 정의한 양끝 공백 제거이며 모든 시각적 공백을 정규화하는 기능은 아닙니다.

계산은 변경 이유가 있는 층에 둡니다

“추천 자격”처럼 업무 의미가 있는 값은 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에 몰지 않고 그 값이 바뀌는 이유로 소유자를 정합니다.

이 테스트의 URL 예시는 루트 컨텍스트에 한정됩니다. form의 action="/posts"는 브라우저에서 호스트 루트 경로이고, 서버 내부의 getRequestDispatcher("/WEB-INF/...")는 애플리케이션 컨텍스트 기준입니다. /board 아래 배포한다면 form 제출 URL도 /board/posts를 가리켜야 하며, 성공 redirect처럼 컨텍스트 경로를 반영하는 URL 생성 기능을 사용합니다. 두 슬래시 경로의 기준을 같다고 가정하지 않습니다.

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

점검표

  • 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로 옮깁니다.

추가 연습 — 검증 단위와 요청 경계를 추적합니다. (1) 비어 있는 새 fixture에서 제목 A😀 를 POST합니다. 검증은 원 코드의 strip().length() >= 3, 생성 ID는 43부터이고 요청마다 새 글을 생성하며 중복 방지 키는 없습니다. POST의 저장 제목·상태·command/query 횟수와 이어지는 GET의 길이 표시를 구하세요. (2) 같은 유효 POST가 네트워크 재시도로 두 번 서버에 도착하고 각각 성공한 뒤, 두 번째 Location만 GET 한 번으로 조회합니다. 초기 command/query는 0이고 ID는 43부터입니다. 생성된 ID와 최종 횟수, 303만으로 중복 생성을 막을 수 있는지를 설명하세요. (3) 서로 다른 조회 projection의 contentLength가 59·60·61이고 recommended는 각각 true·false·true입니다. mapper의 longPost 및 추천 표시를 구하고, 긴 글을 자동 추천하도록 요구가 바뀌면 어느 경계에서 결정할지 설명하세요.

추가 연습의 풀이 확인
  1. 저장 제목은 A😀입니다. UTF-16 길이는 A 1 + 보충 문자 2 = 3이므로 성공하며 303과 /posts/43으로 안내합니다. POST 직후 command=1·query=0이고, GET 한 번 뒤 command=1·query=1이며 예제 표시는 3자입니다. 코드 포인트 수 2나 UTF-8 바이트 수와는 구별합니다. 새 GET에서는 POST의 request attribute를 읽지 않고 ID 43의 projection으로 model을 다시 만듭니다.
  2. 첫 명령은 ID 43, 두 번째는 ID 44를 생성합니다. 두 POST 뒤 command=2·query=0, 두 번째 결과 GET 뒤 command=2·query=1입니다. 303은 다음 조회 경로를 안내할 뿐 이미 두 번 실행된 명령을 합치지 않습니다. 중복 방지를 요구한다면 같은 논리 명령을 식별하고 원자적으로 처리하는 별도 서버 계약이 필요합니다.
  3. longPost는 false·true·true이고 recommended는 전달받은 true·false·true입니다. 60 이상인 두 번째 글이 추천되지 않는 것은 현재 계약에 맞습니다. 단순 화면 badge 기준은 mapper에 두지만, 길이로 추천 자격을 바꾸는 업무 정책은 use case/domain에서 결정하고 view는 그 결과를 표시합니다.