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, 서버가 허용 목록으로 정제해 별도 타입으로 만든 조각만 좁은 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);
}
}따라 하기
처음에는 이 절의 Java 파일·템플릿·테스트를 각 코드의 src/main/..., src/test/... 경로에 놓고 PostExpressionRenderingTest부터 확인합니다. 루트 빌드 계약을 공유한다는 것은 함께 컴파일할 수 있다는 뜻입니다. 아래 ExpressionApplication의 기본 컴포넌트 탐색 범위는 자기 패키지인 board.expression과 그 하위이므로 다음 절의 board.composition까지 자동으로 포함하지 않습니다. 각 절의 테스트가 명시한 애플리케이션과 fixture 설정을 실행 단위로 사용하세요. Spring의 컴포넌트 탐색 규칙도 같은 기본 범위를 설명합니다.
- 테스트 설정의
PostQuery빈이 원 입력을 만든다는 점을 먼저 확인합니다. 이것은src/test의 fixture이므로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로 계산합니다. 이 정의도 요구사항에 따라 달라질 수 있습니다. 사용자에게 보이는 문자소 단위가 필요하면 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는 추가 방어선이지 서버 이스케이프와 정제를 대체하지 않습니다.
정제된 결과를 캐시한다면 원문뿐 아니라 적용한 정책 버전도 구분해야 합니다. 예전에 허용했던 태그를 현재 정책에서 제거했어도 과거 캐시를 그대로 출력하면 새 정책을 통과한 결과가 아닙니다. 생성자가 닫힌 타입은 이 코드의 생성 경로를 제한하지만 모든 과거 데이터에 현재 정책을 자동 재적용하지는 않습니다.
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 테스트를 모두 유지하세요. 둘은 각각 정책의 정확성과 통합 경계의 연결을 검증합니다.
독립 확인
별도 fixture에서 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 텍스트·요소·속성과 모델 단위를 각각 검사하세요. 한 검사가 통과했다고 정제 정책·외부 호스트 승인·모든 배포 경로까지 확인된 것은 아닙니다.
다음 문서에서는 값 하나의 출력 경계를 넘어 목록 반복, 표시 조건, 조각과 페이지 셸을 하나의 최종 DOM으로 조립합니다.
적용: 원문·엔티티 문자열·정책 버전을 구분하기
조건 1. 같은 th:text 위치에 첫 모델은 A & B < C, 두 번째 모델은 글자 그대로 A & B < C를 넘깁니다. 두 모델의 DOM 텍스트가 같을까요? 두 번째 결과를 첫 번째처럼 보이게 하려고 th:utext로 바꿔도 될까요?
풀이. 같지 않습니다. 첫 결과의 DOM 텍스트는 A & B < C, 두 번째는 모델에 있는 엔티티 철자를 그대로 포함한 A & B < C입니다. HTML 응답의 엔티티 표기와 DOM 텍스트는 서로 다른 관측입니다. 저장 단계의 중복 인코딩 문제를 원문·마이그레이션 정책에서 해결해야 하며, 이스케이프를 끄는 우회는 원 입력의 태그가 요소로 해석되게 만들 수 있습니다. 테스트는 모델 값과 해당 위치의 DOM 텍스트·요소 유무를 각각 확인합니다.
조건 2. 제품 정책은 외부 링크를 https://docs.example.com에만 허용하기로 했습니다. 현재 예제의 Safelist는 HTTPS 프로토콜만 제한합니다. 정제 입력의 링크가 https://other.example.net/guide이면 이 예제 정책만으로 제거되거나 승인된 호스트가 됐다고 판단할 수 있나요?
풀이. 둘 다 단정할 수 없습니다. 이 Safelist에는 other.example.net을 거절하는 호스트 규칙이 없으므로 HTTPS href가 남을 수 있습니다. 남았다는 사실은 제품의 호스트 승인 증거가 아닙니다. 서버에서 파싱한 스킴·호스트를 별도 정책으로 확인해 모델에 넣거나 정제 파이프라인에 그 규칙을 추가하고, 최종 DOM에서 허용·거절 목적지를 각각 검증해야 합니다.
조건 3. 과거 정책 P1은 strong을 허용했고 현재 정책 P2는 허용하지 않습니다. P1으로 정제한 캐시를 재정제 없이 th:utext에 넣었다면 닫힌 타입을 사용했다는 이유만으로 P2를 만족하나요?
풀이. 아닙니다. P1의 허용 요소가 캐시에 남아 있으면 P2의 결과와 다릅니다. 캐시 키·메타데이터에 정책 버전을 연결하거나 변경 시 원문에서 다시 정제하는 절차를 마련해야 합니다. 이 예제는 현재 fromUntrusted 생성 경로를 보여주며 정책 변경·캐시 무효화를 구현한 코드는 아닙니다.