김코딩

여긴 못지나간다.(@Valid) 본문

스프링

여긴 못지나간다.(@Valid)

김코딩딩 2025. 5. 19. 19:54

오늘은 Spring에서 검증(Validation)에 대한 글을 작성하였습니다.

처음에 @Valid에 대해 잘 모를 때는 이렇게 생각했습니다.

"음... DB에서도 NOT NULL 같은 걸로 검증을 해주는데,
굳이 컨트롤러에서 또 검증을 해야 하나?"

 

하지만 이제 알아버렸습니다.. DB에서 막는 건 너무 늦었다는 사실을요.


왜 @Valid가 필요할까?

사용자가 입력한 값은 절대 신뢰할 수 없습니다.

  • 이름이 빈 채로 제출될 수 있고
  • 이메일이 이상한 형식일 수 있으며
  • 숫자여야 할 값이 문자로 들어올 수도 있습니다

이런 값이 그대로 서비스 로직이나 DB로 들어가면?

  • 예외가 터지거나
  • 데이터 무결성이 깨지거나
  • 나중에 추적하기 어려운 버그로 이어집니다.

그래서 우리는 가장 앞단에서 잘못된 입력을 걸러낼 필요가 있습니다.
바로 그 역할을 하는 게 @Valid입니다.


@Valid의 기본 사용법

Spring에서는 @Valid를 사용해 컨트롤러에서 DTO의 유효성을 검사할 수 있습니다.

@PostMapping("/join")
public String join(@Valid @ModelAttribute JoinDto dto, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "joinForm"; // 유효성 검사 실패 시
    }

    return "redirect:/home"; // 통과한 경우
}
  • @Valid는 DTO에 선언된 검증 조건을 확인하고,
  • BindingResult에 에러 여부와 메시지를 담습니다.

DTO에는 어떤 조건을 줄 수 있나요?

public class JoinDto {

    @NotBlank(message = "이름은 필수입니다.")
    private String name;

    @Email(message = "이메일 형식이 아닙니다.")
    private String email;

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

}

이처럼 DTO 클래스에 각종 유효성 어노테이션을 붙이면,
잘못된 값은 컨트롤러 진입과 동시에 필터링됩니다.

자주 사용하는 유효성 검증 어노테이션 목록

어노테이션 설명 사용 대상 예시
@NotNull null이 아니어야 함 모든 타입 @NotNull(message = "필수 입력입니다.")
@NotBlank null, 빈 문자열, 공백만 있는 문자열 허용 X 문자열 @NotBlank(message = "이름은 필수입니다.")
@NotEmpty null 또는 빈 값 허용 X (공백은 허용) 문자열, 컬렉션 등 @NotEmpty(message = "값을 입력해주세요.")
@Email 이메일 형식이어야 함 문자열 @Email(message = "이메일 형식이 아닙니다.")
@Size(min, max) 문자열 또는 컬렉션의 길이 제한 문자열, 리스트 등 @Size(min = 2, max = 10, message = "2~10자여야 합니다.")
@Min 지정한 최소값 이상이어야 함 숫자 @Min(value = 1, message = "1 이상이어야 합니다.")
@Max 지정한 최대값 이하이어야 함 숫자 @Max(value = 100, message = "100 이하여야 합니다.")
@Positive 양수여야 함 숫자 @Positive(message = "양수여야 합니다.")
@PositiveOrZero 0 이상 숫자 @PositiveOrZero(message = "0 이상이어야 합니다.")
@Negative 음수여야 함 숫자 @Negative(message = "음수여야 합니다.")
@Pattern 정규식 만족해야 함 문자열 @Pattern(regexp = "^[0-9]{4}$", message = "4자리 숫자여야 합니다.")
@AssertTrue / @AssertFalse 값이 true / false여야 함 boolean, Boolean @AssertTrue(message = "약관 동의는 필수입니다.")

BindingResult란?

@Valid는 DTO에 정의된 유효성 조건을 검사합니다.
그런데 유효성 검증에 실패했을 때는 어떻게 처리될까요?

이때 예외를 던지지 않고, 검증 결과를 담아주는 객체가 바로 BindingResult입니다.

@PostMapping("/join")
public String join(@Valid @ModelAttribute JoinDto dto, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        // 검증 실패 시, 다시 폼으로 돌아가기
        return "joinForm";
    }

    // 검증 성공 시, 가입 처리
    return "redirect:/home";
}

BindingResult가 존재하면,
검증 실패 시에도 컨트롤러 메서드가 호출되고
hasErrors()로 직접 실패 여부를 판단할 수 있습니다.


BindingResult의 위치가 중요한 이유

BindingResult는 반드시 @Valid 바로 뒤에 위치해야 합니다.
중간에 다른 파라미터가 있으면, 스프링이 검증 결과를 바인딩할 수 없어 500 에러가 발생할 수 있습니다.

@PostMapping("/join")
public String join(@Valid @ModelAttribute JoinDto dto,
                   HttpServletRequest request, // 중간에 끼면 안 된다
                   BindingResult bindingResult) {
}

검증 실패 시 실제 동작 예시

예를 들어, 이런 입력값이 들어왔다고 해보겠습니다:

POST /join
Content-Type: application/x-www-form-urlencoded

name=        
email=not-an-email
age=16

 

DTO

public class JoinDto {

    @NotBlank(message = "이름은 필수입니다.")
    private String name;

    @Email(message = "이메일 형식이 아닙니다.")
    private String email;

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

 

컨트롤러 코드:

@PostMapping("/join")
public String join(@Valid @ModelAttribute JoinDto dto, BindingResult bindingResult, Model model) {
    if (bindingResult.hasErrors()) {
        // 에러 메시지 확인
        bindingResult.getFieldErrors().forEach(error -> {
            System.out.println(error.getField() + ": " + error.getDefaultMessage());
        });

        // 에러를 모델에 담아 폼으로 전달
        model.addAttribute("errors", bindingResult);
        return "joinForm";
    }

    return "redirect:/home";
}

 

결과:

name: 이름은 필수입니다.
email: 이메일 형식이 아닙니다.
age: 18세 이상만 가입 가능합니다.

사용자에게 에러 보여줄 때

템플릿 엔진(Thymeleaf 등)을 사용할 경우, BindingResult를 활용해 다음과 같이 에러 메시지를 출력할 수 있습니다.

<form th:action="@{/join}" method="post" th:object="${joinDto}">
    <input type="text" th:field="*{name}" />
    <div th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>

    <input type="email" th:field="*{email}" />
    <div th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></div>

    <input type="number" th:field="*{age}" />
    <div th:if="${#fields.hasErrors('age')}" th:errors="*{age}"></div>

    <button type="submit">가입하기</button>
</form>

정리하기

왜 검증이 필요할까? 

사용자 입력은 신뢰할 수 없으며,
DB나 서비스 레이어에서 처리하기엔 이미 늦습니다.

 

@Valid는 어떤 역할을 하나요?

DTO에 정의된 검증 조건을 기준으로
유효성 검사를 자동으로 수행합니다.

 

BindingResult는 왜 필요한가요?

검사 실패 시 예외를 발생시키지 않고
컨트롤러에서 에러를 직접 처리할 수 있게 해줍니다.

 

DTO에서 어떤 검증 조건을 사용할 수 있나요?

@NotNull, @NotBlank, @Email, @Min, @Pattern 등 다양한 조건을 조합해
입력값의 형식과 범위를 세밀하게 조정할 수 있습니다.

 

BindingResult의 위치는 중요합니다

@Valid 바로 뒤에 선언해야 하며, 그렇지 않으면 에러가 발생할 수 있습니다.