Thymeleaf 표현식
실제 Spring Boot 화면에서 Thymeleaf 표현식의 출력·URL·정제 경계를 설계하고 최종 HTML로 검증합니다.
템플릿은 고정된 HTML 뼈대와 컨트롤러가 넘긴 모델을 결합해 최종 응답을 만듭니다.
Thymeleaf의 ${...}는 모델 값을 읽고, th:text와 th:utext는 그 값을 서로 다른 신뢰 경계로 출력합니다. 사용자가 쓴 제목과 본문은 기본적으로 문자열이어야 합니다. <와 >를 엔티티로 바꾸는 이스케이프가 빠지면 입력이 브라우저에서 요소와 스크립트로 해석될 수 있습니다.
FLOWCHART · OUTPUT CONTEXT · TRUST BOUNDARY
값의 문맥과 신뢰를 먼저 고르면 Thymeleaf 출력 경계가 결정된다
URL은 @{...}, 일반 문자열은 th:text,
허용 목록 정제 결과만 닫힌 타입과 th:utext를 거쳐
최종 DOM 검증으로 합류한다.
-
DECISION 1 · URL CONTEXT
링크 목적지인가?
예 →
@{...}에 경로 변수와 쿼리 인자를 분리해 context path와 URL 인코딩을 맡깁니다.아니오 → 제한 HTML 의도를 확인합니다.
-
DECISION 2 · HTML INTENT
제한 HTML을 의도했는가?
아니오 → 일반 문자열을
th:text로 기본 이스케이프합니다.예 → 원시 입력을 곧바로 출력하지 않고 서버 허용 목록 정제로 보냅니다.
-
TRUST BOUNDARY · HTML
Jsoup Safelist → SanitizedHtml → th:utext
정제 결과만 생성자가 닫힌 타입으로 감싸고, 템플릿의 한 지점에서만 이스케이프하지 않고 출력합니다.
-
FINAL RESPONSE CONTRACT
MockMvc + Jsoup 최종 DOM 검증
실행 가능한 요소가 남지 않았는지, 허용된
href와 context path가 보존되는지 실제 응답으로 확인합니다.
출력 문법은 신뢰를 만들지 않는다. Java가 null·표시 단위·정제 타입과 외부 URL 정책을 먼저 확정하고, 실제 Boot 렌더링의 최종 DOM이 그 계약을 지키는지 검증한다.
도식의 핵심은 출력 문법보다 먼저 값의 출처와 타입을 결정하는 것입니다. 일반 문자열은 th:text, 서버가 허용 목록으로 정제해 별도 타입으로 만든 조각만 좁은 th:utext 경계를 통과합니다. URL도 문자열 연결이 아니라 @{...}가 소유합니다.
이 장의 실행 기준선
ch7-1부터 ch7-8까지의 예제를 한 프로젝트에 모아 실행할 수 있도록 루트 빌드 계약은 이 문서에서 한 번만 정의합니다. Gradle 9.5.1과 Java 25를 사용하고 Spring Boot 4.1.1 BOM은 enforcedPlatform으로 적용합니다. Thymeleaf 3.1.5.RELEASE와 JUnit 6.0.3에는 strict constraint를 더하며, BOM 관리 밖의 Jsoup 1.22.2도 strict version으로 선언합니다.
rootProject.name = 'thymeleaf-view-contracts'plugins {
id 'java'
}
group = 'board'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation enforcedPlatform('org.springframework.boot:spring-boot-dependencies:4.1.1')
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation('org.jsoup:jsoup') {
version { strictly '1.22.2' }
}
testImplementation enforcedPlatform('org.springframework.boot:spring-boot-dependencies:4.1.1')
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation('org.jsoup:jsoup') {
version { strictly '1.22.2' }
}
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
constraints {
implementation('org.thymeleaf:thymeleaf-spring6') {
version { strictly '3.1.5.RELEASE' }
}
testImplementation('org.junit.jupiter:junit-jupiter') {
version { strictly '6.0.3' }
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 25
options.compilerArgs += ['-parameters']
}
tasks.named('test') {
useJUnitPlatform()
}| 구성 요소 | 고정 버전 | 고정 위치 |
|---|---|---|
| Gradle | 9.5.1 | 실행 환경 |
| Java | 25 | Gradle toolchain |
| Spring Boot | 4.1.1 | enforcedPlatform BOM |
| Thymeleaf | 3.1.5.RELEASE | BOM + strict constraint |
| JUnit | 6.0.3 | BOM + strict constraint |
| Jsoup | 1.22.2 | strict direct dependency |
각 문서의 패키지와 템플릿 경로는 서로 겹치지 않습니다. 따라서 아래 애플리케이션을 기준으로 이 장의 소스를 한 그래프에 복사해도 클래스·리소스·요청 매핑이 충돌하지 않습니다.
package board.expression;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExpressionApplication {
public static void main(String[] args) {
SpringApplication.run(ExpressionApplication.class, args);
}
}화면 전용 모델에서 null과 글자 수를 확정한다
엔티티를 그대로 모델에 넣으면 템플릿이 저장용 상태와 연관 객체까지 탐색할 수 있습니다. 조회 포트는 화면 구성에 필요한 스냅샷만 반환하고, 컨트롤러는 이를 불변 뷰 모델로 바꿉니다.
package board.expression;
import java.time.LocalDate;
import java.util.Objects;
public interface PostQuery {
PostSnapshot required(long id);
record PostSnapshot(
long id,
String title,
String plainContent,
String markdownHtml,
LocalDate publishedOn
) {
public PostSnapshot {
if (id <= 0) {
throw new IllegalArgumentException("id must be positive");
}
Objects.requireNonNull(title, "title");
Objects.requireNonNull(plainContent, "plainContent");
Objects.requireNonNull(markdownHtml, "markdownHtml");
Objects.requireNonNull(publishedOn, "publishedOn");
}
}
}package board.expression;
import java.util.Objects;
public record PostPage(
long id,
String title,
String plainContent,
SanitizedHtml sanitizedContent,
int characterCount,
String publishedOn
) {
public PostPage {
if (id <= 0 || characterCount < 0) {
throw new IllegalArgumentException("invalid post page");
}
Objects.requireNonNull(title, "title");
Objects.requireNonNull(plainContent, "plainContent");
Objects.requireNonNull(sanitizedContent, "sanitizedContent");
Objects.requireNonNull(publishedOn, "publishedOn");
}
}String.length()은 UTF-16 코드 단위 수입니다. 화면의 “글자 수”는 보충 평면 문자를 하나로 세도록 codePointCount로 계산합니다. 이 정의도 요구사항에 따라 달라질 수 있습니다. 사용자에게 보이는 문자소 단위가 필요하면 ICU 같은 유니코드 분할기를 별도로 선택해야 합니다.
package board.expression;
import java.time.format.DateTimeFormatter;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
public final class PostPageController {
private static final DateTimeFormatter DATE =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final PostQuery query;
public PostPageController(PostQuery query) {
this.query = query;
}
@GetMapping("/expression/posts/{id}")
String detail(@PathVariable("id") long id, Model model) {
var post = query.required(id);
var content = post.plainContent();
model.addAttribute("post", new PostPage(
post.id(),
post.title(),
content,
SanitizedHtml.fromUntrusted(post.markdownHtml()),
content.codePointCount(0, content.length()),
DATE.format(post.publishedOn())));
return "expression/posts/detail";
}
}null은 템플릿의 Elvis 연산자로 조용히 숨기지 않습니다. 조회 스냅샷과 뷰 모델 생성자가 필수 값을 거부하므로 “본문 없음”이 유효한 상태라면 그 상태를 나타내는 별도 필드나 타입을 먼저 설계해야 합니다.
일반 문자열은 th:text로 출력한다
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title th:text="|${post.title} · 게시판|">게시판</title>
</head>
<body>
<main>
<article>
<h1 th:text="${post.title}">제목</h1>
<p id="plain-content" th:text="${post.plainContent}">본문</p>
<section aria-labelledby="preview-heading">
<h2 id="preview-heading">승인된 Markdown 미리보기</h2>
<div id="safe-markdown"
th:utext="${post.sanitizedContent.value}">
정제된 본문
</div>
</section>
<dl>
<dt>작성일</dt>
<dd th:text="${post.publishedOn}">2026-07-14</dd>
<dt>유니코드 코드 포인트 수</dt>
<dd th:text="|${post.characterCount}자|">45자</dd>
</dl>
<a id="permalink"
th:href="@{/expression/posts/{id}(id=${post.id})}">
고유 링크
</a>
</article>
</main>
</body>
</html>th:text는 값의 <와 >를 HTML 엔티티로 출력합니다. th:utext는 조각을 요소로 해석하므로 원시 입력을 직접 연결하면 안 됩니다. 운영자 입력, 데이터 가져오기, 기존 DB 값도 신뢰 근거가 되지 않습니다.
표현식에는 화면 선택만 남깁니다. ${@postService.findAll()}처럼 빈을 호출하거나 요청·세션을 직접 읽으면 렌더링이 DB 접근과 숨은 입력을 가지게 됩니다. 집계, 권한, 기본 문자열은 Java에서 끝내고 모델에 명시합니다.
정제된 HTML은 생성 경로를 닫은 타입으로 만든다
금칙어를 contains로 찾는 방식은 대소문자, 엔티티, 잘못 중첩된 태그와 파서 보정에 쉽게 우회됩니다. 정제 타입의 생성자를 닫고 HTML 파서의 허용 목록을 통과한 결과만 만들 수 있게 합니다.
package board.expression;
import java.util.Objects;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
public final class SanitizedHtml {
private static final Safelist POLICY = new Safelist()
.addTags("p", "strong", "em", "code", "ul", "ol", "li", "a")
.addAttributes("a", "href")
.addProtocols("a", "href", "https");
private static final Document.OutputSettings OUTPUT =
new Document.OutputSettings().prettyPrint(false);
private final String value;
private SanitizedHtml(String value) {
this.value = value;
}
public static SanitizedHtml fromUntrusted(String rawHtml) {
var cleaned = Jsoup.clean(
Objects.requireNonNull(rawHtml, "rawHtml"),
"",
POLICY,
OUTPUT);
return new SanitizedHtml(cleaned);
}
public String getValue() {
return value;
}
}이 예제의 정책은 서식 태그와 HTTPS 링크만 허용합니다. 실제 제품에서는 Markdown 파서 출력, 이미지 프록시, 링크 호스트 정책 등 요구사항을 먼저 적고 Safelist를 그 계약에 맞춰 좁혀야 합니다. CSP는 추가 방어선이지 서버 이스케이프와 정제를 대체하지 않습니다.
URL 표현식은 경로 조립과 인코딩을 소유한다
@{/expression/posts/{id}(id=${post.id})}는 애플리케이션의 컨텍스트 경로를 포함해 경로 변수를 URL로 만듭니다. 쿼리도 @{/expression/posts(title=${filter.title})}처럼 별도 인자로 둡니다. /, ?, &를 문자열로 이어 붙이면 데이터와 URL 문법이 섞입니다.
외부 URL은 별도 신뢰 경계입니다. 사용자가 보낸 문자열을 th:href에 바로 넣지 말고 서버가 허용한 HTTPS 호스트나 내부 링크 키로 변환한 값만 모델에 넣습니다. 링크 텍스트의 HTML 이스케이프와 링크 목적지의 스킴·호스트 검증은 서로 다른 검사입니다.
| 데이터 | 권장 출력 | 서버가 보장할 것 |
|---|---|---|
| 제목·평문 본문 | th:text | 필수 값과 표시용 형식 |
| 숫자·날짜 | th:text | 계산 단위와 로케일 |
| 허용 목록을 통과한 조각 | 좁은 th:utext 경계 | 생성자가 닫힌 정제 타입 |
| 내부 링크 | @{...} | 경로 변수·쿼리의 의미 |
| 외부 링크 | 검증된 모델 값 | HTTPS 스킴과 허용 호스트 |
| JSON 부트스트랩 값 | JavaScript 인라인 직렬화 | JS 문자열 문맥 이스케이프 |
실제 MVC·Thymeleaf 최종 HTML로 계약을 검증한다
문자열 템플릿 단위 테스트는 Spring MVC 모델, 뷰 이름, 리소스 해석, 컨텍스트 경로를 건너뜁니다. 다음 테스트는 실제 Boot 컨텍스트에서 컨트롤러와 파일 템플릿을 렌더링한 뒤 Jsoup으로 최종 DOM을 검사합니다.
package board.expression;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = {
ExpressionApplication.class,
PostExpressionRenderingTest.FixtureConfiguration.class
})
@AutoConfigureMockMvc
class PostExpressionRenderingTest {
private static final String PLAIN =
"<script>alert('plain')</script> 😀";
@Autowired
MockMvc mvc;
@Test
void controller가_view_model과_code_point_수를_확정한다()
throws Exception {
var result = mvc.perform(get("/expression/posts/7"))
.andExpect(status().isOk())
.andExpect(view().name("expression/posts/detail"))
.andExpect(model().attributeExists("post"))
.andReturn();
var page = (PostPage) result.getModelAndView()
.getModel()
.get("post");
assertThat(page.characterCount())
.isEqualTo(PLAIN.codePointCount(0, PLAIN.length()));
assertThat(page.publishedOn()).isEqualTo("2026-07-14");
}
@Test
void 일반_문자열은_요소가_아닌_text로_렌더링된다()
throws Exception {
var document = render("/expression/posts/7");
assertThat(document.selectFirst("h1").text())
.isEqualTo("<img src=x onerror=alert(1)> 기록");
assertThat(document.selectFirst("#plain-content").text())
.isEqualTo(PLAIN);
assertThat(document.select("h1 img, #plain-content script"))
.isEmpty();
}
@Test
void 정제_HTML만_허용된_markup으로_렌더링된다()
throws Exception {
var document = render("/expression/posts/7");
var preview = document.selectFirst("#safe-markdown");
assertThat(preview.select("strong").text()).isEqualTo("허용");
assertThat(preview.select("script, img, [onerror]"))
.isEmpty();
assertThat(preview.select("a[href^=javascript]")).isEmpty();
assertThat(preview.select("a[href='https://example.com/guide']"))
.hasSize(1);
}
@Test
void URL_표현식은_context_path를_포함한다() throws Exception {
var result = mvc.perform(get("/academy/expression/posts/7")
.contextPath("/academy"))
.andExpect(status().isOk())
.andReturn();
var document = Jsoup.parse(result.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
assertThat(document.selectFirst("#permalink").attr("href"))
.isEqualTo("/academy/expression/posts/7");
}
private Document render(String path) throws Exception {
var result = mvc.perform(get(path))
.andExpect(status().isOk())
.andReturn();
return Jsoup.parse(result.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
}
@TestConfiguration(proxyBeanMethods = false)
static class FixtureConfiguration {
@Bean
PostQuery postQuery() {
return id -> new PostQuery.PostSnapshot(
id,
"<img src=x onerror=alert(1)> 기록",
PLAIN,
"<p><strong>허용</strong>"
+ "<script>alert('x')</script>"
+ "<img src=x onerror=alert(1)>"
+ "<a href=javascript:alert(1)>위험</a>"
+ "<a href=https://example.com/guide>안전</a></p>",
LocalDate.of(2026, 7, 14));
}
}
}테스트는 네 경계를 따로 고정합니다.
- 컨트롤러가 정확한 뷰 모델과 유니코드 계산 결과를 만든다.
- 일반 문자열은 실제 DOM에서 실행 가능한 요소가 되지 않는다.
- 허용 목록 정제 결과만 제한된 마크업을 보존한다.
@{...}가 배포 컨텍스트 경로를 잃지 않는다.
연습 문제
평문과 승인된 Markdown을 함께 지원하되 원시 Markdown, 파서 출력, 정제된 HTML, 최종 응답의 타입과 저장 위치를 정하세요. <script>, 이벤트 처리 속성, javascript: 링크, 허용하지 않은 이미지가 최종 DOM에 남지 않는 테스트를 추가합니다.
해설 보기
원문은 감사와 재처리를 위해 저장할 수 있지만 표시할 때마다 신뢰할 수 있는 Markdown 파서와 제품의 허용 목록을 통과시킵니다. 정제된 결과는 SanitizedHtml처럼 생성 경로가 닫힌 타입으로 감쌉니다. 뷰 모델은 평문과 정제 조각 중 어떤 표시 모드인지 명시하며, 템플릿은 그 타입이 허용된 한 지점에서만 th:utext를 사용합니다.
정제기 단위 테스트와 실제 Thymeleaf 최종 DOM 테스트를 모두 유지하세요. 둘은 각각 정책의 정확성과 통합 경계의 연결을 검증합니다.
다음 문서에서는 값 하나의 출력 경계를 넘어 목록 반복, 표시 조건, 조각과 페이지 셸을 하나의 최종 DOM으로 조립합니다.