oguri's garage

Spring 검증 어노테이션 (@Valid, @NotNull, @NotEmpty, @NotBlank) 본문

개발하다/Spring

Spring 검증 어노테이션 (@Valid, @NotNull, @NotEmpty, @NotBlank)

oguri 2025. 9. 29. 16:28

1. 기본 검증 어노테이션 비교

Spring에서 문자열 검증에 사용되는 세 가지 핵심 어노테이션을 우체통 편지 검사에 비유할 수 있다.

 

 

@NotNull - "편지가 있는지만 확인"

public class User {
    @NotNull(message = "이름은 null일 수 없습니다")
    private String name;
}

검증 규칙:

  • null ❌ (불가능)
  • "" ✅ (빈 문자열 허용)
  • " " ✅ (공백만 있는 문자열 허용)
  • "John" ✅ (정상)

사용 케이스:

  • 객체가 존재하기만 하면 되는 경우
  • Collection, Map, Array 등에서 null만 막고 싶을 때

 

 

@NotEmpty - "편지가 있고 내용이 있는지 확인"

public class User {
    @NotEmpty(message = "이름은 비어있을 수 없습니다")
    private String name;

    @NotEmpty(message = "취미 목록은 비어있을 수 없습니다")
    private List<String> hobbies;
}

검증 규칙:

  • null ❌ (불가능)
  • "" ❌ (빈 문자열 불가능)
  • " " ✅ (공백만 있어도 허용)
  • "John" ✅ (정상)
  • Collections.emptyList() ❌ (빈 컬렉션 불가능)

사용 케이스:

  • 문자열, Collection, Map, Array에서 빈 값을 막고 싶을 때
  • 최소한 하나의 요소나 문자는 있어야 하는 경우

 

 

@NotBlank - "편지가 있고 의미있는 내용이 있는지 확인"

public class User {
    @NotBlank(message = "이름은 공백일 수 없습니다")
    private String name;
}

검증 규칙:

  • null ❌ (불가능)
  • "" ❌ (빈 문자열 불가능)
  • " " ❌ (공백만 있는 문자열 불가능)
  • "John" ✅ (정상)
  • " John " ✅ (앞뒤 공백은 trim되어 정상)

사용 케이스:

  • String에만 적용 가능
  • 의미있는 텍스트가 반드시 필요한 경우 (이름, 제목 등)

 

 


 

2. 실제 예시로 이해하기

public class UserRegistrationForm {
    @NotNull(message = "사용자 ID는 null일 수 없습니다")
    private Long userId;           // Long 타입 - @NotNull만 사용 가능

    @NotBlank(message = "사용자명은 필수입니다")
    private String username;       // 의미있는 텍스트 필요

    @NotEmpty(message = "이메일은 비어있을 수 없습니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;          // 빈 값 방지 + 이메일 검증

    @NotEmpty(message = "최소 하나의 취미는 선택해야 합니다")
    private List<String> hobbies;  // 컬렉션 - @NotEmpty 사용
}

 

테스트 케이스:

// ❌ 검증 실패 케이스들
UserRegistrationForm invalidUser1 = UserRegistrationForm.builder()
    .userId(null)           // @NotNull 위반
    .username("")           // @NotBlank 위반
    .email("   ")           // @Email 위반 (@NotEmpty는 통과)
    .hobbies(List.of())     // @NotEmpty 위반
    .build();

// ✅ 검증 성공 케이스
UserRegistrationForm validUser = UserRegistrationForm.builder()
    .userId(123L)
    .username("john_doe")
    .email("john@example.com")
    .hobbies(List.of("reading", "gaming"))
    .build();

 

 


 

3. @Valid - 검증 실행의 트리거

 

@Valid는 실제 검증을 실행하라고 명령하는 스위치 역할

 

3.1 컨트롤러에서의 사용

@RestController
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<String> createUser(
            @Valid @RequestBody UserRegistrationForm form,  // 검증 실행!
            BindingResult result) {

        if (result.hasErrors()) {
            // 검증 에러 처리
            return ResponseEntity.badRequest()
                .body("검증 실패: " + result.getAllErrors());
        }

        // 검증 성공 시 비즈니스 로직 실행
        userService.createUser(form);
        return ResponseEntity.ok("사용자 생성 성공");
    }
}

 

3.2 중첩 객체 검증

 

@Valid중첩된 객체까지 재귀적으로 검증합니다.

public class Order {
    @NotBlank(message = "주문 번호는 필수입니다")
    private String orderNumber;

    @Valid  // 중첩 객체 검증을 위한 @Valid
    @NotNull(message = "배송 주소는 필수입니다")
    private Address shippingAddress;

    @Valid  // 컬렉션의 각 요소도 검증
    @NotEmpty(message = "최소 하나의 상품은 주문해야 합니다")
    private List<OrderItem> items;
}

public class Address {
    @NotBlank(message = "도시명은 필수입니다")
    private String city;

    @NotBlank(message = "상세 주소는 필수입니다")
    private String street;

    @NotEmpty(message = "우편번호는 필수입니다")
    @Size(min = 5, max = 6, message = "우편번호는 5-6자리여야 합니다")
    private String zipCode;
}

public class OrderItem {
    @NotBlank(message = "상품명은 필수입니다")
    private String productName;

    @Min(value = 1, message = "수량은 1개 이상이어야 합니다")
    private Integer quantity;
}

 

검증 동작:

@PostMapping("/orders")
public ResponseEntity<String> createOrder(@Valid @RequestBody Order order) {
    // @Valid에 의해 다음이 모두 검증됨:
    // 1. Order 객체 자체 (@NotBlank orderNumber)
    // 2. Address 객체 (@NotBlank city, street, @NotEmpty zipCode)
    // 3. List<OrderItem>의 각 OrderItem (@NotBlank productName, @Min quantity)
}

 


 

4. 의존성 설정

 

Spring Boot에서 검증 기능을 사용하려면 다음 의존성이 필요합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

이 의존성이 포함하는 내용:

  • Jakarta Bean Validation API (JSR 380)
  • Hibernate Validator (구현체)
  • 관련 Spring 통합 라이브러리

 


 

5. 실무 사용 패턴

 

5.1 Request DTO 패턴

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateUserRequest {
    @NotBlank(message = "사용자명은 필수입니다")
    private String username;

    @NotBlank(message = "비밀번호는 필수입니다")
    @Size(min = 8, message = "비밀번호는 최소 8자리여야 합니다")
    private String password;

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;

    @Valid
    @NotNull(message = "프로필 정보는 필수입니다")
    private UserProfile profile;
}

public class UserProfile {
    @NotBlank(message = "실명은 필수입니다")
    private String realName;

    @Min(value = 14, message = "최소 연령은 14세입니다")
    @Max(value = 120, message = "최대 연령은 120세입니다")
    private Integer age;
}

 

5.2 컨트롤러 예외 처리

@RestController
@Slf4j
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<UserResponse> createUser(
            @Valid @RequestBody CreateUserRequest request) {

        UserResponse response = userService.createUser(request);
        return ResponseEntity.ok(response);
    }

    // 검증 실패 시 자동으로 호출되는 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {

        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.toList());

        ErrorResponse errorResponse = ErrorResponse.builder()
            .message("입력값 검증 실패")
            .errors(errors)
            .build();

        return ResponseEntity.badRequest().body(errorResponse);
    }
}

 


 

6. 고급 검증 패턴

6.1 조건부 검증 (@Validated + 그룹)

// 검증 그룹 정의
public interface CreateGroup {}
public interface UpdateGroup {}

public class UserRequest {
    @NotNull(groups = UpdateGroup.class, message = "업데이트 시 ID는 필수입니다")
    private Long id;

    @NotBlank(groups = {CreateGroup.class, UpdateGroup.class}, 
              message = "사용자명은 필수입니다")
    private String username;
}

@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
        @Validated(CreateGroup.class) @RequestBody UserRequest request) {
    // id는 검증하지 않고, username만 검증
}

@PutMapping("/users/{id}")
public ResponseEntity<UserResponse> updateUser(
        @PathVariable Long id,
        @Validated(UpdateGroup.class) @RequestBody UserRequest request) {
    // id와 username 모두 검증
}

 

6.2 커스텀 검증 어노테이션

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface ValidPhoneNumber {
    String message() default "올바른 전화번호 형식이 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> {
    @Override
    public boolean isValid(String phoneNumber, ConstraintValidatorContext context) {
        return phoneNumber != null && phoneNumber.matches("^010-\\d{4}-\\d{4}$");
    }
}

// 사용법
public class UserRequest {
    @NotBlank
    @ValidPhoneNumber  // 커스텀 검증
    private String phoneNumber;
}

 


 

7. 검증 어노테이션 선택 가이드

 

상황 권장 어노테이션 이유
Long, Integer 등 숫자 타입 @NotNull null만 방지하면 됨
사용자명, 제목 등 의미있는 텍스트 @NotBlank 공백 문자열까지 방지
이메일 (기본 검증만) @NotEmpty + @Email 빈 값 방지 + 형식 검증
List, Set 등 컬렉션 @NotEmpty 빈 컬렉션 방지
Optional한 필드 검증 어노테이션 없음 null 허용

 

 


 

8. 실무 권장 패턴

 

8.1 Request DTO 표준 패턴

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreatePostRequest {

    @NotBlank(message = "제목은 필수입니다")
    @Size(max = 100, message = "제목은 100자를 초과할 수 없습니다")
    private String title;

    @NotBlank(message = "내용은 필수입니다")
    @Size(max = 5000, message = "내용은 5000자를 초과할 수 없습니다")
    private String content;

    @NotEmpty(message = "최소 하나의 카테고리는 선택해야 합니다")
    private List<String> categories;

    @Valid  // 중첩 검증
    private PostMetadata metadata;
}

public class PostMetadata {
    @NotEmpty(message = "태그는 최소 하나 필요합니다")
    private List<String> tags;

    private Boolean isPublic = true;  // 기본값 설정, 검증 불필요
}

 

8.2 컨트롤러 표준 패턴

@RestController
@RequestMapping("/api/posts")
@Validated
public class PostController {

    @PostMapping
    public ResponseEntity<PostResponse> createPost(
            @Valid @RequestBody CreatePostRequest request) {

        PostResponse response = postService.createPost(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // 글로벌 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {

        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error -> 
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );

        ErrorResponse errorResponse = ErrorResponse.builder()
            .message("입력값이 올바르지 않습니다")
            .fieldErrors(fieldErrors)
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.badRequest().body(errorResponse);
    }
}

 

8.3 서비스 레이어 검증

@Service
@Validated  // 서비스 레벨에서도 검증 가능
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public UserResponse createUser(
            @Valid CreateUserRequest request) {  // 서비스에서도 검증

        // 중복 체크 등 비즈니스 검증
        if (userRepository.existsByUsername(request.getUsername())) {
            throw new BusinessException("이미 존재하는 사용자명입니다");
        }

        User user = User.builder()
            .username(request.getUsername())
            .email(request.getEmail())
            .build();

        User savedUser = userRepository.save(user);

        return UserResponse.builder()
            .id(savedUser.getId())
            .username(savedUser.getUsername())
            .email(savedUser.getEmail())
            .build();
    }
}

 

 


 

9. 검증 실행 흐름

1. HTTP 요청 수신
   ↓
2. @Valid 어노테이션 감지
   ↓  
3. Hibernate Validator 자동 실행
   ↓
4. 각 필드별 검증 어노테이션 확인
   (@NotBlank, @NotEmpty, @NotNull 등)
   ↓
5. 중첩 객체가 있으면 @Valid로 재귀 검증
   ↓
6. 검증 결과
   ├── 성공 → 컨트롤러 메소드 실행
   └── 실패 → MethodArgumentNotValidException 발생

 

 


 

10. 주요 주의사항

  1. @NotBlank는 String에만 사용 가능 - Collection에는 @NotEmpty 사용
  2. @NotEmpty가 @NotNull을 포함함 - 중복 어노테이션 불필요
  3. @Valid 없으면 검증 실행 안됨 - 어노테이션만 있어도 검증되지 않음
  4. 중첩 객체는 반드시 @Valid 필요 - 재귀 검증을 위해 필수
  5. spring-boot-starter-validation 의존성 필요 - 자동 설정을 위해 필수