Thymeleaf 템플릿 엔진을 활용하여 Spring Boot에서 동적인 웹 페이지를 구성하는 방법을 학습하였다. 기존의 HTML은 화면에 고정된 내용만 출력할 수 있었지만, Thymeleaf를 이용하면 Controller에서 전달한 데이터를 화면에 출력하거나, 반복문과 조건문을 이용하여 상황에 따라 다른 화면을 생성할 수 있다.
또한 Spring MVC의 Model 객체를 이용하여 Controller와 View가 데이터를 주고받는 과정, DTO를 활용한 데이터 전달 방식, 반복문과 조건문을 활용한 동적 화면 구성, 그리고 HTML Escape를 통한 XSS 방지 기능까지 실습하면서 웹 애플리케이션의 화면이 내부적으로 어떻게 생성되는지 이해할 수 있었다.
Thymeleaf는 Spring Boot에서 가장 많이 사용하는 서버 사이드(Server Side) 템플릿 엔진이다.
브라우저가 서버에 요청을 보내면 Controller가 필요한 데이터를 조회하고, Thymeleaf는 그 데이터를 HTML에 삽입하여 완성된 페이지를 브라우저로 전달한다.
전체 동작 과정은 다음과 같다.
브라우저 요청
↓
DispatcherServlet
↓
Controller
↓
Service
↓
Repository
↓
DB 조회
↓
Controller(Model 저장)
↓
Thymeleaf
↓
HTML 생성
↓
브라우저 출력
사용자가 최종적으로 보는 HTML은 처음부터 존재했던 것이 아니라 Controller가 전달한 데이터와 Thymeleaf가 합쳐져 새롭게 생성된 결과물이라는 점을 이해하였다.
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 내부에는 다음과 같이 저장된다.
| Key | Value |
|---|---|
| msg | 환영합니다. |
| count | 5 |
이후 Thymeleaf는 이 데이터를 읽어 HTML을 생성한다.
예를 들어 HTML에서
<p th:text="${msg}"></p>
<p th:text="${count}"></p>
를 작성하면 최종 HTML은
<p>환영합니다.</p>
<p>5</p>
가 된다.
즉, Model은 Controller와 View 사이에서 데이터를 전달하는 저장소 역할을 수행한다.
Model이 없다면 Controller에서 조회한 데이터를 View가 사용할 수 없다.
예를 들어
List<Pizza> pizzas = pizzaRepository.findAll();
만 실행한다면 피자 목록은 Java 메모리에만 존재한다.
HTML은 Java 변수에 직접 접근할 수 없기 때문에 화면에는 아무것도 출력되지 않는다.
그래서
model.addAttribute("pizzas", pizzas);
를 이용하여
Controller
↓
Model
↓
View
순서로 데이터를 전달한다.
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>
이라면
브라우저에는
안녕하세요 홍길동 님
으로 출력된다.
프로젝트에서는 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)는 데이터를 전달하기 위한 객체이다.
예를 들어 데이터베이스에는
| id | name | price | created_at |
|---|---|---|---|
| 1 | 불고기 | 22000 | 2026-08-01 |
가 저장되어 있다고 가정하자.
화면에서는
불고기
22000원
만 필요하다.
그렇다면
DTO는
name
price
만 가지고 있으면 된다.
즉,
Entity
↓
DTO
↓
View
순서로 데이터를 전달한다.
이를 통해 화면에는 필요한 데이터만 전달할 수 있고, Entity가 직접 노출되지 않아 보안과 유지보수 측면에서도 유리하다는 점을 이해하였다.
조회한 피자 목록은 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
가 출력된다.
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 | 불고기 |
| count | 1 |
| index | 0 |
| first | true |
| last | false |
두 번째 반복에서는
| 속성 | 값 |
|---|---|
| pizza | 치즈 |
| count | 2 |
| index | 1 |
| first | false |
| last | false |
세 번째 반복에서는
| 속성 | 값 |
|---|---|
| pizza | 페퍼로니 |
| count | 3 |
| index | 2 |
| first | false |
| last | true |
이처럼 status 객체는 반복문의 진행 상황을 알려주는 다양한 정보를 제공한다.
반복문의 현재 위치를 알 수 있기 때문에 화면을 더욱 다양하게 구성할 수 있다.
예를 들어
과 같은 화면을 쉽게 구현할 수 있다.
쇼핑몰을 예로 들면
1. 아이폰 ⭐BEST
2. 갤럭시
3. 맥북
4. 아이패드 ⭐NEW
처럼 특정 순서에 따라 화면을 다르게 표현할 수 있다.
웹 화면에서는 모든 데이터를 항상 출력하는 것이 아니라 특정 조건에서만 보여 주어야 하는 경우가 많다.
이를 위해 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:if와 반대로 조건이 거짓일 때 출력된다.
예를 들어
boolean login = false;
라면
<p th:unless="${login}">
로그인 해주세요.
</p>
는
<p>로그인 해주세요.</p>
가 출력된다.
조건이 여러 개인 경우에는 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와 같은 역할을 수행한다.
예를 들어 회원 등급이 있다면
| 등급 | 화면 출력 |
|---|---|
| 1 | Bronze |
| 2 | Silver |
| 3 | Gold |
| 기타 | Unknown |
처럼 하나의 값에 따라 여러 화면을 표현할 수 있다.
이번 프로젝트에서 가장 중요하게 학습한 내용 중 하나는 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) 라고 한다.
Thymeleaf는
<script>
를
<script>
처럼 변환하여 출력한다.
브라우저는 이것을 HTML 코드가 아닌 단순한 문자열로 인식한다.
즉,
브라우저 화면에는
<script>alert("XSS!")</script>
가 그대로 보일 뿐
실행되지는 않는다.
웹 서비스에서는 사용자가 자유롭게 글을 작성하는 경우가 많다.
예를 들어
등에서 악성 사용자가
<script>
location.href='악성사이트'
</script>
를 입력한다면
Escape가 없을 경우
다른 사용자의 브라우저에서도 해당 스크립트가 실행될 수 있다.
따라서 Thymeleaf는 기본적으로 HTML Escape를 적용하여 이러한 보안 문제를 예방한다.
| 학습 내용 | 세부 학습 내용 |
|---|---|
th:each | Collection 데이터를 반복 출력하며 HTML 요소를 자동으로 생성하는 방법 |
status 객체 | 반복문의 순서, 인덱스, 첫 번째/마지막 여부 등을 활용하여 화면을 제어하는 방법 |
th:if | 조건이 참인 경우에만 HTML 요소를 생성하는 방법 |
th:unless | 조건이 거짓인 경우에만 HTML 요소를 생성하는 방법 |
th:switch, th:case | 여러 조건에 따라 서로 다른 화면을 출력하는 방법 |
| HTML Escape | HTML 태그를 문자열로 변환하여 브라우저에서 실행되지 않도록 하는 기능 |
| XSS 방지 | 악성 스크립트 실행을 방지하여 웹 애플리케이션의 보안을 높이는 방법 |
Thymeleaf는 단순히 데이터를 화면에 출력하는 템플릿 엔진이 아니라, 반복문과 조건문을 활용하여 다양한 화면을 동적으로 구성하고, HTML Escape를 통해 웹 보안까지 고려한 서버 사이드 렌더링 기술이라는 점을 이해할 수 있었다. 특히 th:each와 status 객체를 활용하여 반복 상태를 제어하고, th:if와 th:switch를 이용해 조건에 따라 화면을 다르게 구성하는 방법을 실습하면서, 실제 웹 서비스에서 자주 사용되는 UI 구성 방식을 익힐 수 있었다. 또한 XSS 공격 사례를 통해 HTML Escape의 필요성을 이해하면서, 사용자 입력을 안전하게 처리하는 것이 기능 구현만큼 중요한 요소라는 점을 배울 수 있었다.