728x90
반응형
🎨 들어가며
백엔드 API는 완성했는데, 이제 어떻게 화면에 보여줄까요? React나 Vue.js를 배워야 할까요?
잠깐! Spring Boot와 함께라면 Thymeleaf로 충분합니다. 서버 사이드 렌더링의 강력함과 Bootstrap의 예쁜 디자인을 결합하여, 빠르게 실무에서 사용 가능한 웹 애플리케이션을 만들어보겠습니다.
이 글에서는 Spring Boot CRUD API에 Thymeleaf 템플릿 엔진을 적용하여 완전한 웹 애플리케이션으로 변신시키는 과정을 다룹니다.
📑 목차
Thymeleaf란?
🌿 특징
Thymeleaf는 Java 템플릿 엔진으로, HTML을 그대로 사용하면서 동적 콘텐츠를 생성할 수 있습니다.
html
<!-- 일반 HTML처럼 보이지만 -->
<p>안녕하세요, <span th:text="${user.name}">손님</span>님!</p>
<!-- 서버에서 렌더링되면 -->
<p>안녕하세요, <span>김한동</span>님!</p>
장점
- ✅ Natural Template: 브라우저에서 바로 열어도 깨지지 않음
- ✅ Spring Boot 통합: 설정이 간단하고 Spring과 완벽 호환
- ✅ 서버 사이드 렌더링: SEO 최적화, 초기 로딩 속도 빠름
- ✅ 학습 곡선 완만: HTML만 알면 바로 시작 가능
단점
- ❌ SPA(Single Page Application) 구현 어려움
- ❌ 복잡한 상호작용은 JavaScript 필요
- ❌ 페이지 전체 새로고침
프로젝트 설정
1. 의존성 추가
xml
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Thymeleaf Layout Dialect -->
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
<version>3.3.0</version>
</dependency>
<!-- Bootstrap (WebJar) -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>5.3.0</version>
</dependency>
<!-- jQuery (WebJar) -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.7.1</version>
</dependency>
</dependencies>
2. application.properties 설정
properties
# Thymeleaf 설정
spring.thymeleaf.cache=false # 개발 중에는 캐시 비활성화
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.servlet.content-type=text/html
# 정적 리소스 설정
spring.web.resources.static-locations=classpath:/static/
spring.web.resources.cache.period=0
3. 프로젝트 구조
src/main/resources/
├── templates/
│ ├── layout/
│ │ └── layout.html # 공통 레이아웃
│ ├── students/
│ │ ├── list.html # 목록 페이지
│ │ ├── form.html # 등록/수정 폼
│ │ └── detail.html # 상세 페이지
│ ├── error/
│ │ ├── 404.html # 404 에러 페이지
│ │ └── 500.html # 500 에러 페이지
│ └── index.html # 홈페이지
└── static/
├── css/
│ └── style.css # 커스텀 CSS
├── js/
│ └── app.js # 커스텀 JavaScript
└── images/
└── logo.png # 이미지 파일
Controller 리팩토링
REST Controller → View Controller 변환
기존의 @RestController를 @Controller로 변경하고 뷰를 반환하도록 수정합니다.
java
package com.university.controller;
import com.university.dto.StudentDto;
import com.university.service.StudentService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.validation.Valid;
@Controller // @RestController 대신 @Controller
@RequestMapping("/students")
@RequiredArgsConstructor
public class StudentController {
private final StudentService studentService;
// 학생 목록 페이지
@GetMapping
public String listStudents(
@RequestParam(required = false) String search,
Model model) {
if (search != null && !search.isEmpty()) {
model.addAttribute("students", studentService.searchStudents(search));
model.addAttribute("search", search);
} else {
model.addAttribute("students", studentService.getAllStudents());
}
return "students/list"; // templates/students/list.html
}
// 학생 등록 폼
@GetMapping("/new")
public String showCreateForm(Model model) {
model.addAttribute("student", new StudentDto());
model.addAttribute("pageTitle", "학생 등록");
return "students/form";
}
// 학생 등록 처리
@PostMapping
public String createStudent(
@Valid @ModelAttribute("student") StudentDto studentDto,
BindingResult result,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "students/form";
}
try {
studentService.createStudent(studentDto);
redirectAttributes.addFlashAttribute("successMessage",
"학생이 성공적으로 등록되었습니다.");
return "redirect:/students";
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage",
"등록 중 오류가 발생했습니다: " + e.getMessage());
return "redirect:/students/new";
}
}
// 학생 수정 폼
@GetMapping("/edit/{id}")
public String showEditForm(@PathVariable Long id, Model model) {
StudentDto student = studentService.getStudentById(id);
model.addAttribute("student", student);
model.addAttribute("pageTitle", "학생 정보 수정");
return "students/form";
}
// 학생 수정 처리
@PostMapping("/edit/{id}")
public String updateStudent(
@PathVariable Long id,
@Valid @ModelAttribute("student") StudentDto studentDto,
BindingResult result,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "students/form";
}
try {
studentService.updateStudent(id, studentDto);
redirectAttributes.addFlashAttribute("successMessage",
"학생 정보가 수정되었습니다.");
return "redirect:/students";
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage",
"수정 중 오류가 발생했습니다: " + e.getMessage());
return "redirect:/students/edit/" + id;
}
}
// 학생 삭제
@PostMapping("/delete/{id}")
public String deleteStudent(
@PathVariable Long id,
RedirectAttributes redirectAttributes) {
try {
studentService.deleteStudent(id);
redirectAttributes.addFlashAttribute("successMessage",
"학생이 삭제되었습니다.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage",
"삭제 중 오류가 발생했습니다: " + e.getMessage());
}
return "redirect:/students";
}
// 학생 상세 정보
@GetMapping("/{id}")
public String viewStudent(@PathVariable Long id, Model model) {
model.addAttribute("student", studentService.getStudentById(id));
return "students/detail";
}
}
레이아웃 템플릿 만들기
공통 레이아웃 (layout/layout.html)
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 페이지별 제목 -->
<title layout:title-pattern="$CONTENT_TITLE - $LAYOUT_TITLE">학생 관리 시스템</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- Custom CSS -->
<link th:href="@{/css/style.css}" rel="stylesheet">
<!-- 페이지별 추가 CSS -->
<th:block layout:fragment="css"></th:block>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" th:href="@{/}">
<i class="bi bi-mortarboard-fill"></i>
학생 관리 시스템
</a>
<button class="navbar-toggler" type="button"
data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/}"
th:classappend="${#httpServletRequest.requestURI == '/'} ? 'active'">
<i class="bi bi-house"></i> 홈
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/students}"
th:classappend="${#strings.contains(#httpServletRequest.requestURI, '/students')} ? 'active'">
<i class="bi bi-people"></i> 학생 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/students/new}">
<i class="bi bi-person-plus"></i> 학생 등록
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Alert Messages -->
<div class="container mt-3">
<!-- Success Message -->
<div th:if="${successMessage}"
class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle"></i>
<span th:text="${successMessage}"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<!-- Error Message -->
<div th:if="${errorMessage}"
class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-circle"></i>
<span th:text="${errorMessage}"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</div>
<!-- Main Content -->
<main class="container my-4">
<div layout:fragment="content">
<!-- 각 페이지의 콘텐츠가 여기에 삽입됩니다 -->
</div>
</main>
<!-- Footer -->
<footer class="bg-light py-4 mt-5">
<div class="container text-center">
<p class="text-muted mb-0">
© <span th:text="${#dates.year(#dates.createNow())}"></span>
학생 관리 시스템. All rights reserved.
</p>
</div>
</footer>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Custom JS -->
<script th:src="@{/js/app.js}"></script>
<!-- 페이지별 추가 JavaScript -->
<th:block layout:fragment="script"></th:block>
</body>
</html>
CRUD 페이지 구현
1. 학생 목록 페이지 (students/list.html)
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title>학생 목록</title>
</head>
<body>
<div layout:fragment="content">
<!-- Page Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h2">
<i class="bi bi-people-fill"></i> 학생 목록
</h1>
</div>
<div class="col-auto">
<a th:href="@{/students/new}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> 새 학생 등록
</a>
</div>
</div>
<!-- Search Form -->
<div class="card mb-4">
<div class="card-body">
<form th:action="@{/students}" method="get" class="row g-3">
<div class="col-md-10">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text" name="search" class="form-control"
placeholder="이름, 학번, 이메일로 검색..."
th:value="${search}">
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">검색</button>
</div>
</form>
</div>
</div>
<!-- Students Table -->
<div class="card">
<div class="card-header">
<span class="badge bg-secondary" th:text="${#lists.size(students)} + '명'"></span>
의 학생이 등록되어 있습니다.
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>학번</th>
<th>이름</th>
<th>이메일</th>
<th>학과</th>
<th>전화번호</th>
<th>상태</th>
<th>등록일</th>
<th class="text-center">액션</th>
</tr>
</thead>
<tbody>
<!-- 학생 데이터 반복 -->
<tr th:each="student : ${students}"
th:onclick="'window.location.href=\'' + @{/students/{id}(id=${student.id})} + '\''"
style="cursor: pointer;">
<td th:text="${student.studentId}">20240001</td>
<td>
<strong th:text="${student.name}">홍길동</strong>
</td>
<td>
<a th:href="'mailto:' + ${student.email}"
th:text="${student.email}"
onclick="event.stopPropagation();">hong@example.com</a>
</td>
<td th:text="${student.department}">컴퓨터공학과</td>
<td th:text="${student.phoneNumber}">010-1234-5678</td>
<td>
<span class="badge"
th:classappend="${student.status == 'ACTIVE'} ? 'bg-success' :
(${student.status == 'INACTIVE'} ? 'bg-warning' : 'bg-secondary')"
th:text="${student.status == 'ACTIVE'} ? '활동중' :
(${student.status == 'INACTIVE'} ? '비활동' : '졸업')">
활동중
</span>
</td>
<td th:text="${#temporals.format(student.joinDate, 'yyyy-MM-dd')}">
2024-01-01
</td>
<td class="text-center" onclick="event.stopPropagation();">
<div class="btn-group btn-group-sm" role="group">
<a th:href="@{/students/{id}(id=${student.id})}"
class="btn btn-outline-info" title="상세보기">
<i class="bi bi-eye"></i>
</a>
<a th:href="@{/students/edit/{id}(id=${student.id})}"
class="btn btn-outline-primary" title="수정">
<i class="bi bi-pencil"></i>
</a>
<button type="button"
class="btn btn-outline-danger delete-btn"
th:data-id="${student.id}"
th:data-name="${student.name}"
title="삭제">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
<!-- 데이터가 없을 때 -->
<tr th:if="${#lists.isEmpty(students)}">
<td colspan="8" class="text-center py-5">
<div class="text-muted">
<i class="bi bi-inbox display-1"></i>
<p class="mt-3">등록된 학생이 없습니다.</p>
<a th:href="@{/students/new}" class="btn btn-primary">
첫 학생 등록하기
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-exclamation-triangle text-danger"></i>
삭제 확인
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>정말로 <strong id="deleteStudentName"></strong> 학생을 삭제하시겠습니까?</p>
<p class="text-danger mb-0">
<small>이 작업은 되돌릴 수 없습니다.</small>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
취소
</button>
<form id="deleteForm" method="post" style="display: inline;">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> 삭제
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
$(document).ready(function() {
// 삭제 버튼 클릭 이벤트
$('.delete-btn').click(function() {
const id = $(this).data('id');
const name = $(this).data('name');
$('#deleteStudentName').text(name);
$('#deleteForm').attr('action', '/students/delete/' + id);
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
deleteModal.show();
});
// 검색어 하이라이트
const searchTerm = '[[${search}]]';
if (searchTerm) {
$('.table tbody td').each(function() {
const text = $(this).text();
const regex = new RegExp('(' + searchTerm + ')', 'gi');
const highlighted = text.replace(regex, '<mark>$1</mark>');
$(this).html(highlighted);
});
}
});
</script>
</th:block>
</body>
</html>
2. 학생 등록/수정 폼 (students/form.html)
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title th:text="${pageTitle}">학생 정보</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="mb-0" th:text="${pageTitle}">학생 정보</h3>
</div>
<div class="card-body">
<form th:action="${student.id != null} ? @{/students/edit/{id}(id=${student.id})} : @{/students}"
th:object="${student}"
method="post"
novalidate>
<!-- 학번 -->
<div class="mb-3">
<label for="studentId" class="form-label">
학번 <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control"
id="studentId"
th:field="*{studentId}"
th:classappend="${#fields.hasErrors('studentId')} ? 'is-invalid'"
placeholder="20240001"
required>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('studentId')}"
th:errors="*{studentId}">
학번 오류 메시지
</div>
</div>
<!-- 이름 -->
<div class="mb-3">
<label for="name" class="form-label">
이름 <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control"
id="name"
th:field="*{name}"
th:classappend="${#fields.hasErrors('name')} ? 'is-invalid'"
placeholder="홍길동"
required>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('name')}"
th:errors="*{name}">
이름 오류 메시지
</div>
</div>
<!-- 이메일 -->
<div class="mb-3">
<label for="email" class="form-label">
이메일 <span class="text-danger">*</span>
</label>
<input type="email"
class="form-control"
id="email"
th:field="*{email}"
th:classappend="${#fields.hasErrors('email')} ? 'is-invalid'"
placeholder="hong@example.com"
required>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('email')}"
th:errors="*{email}">
이메일 오류 메시지
</div>
</div>
<!-- 학과 -->
<div class="mb-3">
<label for="department" class="form-label">학과</label>
<select class="form-select"
id="department"
th:field="*{department}">
<option value="">선택하세요</option>
<option value="컴퓨터공학과">컴퓨터공학과</option>
<option value="전자공학과">전자공학과</option>
<option value="기계공학과">기계공학과</option>
<option value="경영학과">경영학과</option>
<option value="국제학부">국제학부</option>
</select>
</div>
<!-- 전화번호 -->
<div class="mb-3">
<label for="phoneNumber" class="form-label">전화번호</label>
<input type="tel"
class="form-control"
id="phoneNumber"
th:field="*{phoneNumber}"
th:classappend="${#fields.hasErrors('phoneNumber')} ? 'is-invalid'"
placeholder="010-1234-5678">
<div class="invalid-feedback"
th:if="${#fields.hasErrors('phoneNumber')}"
th:errors="*{phoneNumber}">
전화번호 오류 메시지
</div>
</div>
<!-- 상태 (수정 시에만 표시) -->
<div class="mb-3" th:if="${student.id != null}">
<label for="status" class="form-label">상태</label>
<select class="form-select"
id="status"
th:field="*{status}">
<option value="ACTIVE">활동중</option>
<option value="INACTIVE">비활동</option>
<option value="GRADUATED">졸업</option>
</select>
</div>
<!-- 자기소개 -->
<div class="mb-3">
<label for="introduction" class="form-label">자기소개</label>
<textarea class="form-control"
id="introduction"
th:field="*{introduction}"
rows="3"
placeholder="간단한 자기소개를 작성해주세요."></textarea>
</div>
<!-- 버튼 -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a th:href="@{/students}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> 취소
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i>
<span th:text="${student.id != null} ? '수정' : '등록'">등록</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
$(document).ready(function() {
// 전화번호 자동 포맷
$('#phoneNumber').on('input', function() {
let value = $(this).val().replace(/[^0-9]/g, '');
let formatted = '';
if (value.length <= 3) {
formatted = value;
} else if (value.length <= 7) {
formatted = value.slice(0, 3) + '-' + value.slice(3);
} else if (value.length <= 11) {
formatted = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7);
}
$(this).val(formatted);
});
// 클라이언트 사이드 유효성 검증
$('form').on('submit', function(e) {
let isValid = true;
// 학번 검증
const studentId = $('#studentId').val();
if (!/^\d{8}$/.test(studentId)) {
$('#studentId').addClass('is-invalid');
isValid = false;
}
// 이메일 검증
const email = $('#email').val();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
$('#email').addClass('is-invalid');
isValid = false;
}
if (!isValid) {
e.preventDefault();
}
});
});
</script>
</th:block>
</body>
</html>
3. 학생 상세 페이지 (students/detail.html)
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title>학생 상세 정보</title>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<div class="col-md-8 mx-auto">
<!-- 학생 정보 카드 -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">
<i class="bi bi-person-badge"></i>
학생 정보
</h3>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4 text-center mb-3">
<div class="avatar-placeholder">
<i class="bi bi-person-circle" style="font-size: 8rem;"></i>
</div>
<h4 class="mt-3" th:text="${student.name}">홍길동</h4>
<span class="badge"
th:classappend="${student.status == 'ACTIVE'} ? 'bg-success' :
(${student.status == 'INACTIVE'} ? 'bg-warning' : 'bg-secondary')"
th:text="${student.status == 'ACTIVE'} ? '활동중' :
(${student.status == 'INACTIVE'} ? '비활동' : '졸업')">
활동중
</span>
</div>
<div class="col-md-8">
<table class="table table-borderless">
<tr>
<th class="text-muted" style="width: 30%;">학번</th>
<td th:text="${student.studentId}">20240001</td>
</tr>
<tr>
<th class="text-muted">이메일</th>
<td>
<a th:href="'mailto:' + ${student.email}"
th:text="${student.email}">
hong@example.com
</a>
</td>
</tr>
<tr>
<th class="text-muted">학과</th>
<td th:text="${student.department ?: '-'}">컴퓨터공학과</td>
</tr>
<tr>
<th class="text-muted">전화번호</th>
<td>
<a th:if="${student.phoneNumber}"
th:href="'tel:' + ${student.phoneNumber}"
th:text="${student.phoneNumber}">
010-1234-5678
</a>
<span th:unless="${student.phoneNumber}">-</span>
</td>
</tr>
<tr>
<th class="text-muted">가입일</th>
<td th:text="${#temporals.format(student.joinDate, 'yyyy년 MM월 dd일')}">
2024년 01월 01일
</td>
</tr>
</table>
</div>
</div>
<!-- 자기소개 -->
<div th:if="${student.introduction}" class="mt-4">
<h5 class="text-muted">자기소개</h5>
<div class="card bg-light">
<div class="card-body">
<p class="mb-0" th:text="${student.introduction}">
자기소개 내용이 여기에 표시됩니다.
</p>
</div>
</div>
</div>
<!-- 타임스탬프 -->
<div class="text-muted small mt-4">
<div th:if="${student.createdAt}">
등록일시: <span th:text="${#temporals.format(student.createdAt, 'yyyy-MM-dd HH:mm:ss')}"></span>
</div>
<div th:if="${student.updatedAt}">
수정일시: <span th:text="${#temporals.format(student.updatedAt, 'yyyy-MM-dd HH:mm:ss')}"></span>
</div>
</div>
</div>
<div class="card-footer">
<div class="d-flex justify-content-between">
<a th:href="@{/students}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> 목록으로
</a>
<div>
<a th:href="@{/students/edit/{id}(id=${student.id})}"
class="btn btn-primary">
<i class="bi bi-pencil"></i> 수정
</a>
<button type="button"
class="btn btn-danger"
th:data-id="${student.id}"
th:data-name="${student.name}"
onclick="confirmDelete(this)">
<i class="bi bi-trash"></i> 삭제
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Page Specific Scripts -->
<th:block layout:fragment="script">
<script>
function confirmDelete(button) {
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
if (confirm(`정말로 ${name} 학생을 삭제하시겠습니까?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/students/delete/${id}`;
document.body.appendChild(form);
form.submit();
}
}
</script>
</th:block>
</body>
</html>
폼 유효성 검증
DTO with Validation
java
package com.university.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;
@Data
public class StudentDto {
private Long id;
@NotBlank(message = "학번은 필수입니다")
@Pattern(regexp = "^\\d{8}$", message = "학번은 8자리 숫자여야 합니다")
private String studentId;
@NotBlank(message = "이름은 필수입니다")
@Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
private String name;
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
private String email;
@Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
private String department;
@Pattern(regexp = "^(010-\\d{4}-\\d{4})?$",
message = "전화번호는 010-xxxx-xxxx 형식이어야 합니다")
private String phoneNumber;
@Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
private String introduction;
private String status = "ACTIVE";
private LocalDate joinDate;
}
전역 유효성 검증 메시지
src/main/resources/messages.properties:
properties
# 공통 메시지
NotBlank={0}은(는) 필수 입력 항목입니다.
NotNull={0}은(는) null일 수 없습니다.
Size={0}은(는) {2}자 이상 {1}자 이하여야 합니다.
Email=올바른 이메일 형식이 아닙니다.
Pattern={0}의 형식이 올바르지 않습니다.
# 필드별 메시지
studentId=학번
name=이름
email=이메일
department=학과
phoneNumber=전화번호
introduction=자기소개
# 커스텀 메시지
student.duplicate.studentId=이미 존재하는 학번입니다.
student.duplicate.email=이미 존재하는 이메일입니다.
student.notfound=학생을 찾을 수 없습니다.
JavaScript 연동
static/js/app.js
javascript
// 전역 설정
$(document).ready(function() {
// CSRF 토큰 설정 (Spring Security 사용 시)
const token = $("meta[name='_csrf']").attr("content");
const header = $("meta[name='_csrf_header']").attr("content");
if (token && header) {
$.ajaxSetup({
beforeSend: function(xhr) {
xhr.setRequestHeader(header, token);
}
});
}
// 툴팁 초기화
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// 알림 메시지 자동 숨김
setTimeout(function() {
$('.alert').fadeOut('slow');
}, 5000);
});
// 공통 함수
const StudentApp = {
// Ajax 요청 래퍼
ajax: function(url, method, data, successCallback, errorCallback) {
$.ajax({
url: url,
method: method,
data: JSON.stringify(data),
contentType: 'application/json',
success: successCallback,
error: function(xhr, status, error) {
if (errorCallback) {
errorCallback(xhr, status, error);
} else {
alert('오류가 발생했습니다: ' + error);
}
}
});
},
// 폼 데이터를 JSON으로 변환
formToJson: function(formSelector) {
const formData = {};
$(formSelector).serializeArray().forEach(function(item) {
formData[item.name] = item.value;
});
return formData;
},
// 로딩 표시
showLoading: function() {
$('body').append('<div class="loading-overlay"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div></div>');
},
hideLoading: function() {
$('.loading-overlay').remove();
}
};
에러 처리
에러 페이지
templates/error/404.html:
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title>페이지를 찾을 수 없습니다</title>
</head>
<body>
<div layout:fragment="content">
<div class="text-center py-5">
<h1 class="display-1">404</h1>
<p class="fs-3"><span class="text-danger">앗!</span> 페이지를 찾을 수 없습니다.</p>
<p class="lead">요청하신 페이지가 존재하지 않습니다.</p>
<a th:href="@{/}" class="btn btn-primary">홈으로 돌아가기</a>
</div>
</div>
</body>
</html>
GlobalExceptionHandler
java
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(StudentNotFoundException.class)
public String handleStudentNotFound(
StudentNotFoundException ex,
RedirectAttributes redirectAttributes) {
redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
return "redirect:/students";
}
@ExceptionHandler(DataIntegrityViolationException.class)
public String handleDataIntegrityViolation(
DataIntegrityViolationException ex,
RedirectAttributes redirectAttributes) {
redirectAttributes.addFlashAttribute("errorMessage",
"데이터 무결성 오류가 발생했습니다. 입력값을 확인해주세요.");
return "redirect:/students";
}
}
실전 팁
1. Thymeleaf 유용한 표현식
html
<!-- 조건부 렌더링 -->
<div th:if="${user.role == 'ADMIN'}">관리자 메뉴</div>
<div th:unless="${user.role == 'ADMIN'}">일반 사용자 메뉴</div>
<!-- 삼항 연산자 -->
<span th:text="${user.active} ? '활성' : '비활성'"></span>
<!-- Switch 문 -->
<div th:switch="${user.status}">
<p th:case="'ACTIVE'">활동중</p>
<p th:case="'INACTIVE'">비활동</p>
<p th:case="*">알 수 없음</p>
</div>
<!-- 컬렉션 반복 with 인덱스 -->
<tr th:each="item, stat : ${items}">
<td th:text="${stat.index}">0</td>
<td th:text="${stat.count}">1</td>
<td th:text="${item.name}">이름</td>
<td th:text="${stat.first} ? '첫번째' : ''"></td>
<td th:text="${stat.last} ? '마지막' : ''"></td>
</tr>
<!-- Fragment 파라미터 -->
<div th:replace="~{fragments/pagination :: pagination(${page}, ${totalPages})}"></div>
<!-- JavaScript 인라인 -->
<script th:inline="javascript">
const userId = /*[[${user.id}]]*/ 0;
const userName = /*[[${user.name}]]*/ 'Guest';
</script>
2. 성능 최적화
properties
# 프로덕션 환경 설정
spring.thymeleaf.cache=true
spring.web.resources.cache.period=31536000
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
3. 보안 강화
html
<!-- CSRF 토큰 -->
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<!-- XSS 방지 -->
<p th:text="${userInput}">자동 이스케이프</p>
<p th:utext="${trustedHtml}">HTML 허용 (주의!)</p>
4. 국제화 (i18n)
messages.properties:
properties
app.title=학생 관리 시스템
student.list.title=학생 목록
student.form.title=학생 정보
messages_en.properties:
properties
app.title=Student Management System
student.list.title=Student List
student.form.title=Student Information
사용:
html
<h1 th:text="#{student.list.title}">학생 목록</h1>
마무리
Thymeleaf는 Spring Boot와 완벽하게 통합되어 빠르게 웹 애플리케이션을 개발할 수 있게 해줍니다. 특히 관리자 페이지나 내부 시스템처럼 복잡한 상호작용이 필요 없는 경우에는 최적의 선택입니다.
장점 정리
- ✅ 빠른 개발: 설정이 간단하고 바로 시작 가능
- ✅ 서버 사이드 렌더링: SEO 친화적, 초기 로딩 빠름
- ✅ Spring 통합: Spring Security, Validation 등과 완벽 호환
- ✅ Natural Template: 디자이너와 협업 용이
언제 사용할까?
- 관리자 대시보드
- 기업 내부 시스템
- SEO가 중요한 콘텐츠 사이트
- 빠른 프로토타이핑
다음 단계
- Spring Security 통합
- WebSocket으로 실시간 기능 추가
- PDF/Excel 내보내기
- 파일 업로드 구현
태그: #SpringBoot #Thymeleaf #Bootstrap #CRUD #WebDevelopment #Java
작성일: 2025년 1월 20일
이 포스트가 도움이 되셨다면 ⭐️ 스타를 눌러주세요!
728x90
반응형
'유용한 컴공 테크닉' 카테고리의 다른 글
Docker로 Spring Boot 애플리케이션 배포하기: 개발부터 운영까지 완벽 가이드 (3) | 2025.07.21 |
---|---|
Spring Boot와 MariaDB/MySQL 연결하기: H2에서 실전 DB로 레벨업 (0) | 2025.07.21 |
Spring Boot H2 Console 사용 완벽 가이드 (0) | 2025.07.11 |
Spring Boot로 CRUD REST API 만들기: 제로부터 배포까지 (3) | 2025.07.10 |
Postman 완벽 가이드: REST API 테스트부터 협업까지 (1) | 2025.07.10 |