하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
2025-07-20 19:54:04
728x90
반응형

🎨 들어가며

백엔드 API는 완성했는데, 이제 어떻게 화면에 보여줄까요? React나 Vue.js를 배워야 할까요?

잠깐! Spring Boot와 함께라면 Thymeleaf로 충분합니다. 서버 사이드 렌더링의 강력함과 Bootstrap의 예쁜 디자인을 결합하여, 빠르게 실무에서 사용 가능한 웹 애플리케이션을 만들어보겠습니다.

이 글에서는 Spring Boot CRUD API에 Thymeleaf 템플릿 엔진을 적용하여 완전한 웹 애플리케이션으로 변신시키는 과정을 다룹니다.

📑 목차

  1. Thymeleaf란?
  2. 프로젝트 설정
  3. Controller 리팩토링
  4. 레이아웃 템플릿 만들기
  5. CRUD 페이지 구현
  6. 폼 유효성 검증
  7. JavaScript 연동
  8. 에러 처리
  9. 실전 팁

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">
                &copy; <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
반응형
이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.