어제 오늘 내일

[Spring Boot] "유효성 검사, 아직도 if문 쓰세요?" @Valid와 커스텀 어노테이션(@NotSpace) 만들기 본문

IT/SpringBoot

[Spring Boot] "유효성 검사, 아직도 if문 쓰세요?" @Valid와 커스텀 어노테이션(@NotSpace) 만들기

hi.anna 2026. 3. 25. 01:00

 
프론트엔드에서 데이터를 보낼 때, 우리는 절대 그 데이터를 믿으면 안 됩니다. 해커가 이상한 값을 보낼 수도 있고, 사용자가 실수를 할 수도 있으니까요.
하지만 그렇다고 모든 메서드에 검증 로직을 넣는 건 최악입니다.

// ❌ 최악의 코드 (if문 지옥)
if (dto.getEmail() == null || !dto.getEmail().contains("@")) {
    throw new IllegalArgumentException("이메일 형식이 아닙니다.");
}
if (dto.getAge() < 20) {
    throw new IllegalArgumentException("미성년자는 가입 불가능합니다.");
}

스프링 부트는 이 귀찮은 작업을 Bean Validation이라는 표준 기술로 해결해 줍니다.


1. 설정하기 (의존성 추가)

스프링 부트 2.3 버전 이상부터는 validation 라이브러리가 별도로 분리되었습니다. build.gradle에 꼭 추가해야 합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

2. DTO에 "규칙" 정의하기

이제 DTO 클래스에 어노테이션만 붙이면 됩니다.

@Getter
public class MemberSignupRequest {

    @NotBlank(message = "이름은 필수입니다.") // null, "", " " 모두 불가
    private String name;

    @Email(message = "이메일 형식이 올바르지 않습니다.")
    @NotBlank
    private String email;

    @Min(value = 14, message = "14세 이상만 가입 가능합니다.")
    private int age;

    @Pattern(regexp = "^01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$", message = "휴대폰 번호 형식이 아닙니다.")
    private String phoneNumber;
}

💡 꿀팁: @NotNull vs @NotEmpty vs `@NotBlank`

  • @NotNull: null만 아니면 됨. ("", " " 통과)
  • @NotEmpty: null""(빈 문자열) 불가. (" " 통과)
  • @NotBlank: 가장 엄격함. null, "", " "(공백) 모두 불가. (String에는 이거 쓰세요!)

3. 컨트롤러에서 검사 수행하기 ()

DTO에 규칙을 정했으니, 검사를 실행하라고 지시해야 합니다. 컨트롤러 메서드의 파라미터 앞에 @Valid를 붙이세요.

@PostMapping("/signup")
public ResponseEntity<String> signup(@Valid @RequestBody MemberSignupRequest request) {
    // 여기까지 들어왔다는 건 검증을 통과했다는 뜻!
    memberService.signup(request);
    return ResponseEntity.ok("가입 성공");
}

만약 검증에 실패하면요?
스프링이 알아서 MethodArgumentNotValidException 예외를 던지고, 400 Bad Request를 반환합니다. (물론 @RestControllerAdvice로 예쁘게 잡아서 처리하는 게 정석입니다.)


4. 나만의 어노테이션 만들기:

"닉네임에 공백(스페이스바)이 들어가면 안 된다"는 규칙이 있다고 칩시다. @NotBlank는 전체가 공백인 것만 막아주지, "Hello World" 처럼 중간에 공백이 있는 건 못 막습니다.

이럴 때 커스텀 어노테이션을 만듭니다.

① 어노테이션 정의 ()

@Target(ElementType.FIELD) // 필드에 붙일 거야
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotSpaceValidator.class) // 검증 로직은 여기서 처리해!
public @interface NotSpace {

    String message() default "공백이 포함될 수 없습니다."; // 기본 에러 메시지

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

② 검증 로직 구현 ()

public class NotSpaceValidator implements ConstraintValidator<NotSpace, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 1. null은 다른 어노테이션(@NotNull)에게 맡긴다. (보통 이렇게 함)
        if (value == null) {
            return true; 
        }

        // 2. 공백이 포함되어 있으면 false (검증 실패)
        return !value.contains(" ");
    }
}

③ 사용하기

public class MemberSignupRequest {

    @NotSpace(message = "닉네임에 띄어쓰기를 넣지 마세요!")
    private String nickname;

    // ...
}

이제 "Hello World"라는 닉네임을 보내면 "닉네임에 띄어쓰기를 넣지 마세요!"라는 에러 메시지가 뜹니다. 정말 멋지죠?


5. 실무 팁: 에러 메시지 예쁘게 주기

그냥 두면 스프링이 기본 에러 응답을 주는데, 프론트엔드 개발자가 알아보기 힘듭니다. GlobalExceptionHandler에서 깔끔하게 가공해 줍시다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();

        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });

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

응답 예시:

{
    "email": "이메일 형식이 올바르지 않습니다.",
    "age": "14세 이상만 가입 가능합니다."
}

마치며

오늘의 결론입니다.

  1. 유효성 검사 로직은 컨트롤러(if-else)가 아니라 DTO(@Annotations)에 있어야 한다.
  2. @Valid를 붙여야 검증이 동작한다.
  3. 기본 제공 어노테이션(@Email, @Min 등)으로 부족하면 커스텀 어노테이션을 직접 만들면 된다.

이제 여러분의 컨트롤러는 비즈니스 로직만 남고 아주 깨끗해졌습니다.
다음 포스팅에서는 "로그 파일이 10GB가 넘어서 서버가 멈췄어요..." 로그 관리의 정석! Logback 설정(RollingFileAppender) 완벽 정리에 대해 알아보겠습니다.
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊

반응형
Comments