1. DTO에서 왜 검증이 필요할까?
스프링 부트 애플리케이션에서 클라이언트로부터 입력받는 데이터(주로 @RequestBody로 전달되는 JSON 요청 등)는 유효성 검사가 필요합니다.
예를 들어, 회원가입 시 다음과 같은 상황이 발생할 수 있습니다.
- 이메일 형식이 잘못됨
- 비밀번호가 너무 짧거나 빈 값
- 나이(age)가 음수
만약 입력값에 대한 검증을 제대로 하지 않는다면, 잘못된 데이터가 DB에 저장되거나, 내부 로직에서 오류를 발생시킬 수 있습니다.
이를 방지하기 위해 DTO(Data Transfer Object)에 검증 노테이션을 달아 유효성을 검사하고, 문제가 있다면 적절한 에러 응답을 반환하도록 하는 방식을 사용합니다.
2. 의존성 추가 (Validation Starter)
스프링 부트에서 Validation을 사용하려면, 보통 spring-boot-starter-validation 의존성을 추가해야 합니다.
Maven 기준으로 pom.xml에 다음과 같이 추가합니다.
<dependencies>
<!-- 다른 의존성들... -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
dependencies {
// 다른 의존성들...
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
3. DTO 클래스에 검증 어노테이션 적용하기
가장 많이 사용하는 검증 노테이션으로는 다음과 같은 것들이 있습니다.
- @NotNull: null이 아니어야 함
- @NotEmpty: null이 아니고, 빈 문자열도 아니어야 함(공백 문자열은 가능)
- @NotBlank: null이 아니고, 빈 문자열, 공백 문자열도 아니어야 함
- @Size(min=, max=): 문자열, 배열, 컬렉션 등 크기에 대한 제한
- @Min(value): 숫자에 대한 최소값
- @Max(value): 숫자에 대한 최대값
- @Pattern(regexp=): 정규 표현식을 통한 검증
- @Email: 이메일 형식 검증
예시로 회원가입을 위한 UserSignupRequest DTO를 정의해보겠습니다.
package com.example.demo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class UserSignupRequest {
@NotBlank(message = "이름(name)은 필수 입력 값입니다.")
private String name;
@Email(message = "이메일 형식이 올바르지 않습니다.")
@NotBlank(message = "이메일(email)은 필수 입력 값입니다.")
private String email;
@NotBlank(message = "비밀번호(password)는 필수 입력 값입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
private String password;
public UserSignupRequest() {
}
// Getter/Setter 생략 가능(또는 Lombok @Getter, @Setter 사용)
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
여기서 @NotBlank, @Email, @Size 등을 사용해, 요청으로 들어오는 각 필드를 검증합니다.
- 예) @NotBlank가 붙은 필드에 null이나 ""(빈 문자열)를 보내면 검증 에러가 발생합니다.
Tip: 자카르타 패키지(jakarta.validation.constraints.*)와 기존의 javax.validation.constraints.* 패키지의 차이는 스프링 부트 버전과 관련이 있습니다. 새 버전에서는 jakarta 패키지를 사용합니다.
4. Controller에서 @Valid 사용하기
DTO에 설정한 검증 어노테이션이 적용되려면 Controller에서 @Valid를 사용해야 합니다. 간단한 예시를 들어보겠습니다.
package com.example.demo.controller;
import com.example.demo.dto.UserSignupRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api")
public class UserController {
@PostMapping("/signup")
public ResponseEntity<String> signup(@Valid @RequestBody UserSignupRequest request) {
// 정상적으로 검증이 끝나면 이곳에 도달
// TODO: UserService로 회원가입 처리 로직 수행 (가정)
return ResponseEntity.ok("회원가입 성공");
}
}
- @RequestBody DTO의 데이터를 JSON → 자바 객체로 변환
- @Valid 어노테이션이 붙어있으면, 스프링이 자동으로 DTO에 설정된 검증 노테이션을 확인
- 만약 검증 에러가 발생하면, MethodArgumentNotValidException 등이 발생하여 예외 처리 로직으로 넘어감
5. 검증 에러 처리 (Exception Handling)
@Valid를 사용하면, 검증 실패 시 MethodArgumentNotValidException이 발생합니다.
스프링 부트에서는 기본적으로 400 Bad Request 에러와 함께 검증 실패 메시지를 반환해주지만, 사용자 정의 에러 형식을 만들기 위해 컨트롤러 레벨에서 예외를 처리할 수도 있습니다.
5.1 @ExceptionHandler로 처리하기
아래 예시는 Controller 내 혹은 전역 예외 처리(ControllerAdvice)에서 MethodArgumentNotValidException을 직접 처리하는 방법입니다.
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.validation.FieldError;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
// BindingResult에서 발생한 모든 필드 에러를 순회하며, 에러 메시지를 매핑
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
- @RestControllerAdvice: 모든 @RestController에 대해 발생하는 예외를 처리할 수 있는 전역 컨트롤러 어드바이스
- MethodArgumentNotValidException: DTO 검증 실패 시 발생하는 예외
- FieldError: 어떤 필드에서 에러가 발생했는지, 그리고 어떤 메시지가 있는지를 확인할 수 있음
이렇게 하면 다음과 같은 JSON 형태로 에러 응답을 보낼 수 있습니다(예시):
{
"name": "이름(name)은 필수 입력 값입니다.",
"email": "이메일(email)은 필수 입력 값입니다."
}
5.2 BindingResult로 처리하기(Controller에)
또 다른 방식으로, 컨트롤러 메서드 파라미터에 BindingResult를 추가해 에러를 직접 확인하고 처리할 수도 있습니다.
@PostMapping("/signup")
public ResponseEntity<?> signup(
@Valid @RequestBody UserSignupRequest request,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// 에러가 있다면, 에러를 모아서 반환
Map<String, String> errors = new HashMap<>();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
errors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
return ResponseEntity.badRequest().body(errors);
}
// 에러가 없으면 다음 로직 실행
return ResponseEntity.ok("회원가입 성공");
}
- 파라미터에 BindingResult를 선언하면, 검증 이후 에러 정보를 직접 확인하고 원하는 로직을 적용할 수 있음
- 전역 예외 처리(@RestControllerAdvice)와는 달리, 메서드별로 에러 처리를 다르게 하고 싶을 때 유용
6. 커스텀 검증 어노테이션
기본 어노테이션으로 처리가 어려운 경우, 커스텀 검증 어노테이션을 만드는 방법도 있습니다.
예를 들어, 한글 이름만 허용한다거나, 특정 규칙(예: 비밀번호 복잡도) 등을 검사할 때 활용합니다.
6.1 커스텀 노테이션 정의
package com.example.demo.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = {OnlyKoreanValidator.class})
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface OnlyKorean {
String message() default "한글만 입력 가능합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
6.2 실제 검증 로직 구현
package com.example.demo.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class OnlyKoreanValidator implements ConstraintValidator<OnlyKorean, String> {
@Override
public void initialize(OnlyKorean constraintAnnotation) {
// 초기화 코드(필요하다면)
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// null이면 다른 곳(@NotBlank 등)에서 처리가 가능하다고 가정
if (value == null) {
return true;
}
// 한글 정규식 체크
return value.matches("^[가-힣]+$");
}
}
6.3 DTO에서 사용
public class UserSignupRequest {
@OnlyKorean
@NotBlank(message = "이름(name)은 필수 입력 값입니다.")
private String name;
// ... 생략
}
이렇게 하면, UserSignupRequest의 name 필드에 한글만 입력될 수 있도록 검증할 수 있습니다.
7. 정리
- 의존성 추가: spring-boot-starter-validation(필요하다면)
- DTO에 검증 어노테이션 붙이기: @NotBlank, @Email, @Size 등
- Controller에서 @Valid 사용: (@Valid @RequestBody DTO)
- 에러 처리: 전역 예외 처리(@RestControllerAdvice) 또는 메서드 단위 BindingResult 활용
- 커스텀 검증: 필요 시 직접 노테이션과 ConstraintValidator 구현
스프링 부트에서 DTO 검증을 잘 활용하면, 비즈니스 로직에 앞서 잘못된 요청을 걸러낼 수 있고, 코드 품질과 안정성을 크게 높일 수 있습니다.
더 나아가 커스텀 검증 로직을 활용하면 서비스 요구사항에 맞는 정교한 입력 데이터 검증이 가능해집니다.