이전 글에서는 Thymeleaf가 무엇인지, 서버 사이드 템플릿 엔진이 어떤 방식으로 동작하는지 정리했다.
이번 글에서는 Thymeleaf를 사용할 때 자주 마주치는 기본 문법을 정리해보려고 한다.
Thymeleaf는 HTML 태그에 th:* 형태의 속성을 추가해서 사용한다.
처음 보면 낯설 수 있지만, 결국 핵심은 다음과 같다.
Controller에서 Model에 데이터 저장
↓
Thymeleaf HTML에서 Model 데이터 사용
↓
서버에서 HTML 렌더링
↓
브라우저에 완성된 HTML 응답
이번 글에서는 오류신고 관리 시스템 예시를 기준으로 다음 문법들을 정리한다.
- th:text
- th:if
- th:unless
- th:each
- th:href
- th:action
- th:value
- th:object
- th:field
예제에서 사용할 데이터
먼저 예제에서 사용할 상황을 정리해보자.
오류신고 목록 화면을 만든다고 가정한다.
Controller에서는 오류신고 목록 데이터를 조회해서 Model에 담는다.
@GetMapping("/reports")
public String reportList(Model model) {
List<Report> reports = reportService.findAll();
model.addAttribute("reports", reports);
return "reports/list";
}
위 코드의 의미는 다음과 같다.
/reports 요청을 받는다.
↓
오류신고 목록을 조회한다.
↓
reports라는 이름으로 Model에 담는다.
↓
templates/reports/list.html 화면을 반환한다.

이제 reports/list.html에서는 ${reports}라는 이름으로 데이터를 사용할 수 있다.
Thymeleaf 표현식 기본
Thymeleaf에서는 서버에서 전달받은 데이터를 ${...} 형태로 사용한다.
<p th:text="${username}">사용자명</p>
여기서 ${username}은 Controller에서 Model에 담은 username 값을 의미한다.
model.addAttribute("username", "길동");
위처럼 값을 전달하면 Thymeleaf는 HTML을 렌더링할 때 ${username} 부분을 "길동"으로 바꿔준다.
<p>길동</p>
정리하면 다음과 같다.
| 표현식 | 의미 |
| ${username} | Model에 담긴 username 값 |
| ${report.title} | report 객체의 title 값 |
| ${reports} | Model에 담긴 reports 목록 |
| @{/reports} | URL 경로 표현식 |
이번 글에서는 가장 자주 사용하는 ${...}와 @{...}를 중심으로 살펴본다.
th:text - 텍스트 출력하기
th:text는 값을 화면에 출력할 때 사용한다.
일반 HTML에서는 값을 직접 작성한다.
<p>사용자명</p>
Thymeleaf에서는 다음과 같이 작성한다.
<p th:text="${username}">사용자명</p>
Controller에서 다음과 같이 데이터를 전달했다고 해보자.
model.addAttribute("username", "길동");
그러면 서버에서 렌더링된 최종 HTML은 다음과 같다.
<p>길동</p>
즉, th:text는 태그 내부의 기본 텍스트를 서버에서 전달받은 값으로 대체한다.
오류신고 제목 출력 예시
오류신고 상세 화면에서는 제목, 내용, 처리상태 등을 출력할 수 있다.
<h2 th:text="${report.title}">오류신고 제목</h2>
<p th:text="${report.content}">오류신고 내용</p>
<span th:text="${report.status}">처리상태</span>
Controller에서는 다음과 같이 report 데이터를 넘겨준다.
@GetMapping("/reports/{id}")
public String reportDetail(@PathVariable Long id, Model model) {
Report report = reportService.findById(id);
model.addAttribute("report", report);
return "reports/detail";
}
데이터가 다음과 같다면,
title = "로그인 오류"
content = "로그인 시 오류가 발생합니다."
status = "접수"
최종 HTML은 다음과 비슷하게 렌더링된다.
<h2>로그인 오류</h2>
<p>로그인 시 오류가 발생합니다.</p>
<span>접수</span>
th:if - 조건이 참일 때만 출력하기
th:if는 조건이 참일 때만 해당 태그를 출력한다.
예를 들어 로그인한 사용자에게만 로그아웃 버튼을 보여주고 싶다면 다음과 같이 작성할 수 있다.
<a th:if="${loginUser != null}" href="/logout">로그아웃</a>
loginUser가 존재하면 로그아웃 링크가 출력되고,
loginUser가 없으면 해당 태그는 출력되지 않는다.
관리자 메뉴 출력 예시
관리자에게만 관리자 페이지 링크를 보여주고 싶다고 해보자.
<a th:if="${user.role == 'ADMIN'}" href="/admin/reports">
관리자 페이지
</a>
user.role 값이 ADMIN이면 관리자 페이지 링크가 출력된다.
반대로 일반 사용자라면 해당 링크는 HTML 결과에 포함되지 않는다.
user.role == 'ADMIN'
↓ true
관리자 페이지 링크 출력
user.role == 'USER'
↓ false
관리자 페이지 링크 출력 안 됨
처리상태에 따른 버튼 출력 예시
오류신고가 아직 처리되지 않은 상태일 때만 답변 등록 버튼을 보여줄 수도 있다.
<button th:if="${report.status == '접수'}">
답변 등록
</button>
또는 관리자 화면에서 처리 완료된 건은 수정 버튼만 보여줄 수도 있다.
<button th:if="${report.status == '완료'}">
답변 수정
</button>
th:if는 로그인 여부, 권한, 처리상태처럼 조건에 따라 화면을 다르게 보여줄 때 자주 사용한다.
th:unless - 조건이 거짓일 때 출력하기
th:unless는 th:if의 반대라고 보면 된다.
조건이 거짓일 때 해당 태그를 출력한다.
예를 들어 오류신고 목록이 비어 있을 때 안내 문구를 보여주고 싶다고 해보자.
<p th:unless="${#lists.isEmpty(reports)}">
오류신고가 존재합니다.
</p>
다만 위 코드는 목록이 비어 있지 않을 때 출력된다.
목록이 비어 있을 때 문구를 보여주려면 다음처럼 작성할 수 있다.
<p th:if="${#lists.isEmpty(reports)}">
등록된 오류신고가 없습니다.
</p>
또는 th:unless를 사용하면 다음과 같이 쓸 수도 있다.
<p th:unless="${!#lists.isEmpty(reports)}">
등록된 오류신고가 없습니다.
</p>
하지만 개인적으로는 초반에는 th:unless보다 th:if를 명확하게 쓰는 편이 읽기 쉽다.
<p th:if="${#lists.isEmpty(reports)}">
등록된 오류신고가 없습니다.
</p>
실무 코드에서도 조건이 복잡해지면 unless보다 if로 명확하게 표현하는 것이 더 읽기 쉬울 때가 많다.
th:each - 목록 반복 출력하기
th:each는 목록 데이터를 반복 출력할 때 사용한다.
오류신고 목록 화면에서는 여러 건의 오류신고를 테이블 형태로 출력해야 한다.
일반 HTML에서는 다음과 같이 직접 여러 줄을 작성해야 한다.
<tr>
<td>1</td>
<td>로그인 오류</td>
<td>접수</td>
</tr>
<tr>
<td>2</td>
<td>지도 화면 오류</td>
<td>완료</td>
</tr>
하지만 실제 데이터는 DB에서 조회되기 때문에 몇 건이 나올지 알 수 없다.
이때 th:each를 사용한다.
<tr th:each="report : ${reports}">
<td th:text="${report.id}">1</td>
<td th:text="${report.title}">제목</td>
<td th:text="${report.status}">처리상태</td>
</tr>
여기서 핵심은 이 부분이다.
th:each="report : ${reports}"
Java의 향상된 for문과 비슷하게 보면 된다.
for (Report report : reports) {
// report 사용
}
즉, ${reports} 목록에서 데이터를 하나씩 꺼내 report라는 이름으로 사용한다.

반복 상태값 사용하기
th:each에서는 반복 상태값도 사용할 수 있다.
<tr th:each="report, stat : ${reports}">
<td th:text="${stat.count}">1</td>
<td th:text="${report.title}">제목</td>
<td th:text="${report.status}">처리상태</td>
</tr>
여기서 stat은 반복 상태 정보를 담고 있다.
| 속성 | 의미 |
| stat.count | 1부터 시작하는 순번 |
| stat.first | 첫 번째 요소인지 여부 |
| stat.last | 마지막 요소인지 여부 |
| stat.even | 짝수 번째 여부 |
| stat.odd | 홀수 번째 여부 |
목록 화면에서 번호를 출력할 때는 보통 stat.count를 사용할 수 있다.
<td th:text="${stat.count}">1</td>
이렇게 하면 DB의 id 값과 별개로 화면에 1, 2, 3, 4 순번을 출력할 수 있다.
th:href - 동적 링크 만들기
th:href는 링크 URL을 동적으로 만들 때 사용한다.
일반 HTML에서는 다음과 같이 고정된 링크를 작성한다.
<a href="/reports/1">상세보기</a>
하지만 목록 화면에서는 각 오류신고의 id가 다르다.
/reports/1
/reports/2
/reports/3
이런 경우 th:href를 사용한다.
<a th:href="@{/reports/{id}(id=${report.id})}">
상세보기
</a>
만약 report.id가 10이라면 최종 HTML은 다음과 같이 만들어진다.
<a href="/reports/10">
상세보기
</a>
오류신고 제목을 클릭하면 상세 화면으로 이동하기
목록 화면에서는 제목을 클릭했을 때 상세 화면으로 이동하도록 만들 수 있다.
<tr th:each="report : ${reports}">
<td th:text="${report.id}">1</td>
<td>
<a th:href="@{/reports/{id}(id=${report.id})}"
th:text="${report.title}">
제목
</a>
</td>
<td th:text="${report.status}">처리상태</td>
</tr>
여기서 th:href는 상세 페이지 URL을 만들고,
th:text는 링크에 표시할 제목을 출력한다.
정리하면 다음과 같다.
report.id = 10
report.title = "로그인 오류"
↓ 렌더링
<a href="/reports/10">로그인 오류</a>
th:action - form 요청 URL 설정하기
th:action은 form 요청을 보낼 URL을 설정할 때 사용한다.
예를 들어 오류신고 등록 화면이 있다고 해보자.
<form th:action="@{/reports}" method="post">
<input type="text" name="title" placeholder="제목">
<textarea name="content" placeholder="내용"></textarea>
<button type="submit">등록</button>
</form>
위 코드는 사용자가 입력한 데이터를 POST /reports로 전송한다.
Controller에서는 다음과 같이 요청을 받을 수 있다.
@PostMapping("/reports")
public String createReport(ReportCreateRequest request) {
reportService.create(request);
return "redirect:/reports";
}
흐름을 정리하면 다음과 같다.
GET /reports/new
오류신고 등록 화면 조회
↓ 사용자가 입력 후 등록 버튼 클릭
POST /reports
오류신고 등록 처리
↓
redirect:/reports
목록 화면으로 이동

th:action은 회원가입, 로그인, 게시글 등록, 관리자 답변 등록처럼 form 전송이 필요한 화면에서 자주 사용한다.
th:value - input 값 설정하기
th:value는 input 태그의 값을 설정할 때 사용한다.
예를 들어 오류신고 수정 화면에서 기존 제목을 input에 보여주고 싶다고 해보자.
<input type="text" name="title" th:value="${report.title}">
만약 report.title 값이 "로그인 오류"라면 최종 HTML은 다음과 같이 렌더링된다.
<input type="text" name="title" value="로그인 오류">
수정 화면에서는 기존 데이터를 input에 미리 채워 넣어야 하기 때문에 th:value를 자주 사용한다.
<form th:action="@{/reports/{id}(id=${report.id})}" method="post">
<input type="text" name="title" th:value="${report.title}">
<textarea name="content" th:text="${report.content}"></textarea>
<button type="submit">수정</button>
</form>
다만 form 객체 바인딩을 사용하면 th:value보다 th:field를 더 많이 사용하게 된다.
th:object - form 객체 연결하기
th:object는 form에서 사용할 객체를 지정할 때 사용한다.
예를 들어 오류신고 등록 폼에서 reportForm 객체를 사용한다고 해보자.
Controller에서 다음과 같이 빈 form 객체를 전달한다.
@GetMapping("/reports/new")
public String reportForm(Model model) {
model.addAttribute("reportForm", new ReportForm());
return "reports/new";
}
HTML에서는 th:object로 form 객체를 연결한다.
<form th:action="@{/reports}" th:object="${reportForm}" method="post">
<input type="text" th:field="*{title}" placeholder="제목">
<textarea th:field="*{content}" placeholder="내용"></textarea>
<button type="submit">등록</button>
</form>
여기서 th:object="${reportForm}"은 이 form에서 사용할 기준 객체가 reportForm이라는 의미다.
그리고 내부에서는 *{title}, *{content}처럼 객체의 필드를 간단하게 사용할 수 있다.
th:object="${reportForm}"
↓
*{title} → reportForm.title
*{content} → reportForm.content
th:field - form 필드 바인딩하기
th:field는 form 객체의 필드와 input, textarea 같은 입력 요소를 연결할 때 사용한다.
<input type="text" th:field="*{title}">
위 코드는 reportForm 객체의 title 필드와 input을 연결한다.
th:field를 사용하면 Thymeleaf가 name, id, value 같은 속성을 자동으로 처리해준다.
예를 들어 다음 코드가 있다고 해보자.
<form th:object="${reportForm}">
<input type="text" th:field="*{title}">
</form>
렌더링 결과는 다음과 비슷하게 만들어진다.
<input type="text" id="title" name="title" value="">
수정 화면에서 reportForm.title 값이 존재한다면 value에도 기존 값이 들어간다.
<input type="text" id="title" name="title" value="로그인 오류">
즉, th:field는 form 입력값을 객체의 필드와 연결할 때 사용하는 문법이다.

오류신고 등록 폼 예시
지금까지 본 문법을 사용해서 오류신고 등록 폼을 만들어보자.
먼저 Controller에서 form 객체를 전달한다.
@GetMapping("/reports/new")
public String reportForm(Model model) {
model.addAttribute("reportForm", new ReportForm());
return "reports/new";
}
등록 요청을 처리하는 Controller는 다음과 같이 작성할 수 있다.
@PostMapping("/reports")
public String createReport(ReportForm reportForm) {
reportService.create(reportForm);
return "redirect:/reports";
}
이제 Thymeleaf HTML은 다음과 같이 작성할 수 있다.
<form th:action="@{/reports}" th:object="${reportForm}" method="post">
<div>
<label for="title">제목</label>
<input type="text" th:field="*{title}" placeholder="제목을 입력하세요">
</div>
<div>
<label for="content">내용</label>
<textarea th:field="*{content}" placeholder="내용을 입력하세요"></textarea>
</div>
<button type="submit">등록</button>
</form>
이 코드에서 사용한 Thymeleaf 문법은 다음과 같다.
| 문법 | 역할 |
| th:action | form 요청 URL 설정 |
| th:object | form 객체 연결 |
| th:field | form 객체의 필드와 입력 요소 연결 |
오류신고 등록 화면처럼 입력 폼이 필요한 경우에는 th:action, th:object, th:field를 함께 사용하는 경우가 많다.
오류신고 목록 화면 예시
이번에는 오류신고 목록 화면 예시를 보자.
Controller에서는 목록 데이터를 Model에 담는다.
@GetMapping("/reports")
public String reportList(Model model) {
List<Report> reports = reportService.findAll();
model.addAttribute("reports", reports);
return "reports/list";
}
HTML에서는 th:each로 목록을 반복 출력한다.
<table>
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>처리상태</th>
<th>상세</th>
</tr>
</thead>
<tbody>
<tr th:each="report, stat : ${reports}">
<td th:text="${stat.count}">1</td>
<td th:text="${report.title}">제목</td>
<td th:text="${report.status}">접수</td>
<td>
<a th:href="@{/reports/{id}(id=${report.id})}">
상세보기
</a>
</td>
</tr>
</tbody>
</table>
이 코드에서 사용한 문법은 다음과 같다.
| 문법 | 역할 |
| th:each | 오류신고 목록 반복 |
| th:text | 번호, 제목, 상태 출력 |
| th:href | 상세 페이지 링크 생성 |
목록 화면에서는 th:each, th:text, th:href 조합을 자주 사용한다.
자주 사용하는 문법 한 번에 정리
이번 글에서 살펴본 문법을 한 번에 정리하면 다음과 같다.
| 문법 | 역할 | 예시 |
| th:text | 텍스트 출력 | <p th:text="${report.title}">제목</p> |
| th:if | 조건이 참일 때 출력 | <button th:if="${report.status == '접수'}">답변 등록</button> |
| th:unless | 조건이 거짓일 때 출력 | <p th:unless="${loginUser != null}">로그인이 필요합니다.</p> |
| th:each | 반복 출력 | <tr th:each="report : ${reports}"> |
| th:href | 링크 URL 설정 | <a th:href="@{/reports/{id}(id=${report.id})}">상세</a> |
| th:action | form 요청 URL 설정 | <form th:action="@{/reports}" method="post"> |
| th:value | input 값 설정 | <input th:value="${report.title}"> |
| th:object | form 객체 연결 | <form th:object="${reportForm}"> |
| th:field | form 필드 바인딩 | <input th:field="*{title}"> |
정리
Thymeleaf는 HTML 태그에 th:* 속성을 추가해서 서버 데이터를 화면에 출력하거나, 조건 처리, 반복 처리, URL 생성, form 바인딩 등을 수행할 수 있다.
이번 글에서 정리한 핵심 문법은 다음과 같다.
값 출력 → th:text
조건 출력 → th:if, th:unless
반복 출력 → th:each
링크 생성 → th:href
form 요청 → th:action
input 값 설정 → th:value
form 바인딩 → th:object, th:field
처음에는 문법이 많아 보이지만, 실제로 CRUD 화면을 만들 때 자주 쓰는 조합은 어느 정도 정해져 있다.
목록 화면에서는 주로 다음 조합을 사용한다.
th:each + th:text + th:href
등록/수정 화면에서는 주로 다음 조합을 사용한다.
th:action + th:object + th:field
즉, Thymeleaf를 처음 공부할 때는 모든 문법을 한 번에 외우려고 하기보다,
목록 화면과 등록 화면을 직접 만들면서 필요한 문법을 익히는 것이 좋다.
다음 글에서는 다시 오류신고 관리 시스템 프로젝트로 돌아가서, 실제 화면 흐름을 기준으로 사용자 URL과 관리자 URL을 나누어 API/URL 설계서를 정리해보려고 한다.
참고 자료
'개발 > Spring' 카테고리의 다른 글
| [Spring Boot] Thymeleaf란? 서버 사이드 템플릿 엔진 개념 정리 (0) | 2026.05.29 |
|---|