본문 바로가기

서블릿·JSP 책임

Servlet controller와 내부 view의 typed model 경계, forward·303 요청 수명, JSP·Thymeleaf escaping을 embedded Tomcat wire test로 검증합니다.

서블릿이 요청의 해석과 사용 사례 호출, HTML 문자열까지 모두 맡으면 제어와 표현이 한데 묶입니다. 반대로 JSP의 스크립틀릿(scriptlet)이 요청과 저장소, 업무 규칙을 실행하면 템플릿이 애플리케이션의 흐름까지 맡게 됩니다. 둘 다 처음에는 빠르지만, 입력과 업무, 화면 가운데 하나만 바뀌어도 다른 층을 함께 고치고 실행해야 합니다.

타입을 정한 모델(typed model)도 템플릿의 문자열까지 컴파일러가 검사해 주지는 않습니다. Java에서 PostPageModel의 필드와 생성자는 타입으로 제한되지만, JSP의 ${post.title} 같은 속성 이름은 렌더링할 때 EL이 해석합니다. 이 예제의 게터는 템플릿이 어떤 이름으로 값을 읽는지를 드러내며, 오타가 없는지와 화면의 값이 맞는지는 렌더링 결과를 확인하는 테스트로 봅니다. 모델의 타입을 정하는 일과, HTML 문맥에 맞게 이스케이프하는 일은 각각 따로 필요합니다.

표현과 제어를 분리하고 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 책임 아님화면 출력이 데이터 조회와 분리됩니다.

그림은 두 극단을 피한 구조를 보여 줍니다. GET /posts/42를 받은 PostPageServlet이 PostQuery에서 조회 결과(projection)를 얻고, PostPageMapper가 화면에 필요한 값만 담은 PostPageModel을 만들어 요청 속성에 둔 뒤, 고정된 /WEB-INF 아래의 뷰로 포워드합니다. 뷰는 전달받은 모델로 마크업만 만들고 <c:out>이나 th:text로 텍스트를 이스케이프하며, 저장소를 다시 호출하거나 입력을 해석하지 않습니다. /WEB-INF는 바깥의 직접 요청을 막지만 서버 안의 디스패치는 허용하므로, 인증이나 권한을 대신하지는 않습니다.


두 혼합 극단을 제거합니다

서블릿에서 "<h1>" + post.title() + "</h1>"처럼 HTML을 조립하면, Java 문자열과 출력 문맥별 이스케이프, CSS 클래스와 화면 구조가 한 번에 바뀌는 단위가 됩니다. 사용자의 제목을 그대로 이어 붙이면 XSS가 생기고, HTML을 고치는 일이 컨트롤러의 컴파일과 배포에 묶입니다.

JSP 자체가 문제는 아닙니다. Jakarta Server Pages 4.0은 서버에서 동적인 마크업을 만드는 뷰 기술입니다. 문제는 스크립틀릿 안에서 파라미터를 해석하고 저장소를 찾고 업무 분기를 실행하는 구조입니다. 데이터 조회나 권한 확인이 렌더링 도중 실패하면 응답의 일부가 이미 써졌을 수 있고, 뷰를 테스트하려 해도 서블릿 컨텍스트와 DB가 필요해집니다. 그래서 각자의 일을 다음처럼 좁힙니다.

구성 요소맡는 일맡지 않는 일
서블릿 컨트롤러HTTP 입력 검증, 사용 사례 호출, 타입이 있는 모델과 고정된 뷰 선택HTML 문자열, SQL, 업무 상태 계산
사용 사례와 도메인조회, 권한, 상태 전이, 추천 자격 같은 업무 의미HTML 태그와 로케일별 표시 문자열
프레젠터와 뷰 모델 매퍼화면 필드 선택, 날짜·숫자·레이블 형식, 표시 상태요청 디스패치와 DB 접근
JSP와 Thymeleaf 뷰요소 배치와 출력 문맥에 맞는 이스케이프저장소, 요청 해석, 복잡한 업무 식

PostPageModel 같은 작은 타입은 화면에 필요한 값만 공개합니다. JPA 엔티티나 JDBC 행, 내부 식별자와 비밀 필드를 템플릿에 그대로 넘기지 않습니다. 범용 Map<String, Object>만 써서 키의 오타가 실행할 때까지 숨는 구조도 피합니다.


내부 view와 escaping은 따로 봅니다

/WEB-INF 아래의 리소스는 클라이언트가 직접 요청하면 404이지만, 서버 코드는 RequestDispatcher로 접근할 수 있습니다. 그러니 /WEB-INF를 권한의 울타리로 과장하지 않습니다. 컨트롤러가 상수로 정해 둔 뷰 경로만 고르고, 요청 파라미터로 JSP 경로를 받지 않도록 하는 화면 이동의 규칙으로 씁니다.

JSP의 ${post.title}은 값을 평가해 출력할 뿐, 모든 경우에 HTML을 이스케이프해 주는 기능이 아닙니다. HTML 텍스트 위치에서는 Jakarta Tags 3.0의 c:out처럼 기본값이 escapeXml="true"인 출력 기능을 씁니다. Thymeleaf에서는 th:text가 텍스트를 이스케이프하고 th:utext는 원시 마크업을 그대로 출력합니다. URL이나 JavaScript, CSS, HTML 속성에는 문맥마다 다른 인코딩 규칙이 있으므로, 텍스트 이스케이프 하나를 모든 문맥의 해답으로 쓰지 않습니다.


JSP 선택 실습을 준비합니다

1장 첫 글의 빌드는 Spring MVC와 Thymeleaf, 테스트 스타터를 갖추고 있습니다. 아래의 JSP 비교 실습에는 JSP 파일과 jakarta.tags.core의 <c:out>, <c:if>를 해석할 Jasper와 Jakarta Tags가 더 필요하므로, 이 실습을 따라 할 때만 기존 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.2 BOM이 버전을 관리하므로 선언에 버전을 다시 붙이지 않습니다. 이 BOM의 기준은 Jasper 11.0.27, Jakarta Tags API 3.0.2, GlassFish 구현 3.0.1입니다. BOM은 버전만 관리하고, 위 선언이 선택 실습의 테스트 런타임에 라이브러리를 더합니다. 기본 경로인 Thymeleaf를 공부하는 동안에는 이 JSP 실습을 뒤로 미루어도 됩니다.

이 테스트는 임시 디렉터리에 JSP를 만들어 Tomcat의 문서 루트로 연결하는 독립 예제입니다. src/main/webapp이나 운영 JSP 화면을 추가하지 않으며, 이 구성을 실행 가능한 JAR의 JSP 배포 지원으로 일반화하지 않습니다. JSP가 해석되지 않거나 태그 라이브러리를 찾지 못하면, 먼저 테스트 런타임의 Jasper와 Tags API, 구현의 구성과 jakarta.tags.core URI를 확인합니다. 다음 테스트 파일은 서블릿 컨트롤러와 타입이 있는 모델, 실제 Jasper JSP, Thymeleaf 렌더러, 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 컨트롤러는 PostQuery를 호출하고, 내부 비밀 값을 뺀 PostPageModel만 요청에 둔 뒤 상수 /WEB-INF/views/post-detail.jsp로 포워드합니다. /WEB-INF의 JSP를 직접 HTTP로 요청하면 404이고, ?view= 파라미터로는 컨트롤러가 정한 뷰를 바꿀 수 없습니다. 검증에 실패한 POST는 422 상태와 입력, 필드 오류를 같은 요청의 속성에 두고 폼 뷰로 포워드하고, 성공한 POST는 서블릿 6.1의 두 인자 sendRedirect(location, SC_SEE_OTHER)로 303과 Location을 보내며, 클라이언트가 새 GET을 보내면 이전 요청의 속성 없이 모델을 다시 만듭니다. forward()는 커밋 전의 본문 버퍼를 지우지만, flushBuffer() 뒤에 호출하면 IllegalStateException입니다. 마지막으로 실제 Jasper가 원시 JSP EL과 기본 escapeXml=true인 <c:out>을 다르게 렌더링하는지, 실제 Thymeleaf가 th:text와 th:utext를 다르게 렌더링하는지 확인합니다. 원시로 출력하는 th:utext와 JSP EL 테스트는 위험의 차이를 보여 주려는 진단용이며, 사용자 값을 원시로 내보내는 구현을 권하는 것이 아닙니다.

조회와 검증 실패는 같은 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 계약과 함께 설계해야 하며, 상태 코드 하나가 자동으로 보장하지 않습니다.

그림의 위쪽은 GET이나 검증에 실패한 POST가 속성을 만들어 같은 요청으로 포워드하는 경로이고, 아래쪽은 성공한 POST가 303과 Location을 받은 뒤 클라이언트가 별도의 GET을 보내 모델을 새로 만드는 경로입니다. 두 영역은 서로 다른 경우를 비교한 것이며, 앞 요청의 속성이 새 요청으로 옮겨지지 않습니다. 아래쪽 상자들은 업무 값과 화면 형식, 마크업이 각각 어디에 있어야 하는지를 정리합니다.

더 깊이: 내장 서버에서 JSP를 쓸 때의 제약

이 실습이 JSP를 임시 문서 루트에 두고 테스트 안에서만 실행한 데는 이유가 있습니다. Spring Boot의 서블릿 웹 문서는 내장 서블릿 컨테이너를 쓰고 실행 가능한 아카이브로 패키징한 애플리케이션에서 JSP 지원에 몇 가지 제약이 있다고 밝히고, 가능하면 JSP를 피하라고 권합니다. Tomcat과 Jetty에서는 war로 패키징하면 동작하며, 실행 가능한 war는 java -jar로도 실행되고 일반 서블릿 컨테이너에도 배포할 수 있습니다. 반면 실행 가능한 jar에서는 JSP가 지원되지 않습니다.

같은 문서는 두 가지를 더 적습니다. 직접 만든 error.jsp는 Boot의 기본 오류 뷰를 대신하지 못하므로, 오류 화면은 Boot의 사용자 정의 오류 페이지 방식으로 만들어야 합니다. 또 bootRun으로 실행하면서 JSP를 표준 위치인 src/main/webapp이 아닌 곳에 두었다면, Boot가 JSP를 찾도록 WAR_SOURCE_DIRECTORY 환경 변수를 지정해야 할 수 있습니다. 1장부터 쓰는 게시판 프로젝트는 실행 가능한 jar를 만들므로, 화면을 클래스 경로의 Thymeleaf 템플릿으로 만드는 기본 경로가 이 제약을 피하는 선택이기도 합니다.


forward와 303은 request 수명이 다릅니다

서블릿 6.1의 RequestDispatcher.forward는 커밋 전에만 호출할 수 있습니다. 커밋되지 않은 본문 버퍼를 지우고 같은 요청과 응답을 대상에 넘기며, 대상의 경로가 지금의 디스패치 뷰에 반영되고 원래 요청의 정보는 jakarta.servlet.forward.* 속성으로 남습니다. 정상적으로 돌아오기 전에 비동기가 시작되지 않았다면, 컨테이너가 응답을 보내고 커밋하고 끝냅니다.

반면 RFC 9110의 303 See Other는 Location이 가리키는 다른 리소스를 GET이나 HEAD로 조회해, 원래 요청의 간접적인 응답으로 보여 주라고 안내합니다. 성공한 POST의 결과 화면을 별도의 GET 리소스로 만들 때 알맞습니다. 서블릿 6.1의 한 인자 sendRedirect(location)은 302이므로, PRG의 의도를 정확히 나타내려면 상태 코드를 받는 메서드를 씁니다.

요구forward303 redirect
대표적인 쓰임GET 결과, POST 검증 실패의 화면성공한 POST 뒤의 결과 리소스
요청같은 요청브라우저가 새 GET 요청을 만듦
요청 속성뷰까지 유지됨전달되지 않음
브라우저 주소원래 URI 그대로Location의 URI로 바뀜
응답내부 디스패치가 렌더링을 마침303 응답 뒤 별도의 왕복

PRG는 정상적으로 리다이렉트를 따라간 뒤 새로 고칠 때 POST를 다시 보내지 않게 해 주는 애플리케이션의 규칙입니다. 이중 클릭이나 네트워크 재시도, 여러 클라이언트의 동시 명령에서 정확히 한 번 실행되는 것까지 보장하지는 않으므로, 중요한 명령에는 멱등성 키와 유일성 제약 조건, 트랜잭션 같은 서버 쪽의 장치를 따로 둡니다.

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


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

"추천 자격"처럼 업무 의미가 있는 값은 도메인이나 사용 사례가 계산합니다. "1,200자" 같은 표시나 로케일별 날짜, 화면의 레이블처럼 표현을 준비하는 일은 프레젠터나 타입이 있는 뷰 모델 매퍼가 맡고, 요소 배치와 문맥에 맞는 이스케이프는 템플릿이 맡습니다. contentLength >= 60일 때 긴 글 표시를 보여 주는 정도는 화면의 표시 상태일 수 있지만, "길면 추천한다"가 서비스 정책이라면 템플릿의 참거짓 식이 아니라 업무 계층으로 옮깁니다. 모든 참거짓 값을 무조건 도메인이나 뷰에 몰지 않고, 그 값이 바뀌는 이유를 보고 맡을 곳을 정합니다.

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

이 글의 내용은 Jakarta Servlet 6.1 명세와 HttpServletResponse, Jakarta Server Pages 4.0, Jakarta Tags 3.0, Thymeleaf 3.1 튜토리얼, RFC 9110 §15.4.4, Spring Boot의 서블릿 웹 문서를 기준으로 합니다.


정리

  • 서블릿이 HTML을 조립하거나 JSP가 업무와 데이터 접근을 실행하면 제어와 표현이 섞이므로, 컨트롤러는 입력과 사용 사례 호출, 타입이 있는 모델, 고정된 뷰 선택만 맡습니다.
  • 뷰에는 화면에 필요한 값만 담은 작은 모델을 넘기고, 엔티티나 비밀 필드, 범용 맵을 그대로 넘기지 않습니다.
  • /WEB-INF는 바깥의 직접 요청만 막을 뿐 권한 검사를 대신하지 않으며, JSP의 원시 ${...}와 Thymeleaf의 th:utext는 이스케이프하지 않습니다.
  • 조회와 검증 실패는 같은 요청으로 포워드하고, 성공한 POST는 303과 Location으로 새 GET을 하게 해 모델을 다시 만듭니다.
  • PRG는 새로 고침의 재전송만 막을 뿐 중복 명령을 막지 못하며, 업무 값과 화면 형식은 바뀌는 이유에 따라 맡을 곳을 정합니다.

연습 문제

1. 검증, 저장, 새 조회 나누기

새 RunningServer 하나에서 다른 요청이나 동시 접근 없이 차례로 확인합니다. 공백을 포함한 제목은 폼 인코딩으로 보내고, 따로 인증이나 저장 실패는 없습니다. ① 제목 AB 로 POST하면 상태와 명령 횟수, 폼 뷰에 남는 원래 입력은 무엇인가요? ② 이어서 제목 새 글 로 POST하되 리다이렉트는 아직 따라가지 않습니다. Location의 게시글 번호와 명령, 조회 횟수를 판단하세요. ③ 그 Location으로 GET을 보냅니다. 명령과 조회 횟수, displayLength, 긴 글과 추천 표시, 이전 POST의 postRequestOnly가 전달되는지를 판단하세요.

해설 보기

① 첫 입력은 strip()하면 두 글자이므로 검증을 통과하지 못합니다. 422 상태로 같은 요청의 폼을 렌더링하고 명령 횟수는 0입니다. PostFormModel에는 공백을 지운 값이 아니라 원래 제목을 넣으므로 양쪽 공백까지 그대로 남습니다. 입력을 그대로 남긴다는 것이 원시 마크업으로 출력한다는 뜻은 아니며, 폼의 <c:out>은 글자를 이스케이프합니다.

② 둘째 입력은 strip()하면 공백 하나를 포함한 세 글자 새 글이므로 3자 이상이라는 기준을 만족해, 처음의 다음 번호인 43으로 저장됩니다. POST 응답은 303, Location의 번호는 43이고 명령은 1, 조회는 0입니다. 저장의 성공과 조회의 실행을 같은 단계로 세지 않습니다. 이 HTTP 클라이언트는 리다이렉트를 자동으로 따라가지 않으므로, 303을 받는 것만으로 조회 횟수가 늘지 않습니다.

③ 새 GET이 조회를 한 번 실행하므로 명령 1, 조회 1이 됩니다. 새 게시글의 contentLength는 저장한 제목의 Java 문자열 길이 3이므로 displayLength는 3자이고, longPost와 recommended는 모두 거짓입니다. 이 길이는 앞 글의 HTTP 바이트 길이와 다른 화면 픽스처의 값입니다. 새 요청에는 이전 POST의 postRequestOnly가 전달되지 않으며, 결과 페이지는 저장소의 조회 결과로 모델을 새로 만듭니다. 이 풀이는 코드의 분기와 횟수, 요청의 수명을 따라간 예상이므로 테스트를 실행해 확인합니다.

2. 재시도와 화면 표시

① 같은 올바른 POST가 네트워크 재시도로 서버에 두 번 도착해 둘 다 성공하고, 두 번째 Location만 GET으로 한 번 조회합니다. 처음의 명령과 조회 횟수는 0이고 번호는 43부터입니다. 만들어진 번호와 최종 횟수는 무엇이며, 303만으로 중복 생성을 막을 수 있을까요? ② 서로 다른 조회 결과의 contentLength가 59, 60, 61이고 recommended는 각각 참, 거짓, 참입니다. 매퍼의 longPost와 추천 표시를 구하고, 긴 글을 자동으로 추천하도록 요구가 바뀐다면 어디서 결정해야 할지 설명하세요.

해설 보기

① 첫 명령은 43, 두 번째는 44를 만듭니다. 두 POST 뒤의 횟수는 명령 2, 조회 0이고, 두 번째 결과를 GET한 뒤에는 명령 2, 조회 1입니다. 303은 다음에 조회할 곳을 안내할 뿐 이미 두 번 실행된 명령을 하나로 합치지 않습니다. 중복을 막으려면 같은 논리적 명령을 식별하고 원자적으로 처리하는 서버 쪽의 규칙이 따로 필요합니다.

② longPost는 거짓, 참, 참이고, recommended는 전달받은 그대로 참, 거짓, 참입니다. 60 이상인 두 번째 글이 추천되지 않는 것은 지금의 규칙에 맞습니다. 화면의 표시 기준은 매퍼에 두지만, 길이로 추천 자격을 바꾸는 업무 정책은 사용 사례나 도메인에서 결정하고, 뷰는 그 결과만 표시합니다.

다음 글 MVC 역할 경계에서는 이 컨트롤러와 모델, 뷰의 구분을 순수 단위 테스트와 Spring의 독립형 MockMvc 테스트로 나누어 확인합니다.