반응형
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의 완전한 통합에 대해 알아보았습니다.
핵심 내용 요약
- Spring MVC 연동
- 컨트롤러에서 Model을 통한 데이터 전달
- 뷰 이름 해석과 템플릿 렌더링
- 폼 처리와 검증 연동
- Spring Security 통합
- 인증과 권한 기반 UI 구성
- 보안 네임스페이스 사용법
- 사용자별 맞춤형 인터페이스
- Spring Boot 자동 설정
- 간편한 의존성 관리
- 프로파일별 설정 관리
- 국제화와 메시지 소스 통합
- 커스텀 설정과 확장
- 애플리케이션 프로퍼티 활용
- 커스텀 유틸리티 서비스
- 전역 모델 속성 설정
이러한 통합을 통해 Spring의 강력한 기능들을 Thymeleaf 템플릿에서 완전히 활용할 수 있으며, 보안과 설정 관리가 용이한 웹 애플리케이션을 구축할 수 있습니다.
반응형