본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
7장 : View·폼·메시지·검증

Thymeleaf 표현식

게시판 상세 화면에서 Thymeleaf 표현식과 링크를 사용하고 XSS를 막는 뷰 경계를 익힙니다.

템플릿은 고정된 HTML 뼈대에 서버가 만든 데이터를 채워 최종 HTML을 만드는 파일입니다.

Thymeleaf는 HTML 속성 th:*로 어느 위치에 어떤 모델 값을 넣을지 표현하는 템플릿 엔진입니다.

모델은 컨트롤러가 뷰에 전달하는 화면용 데이터입니다.

서버가 HTML을 만들 때 가장 먼저 지켜야 할 계약은 “데이터를 표시하되 마크업으로 실행하지 않는다”입니다.

사용자 입력의 <>를 화면용 문자로 바꾸는 일을 이스케이프라고 합니다.

게시판의 제목과 본문은 사용자가 입력하므로 기본 출력은 반드시 이스케이프 경로를 택합니다.


화면 전용 뷰 모델

도메인 객체를 그대로 모델에 넣으면 화면이 내부 식별자와 저장용 상태까지 탐색할 수 있습니다.

상세 화면에 필요한 값만 불변 레코드로 옮겨 화면 계약을 분명히 합니다.

src/main/java/board/web/PostPageController.java
package board.web;

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("/posts/{id}")
    String detail(@PathVariable long id, Model model) {
        var post = query.required(id);
        model.addAttribute("post", new PostPage(
                post.id(),
                post.title(),
                post.content(),
                post.content().length(),
                DATE.format(post.publishedOn())));
        return "posts/detail";
    }

    record PostPage(
            long id,
            String title,
            String content,
            int characterCount,
            String publishedOn
    ) {
    }
}

뷰는 PostPage의 접근자만 사용합니다.

시간 형식과 null 정책까지 컨트롤러 어댑터에서 결정하면 템플릿이 도메인 계산을 수행하지 않습니다.


th:text·th:utext

src/main/resources/templates/posts/detail.html
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8" />
  <title th:text="|${post.title} · 게시판|">게시판</title>
</head>
<body>
  <main>
    <h1 th:text="${post.title}">제목</h1>
    <p th:text="${post.content}">본문</p>
    <dl>
      <dt>작성일</dt>
      <dd th:text="${post.publishedOn}">2026-07-14</dd>
      <dt>글자 수</dt>
      <dd th:text="|${post.characterCount}자|">45자</dd>
    </dl>
  </main>
</body>
</html>

입력이 <img src=x onerror=alert(1)>이면 th:text<>를 엔티티로 바꾸어 글자로 보이게 합니다.

th:utext는 HTML 조각을 그대로 삽입하므로 동일 입력이 요소가 됩니다.

Markdown을 허용하는 기능이라도 원시 입력에 바로 th:utext를 쓰지 않습니다.

허용 태그와 속성을 제한한 정제기 결과라는 별도 타입을 만든 뒤 그 경계에서만 이스케이프하지 않고 렌더링합니다.


렌더링 보안 테스트

템플릿 파일이 안전해 보이는 것과 실제 엔진 결과가 안전한 것은 다릅니다.

표현식과 방언 설정을 포함한 렌더링을 실행합니다.

src/test/java/board/web/PostTemplateTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Locale;

import org.junit.jupiter.api.Test;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templateresolver.StringTemplateResolver;

class PostTemplateTest {
    @Test
    void 사용자_본문은_markup이_아니라_text로_출력된다() {
        var resolver = new StringTemplateResolver();
        resolver.setTemplateMode("HTML");
        var engine = new TemplateEngine();
        engine.setTemplateResolver(resolver);
        var context = new Context(Locale.KOREAN);
        context.setVariable("content",
                "<img src=x onerror=alert(1)>");

        var html = engine.process(
                "<p th:text=\"${content}\">fallback</p>",
                context);

        assertThat(html)
                .contains("&lt;img src=x onerror=alert(1)&gt;")
                .doesNotContain("<img src=x");
    }
}
실행 결과
PostTemplateTest
  > 사용자_본문은_markup이_아니라_text로_출력된다() PASSED
rendered = <p>&lt;img src=x onerror=alert(1)&gt;</p>
executable img element = false

실패한 버전은 th:utext를 사용해 doesNotContain 검증이 깨집니다.

이 차이는 “브라우저가 알아서 막는다”는 기대가 아니라 서버 출력 바이트로 고정됩니다.


SpringEL 사용 범위

${post.title}는 모델 속성을 읽고, ${posts.?[characterCount >= 30]}는 컬렉션을 필터링할 수 있습니다.

그러나 긴 선택·투영 표현식은 템플릿을 디버깅하기 어렵게 만듭니다.

화면용 집계와 권한 판단은 Java에서 끝내고 불리언과 표시 문자열을 전달합니다.

기본 객체 접근도 최소화합니다.

요청 파라미터와 세션 속성을 템플릿에서 직접 읽으면 컨트롤러 계약을 우회합니다.

필요한 값은 모델에 명시합니다.

특히 ${@postService.findAll()}처럼 Spring 빈을 호출하면 렌더링이 DB 접근과 예외를 일으킬 수 있으므로 금지합니다.

null은 화면 계약에서 처리합니다.

“본문 없음” 문구가 필요하면 뷰 모델이 기본 문자열을 제공하거나 th:if로 존재 여부를 분기합니다.

도메인의 예상하지 못한 null을 Elvis 연산자로 숨기지 않습니다.


URL 표현식

th:href="@{/posts/{id}(id=${post.id})}"는 컨텍스트 경로를 포함하고 경로 변수를 인코딩합니다.

쿼리 파라미터는 @{/posts(title=${filter.title})}처럼 별도 인자로 둡니다.

문자열 연결로 URL을 만들면 /, ?, &, 공백의 의미가 섞입니다.

외부 URL은 신뢰 경계를 따로 둡니다.

사용자 입력을 th:href에 바로 넣으면 javascript: 스킴이나 열린 리다이렉트가 될 수 있습니다.

서버가 허용한 HTTPS 호스트 또는 내부 경로 키로 변환한 값만 모델에 넣습니다.

링크 텍스트와 링크 목적지는 각각 이스케이프와 스킴 검증이 필요합니다.


출력 방식 선택

데이터권장 출력이유
제목·본문th:text사용자 입력을 텍스트로 표시
서버가 만든 숫자·날짜th:text로케일 형식을 텍스트로 표시
정제기를 통과한 제한 HTML좁은 th:utext 경계허용 마크업만 삽입
내부 링크@{...}컨텍스트 경로와 인코딩
JSON 부트스트랩 데이터JavaScript 인라인 직렬화기문자열 리터럴 이스케이프

“운영자가 입력했다”는 이유만으로 신뢰할 수 있는 HTML이 되지 않습니다.

계정 탈취나 CSV 가져오기를 거치면 공격 문자열이 들어올 수 있습니다.

기본값을 이스케이프된 출력으로 두고 예외 경계에 증거를 요구합니다.


연습 문제

게시글 본문에 평문 텍스트와 승인된 Markdown 두 형식을 지원하세요.

원시 Markdown, 정제된 HTML, 렌더링된 페이지 사이의 타입과 저장 위치를 정하고 <script>, 이벤트 핸들러, javascript: 링크가 최종 HTML에 남지 않는 테스트를 작성합니다.

해설 보기

원시 Markdown은 감사와 재처리를 위해 원문으로 저장할 수 있지만 렌더링 직전에 신뢰할 수 있는 파서와 허용 목록 정제기를 통과시킵니다.

정제된 결과는 원시 String과 구별되는 타입으로 감쌉니다.

package board.web;

public record SanitizedHtml(String value) {
    public SanitizedHtml {
        if (value == null || value.contains("<script")
                || value.contains("onerror=")
                || value.contains("javascript:")) {
            throw new IllegalArgumentException(
                    "sanitized HTML contains a forbidden construct");
        }
    }
}

뷰 모델에는 plainContentsanitizedContent 중 하나만 존재하게 합니다.

테스트는 정제기 단위 결과와 Thymeleaf 최종 렌더링을 모두 확인합니다.

CSP는 추가 방어선이지 서버 측 이스케이프를 대체하지 않습니다.

다음 문서에서는 한 값의 안전한 출력을 넘어 컬렉션 반복, 조건 분기, 조각과 레이아웃의 책임을 조합합니다.