[Thymeleaf] 스프링부트 타임리프에서 유효성 검증 (@Valid 활용)

스프링부트에서 프로젝트를 진행하면서, 타임리프를 사용하고 있다.
타임리프는 템플릿 엔진 중 하나로, Spring Boot 로 프로젝트를 진행하는 경우,
프론트엔드에서 JSP가 아닌, 타임리프를 사용한 html이 권장되기 때문이다.
회원가입 기능을 구현하며, 타임리프에서 validation check 를 할 필요가 생겼고,
해당 포스팅을 작성하며 타임리프 유효성 검증에 대한 내용을 공부&기록해두었다.
🚩 학습 목표
- 타임리프를 사용한 유효성 검증 (Validation check) 방법 학습 실전 연습
✅ 타임리프(Thymeleaf) 소개
📌 타임리프란?
Thymeleaf란 HTML, XML, JavaScript, CSS 및 일반 텍스트까지 처리할 수 있는 웹 및 독립 환경을 위한 서버 사이드 Java 템플릿 엔진으로 JSP, Freemarker 와 같이 서버에서 클라이언트에게 응답할 브라우저 화면을 만들어주는 역할을 수행
- 자바 라이브러리
- 웹과 환경 양쪽에서 TEXT, HTML, XML, Javascript, CSS를 생성할 수 있는 템플릿 엔진
- 스프링 MVC와의 통합 모듈 제공
- JSP 완전 대체 가능
❗ 스프링 부트에서 타임리프를 권장하는 이유
Spring Boot에서는 JSP 설정과 관련해 자동 설정 지원 X
반면에, 타임리프는 Dependency만 추가하면 자동으로 설정되어 간편하게 사용 가능!
📌 타임리프 장점
- 기존 HTML 코드와 구조를 변경하지 않고 덧붙이는 방식이기 때문에 유지관리 및 확장이 쉽고, 서버팀과 퍼블팀 간의 협업이 수월
- 비즈니스 로직과 분리되어 View에 집중 가능
- 서버상에서 동작하지 않아도 되기 때문에, 서버 동작 없이 화면을 확인 가능 (서버가 구동하지 않는 경우 정적 컨텐츠)
✅ Validation (유효성 검증)
회원가입 시, 사용자가 입력한 데이터가 서버로 전송되기 전에 설정한 규칙에 맞게 입력됐는지,
아이디/닉네임/이메일 등이 중복됐는지 체크하는 검증 단계가 필요함.
- 클라이언트 검증 및 서버 검증을 모두 해주는 것이 좋되, 최종적으로 서버 유효성 검증이 필수적임 (프론트 유효성검사 : UX 측면, 백엔드 유효성 검사 : 보안 측면)
- API 방식 사용 시, API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨줘야함
- @Validated(스프링 전용 검증 애너테이션)과 @Valid(자바 표준 검증 애너테이션)의 기능은 동일
- 검증 로직을 공통화/표준화 한 것이 바로 Bean Validation (모든 프로젝트 적용 가능)
📌 유효성 검증 순서
@ModelAttribute ➡️ 각각의 필드 타입 변환 시도 ➡️ 변환 성공한 필드만 BeanValidation 적용
✅ 타임리프로 유효성 검사하기 (예제 코드)
1) 의존성 추가 (build.gradle )
spring boot 2.3 이상부터는 모듈로 빠져 validation 의존성을 따로 추가해야함
implementation 'org.springframework.boot:spring-boot-starter-validation'
2) DTO (UserDto)
dto 클래스 필드에 적절한 애너테이션(bean validation)을 추가해 유효성 체크.
조건을 만족하지 못할 경우, 설정해둔 에러 메세지 반환 가능.
DTO 의 역할
1. Client ↔ Server 간 데이터 캡슐화 전달 : 클라이언트의 요청데이터가 dto 클래스를 통해 캡슐화되어 서버로 전달
2. Controller ↔ Service 계층 간 데이터 전달
package org.example.User;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.*;
@Getter
@Setter
@ToString
@NoArgsConstructor
public class UserDto {
private Long id;
@NotBlank(message = "닉네임은 필수 입력 값입니다.")
private String nickname;
@NotBlank(message = "이메일은 필수 입력 값입니다.")
@Email(message = "이메일 형식에 맞지 않습니다.")
private String email;
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
@Pattern(regexp="^(?=.*[A-Za-z])(?=.*\\d)(?=.*[$@$!%*#?&])[A-Za-z\\d$@$!%*#?&]{6,20}$",
message = "비밀번호는 영문, 숫자, 특수문자 조합으로 6 ~ 20자리까지 가능합니다.")
private String password;
@Builder
public UserDto(Long id, String nickname, String email, String password) {
this.id = id;
this.nickname = nickname;
this.email = email;
this.password = password;
}
}
3) Controller (UserController)
@Valid : dto 클래스의 필드에 작성한 애너테이션을 기준으로 유효성 검사
Error 객체 : dto 에 binding된 필드의 유효성 검사 오류 정보 저장/노출. 유효성 검사에 실패한 필드가 있는지 확인.
model.addAttribute("userDto", userDto);
: 회원가입 실패 시, 회원가입 페이지에서 입력했던 정보를 그대로 유지하기위해 입력받은 데이터 그대로 할당.
ㅇ (UserDto 클래스) viewSignup(UserDto userDto) 함수에 UserDto 파라미터를 정의해준 이유
ㅇ UX 측면에서 good (Validation 관점에서는 필요 x)
ㅇ thymeleaf 에도 코드가 들어가야함.
package org.example.User;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Map;
@Controller
@AllArgsConstructor
public class UserController {
private UserService userService;
@GetMapping("/user/signup")
public String viewSignup(UserDto userDto) {
return "/signup";
}
@PostMapping("/user/signup")
public String apiSignup(@Valid UserDto userDto, Errors errors, Model model) {
/* 검증 */
if (errors.hasErrors()) {
// 회원가입 실패시, 입력 데이터를 유지
model.addAttribute("userDto", userDto);
// 유효성 통과 못한 필드와 메시지를 핸들링 (to 서비스 계층)
Map<String, String> validatorResult = userService.validateHandling(errors);
for (String key : validatorResult.keySet()) {
model.addAttribute(key, validatorResult.get(key));
}
// 회원가입 페이지 리턴
return "/signup";
}
// 회원가입 성공시, 로그인 페이지로 리다이렉트
userService.signUp(userDto);
return "redirect:/user/login";
}
@GetMapping("/user/login")
public String veiwlogin() {
return "/login";
}
}
4) Service (UserService)
Controller 에서 유효성 검사에 실패한 필드가 있을 시, Service 계층으로 Error 객체를 전달/비즈니스 로직 구현
package org.example.User;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.validation.Errors;
import org.springframework.validation.FieldError;
import java.util.HashMap;
import java.util.Map;
@Service
@AllArgsConstructor
public class UserService {
// 회원가입 시, 유효성 체크
public Map<String, String> validateHandling(Errors errors) {
// 유효성 검사에 실패한 필드들을 Map 에 키(필드명)/값(에러메세지)으로 응답
// 키 : valid_{dto 필드명}
// 메시지 : dto에서 작성한 message 값
Map<String, String> validatorResult = new HashMap<>();
for (FieldError error : errors.getFieldErrors()) { // errors.getFieldErrors() : 유효성 검사에 실패한 필드 목록
String validKeyName = String.format("valid_%s", error.getField()); // error.getField() : 유효성 검사에 실패한 필드명
validatorResult.put(validKeyName, error.getDefaultMessage()); // error.getDefaultMessage() : 유효성 검사에 실패한 필드에 정의된 메세지
}
return validatorResult;
}
// 회원가입
public void signUp(UserDto userDto) {
// 회원 가입 비즈니스 로직 구현
}
}
5) 타임리프 (signup.html, login.html)
signup.html
modelAttribute="userDto"
: 회원가입 실패 시, 할당된 dto를 form의 modelAttribute 애트리뷰트로 할당
th:value="${userDto.email}"
:값을 넣어, 회원가입 실패시에도 입력값을 그대로 유지
<p th:text="${valid_email}"></p>
:에러 메시지를 노출하는 부분 (Service에서 valid_{dto 필드명} 포맷으로 전달한 키 참고)
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<link th:href="@{/css/signin.css}" rel="stylesheet">
<style>
.background {
background: ghostwhite;
}
.field-error {
border-color: red;
color: red;
font-size: small;
font-family: sans-serif;
}
</style>
</head>
<body class="background">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card white" style="border-radius: 1rem; width: 300px">
<div class="card-body p-5 text-center">
<h1 class="text-black text-center mt-2 mb-4">회원 가입</h1>
<form th:action="@{/user/signup}" method="post" modelAttribute="userDto">
<div class="mb-1 text-left">
<label class="form-label">아이디</label>
<input type="text" name="email" th:value="${email}" placeholder="이메일을 입력해주세요.">
<span class="field-error" th:text="${valid_email}">Incorrect data</span>
</div>
<div class="mb-1 text-left">
<label class="form-label">닉네임</label>
<input type="text" name="nickname" th:value="${nickname}" placeholder="닉네임을 입력해주세요.">
<span class="field-error" th:text="${valid_nickname}"></span>
</div>
<div class="mb-1 text-left">
<label class="form-label">비밀번호</label>
<input type="password" name="password" th:value="${password}" placeholder="비밀번호">
<p class="field-error" th:text="${valid_password}"></p>
</div>
<button>회원가입</button>
</form>
</div>
</div>>
</div>>
</section>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>로그인</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr>
</body>
</html>
6) 테스트
유효성 검사 통과 X

참고 문헌:
https://azurealstn.tistory.com/88
https://victorydntmd.tistory.com/332
https://1-7171771.tistory.com/108
https://ittrue.tistory.com/244
https://velog.io/@jyleedev/%EC%9C%A0%ED%9A%A8%EC%84%B1%EA%B2%80%EC%82%AC
https://oh-sh-2134.tistory.com/135
https://devofroad.tistory.com/122
https://aamoos.tistory.com/662
https://velog.io/@devharrypmw/Thymeleaf-%ED%83%80%EC%9E%84%EB%A6%AC%ED%94%84