카테고리 없음

Thymeleaf 가이드 - #7장. Spring과의 통합

shaprimanDev 2025. 8. 26. 11:08
반응형

7장. Spring과의 통합

7.1 Spring MVC와 Thymeleaf 연동

Thymeleaf는 Spring Framework와의 완벽한 통합을 위해 설계되었습니다. Spring MVC의 Model-View-Controller 패턴과 자연스럽게 연동되며, Spring의 다양한 기능을 템플릿에서 직접 활용할 수 있습니다.

기본 Spring MVC 설정

// src/main/java/com/example/config/WebConfig.java
@Configuration
@EnableWebMvc
@ComponentScan("com.example.controller")
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public SpringResourceTemplateResolver templateResolver() {
        SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
        templateResolver.setPrefix("classpath:/templates/");
        templateResolver.setSuffix(".html");
        templateResolver.setTemplateMode(TemplateMode.HTML);
        templateResolver.setCharacterEncoding("UTF-8");
        templateResolver.setCacheable(false); // 개발 환경에서만
        return templateResolver;
    }

    @Bean
    public SpringTemplateEngine templateEngine() {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver());
        templateEngine.setEnableSpringELCompiler(true);
        return templateEngine;
    }

    @Bean
    public ThymeleafViewResolver viewResolver() {
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(templateEngine());
        viewResolver.setCharacterEncoding("UTF-8");
        return viewResolver;
    }
}

Spring Boot 자동 설정

Spring Boot를 사용하면 설정이 훨씬 간단해집니다:

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
# application.yml
spring:
  thymeleaf:
    cache: false  # 개발 시에만
    prefix: classpath:/templates/
    suffix: .html
    mode: HTML
    encoding: UTF-8

컨트롤러와 모델 데이터 전달

// src/main/java/com/example/controller/HomeController.java
@Controller
public class HomeController {

    @Autowired
    private ProductService productService;

    @Autowired
    private UserService userService;

    @GetMapping("/")
    public String home(Model model, Authentication authentication) {
        // 기본 페이지 정보 설정
        model.addAttribute("pageTitle", "홈");
        model.addAttribute("pageDescription", "MyApp에 오신 것을 환영합니다");

        // 추천 상품 데이터
        List<Product> featuredProducts = productService.getFeaturedProducts();
        model.addAttribute("featuredProducts", featuredProducts);

        // 현재 사용자 정보 (Spring Security 연동)
        if (authentication != null && authentication.isAuthenticated()) {
            String username = authentication.getName();
            User currentUser = userService.findByUsername(username);
            model.addAttribute("currentUser", currentUser);
        }

        // 사이드바 데이터
        model.addAttribute("recentPosts", productService.getRecentPosts(5));
        model.addAttribute("categories", productService.getAllCategories());

        return "home"; // templates/home.html
    }

    @GetMapping("/products")
    public String products(
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "12") int size,
            @RequestParam(required = false) String sort,
            Model model) {

        // 페이지네이션과 필터링
        Pageable pageable = PageRequest.of(page, size, getSortOrder(sort));
        Page<Product> productPage;

        if (category != null && !category.isEmpty()) {
            productPage = productService.findByCategory(category, pageable);
        } else {
            productPage = productService.findAll(pageable);
        }

        // 모델에 데이터 추가
        model.addAttribute("products", productPage.getContent());
        model.addAttribute("currentPage", page);
        model.addAttribute("totalPages", productPage.getTotalPages());
        model.addAttribute("totalProducts", productPage.getTotalElements());
        model.addAttribute("selectedCategory", category);
        model.addAttribute("sort", sort);

        // 페이지 정보
        model.addAttribute("pageTitle", "상품 목록");
        model.addAttribute("breadcrumbs", Arrays.asList(
            new Breadcrumb("상품", "/products")
        ));

        // 필터 옵션
        model.addAttribute("categories", productService.getAllCategories());

        return "products/list";
    }

    @GetMapping("/products/{id}")
    public String productDetail(@PathVariable Long id, Model model) {
        Product product = productService.findById(id)
            .orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다: " + id));

        model.addAttribute("product", product);
        model.addAttribute("pageTitle", product.getName());
        model.addAttribute("pageDescription", product.getShortDescription());

        // 브레드크럼 설정
        model.addAttribute("breadcrumbs", Arrays.asList(
            new Breadcrumb("상품", "/products"),
            new Breadcrumb(product.getCategory().getName(), 
                          "/products?category=" + product.getCategory().getId()),
            new Breadcrumb(product.getName(), "")
        ));

        // 관련 상품
        List<Product> relatedProducts = productService.getRelatedProducts(product, 4);
        model.addAttribute("relatedProducts", relatedProducts);

        // 리뷰
        List<Review> reviews = productService.getReviews(id, 10);
        model.addAttribute("reviews", reviews);

        return "products/detail";
    }

    private Sort getSortOrder(String sort) {
        if (sort == null) return Sort.by("name").ascending();

        switch (sort) {
            case "price-low": return Sort.by("price").ascending();
            case "price-high": return Sort.by("price").descending();
            case "newest": return Sort.by("createdAt").descending();
            case "name": return Sort.by("name").ascending();
            default: return Sort.by("name").ascending();
        }
    }
}

폼 처리 컨트롤러

// src/main/java/com/example/controller/ContactController.java
@Controller
@RequestMapping("/contact")
public class ContactController {

    @Autowired
    private ContactService contactService;

    @GetMapping
    public String showContactForm(Model model) {
        model.addAttribute("contactForm", new ContactForm());
        model.addAttribute("pageTitle", "문의하기");
        model.addAttribute("inquiryTypes", getInquiryTypes());

        return "contact";
    }

    @PostMapping
    public String submitContact(@Valid @ModelAttribute ContactForm contactForm,
                              BindingResult bindingResult,
                              Model model,
                              RedirectAttributes redirectAttributes) {

        // 유효성 검증 실패 시
        if (bindingResult.hasErrors()) {
            model.addAttribute("pageTitle", "문의하기");
            model.addAttribute("inquiryTypes", getInquiryTypes());
            return "contact";
        }

        try {
            // 문의 처리
            contactService.processInquiry(contactForm);

            // 성공 메시지
            redirectAttributes.addFlashAttribute("successMessage", 
                "문의가 성공적으로 접수되었습니다. 빠른 시일 내에 답변드리겠습니다.");

            return "redirect:/contact";

        } catch (Exception e) {
            model.addAttribute("errorMessage", "문의 처리 중 오류가 발생했습니다. 다시 시도해주세요.");
            model.addAttribute("pageTitle", "문의하기");
            model.addAttribute("inquiryTypes", getInquiryTypes());
            return "contact";
        }
    }

    private List<InquiryType> getInquiryTypes() {
        return Arrays.asList(
            new InquiryType("general", "일반 문의"),
            new InquiryType("support", "기술 지원"),
            new InquiryType("sales", "영업 문의"),
            new InquiryType("partnership", "제휴 문의")
        );
    }
}

모델 클래스

// src/main/java/com/example/model/ContactForm.java
public class ContactForm {

    @NotBlank(message = "이름을 입력해주세요")
    @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하로 입력해주세요")
    private String name;

    @NotBlank(message = "이메일을 입력해주세요")
    @Email(message = "올바른 이메일 형식을 입력해주세요")
    private String email;

    @NotBlank(message = "문의 유형을 선택해주세요")
    private String inquiryType;

    @NotBlank(message = "메시지를 입력해주세요")
    @Size(min = 10, max = 1000, message = "메시지는 10자 이상 1000자 이하로 입력해주세요")
    private String message;

    private String company; // 선택적 필드
    private String phone;   // 선택적 필드

    // 생성자, getter, setter
    public ContactForm() {}

    // getters and setters...
}

// src/main/java/com/example/model/Breadcrumb.java
public class Breadcrumb {
    private String title;
    private String url;

    public Breadcrumb(String title, String url) {
        this.title = title;
        this.url = url;
    }

    // getters and setters...
}

// src/main/java/com/example/model/InquiryType.java
public class InquiryType {
    private String value;
    private String label;

    public InquiryType(String value, String label) {
        this.value = value;
        this.label = label;
    }

    // getters and setters...
}

7.2 Spring Security 통합

Spring Security와 Thymeleaf의 통합을 통해 인증과 권한 기반의 UI를 구현할 수 있습니다.

의존성 추가

<!-- pom.xml -->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>

Security 설정

// src/main/java/com/example/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/", "/home", "/products/**", "/css/**", "/js/**", "/images/**").permitAll()
                .requestMatchers("/signup", "/login").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/dashboard", true)
                .failureUrl("/login?error")
                .usernameParameter("email")
                .passwordParameter("password")
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
            )
            .rememberMe(remember -> remember
                .key("mySecretKey")
                .tokenValiditySeconds(86400) // 1일
                .userDetailsService(userDetailsService)
            );

        return http.build();
    }
}

로그인 페이지

<!-- templates/auth/login.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
      th:with="pageTitle='로그인'">

<head th:replace="~{layouts/base :: head}"></head>

<body class="auth-page">
    <div th:replace="~{fragments/common :: header}"></div>

    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-6 col-lg-4">
                <div class="card shadow">
                    <div class="card-body p-5">
                        <div class="text-center mb-4">
                            <h2>로그인</h2>
                            <p class="text-muted">계정에 로그인하세요</p>
                        </div>

                        <!-- 오류 메시지 -->
                        <div th:if="${param.error}" class="alert alert-danger">
                            <i class="fas fa-exclamation-triangle"></i>
                            이메일 또는 비밀번호가 올바르지 않습니다.
                        </div>

                        <!-- 로그아웃 메시지 -->
                        <div th:if="${param.logout}" class="alert alert-success">
                            <i class="fas fa-check-circle"></i>
                            성공적으로 로그아웃되었습니다.
                        </div>

                        <form th:action="@{/login}" method="post">
                            <div class="mb-3">
                                <label for="email" class="form-label">이메일</label>
                                <input type="email" class="form-control" id="email" name="email" 
                                       th:value="${param.email}" required>
                            </div>

                            <div class="mb-3">
                                <label for="password" class="form-label">비밀번호</label>
                                <input type="password" class="form-control" id="password" name="password" required>
                            </div>

                            <div class="mb-3 form-check">
                                <input type="checkbox" class="form-check-input" id="remember-me" name="remember-me">
                                <label class="form-check-label" for="remember-me">
                                    로그인 상태 유지
                                </label>
                            </div>

                            <div class="d-grid">
                                <button type="submit" class="btn btn-primary">로그인</button>
                            </div>

                            <div class="text-center mt-3">
                                <p class="mb-0">계정이 없으신가요? 
                                    <a th:href="@{/signup}">회원가입</a>
                                </p>
                                <a th:href="@{/forgot-password}" class="text-muted">비밀번호를 잊으셨나요?</a>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div th:replace="~{fragments/common :: footer}"></div>
</body>
</html>

권한 기반 네비게이션

<!-- templates/fragments/navigation.html -->
<nav th:fragment="userMenu" 
     xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
     class="user-navigation">

    <!-- 로그인하지 않은 사용자 -->
    <div sec:authorize="!isAuthenticated()" class="guest-menu">
        <a th:href="@{/login}" class="btn btn-outline-primary">로그인</a>
        <a th:href="@{/signup}" class="btn btn-primary">회원가입</a>
    </div>

    <!-- 로그인한 사용자 -->
    <div sec:authorize="isAuthenticated()" class="user-menu">
        <div class="dropdown">
            <button class="btn btn-link dropdown-toggle" type="button" 
                    data-bs-toggle="dropdown" aria-expanded="false">
                <i class="fas fa-user"></i>
                <span sec:authentication="name">사용자</span>
            </button>

            <ul class="dropdown-menu">
                <!-- 일반 사용자 메뉴 -->
                <li sec:authorize="hasRole('USER')">
                    <a class="dropdown-item" th:href="@{/profile}">
                        <i class="fas fa-user-circle"></i> 내 프로필
                    </a>
                </li>

                <li sec:authorize="hasRole('USER')">
                    <a class="dropdown-item" th:href="@{/orders}">
                        <i class="fas fa-shopping-bag"></i> 주문 내역
                    </a>
                </li>

                <li sec:authorize="hasRole('USER')">
                    <a class="dropdown-item" th:href="@{/wishlist}">
                        <i class="fas fa-heart"></i> 찜 목록
                    </a>
                </li>

                <li><hr class="dropdown-divider"></li>

                <!-- 관리자 메뉴 -->
                <li sec:authorize="hasRole('ADMIN')">
                    <a class="dropdown-item" th:href="@{/admin/dashboard}">
                        <i class="fas fa-tachometer-alt"></i> 관리자 대시보드
                    </a>
                </li>

                <li sec:authorize="hasRole('ADMIN')">
                    <a class="dropdown-item" th:href="@{/admin/users}">
                        <i class="fas fa-users"></i> 사용자 관리
                    </a>
                </li>

                <li sec:authorize="hasRole('ADMIN')">
                    <a class="dropdown-item" th:href="@{/admin/products}">
                        <i class="fas fa-boxes"></i> 상품 관리
                    </a>
                </li>

                <li sec:authorize="hasRole('ADMIN')"><hr class="dropdown-divider"></li>

                <!-- 공통 메뉴 -->
                <li>
                    <a class="dropdown-item" th:href="@{/settings}">
                        <i class="fas fa-cog"></i> 설정
                    </a>
                </li>

                <li>
                    <form th:action="@{/logout}" method="post" style="display: inline;">
                        <button type="submit" class="dropdown-item text-danger border-0 bg-transparent">
                            <i class="fas fa-sign-out-alt"></i> 로그아웃
                        </button>
                    </form>
                </li>
            </ul>
        </div>
    </div>
</nav>

권한 기반 콘텐츠 표시

<!-- templates/dashboard.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
      th:with="pageTitle='대시보드'">

<head th:replace="~{layouts/base :: head}"></head>

<body>
    <div th:replace="~{fragments/common :: header}"></div>

    <div class="container mt-4">
        <!-- 사용자 환영 메시지 -->
        <div class="row">
            <div class="col-12">
                <div class="welcome-section mb-4">
                    <h1>안녕하세요, <span sec:authentication="name">사용자</span>님!</h1>
                    <p class="text-muted">
                        귀하의 권한: 
                        <span sec:authorize="hasRole('ADMIN')" class="badge bg-danger">관리자</span>
                        <span sec:authorize="hasRole('MODERATOR')" class="badge bg-warning">운영자</span>
                        <span sec:authorize="hasRole('USER')" class="badge bg-primary">일반 사용자</span>
                    </p>
                </div>
            </div>
        </div>

        <div class="row">
            <!-- 일반 사용자 대시보드 -->
            <div sec:authorize="hasRole('USER')" class="col-md-6">
                <div class="card">
                    <div class="card-header">
                        <h5>내 활동</h5>
                    </div>
                    <div class="card-body">
                        <ul class="list-unstyled">
                            <li><a th:href="@{/orders}">주문 내역 (<span th:text="${userStats.orderCount}">0</span>)</a></li>
                            <li><a th:href="@{/reviews}">내 리뷰 (<span th:text="${userStats.reviewCount}">0</span>)</a></li>
                            <li><a th:href="@{/wishlist}">찜 목록 (<span th:text="${userStats.wishlistCount}">0</span>)</a></li>
                            <li><a th:href="@{/profile}">프로필 관리</a></li>
                        </ul>
                    </div>
                </div>
            </div>

            <!-- 관리자 전용 대시보드 -->
            <div sec:authorize="hasRole('ADMIN')" class="col-md-6">
                <div class="card border-danger">
                    <div class="card-header bg-danger text-white">
                        <h5>관리자 도구</h5>
                    </div>
                    <div class="card-body">
                        <div class="row text-center">
                            <div class="col-4">
                                <div class="stat-box">
                                    <h3 th:text="${adminStats.totalUsers}">1,234</h3>
                                    <p>총 사용자</p>
                                    <a th:href="@{/admin/users}" class="btn btn-sm btn-outline-primary">관리</a>
                                </div>
                            </div>
                            <div class="col-4">
                                <div class="stat-box">
                                    <h3 th:text="${adminStats.totalProducts}">567</h3>
                                    <p>총 상품</p>
                                    <a th:href="@{/admin/products}" class="btn btn-sm btn-outline-success">관리</a>
                                </div>
                            </div>
                            <div class="col-4">
                                <div class="stat-box">
                                    <h3 th:text="${adminStats.totalOrders}">890</h3>
                                    <p>총 주문</p>
                                    <a th:href="@{/admin/orders}" class="btn btn-sm btn-outline-info">관리</a>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- 운영자 전용 도구 -->
            <div sec:authorize="hasRole('MODERATOR')" class="col-md-6">
                <div class="card border-warning">
                    <div class="card-header bg-warning">
                        <h5>운영자 도구</h5>
                    </div>
                    <div class="card-body">
                        <ul class="list-unstyled">
                            <li>
                                <a th:href="@{/moderate/posts}">
                                    게시글 검토 
                                    <span th:if="${moderationStats.pendingPosts > 0}" 
                                          class="badge bg-warning" 
                                          th:text="${moderationStats.pendingPosts}">5</span>
                                </a>
                            </li>
                            <li>
                                <a th:href="@{/moderate/reviews}">
                                    리뷰 검토
                                    <span th:if="${moderationStats.pendingReviews > 0}" 
                                          class="badge bg-warning" 
                                          th:text="${moderationStats.pendingReviews}">3</span>
                                </a>
                            </li>
                            <li>
                                <a th:href="@{/moderate/reports}">
                                    신고 처리
                                    <span th:if="${moderationStats.pendingReports > 0}" 
                                          class="badge bg-danger" 
                                          th:text="${moderationStats.pendingReports}">2</span>
                                </a>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>

            <!-- 최근 활동 (모든 사용자) -->
            <div class="col-12 mt-4">
                <div class="card">
                    <div class="card-header">
                        <h5>최근 활동</h5>
                    </div>
                    <div class="card-body">
                        <div th:if="${#lists.isEmpty(recentActivities)}" class="text-center text-muted">
                            <p>최근 활동이 없습니다.</p>
                        </div>

                        <div th:unless="${#lists.isEmpty(recentActivities)}">
                            <div th:each="activity : ${recentActivities}" class="activity-item mb-3">
                                <div class="d-flex">
                                    <div class="activity-icon me-3">
                                        <i th:class="${activity.iconClass}" 
                                           th:classappend="${activity.type}"></i>
                                    </div>
                                    <div class="activity-content flex-grow-1">
                                        <p class="mb-1" th:text="${activity.description}">활동 설명</p>
                                        <small class="text-muted" 
                                               th:text="${#temporals.format(activity.timestamp, 'yyyy-MM-dd HH:mm')}">
                                               2024-01-15 14:30
                                        </small>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <!-- 권한별 추가 정보 -->
        <div class="row mt-4">
            <div class="col-12">
                <div class="alert alert-info" sec:authorize="hasRole('ADMIN')">
                    <h6>관리자 알림</h6>
                    <p>시스템 상태가 정상입니다. 
                       대기 중인 작업: <strong th:text="${adminStats.pendingTasks}">0</strong>개</p>
                </div>

                <div class="alert alert-warning" sec:authorize="hasRole('MODERATOR')">
                    <h6>운영자 알림</h6>
                    <p>검토가 필요한 콘텐츠가 있습니다. 
                       총 <strong th:text="${moderationStats.totalPending}">0</strong>개의 항목이 대기 중입니다.</p>
                </div>

                <div class="alert alert-success" sec:authorize="hasRole('USER') and !hasRole('MODERATOR') and !hasRole('ADMIN')">
                    <h6>환영합니다!</h6>
                    <p>MyApp의 모든 기능을 자유롭게 이용하실 수 있습니다.</p>
                </div>
            </div>
        </div>
    </div>

    <div th:replace="~{fragments/common :: footer}"></div>
</body>
</html>

보안 관련 유틸리티 메소드

<!-- 현재 사용자 정보 접근 -->
<div>
    <!-- 인증 상태 확인 -->
    <span sec:authorize="isAuthenticated()">로그인됨</span>
    <span sec:authorize="!isAuthenticated()">로그인 안됨</span>

    <!-- 사용자명 표시 -->
    <span sec:authentication="name">username</span>

    <!-- 사용자 권한 표시 -->
    <span sec:authentication="principal.authorities">[ROLE_USER, ROLE_ADMIN]</span>

    <!-- 특정 권한 확인 -->
    <div sec:authorize="hasRole('ADMIN')">관리자만 볼 수 있음</div>
    <div sec:authorize="hasAuthority('WRITE_PRIVILEGE')">쓰기 권한이 있는 사용자만</div>

    <!-- 복합 권한 확인 -->
    <div sec:authorize="hasRole('ADMIN') or hasRole('MODERATOR')">관리자 또는 운영자만</div>
    <div sec:authorize="hasRole('USER') and !hasRole('BANNED')">정상 사용자만</div>

    <!-- 자신의 리소스만 접근 허용 -->
    <div sec:authorize="@securityService.canAccessUser(authentication.name, #user.username)">
        개인 정보 수정 가능
    </div>
</div>

7.3 Spring Boot 자동 설정

Spring Boot는 Thymeleaf 통합을 위한 강력한 자동 설정을 제공합니다.

자동 설정 활성화

# application.yml
spring:
  thymeleaf:
    # 템플릿 캐싱 (운영: true, 개발: false)
    cache: false

    # 템플릿 위치 설정
    prefix: classpath:/templates/
    suffix: .html

    # 템플릿 모드
    mode: HTML

    # 문자 인코딩
    encoding: UTF-8

    # 템플릿 존재 확인
    check-template: true
    check-template-location: true

    # 렌더링 전 템플릿 존재 확인
    enabled: true

    # Spring EL 컴파일러 사용
    enable-spring-el-compiler: true

    # 뷰 이름 패턴 (선택적)
    view-names: "*.html,*.xhtml"

    # 제외할 뷰 이름 패턴
    excluded-view-names: "*.txt,*.xml"

# 프로파일별 설정
---
spring:
  config:
    activate:
      on-profile: development
  thymeleaf:
    cache: false
  devtools:
    restart:
      enabled: true
    livereload:
      enabled: true

---
spring:
  config:
    activate:
      on-profile: production
  thymeleaf:
    cache: true

커스텀 설정

// src/main/java/com/example/config/ThymeleafConfig.java
@Configuration
public class ThymeleafConfig {

    @Bean
    @Primary
    public SpringTemplateEngine templateEngine() {
        SpringTemplateEngine engine = new SpringTemplateEngine();
        engine.setEnableSpringELCompiler(true);

        // 커스텀 Dialect 추가
        engine.addDialect(new CustomDialect());

        // 추가 유틸리티 객체 등록
        engine.addTemplateResolver(templateResolver());

        return engine;
    }

    @Bean
    public ITemplateResolver templateResolver() {
        SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
        resolver.setApplicationContext(applicationContext);
        resolver.setPrefix("classpath:/templates/");
        resolver.setSuffix(".html");
        resolver.setTemplateMode(TemplateMode.HTML);
        resolver.setCharacterEncoding("UTF-8");
        resolver.setCacheable(false);
        resolver.setOrder(1);
        return resolver;
    }

    @Autowired
    private ApplicationContext applicationContext;
}

메시지 소스 통합

// src/main/java/com/example/config/MessageConfig.java
@Configuration
public class MessageConfig {

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = 
            new ReloadableResourceBundleMessageSource();

        messageSource.setBasenames(
            "classpath:messages/messages",
            "classpath:messages/validation",
            "classpath:messages/errors"
        );

        messageSource.setDefaultEncoding("UTF-8");
        messageSource.setCacheSeconds(300); // 5분 캐시
        messageSource.setFallbackToSystemLocale(false);

        return messageSource;
    }

    @Bean
    public LocaleResolver localeResolver() {
        SessionLocaleResolver resolver = new SessionLocaleResolver();
        resolver.setDefaultLocale(Locale.KOREAN);
        return resolver;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
        interceptor.setParamName("lang");
        return interceptor;
    }
}

메시지 파일

# src/main/resources/messages/messages.properties (기본)
app.name=MyApp
app.description=최고의 온라인 쇼핑몰
app.welcome=MyApp에 오신 것을 환영합니다!

menu.home=홈
menu.products=상품
menu.about=회사소개
menu.contact=문의
menu.login=로그인
menu.logout=로그아웃
menu.signup=회원가입

page.home.title=홈
page.products.title=상품 목록
page.contact.title=문의하기

button.submit=제출
button.cancel=취소
button.save=저장
button.delete=삭제
button.edit=수정
# src/main/resources/messages/messages_en.properties (영어)
app.name=MyApp
app.description=The Best Online Shopping Mall
app.welcome=Welcome to MyApp!

menu.home=Home
menu.products=Products
menu.about=About
menu.contact=Contact
menu.login=Login
menu.logout=Logout
menu.signup=Sign Up

page.home.title=Home
page.products.title=Product List
page.contact.title=Contact Us

button.submit=Submit
button.cancel=Cancel
button.save=Save
button.delete=Delete
button.edit=Edit

국제화 활용 예제

<!-- templates/fragments/language-switcher.html -->
<div th:fragment="languageSwitcher" class="language-switcher">
    <div class="dropdown">
        <button class="btn btn-link dropdown-toggle" type="button" data-bs-toggle="dropdown">
            <i class="fas fa-globe"></i>
            <span th:text="#{language.current}">한국어</span>
        </button>
        <ul class="dropdown-menu">
            <li>
                <a class="dropdown-item" th:href="@{''(lang='ko')}">
                    <img src="/images/flags/kr.png" alt="한국어" class="flag-icon"> 한국어
                </a>
            </li>
            <li>
                <a class="dropdown-item" th:href="@{''(lang='en')}">
                    <img src="/images/flags/us.png" alt="English" class="flag-icon"> English
                </a>
            </li>
            <li>
                <a class="dropdown-item" th:href="@{''(lang='ja')}">
                    <img src="/images/flags/jp.png" alt="日本語" class="flag-icon"> 日本語
                </a>
            </li>
        </ul>
    </div>
</div>

7.4 커스텀 설정과 프로퍼티

애플리케이션 프로퍼티 정의

// src/main/java/com/example/config/AppProperties.java
@ConfigurationProperties(prefix = "app")
@Component
@Data
public class AppProperties {

    private String name = "MyApp";
    private String version = "1.0.0";
    private String description = "온라인 쇼핑몰";

    private Contact contact = new Contact();
    private Features features = new Features();
    private Upload upload = new Upload();

    @Data
    public static class Contact {
        private String email = "info@myapp.com";
        private String phone = "02-1234-5678";
        private String address = "서울시 강남구 테헤란로 123";
    }

    @Data
    public static class Features {
        private boolean enableReviews = true;
        private boolean enableWishlist = true;
        private boolean enableNotifications = true;
        private int maxReviewLength = 1000;
        private int productsPerPage = 12;
    }

    @Data
    public static class Upload {
        private String path = "/uploads/";
        private long maxFileSize = 5242880; // 5MB
        private String[] allowedExtensions = {"jpg", "jpeg", "png", "gif"};
    }
}
# application.yml
app:
  name: MyApp
  version: 1.2.0
  description: 최고의 온라인 쇼핑몰
  contact:
    email: contact@myapp.com
    phone: 02-9876-5432
    address: 서울시 서초구 강남대로 456
  features:
    enable-reviews: true
    enable-wishlist: true
    enable-notifications: true
    max-review-length: 500
    products-per-page: 16
  upload:
    path: /app/uploads/
    max-file-size: 10485760  # 10MB
    allowed-extensions:
      - jpg
      - jpeg
      - png
      - gif
      - webp

템플릿에서 프로퍼티 사용

<!-- templates/fragments/app-info.html -->
<div th:fragment="appInfo" class="app-info">
    <h1 th:text="${@appProperties.name}">MyApp</h1>
    <p th:text="${@appProperties.description}">앱 설명</p>
    <small>Version <span th:text="${@appProperties.version}">1.0.0</span></small>

    <div class="contact-info">
        <p>
            <i class="fas fa-envelope"></i>
            <a th:href="'mailto:' + ${@appProperties.contact.email}" 
               th:text="${@appProperties.contact.email}">contact@myapp.com</a>
        </p>
        <p>
            <i class="fas fa-phone"></i>
            <span th:text="${@appProperties.contact.phone}">02-1234-5678</span>
        </p>
        <p>
            <i class="fas fa-map-marker-alt"></i>
            <span th:text="${@appProperties.contact.address}">서울시 강남구</span>
        </p>
    </div>
</div>

<!-- 기능 플래그 사용 -->
<div th:fragment="conditionalFeatures">
    <!-- 리뷰 기능이 활성화된 경우만 표시 -->
    <div th:if="${@appProperties.features.enableReviews}" class="reviews-section">
        <h3>상품 리뷰</h3>
        <p>최대 <span th:text="${@appProperties.features.maxReviewLength}">1000</span>자까지 입력 가능합니다.</p>
    </div>

    <!-- 찜하기 기능이 활성화된 경우만 표시 -->
    <button th:if="${@appProperties.features.enableWishlist}" 
            class="btn btn-outline-heart">
        <i class="fas fa-heart"></i> 찜하기
    </button>

    <!-- 알림 기능이 활성화된 경우만 표시 -->
    <div th:if="${@appProperties.features.enableNotifications}" class="notification-bell">
        <i class="fas fa-bell"></i>
    </div>
</div>

환경별 설정

# application-development.yml
spring:
  thymeleaf:
    cache: false
  h2:
    console:
      enabled: true
logging:
  level:
    com.example: DEBUG
    org.thymeleaf: DEBUG

app:
  features:
    enable-notifications: false  # 개발 환경에서는 알림 비활성화

---
# application-production.yml
spring:
  thymeleaf:
    cache: true
logging:
  level:
    com.example: INFO
    org.thymeleaf: WARN

app:
  upload:
    path: /var/app/uploads/
    max-file-size: 20971520  # 20MB (운영환경에서 더 큰 용량)

커스텀 유틸리티 빈

// src/main/java/com/example/service/ThymeleafUtilityService.java
@Service("utilService")
public class ThymeleafUtilityService {

    @Autowired
    private AppProperties appProperties;

    public String formatFileSize(long bytes) {
        if (bytes < 1024) return bytes + " B";
        int exp = (int) (Math.log(bytes) / Math.log(1024));
        String pre = "KMGTPE".charAt(exp - 1) + "i";
        return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre);
    }

    public boolean isFeatureEnabled(String featureName) {
        switch (featureName.toLowerCase()) {
            case "reviews": return appProperties.getFeatures().isEnableReviews();
            case "wishlist": return appProperties.getFeatures().isEnableWishlist();
            case "notifications": return appProperties.getFeatures().isEnableNotifications();
            default: return false;
        }
    }

    public String truncateText(String text, int maxLength) {
        if (text == null || text.length() <= maxLength) {
            return text;
        }
        return text.substring(0, maxLength - 3) + "...";
    }

    public String getUploadUrl(String filename) {
        return appProperties.getUpload().getPath() + filename;
    }

    public boolean isValidFileExtension(String filename) {
        String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        return Arrays.asList(appProperties.getUpload().getAllowedExtensions()).contains(extension);
    }
}

템플릿에서 커스텀 유틸리티 사용

<!-- templates/products/detail.html -->
<div class="product-detail">
    <!-- 기능 플래그 기반 조건부 렌더링 -->
    <div th:if="${@utilService.isFeatureEnabled('reviews')}" class="reviews-section">
        <h3>상품 리뷰</h3>
        <div th:each="review : ${reviews}" class="review">
            <h5 th:text="${review.author}">작성자</h5>
            <!-- 텍스트 길이 제한 -->
            <p th:text="${@utilService.truncateText(review.content, 200)}">리뷰 내용</p>
            <small th:text="${#temporals.format(review.createdAt, 'yyyy-MM-dd')}">2024-01-15</small>
        </div>
    </div>

    <!-- 찜하기 버튼 (기능 활성화 시에만) -->
    <button th:if="${@utilService.isFeatureEnabled('wishlist')}" 
            class="btn btn-outline-danger">
        <i class="fas fa-heart"></i> 찜하기
    </button>

    <!-- 파일 업로드 정보 -->
    <div class="upload-info">
        <p>최대 업로드 크기: 
           <span th:text="${@utilService.formatFileSize(@appProperties.upload.maxFileSize)}">5.0 MiB</span>
        </p>
        <p>허용 확장자: 
           <span th:text="${#strings.listJoin(@appProperties.upload.allowedExtensions, ', ')}">jpg, png</span>
        </p>
    </div>
</div>

Spring Boot Actuator 통합

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,thymeleaf
  endpoint:
    health:
      show-details: when_authorized
<!-- templates/admin/system-info.html -->
<div class="system-info" sec:authorize="hasRole('ADMIN')">
    <h2>시스템 정보</h2>

    <div class="row">
        <div class="col-md-4">
            <div class="card">
                <div class="card-header">애플리케이션</div>
                <div class="card-body">
                    <p>이름: <span th:text="${@appProperties.name}">MyApp</span></p>
                    <p>버전: <span th:text="${@appProperties.version}">1.0.0</span></p>
                    <p>프로파일: <span th:text="${@environment.getActiveProfiles()[0]}">development</span></p>
                </div>
            </div>
        </div>

        <div class="col-md-4">
            <div class="card">
                <div class="card-header">Thymeleaf 설정</div>
                <div class="card-body">
                    <p>캐시: <span th:text="${@environment.getProperty('spring.thymeleaf.cache')}">false</span></p>
                    <p>모드: <span th:text="${@environment.getProperty('spring.thymeleaf.mode')}">HTML</span></p>
                    <p>인코딩: <span th:text="${@environment.getProperty('spring.thymeleaf.encoding')}">UTF-8</span></p>
                </div>
            </div>
        </div>

        <div class="col-md-4">
            <div class="card">
                <div class="card-header">기능 상태</div>
                <div class="card-body">
                    <p>리뷰: 
                       <span th:class="${@utilService.isFeatureEnabled('reviews')} ? 'text-success' : 'text-danger'"
                             th:text="${@utilService.isFeatureEnabled('reviews')} ? '활성' : '비활성'">활성</span>
                    </p>
                    <p>찜하기: 
                       <span th:class="${@utilService.isFeatureEnabled('wishlist')} ? 'text-success' : 'text-danger'"
                             th:text="${@utilService.isFeatureEnabled('wishlist')} ? '활성' : '비활성'">활성</span>
                    </p>
                    <p>알림: 
                       <span th:class="${@utilService.isFeatureEnabled('notifications')} ? 'text-success' : 'text-danger'"
                             th:text="${@utilService.isFeatureEnabled('notifications')} ? '활성' : '비활성'">활성</span>
                    </p>
                </div>
            </div>
        </div>
    </div>
</div>

전역 모델 속성

// src/main/java/com/example/controller/GlobalControllerAdvice.java
@ControllerAdvice
public class GlobalControllerAdvice {

    @Autowired
    private AppProperties appProperties;

    @Autowired
    private CategoryService categoryService;

    @ModelAttribute("appInfo")
    public AppProperties appInfo() {
        return appProperties;
    }

    @ModelAttribute("globalCategories")
    public List<Category> globalCategories() {
        return categoryService.getAllCategories();
    }

    @ModelAttribute("currentYear")
    public int currentYear() {
        return LocalDate.now().getYear();
    }

    @ModelAttribute("isProduction")
    public boolean isProduction(@Value("${spring.profiles.active:default}") String profile) {
        return "production".equals(profile);
    }
}

이제 모든 템플릿에서 이 전역 속성들을 사용할 수 있습니다:

<!-- 어떤 템플릿에서든 사용 가능 -->
<footer>
    <p>© <span th:text="${currentYear}">2024</span> <span th:text="${appInfo.name}">MyApp</span></p>
    <p th:unless="${isProduction}">개발 환경에서 실행 중</p>
</footer>

<nav>
    <ul>
        <li th:each="category : ${globalCategories}">
            <a th:href="@{/products(category=${category.id})}" th:text="${category.name}">카테고리</a>
        </li>
    </ul>
</nav>

정리

7장에서는 Spring Framework와 Thymeleaf의 완전한 통합에 대해 알아보았습니다.

핵심 내용 요약

  1. Spring MVC 연동
    • 컨트롤러에서 Model을 통한 데이터 전달
    • 뷰 이름 해석과 템플릿 렌더링
    • 폼 처리와 검증 연동
  2. Spring Security 통합
    • 인증과 권한 기반 UI 구성
    • 보안 네임스페이스 사용법
    • 사용자별 맞춤형 인터페이스
  3. Spring Boot 자동 설정
    • 간편한 의존성 관리
    • 프로파일별 설정 관리
    • 국제화와 메시지 소스 통합
  4. 커스텀 설정과 확장
    • 애플리케이션 프로퍼티 활용
    • 커스텀 유틸리티 서비스
    • 전역 모델 속성 설정

이러한 통합을 통해 Spring의 강력한 기능들을 Thymeleaf 템플릿에서 완전히 활용할 수 있으며, 보안과 설정 관리가 용이한 웹 애플리케이션을 구축할 수 있습니다.

반응형