26/08/04 IL(I Learned) - Thymeleaf

Let's take a break·2026년 8월 5일

Thymeleaf 템플릿 엔진을 활용한 동적 웹 페이지 구성과 Spring MVC 데이터 전달 과정

Thymeleaf 템플릿 엔진을 활용하여 Spring Boot에서 동적인 웹 페이지를 구성하는 방법을 학습하였다. 기존의 HTML은 화면에 고정된 내용만 출력할 수 있었지만, Thymeleaf를 이용하면 Controller에서 전달한 데이터를 화면에 출력하거나, 반복문과 조건문을 이용하여 상황에 따라 다른 화면을 생성할 수 있다.

또한 Spring MVC의 Model 객체를 이용하여 Controller와 View가 데이터를 주고받는 과정, DTO를 활용한 데이터 전달 방식, 반복문과 조건문을 활용한 동적 화면 구성, 그리고 HTML Escape를 통한 XSS 방지 기능까지 실습하면서 웹 애플리케이션의 화면이 내부적으로 어떻게 생성되는지 이해할 수 있었다.


Thymeleaf 템플릿 엔진의 역할

Thymeleaf는 Spring Boot에서 가장 많이 사용하는 서버 사이드(Server Side) 템플릿 엔진이다.

브라우저가 서버에 요청을 보내면 Controller가 필요한 데이터를 조회하고, Thymeleaf는 그 데이터를 HTML에 삽입하여 완성된 페이지를 브라우저로 전달한다.

전체 동작 과정은 다음과 같다.

브라우저 요청

↓

DispatcherServlet

↓

Controller

↓

Service

↓

Repository

↓

DB 조회

↓

Controller(Model 저장)

↓

Thymeleaf

↓

HTML 생성

↓

브라우저 출력

사용자가 최종적으로 보는 HTML은 처음부터 존재했던 것이 아니라 Controller가 전달한 데이터와 Thymeleaf가 합쳐져 새롭게 생성된 결과물이라는 점을 이해하였다.


Controller와 Model을 이용한 데이터 전달

Controller는 사용자의 요청을 처리하고, 화면에 필요한 데이터를 조회하여 View에 전달하는 역할을 수행한다.

@GetMapping
public String index(Model model) {

    model.addAttribute("msg", appProperties.message());

    model.addAttribute(
            "pizzas",
            pizzaRepository.findAll()
                    .stream()
                    .map(PizzaDTO::fromEntity)
                    .toList()
    );

    return "index";
}

사용자가

http://localhost:8080/

으로 접속하면 Spring은 가장 먼저 Controller의 index() 메서드를 실행한다.

이때 Controller는 데이터베이스에서 피자 목록을 조회하고, Model 객체에 데이터를 저장한다.

model.addAttribute("msg", "환영합니다.");
model.addAttribute("count", 5);

를 실행하면 Model 내부에는 다음과 같이 저장된다.

KeyValue
msg환영합니다.
count5

이후 Thymeleaf는 이 데이터를 읽어 HTML을 생성한다.

예를 들어 HTML에서

<p th:text="${msg}"></p>

<p th:text="${count}"></p>

를 작성하면 최종 HTML은

<p>환영합니다.</p>

<p>5</p>

가 된다.

즉, Model은 Controller와 View 사이에서 데이터를 전달하는 저장소 역할을 수행한다.


Model을 사용하는 이유

Model이 없다면 Controller에서 조회한 데이터를 View가 사용할 수 없다.

예를 들어

List<Pizza> pizzas = pizzaRepository.findAll();

만 실행한다면 피자 목록은 Java 메모리에만 존재한다.

HTML은 Java 변수에 직접 접근할 수 없기 때문에 화면에는 아무것도 출력되지 않는다.

그래서

model.addAttribute("pizzas", pizzas);

를 이용하여

Controller

↓

Model

↓

View

순서로 데이터를 전달한다.


Thymeleaf 표현식(Expression)

Thymeleaf는 다양한 표현식을 제공한다.

<h1 th:text="#{page.headline}"></h1>

<p th:text="${msg}"></p>

<p>[[${msg2}]]</p>

${} 표현식

${}는 Model에 저장된 데이터를 가져오는 표현식이다.

예를 들어

model.addAttribute("name", "홍길동");

를 저장하면

<p th:text="${name}"></p>

은

<p>홍길동</p>

으로 출력된다.


#{} 표현식

#{}는 국제화(MessageSource) 파일의 내용을 가져온다.

예를 들어

message.properties

page.headline=피자 주문 시스템

이 있다면

<h1 th:text="#{page.headline}"></h1>

은

피자 주문 시스템

으로 출력된다.

브라우저 언어가 영어라면

message_en.properties의 값을 출력한다.

즉, 같은 HTML이라도 사용자의 언어에 따라 다른 내용을 출력할 수 있다.


[[ ]] 표현식

[[ ]]는 Thymeleaf의 인라인 표현식이다.

기능은 ${}와 비슷하지만 HTML 태그 밖에서도 자연스럽게 사용할 수 있다.

예를 들어

<p>안녕하세요 [[${name}]] 님</p>

이라면

브라우저에는

안녕하세요 홍길동 님

으로 출력된다.


DTO를 이용한 화면 데이터 관리

프로젝트에서는 Entity를 그대로 View에 전달하지 않고 DTO를 사용하였다.

public record PizzaDTO(
        String name,
        int price
) {

    Pizza toEntity() {
        return Pizza.builder()
                .name(name)
                .price(price)
                .build();
    }

    static PizzaDTO fromEntity(Pizza pizza) {
        return new PizzaDTO(
                pizza.getName(),
                pizza.getPrice()
        );
    }
}

DTO(Data Transfer Object)는 데이터를 전달하기 위한 객체이다.

예를 들어 데이터베이스에는

idnamepricecreated_at
1불고기220002026-08-01

가 저장되어 있다고 가정하자.

화면에서는

불고기

22000원

만 필요하다.

그렇다면

DTO는

name

price

만 가지고 있으면 된다.

즉,

Entity

↓

DTO

↓

View

순서로 데이터를 전달한다.

이를 통해 화면에는 필요한 데이터만 전달할 수 있고, Entity가 직접 노출되지 않아 보안과 유지보수 측면에서도 유리하다는 점을 이해하였다.


th:each를 이용한 반복 출력

조회한 피자 목록은 th:each를 이용하여 반복 출력하였다.

<div th:each="pizza, status : ${pizzas}">

    <code th:text="${pizza.name}"></code>

    <code th:text="${pizza.price}"></code>

</div>

Controller가

model.addAttribute(
    "pizzas",
    List.of(
        new PizzaDTO("불고기",22000),
        new PizzaDTO("치즈",18000),
        new PizzaDTO("페퍼로니",25000)
    )
);

를 전달했다고 가정하면

Thymeleaf는 내부적으로 다음과 같이 반복한다.

첫 번째 반복

pizza

↓

불고기

22000

두 번째 반복

pizza

↓

치즈

18000

세 번째 반복

pizza

↓

페퍼로니

25000

즉, HTML 요소 하나를 Collection의 크기만큼 복제하여 출력한다.

최종 브라우저에는

불고기   22000

치즈     18000

페퍼로니 25000

가 출력된다.


th:each와 status 객체를 이용한 반복 상태 관리

Thymeleaf의 th:each는 단순히 데이터를 반복해서 출력하는 기능만 제공하는 것이 아니라, 반복문의 현재 상태를 나타내는 status 객체도 함께 제공한다.

<div th:each="pizza, status : ${pizzas}">

    <span th:text="${status.count}"></span>

    <span th:text="${status.index}"></span>

    <span th:text="${status.first}"></span>

    <span th:text="${status.last}"></span>

</div>

예를 들어 Controller에서 다음과 같은 데이터를 전달했다고 가정하자.

List<PizzaDTO> pizzas = List.of(
    new PizzaDTO("불고기",22000),
    new PizzaDTO("치즈",18000),
    new PizzaDTO("페퍼로니",25000)
);

첫 번째 반복에서는

속성값
pizza불고기
count1
index0
firsttrue
lastfalse

두 번째 반복에서는

속성값
pizza치즈
count2
index1
firstfalse
lastfalse

세 번째 반복에서는

속성값
pizza페퍼로니
count3
index2
firstfalse
lasttrue

이처럼 status 객체는 반복문의 진행 상황을 알려주는 다양한 정보를 제공한다.


status 객체를 사용하는 이유

반복문의 현재 위치를 알 수 있기 때문에 화면을 더욱 다양하게 구성할 수 있다.

예를 들어

  • 첫 번째 상품에는 "BEST" 표시
  • 마지막 상품에는 "NEW" 표시
  • 홀수 번째 행은 회색 배경
  • 짝수 번째 행은 흰색 배경

과 같은 화면을 쉽게 구현할 수 있다.

쇼핑몰을 예로 들면

1. 아이폰 ⭐BEST

2. 갤럭시

3. 맥북

4. 아이패드 ⭐NEW

처럼 특정 순서에 따라 화면을 다르게 표현할 수 있다.


th:if와 th:unless를 이용한 조건부 출력

웹 화면에서는 모든 데이터를 항상 출력하는 것이 아니라 특정 조건에서만 보여 주어야 하는 경우가 많다.

이를 위해 Thymeleaf에서는 th:if와 th:unless를 제공한다.

<mark th:if="${status.first}">
    첫 번째 메뉴입니다.
</mark>

<mark th:unless="${status.first}">
    첫 번째 메뉴가 아닙니다.
</mark>

th:if는 조건이 참(true) 일 때만 HTML 요소를 생성한다.

예를 들어

boolean admin = true;

라면

<p th:if="${admin}">
    관리자 메뉴
</p>

은 브라우저에서

<p>관리자 메뉴</p>

로 출력된다.

반대로

boolean admin = false;

라면

브라우저에는 해당 <p> 태그 자체가 생성되지 않는다.

즉, CSS로 숨기는 것이 아니라 HTML 자체를 만들지 않는 것이다.


th:unless

th:unless는 th:if와 반대로 조건이 거짓일 때 출력된다.

예를 들어

boolean login = false;

라면

<p th:unless="${login}">
    로그인 해주세요.
</p>

는

<p>로그인 해주세요.</p>

가 출력된다.


th:switch와 th:case를 이용한 여러 조건 처리

조건이 여러 개인 경우에는 th:switch를 사용할 수 있다.

<section th:switch="${status.count}">

    <p th:case="1">첫 번째 메뉴</p>

    <p th:case="2">두 번째 메뉴</p>

    <p th:case="*">기타 메뉴</p>

</section>

Java의 switch 문과 동일한 방식으로 동작한다.

예를 들어

int level = 2;

라면

<div th:switch="${level}">

<p th:case="1">초급</p>

<p th:case="2">중급</p>

<p th:case="3">고급</p>

<p th:case="*">알 수 없음</p>

</div>

의 결과는

중급

이 된다.

*는 Java의 default와 같은 역할을 수행한다.


언제 사용하는가?

예를 들어 회원 등급이 있다면

등급화면 출력
1Bronze
2Silver
3Gold
기타Unknown

처럼 하나의 값에 따라 여러 화면을 표현할 수 있다.


HTML Escape와 XSS 방지

이번 프로젝트에서 가장 중요하게 학습한 내용 중 하나는 HTML Escape였다.

Controller에서는 다음과 같은 문자열을 View로 전달하였다.

String data = """
<script>alert('XSS!')</script>
""";

그리고 View에서는

<p th:text="${data}"></p>

<p>[[${data}]]</p>

만약 HTML Escape가 존재하지 않는다면

브라우저는

<script>alert("XSS!")</script>

를 실행하게 된다.

그러면

사용자의 브라우저에서

⚠ alert 창 실행

이 발생한다.

이러한 공격을 XSS(Cross Site Scripting) 라고 한다.


HTML Escape가 적용되면

Thymeleaf는

<script>

를

&lt;script&gt;

처럼 변환하여 출력한다.

브라우저는 이것을 HTML 코드가 아닌 단순한 문자열로 인식한다.

즉,

브라우저 화면에는

<script>alert("XSS!")</script>

가 그대로 보일 뿐

실행되지는 않는다.


HTML Escape가 중요한 이유

웹 서비스에서는 사용자가 자유롭게 글을 작성하는 경우가 많다.

예를 들어

  • 게시판
  • 댓글
  • 리뷰
  • 문의 게시판

등에서 악성 사용자가

<script>
location.href='악성사이트'
</script>

를 입력한다면

Escape가 없을 경우

다른 사용자의 브라우저에서도 해당 스크립트가 실행될 수 있다.

따라서 Thymeleaf는 기본적으로 HTML Escape를 적용하여 이러한 보안 문제를 예방한다.


학습한 내용

학습 내용세부 학습 내용
th:eachCollection 데이터를 반복 출력하며 HTML 요소를 자동으로 생성하는 방법
status 객체반복문의 순서, 인덱스, 첫 번째/마지막 여부 등을 활용하여 화면을 제어하는 방법
th:if조건이 참인 경우에만 HTML 요소를 생성하는 방법
th:unless조건이 거짓인 경우에만 HTML 요소를 생성하는 방법
th:switch, th:case여러 조건에 따라 서로 다른 화면을 출력하는 방법
HTML EscapeHTML 태그를 문자열로 변환하여 브라우저에서 실행되지 않도록 하는 기능
XSS 방지악성 스크립트 실행을 방지하여 웹 애플리케이션의 보안을 높이는 방법

느낀 점

Thymeleaf는 단순히 데이터를 화면에 출력하는 템플릿 엔진이 아니라, 반복문과 조건문을 활용하여 다양한 화면을 동적으로 구성하고, HTML Escape를 통해 웹 보안까지 고려한 서버 사이드 렌더링 기술이라는 점을 이해할 수 있었다. 특히 th:each와 status 객체를 활용하여 반복 상태를 제어하고, th:if와 th:switch를 이용해 조건에 따라 화면을 다르게 구성하는 방법을 실습하면서, 실제 웹 서비스에서 자주 사용되는 UI 구성 방식을 익힐 수 있었다. 또한 XSS 공격 사례를 통해 HTML Escape의 필요성을 이해하면서, 사용자 입력을 안전하게 처리하는 것이 기능 구현만큼 중요한 요소라는 점을 배울 수 있었다.

0개의 댓글