Thymeleaf 표현식
실제 Spring Boot 화면에서 Thymeleaf 표현식의 출력·URL·정제 경계를 설계하고 최종 HTML로 검증합니다.
템플릿은 고정된 HTML 뼈대와 컨트롤러가 넘긴 모델을 결합해 최종 응답을 만듭니다. Thymeleaf의 ${...}는 모델 값을 읽고, th:text와 th:utext는 그 값을 서로 다른 신뢰 수준으로 출력합니다. 사용자가 쓴 제목과 본문은 기본적으로 문자열로 다뤄야 하며, <와 >를 엔티티로 바꾸는 이스케이프가 빠지면 입력이 브라우저에서 요소와 스크립트로 해석될 수 있습니다.
FLOWCHART · OUTPUT CONTEXT · TRUST BOUNDARY
값의 문맥과 신뢰를 먼저 고르면 Thymeleaf 출력 경계가 결정된다
일반 문자열은 th:text, 정제 결과만 닫힌 타입을 거쳐 th:utext, 내부 URL은 @{...}로 출력합니다. 외부 URL의 스킴·호스트 정책은 별도 검사이며 실제 최종 DOM으로 결과를 확인합니다.
세 영역은 입력 문맥별 대안입니다. 오른쪽과 왼쪽 우회선은 각각의 출력 결과를 최종 DOM 검사로 연결하며 다른 영역의 정제 단계를 통과한다는 뜻이 아닙니다. HTTPS 스킴 제한과 외부 호스트 허용은 서로 다른 정책입니다.
PLAIN TEXT
태그처럼 생긴 입력도 문자열로
제목의 <img src=x onerror=alert(1)>와 평문 <script>…</script>를 th:text로 출력합니다. fixture의 최종 DOM에서 텍스트는 보존되고 해당 위치의 img·script 요소는 없습니다.
SANITIZED HTML
정제 정책이 먼저, th:utext는 그 다음
Jsoup은 허용 서식과 HTTPS href만 남깁니다. 생성자가 닫힌 SanitizedHtml에 정제 결과를 넣고 한 지점에서 출력합니다. fixture는 strong과 승인 HTTPS 링크 보존, script·img·onerror·javascript href 제거를 확인합니다.
INTERNAL URL
URL 표현식이 애플리케이션 경로를 보존
경로 변수·쿼리 인자를 @{...}에 분리합니다. 실제 context path가 /academy인 fixture의 permalink는 /academy/expression/posts/7입니다.
EXTERNAL URL · SEPARATE POLICY
HTML 이스케이프와 목적지 검증을 분리
링크 텍스트를 이스케이프해도 목적지가 안전해지지는 않습니다. 사용자 URL은 서버의 HTTPS 스킴·허용 호스트 정책을 통과한 모델 값으로 만듭니다. 이 장의 sanitizer는 HTTPS를 제한하며 일반 외부 호스트 allowlist fixture까지 증명하지 않습니다.
JAVA VIEW MODEL
null·날짜·표시 단위는 렌더링 전에
필수 null은 생성자가 거부하고 날짜는 Java에서 표시 형식을 정합니다. characterCount는 Unicode code point 수입니다. UTF-16 length나 사용자에게 보이는 문자소 수와 같은 단위로 주장하지 않습니다.
출력 문법은 신뢰를 만들지 않는다. Java가 null·표시 단위·정제 타입과 외부 URL 정책을 먼저 확정하고, 실제 Boot 렌더링의 최종 DOM이 그 계약을 지키는지 검증한다.
그림에서 중요한 것은 출력 문법을 고르기 전에 값의 출처와 타입부터 정한다는 점입니다. 제목이나 평문 같은 일반 문자열은 th:text로 이스케이프하고, Jsoup의 Safelist가 허용한 결과만 생성 경로가 닫힌 SanitizedHtml 타입에 담아 한 지점의 th:utext로 출력합니다. 내부 링크는 문자열을 이어 붙이지 않고 @{...}가 컨텍스트 경로와 경로 변수를 조립합니다. 세 경로는 입력의 성격에 따라 하나를 고르는 대안이며, 모두 실제 MVC와 Thymeleaf가 만든 응답을 Jsoup으로 파싱한 최종 DOM에서 확인합니다. 외부 링크의 스킴과 호스트 검사, null과 날짜, 글자 수 계산은 렌더링 전에 서버가 따로 정합니다.
이 장의 실행 기준
7장 1편부터 8편까지의 예제를 한 프로젝트에 모아 실행할 수 있도록, 루트 빌드는 이 글에서 한 번만 정의합니다. Gradle 9.8.2와 Java 25를 쓰고, Spring Boot 4.1.2 BOM은 enforcedPlatform으로 적용합니다. Thymeleaf 3.1.5.RELEASE와 JUnit 6.0.3에는 strict 제약을 더하고, BOM이 관리하지 않는 Jsoup도 1.23.2를 strict 버전으로 선언합니다. 2026년 11월 3일에 1.24.1이 나왔지만, 이 장의 예제는 1.23.2를 기준으로 합니다. Jsoup 1.23.1에는 직접 만든 Safelist로 잘못된 HTML을 정제할 때의 Cleaner 보안 수정이 들어 있습니다.
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.2')
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.23.2' }
}
testImplementation enforcedPlatform('org.springframework.boot:spring-boot-dependencies:4.1.2')
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation('org.jsoup:jsoup') {
version { strictly '1.23.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.8.2 | 실행 환경 |
| Java | 25 | Gradle 툴체인 |
| Spring Boot | 4.1.2 | enforcedPlatform BOM |
| Thymeleaf | 3.1.5.RELEASE | BOM과 strict 제약 |
| JUnit | 6.0.3 | BOM과 strict 제약 |
| Jsoup | 1.23.2 | strict 직접 의존성 |
글마다 패키지와 템플릿 경로가 겹치지 않으므로, 아래 애플리케이션을 기준으로 이 장의 소스를 한 그래프에 복사해도 클래스와 리소스, 요청 매핑이 충돌하지 않습니다.
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);
}
}처음에는 이 글의 Java 파일과 템플릿, 테스트를 각 코드에 적힌 src/main/..., src/test/... 경로에 두고 PostExpressionRenderingTest부터 확인합니다. 루트 빌드를 함께 쓴다는 것은 함께 컴파일할 수 있다는 뜻입니다. ExpressionApplication의 기본 컴포넌트 탐색 범위는 자기 패키지인 board.expression과 그 하위뿐이므로, 다음 글의 board.composition까지 저절로 포함하지 않습니다. 각 글의 테스트가 지정한 애플리케이션과 예제 설정을 실행 단위로 씁니다. Spring의 컴포넌트 탐색 규칙도 같은 기본 범위를 설명합니다.
- 테스트 설정의
PostQuery빈이 입력을 만든다는 점을 먼저 확인합니다. 이것은src/test의 예제이므로,main애플리케이션만 시작한다고 같은 조회 빈이 등록되지는 않습니다. 실제 서버를 띄우려면 제품의PostQuery구현을 따로 등록해야 합니다. - 컨트롤러가 돌려준 뷰 이름
expression/posts/detail을templates/expression/posts/detail.html과 연결합니다. 이 파일을 브라우저에서 직접 열면 Thymeleaf가 처리하기 전의 대체 글자만 보입니다. - 테스트의 모델 검사와 최종 DOM 검사를 나누어 읽습니다. 화면에 태그 문자열이 보여야 하는지, 실제 허용 요소가 있어야 하는지, 링크가 어느 컨텍스트 경로를 가리켜야 하는지를 각각 확인합니다.
화면 전용 모델에서 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로 계산합니다. 이 정의도 요구 사항에 따라 달라질 수 있으며, 사용자에게 보이는 글자(grapheme) 단위가 필요하면 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 값이라고 해서 믿어도 되는 것은 아닙니다.
이스케이프는 원문을 바꾸는 저장 규칙이 아닙니다. 모델에는 A & B < C 같은 원문을 두고, HTML로 출력할 때 th:text가 문맥에 맞게 처리하게 합니다. 응답 소스에 엔티티 표기가 있어도 브라우저의 DOM 텍스트는 원문입니다. 이미 < 같은 엔티티 문자열을 저장해 넘기면 그 앰퍼샌드도 이스케이프되어 화면에 <라는 글자가 보일 수 있습니다. 이를 고치려고 원시 입력을 th:utext로 바꾸면, 출력 문제를 풀면서 신뢰 구분을 무너뜨리게 됩니다.
표현식에는 화면에서 무엇을 보여 줄지만 남깁니다. ${@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;
}
}예를 들어 제목이 <strong>배움</strong>이면 h1의 DOM 텍스트는 그 문자열 그대로이고 h1 strong 요소는 없습니다. 같은 문자열이 정제 입력이라면 strong은 허용 태그이므로 #safe-markdown strong 요소가 생길 수 있습니다. 출력 문법만 바꾼 결과가 아니라, 입력의 용도와 정제 경로가 달라서 생긴 결과입니다.
허용한 a 태그라도 http: 링크의 href는 이 HTTPS 전용 정책에서 지워집니다. 따라서 링크 글자와 a 요소가 남았다고 해서 클릭할 목적지가 안전하다고 볼 수 없고, 반대로 HTTPS 스킴만 허용했다고 특정 호스트를 허용한 것도 아닙니다. Jsoup Safelist는 태그와 속성, URL 프로토콜을 각각 제한합니다.
이 예제의 정책은 서식 태그와 HTTPS 링크만 허용합니다. 실제 제품에서는 Markdown 파서의 출력과 이미지 프록시, 링크 호스트 정책 같은 요구 사항을 먼저 적고, Safelist를 그에 맞춰 좁혀야 합니다. CSP(Content Security Policy)는 추가 방어선일 뿐 서버의 이스케이프와 정제를 대신하지 않습니다.
정제된 결과를 캐시한다면 원문뿐 아니라 적용한 정책의 버전도 구분해야 합니다. 예전에 허용했던 태그를 지금의 정책에서 뺐더라도, 과거의 캐시를 그대로 출력하면 새 정책을 통과한 결과가 아닙니다. 생성자를 닫은 타입은 이 코드의 생성 경로를 제한할 뿐, 모든 과거 데이터에 현재 정책을 다시 적용해 주지는 않습니다.
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에서 실행할 수 있는 요소가 되지 않습니다.
- 허용 목록으로 정제한 결과만 제한된 마크업을 유지합니다.
@{...}가 배포 환경의 컨텍스트 경로를 잃지 않습니다.
더 깊이: Thymeleaf 3.1이 표현식에 거는 제한
Thymeleaf 3.1.5 튜토리얼의 부록 D는 보안을 위해 표현식에 두 종류의 제한을 둔다고 설명합니다. 하나는 모든 표현식에 적용되는 일반 제한으로, Thymeleaf와 Spring, Java·Jakarta의 기반 클래스 대부분과 잘 알려진 일부 서드파티 패키지를 정적으로 사용하는 것, 곧 인스턴스를 만들거나 정적 메서드를 부르는 것을 막습니다. 다른 하나는 특정 문맥에서만 켜지는 더 엄격한 제한으로, 이를 제한된 표현식 평가 모드(restricted expression evaluation mode)라고 부릅니다.
이 글에서 쓴 기능 가운데 두 곳이 제한 모드에 해당합니다. th:utext처럼 이스케이프하지 않는 출력과, @{...}의 파라미터가 아닌 기본 URL 부분입니다. 이 밖에도 __...__ 전처리, th:on* 이벤트 처리기 속성, 템플릿과 조각의 이름과 조각 파라미터, TEXT 템플릿 모드의 모든 출력이 같은 모드로 평가됩니다. 임의의 이름으로 속성을 만드는 th:attr과 기본 속성 처리기도 여기에 들어갑니다. 이 모드에서는 ${param.*}로 요청 파라미터를 읽는 것, 새 객체를 만드는 것, T(...) 같은 정적 클래스 접근, #execInfo 같은 일부 유틸리티 객체를 금지하고, Spring 애플리케이션에서는 @beanName으로 빈에 접근하는 것도 막습니다. th:on* 속성에서는 숫자나 불리언이 아닌 값을 내는 변수 표현식도 쓸 수 없습니다.
제한에 걸린 표현식은 실행 중에 템플릿 처리 예외를 일으키며, 문서는 이것이 끌 수 없는 보안 기능이라고 밝힙니다. 반대로 위의 목록에 없는 문맥에서는 이런 검사가 전혀 없으므로, 일반 th:text 안의 ${@postService.findAll()} 같은 호출은 그대로 실행됩니다. 문서는 이 제한을 입력 검증을 대신하는 첫 방어선이 아니라 추가 방어선으로만 보라고 강조하며, 업그레이드 뒤 제한 오류가 나면 그 값을 템플릿 안에서 계산하지 말고 애플리케이션 코드가 검증한 뒤 모델에 넣으라고 권합니다. 이 글이 집계와 기본 문자열을 Java에서 끝내고 정제된 값을 닫힌 타입에 담은 것도 같은 방향입니다.
정리
- 출력 문법보다 값의 출처와 타입을 먼저 정하고, 일반 문자열은
th:text로 이스케이프합니다. th:utext는 Safelist를 통과한 결과만 담는 닫힌 타입에 한 지점에서만 쓰며, 정책 버전이 바뀌면 캐시도 다시 정제합니다.- 이스케이프는 출력할 때의 처리이므로 원문을 저장하고, 엔티티 문자열을 저장해 두었다가
th:utext로 우회하지 않습니다. - 내부 링크는
@{...}로 컨텍스트 경로와 경로 변수를 조립하고, 외부 링크의 스킴과 호스트는 서버가 따로 검사합니다. null과 날짜, 글자 수는 Java 뷰 모델에서 확정하고, 결과는 실제 렌더링된 최종 DOM으로 확인합니다.
연습 문제
1. 평문과 승인된 Markdown 함께 지원하기
평문과 승인된 Markdown을 함께 지원하려고 합니다. 원시 Markdown과 파서의 출력, 정제된 HTML, 최종 응답을 각각 어떤 타입으로 두고 어디에 저장해야 할까요? <script>와 이벤트 처리 속성, javascript: 링크, 허용하지 않은 이미지가 최종 DOM에 남지 않는지는 어떻게 확인하나요?
해설 보기
원문은 감사와 재처리를 위해 저장할 수 있지만, 보여 줄 때마다 믿을 수 있는 Markdown 파서와 제품의 허용 목록을 통과시킵니다. 정제된 결과는 SanitizedHtml처럼 생성 경로를 닫은 타입으로 감쌉니다. 뷰 모델은 평문과 정제 조각 가운데 어떤 표시 방식인지를 명시하고, 템플릿은 그 타입을 허용한 한 지점에서만 th:utext를 씁니다.
정제기의 단위 테스트와 실제 Thymeleaf 최종 DOM 테스트를 모두 둡니다. 앞의 것은 정책이 정확한지를, 뒤의 것은 전체가 제대로 이어졌는지를 확인합니다. 최종 DOM 테스트에서는 script와 img 요소, onerror 같은 속성, href가 javascript로 시작하는 링크가 하나도 없는지 선택자로 검사합니다.
2. 다른 입력으로 결과 예측하기
별도의 예제에서 ID 8, 제목 <strong>배움</strong>, 평문 e + 결합 악센트 U+0301 + 😀를 돌려준다고 가정합니다. 정제 입력은 <p><strong class="badge">허용</strong><a href="http://example.com/guide">안내</a></p>이고, 날짜는 형식에 맞는 올바른 값입니다. 정제 정책과 템플릿은 그대로 쓰고 컨텍스트 경로는 /academy입니다. 뷰 모델의 characterCount, h1의 텍스트와 안쪽 strong 유무, 정제 영역의 strong과 class, a[href], permalink를 예측하세요. 평문이 화면에서 두 글자처럼 보이는데 계산 결과가 다른 이유도 설명하세요.
해설 보기
평문은 코드 포인트 세 개입니다. e와 결합 악센트가 하나씩이고 보충 평면의 😀가 하나이므로 characterCount=3입니다. UTF-16 단위로는 1+1+2=4이고, 사용자가 보는 글자로는 보통 é와 😀 두 개지만 이 코드는 그 단위로 세지 않습니다.
h1의 텍스트는 <strong>배움</strong> 그대로이며 안쪽의 strong 요소는 0개입니다. 정제 영역은 허용된 strong 요소를 남기지만, class는 허용하지 않았으므로 지웁니다. a 태그와 안내라는 글자는 남아도 http 프로토콜의 href는 남지 않아 a[href]는 0개입니다. permalink는 컨텍스트 경로와 ID를 결합한 /academy/expression/posts/8입니다.
이것은 코드와 정책에서 끌어낸 예상입니다. 새 테스트를 만들 때는 직렬화된 HTML 전체의 공백이나 엔티티 표기를 고정하기보다, DOM의 텍스트와 요소, 속성, 모델의 값을 각각 검사합니다.
3. 원문과 엔티티 문자열, 정책 버전
① 같은 th:text 위치에 첫 모델은 A & B < C를, 두 번째 모델은 글자 그대로 A & B < C를 넘깁니다. 두 DOM 텍스트가 같을까요? 두 번째 결과를 첫 번째처럼 보이게 하려고 th:utext로 바꿔도 될까요? ② 제품 정책이 외부 링크를 https://docs.example.com에만 허용하기로 했습니다. 지금 예제의 Safelist는 HTTPS 프로토콜만 제한합니다. 정제 입력의 링크가 https://other.example.net/guide라면 이 정책만으로 지워지거나 승인된 호스트가 되었다고 판단할 수 있나요? ③ 과거 정책 P1은 strong을 허용했고 지금의 정책 P2는 허용하지 않습니다. P1으로 정제한 캐시를 다시 정제하지 않고 th:utext에 넣었다면, 닫힌 타입을 썼다는 이유만으로 P2를 만족하나요?
해설 보기
① 같지 않습니다. 첫 결과의 DOM 텍스트는 A & B < C이고, 두 번째는 모델의 엔티티 철자를 그대로 담은 A & B < C입니다. HTML 응답의 엔티티 표기와 DOM 텍스트는 서로 다른 것입니다. 저장 단계에서 두 번 인코딩된 문제는 원문 저장과 마이그레이션 정책으로 풀어야 하며, 이스케이프를 끄는 우회는 원시 입력의 태그를 요소로 해석되게 만들 수 있습니다.
② 둘 다 단정할 수 없습니다. 이 Safelist에는 other.example.net을 거절하는 호스트 규칙이 없으므로 HTTPS href가 남을 수 있으며, 남았다는 것이 제품의 호스트 승인을 뜻하지도 않습니다. 서버에서 파싱한 스킴과 호스트를 별도의 정책으로 확인해 모델에 넣거나 정제 파이프라인에 그 규칙을 더하고, 최종 DOM에서 허용과 거절 목적지를 각각 검증해야 합니다.
③ 아닙니다. P1이 허용한 요소가 캐시에 남아 있으면 P2의 결과와 다릅니다. 캐시 키나 메타데이터에 정책 버전을 묶어 두거나, 정책이 바뀔 때 원문에서 다시 정제하는 절차가 필요합니다. 이 예제는 지금의 fromUntrusted 생성 경로만 보여 주며, 정책 변경이나 캐시 무효화를 구현한 코드는 아닙니다.
다음 글 Thymeleaf 조건·반복·조각에서는 값 하나의 출력을 넘어, 목록의 반복과 표시 조건, 조각과 페이지 셸을 하나의 최종 DOM으로 조립합니다.