본문 바로가기
Java

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

by yapdol 2023. 12. 5.

 

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

 

 


 

 

 

 

스프링부트에서 프로젝트를 진행하면서, 타임리프를 사용하고 있다.

타임리프는 템플릿 엔진 중 하나로, Spring Boot 로 프로젝트를 진행하는 경우,

프론트엔드에서 JSP가 아닌, 타임리프를 사용한 html이 권장되기 때문이다.

회원가입 기능을 구현하며, 타임리프에서 validation check 를 할 필요가 생겼고,

해당 포스팅을 작성하며 타임리프 유효성 검증에 대한 내용을 공부&기록해두었다.

 

 


 

 

🚩 학습 목표

  • 타임리프를 사용한 유효성 검증 (Validation check) 방법 학습  실전 연습

 

 

 

✅ 타임리프(Thymeleaf) 소개

📌 타임리프란?

Thymeleaf란 HTML, XML, JavaScript, CSS 및 일반 텍스트까지 처리할 수 있는 웹 및 독립 환경을 위한 서버 사이드 Java 템플릿 엔진으로 JSPFreemarker 와 같이 서버에서 클라이언트에게 응답할 브라우저 화면을 만들어주는 역할을 수행

  • 자바 라이브러리
  • 웹과 환경 양쪽에서 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://hstory0208.tistory.com/entry/Spring-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0-Validation

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