"Validaion"
특정 데이터(주로 클라이언트의 요청 데이터)의 값이 유효한지 검증하는 단계
클라이언트 요청 데이터 -> ex) CreateMovieRequest, UpdateMovieRequest
Validation은 Controller의 주요 역할 중 하나, HTTP요청이 정상인지 검증한다.
"시스템이 미리 정의한 사양에 부합하고 있는지를 검증"

음식점에 메뉴가 제육덮밥, 라면, 돈까스, 수제비가 있다면 손님은 메뉴판에 있는 메뉴를 골라서 주문해야된다.
- Validation의 역할
- 검증을 통해 적절한 메세지를 유저에게 보여주어야 한다.
- 검증 오류로 인해 정상적인 동작을 하지 못하는 경우는 없어야 한다.
- 사용자가 입력한 데이터는 유지된 상태여야 한다.
이러한 검증이 없으면, 이메일 형식이 아닌데도 이메일 가입이 가능하고 (ex abc.com), 나이 입력에 숫자가 아닌 문자로 나이를 입력하여 서버에 오류가 발생하고(ex 열다섯살, 스물세살), 비밀번호를 비워두고 제출하여 DB에 null값으로 저장이 되는 등 엉망 진창의 상황이 발생한다.
"Bean Validation"
Bean Validation은 웹 애플리케이션에서 가장 중요한 것 중 하나는 잘못된 데이터가 시스템에 들어오지 못하게 막는 것
Bean Validation은 검증 로직을 어노테이션으로 표현하는 기술로, 어노테이션 선언만으로 편하고 직관적으로 데이터를 검증할 수 있다.
- Bean Validation을 사용하기 위해서는 먼저 build.gradle에 의존성을 추가해야한다.
build.gradle 내부의 depedency
implementation 'org.springframework.boot:spring-boot-starter-validation'
- 그리고 주로 Request DTO에 Validation 어노테이션을 사용한다.
CreateRequestDTO
public class CreateMovieRequestDto {
@NotBlank // 1. "값이 꼭 있어야 해요!" (null, "", " " 모두 거부)
private String title;
@Email // 2. "이메일 형식이어야 해요!" (xxx@xxx.xxx)
private String email;
@Size(min = 8, max = 20) // 3. "8~20자 사이여야 해요!"
private String password;
@Min(19) // 4. "최소 19 이상이어야 해요!"
private Integer age;
@Pattern(regexp = "^010-\\d{4}-\\d{4}$") // 5. "이 패턴과 일치해야 해요!"
private String phone;
}
- @NotBlank: 공백이 아닌 문자가 1개 이상 (null, "", " " 모두 거부)
- @Email: @가 포함된 이메일 형식
- @Size: 문자열 길이나 컬렉션 크기 제한 -> @Size(min = 8, max = 20)
- @Min/@Max: 숫자의 최소/최대값 -> (@Min(10), @Max(50))
- @Pattern: 정규표현식 패턴 (전화번호, 주민번호 형식 등) -> @Pattern(regexp = "^010-\\d{4}-\\d{4}$")
그리고 이 어노테이션들을 활용하여 에러메세지를 커스터마이징 할 수 있다.
// 직접 작성
@NotBlank(message = "이름은 공백일 수 없습니다.")
private String name;
// 다른 옵션값 포함 가능
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
private String password;
중요한 포인트는 이것은 "메세지"이다. 에러 처리를 해주는 것이 아니다! 해당 에러가 발생했을 때 던져줄 수 있는 메세지일 뿐이다!!
이러한 어노테이션들을 활용하여 Request DTO에 설정해줬다면, Controller에서는 @RequestBody 앞에 @Valid 어노테이션을 붙여주면 자동으로 검증을 한다.
@RestController
public class MovieController {
// @RequestBody 앞에 @Valid만 붙이면 자동으로 검증 실행!
@PostMapping("/signup")
public String signup(@Valid @RequestBody CreateMovieRequestDto request) {
// ...
return "가입이 완료되었습니다.";
}
}
"Spring에서 예외처리 전략"
우리는 지금까지 Java에 있는 기본 예외처리를 사용해 왔다.
ex) new IllegalStateException("없는 멤버입니다.")
// MemberService
@Transactional(readOnly = true)
public GetMemberResponse findOne(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(
() -> new IllegalStateException("없는 멤버입니다.")
);
return new GetMemberResponse(
member.getId(),
member.getNickname()
);
}
이렇게 할 경우 500에러가 발생하게 된다. 만약, 서버의 잘못이 아니고 클라이언트의 잘못일 경우 400번대를 내려주는게 맞는데, 500에러로 내려지게 되어 불편한 상황이 발생할 수 있다.
그래서 스프링에서는 다음과 같이 에러 핸들링을 한다.
1. @ExceptionHandler
2. @RestControllerAdvice
3. Spring의 기본 예외 처리
이렇게 3가지가 존재하고, 순서대로 우선순위를 갖는다.
- @ExceptionHandler
- @ExceptionHandler는 컨트롤러 별 예외처리인데, 해당 컨트롤러에서 발생하는 예외만 처리한다. 그래서 컨트롤러마다 @ExceptionHandler어노테이션을 계속 설정해줘야해서 불편하다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping("/members/{memberId}")
public ResponseEntity<GetMemberResponse> getMember(
@PathVariable Long memberId
) {
return ResponseEntity.status(HttpStatus.OK).body(memberService.findOne(memberId));
}
// 🚨 예외 처리 메서드
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<String> handlerIllegalStateException(IllegalStateException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body("요청 오류: " + e.getMessage());
}
}
이렇게 예외처리를 해주면,status(HttpStatus.BAD_REQUEST)로 인하여 클라이언트에게 400 BadRequest 상태 코드와 "요청오류: 없는 멤버입니다" 에러메세지가 같이 전달된다.
하지만, 이것 또한 문제점이 있다. 바로 컨트롤러 마다 개별적으로 @ExceptionHandler 어노테이션을 계속 붙여줘야한다.
그래서 이 방법 보다는 전역 예외처리를 해주는 @RestControllerAdvice를 많이 사용한다.
- @RestControllerAdvice(전역 예외 처리)
- 전역 예외 처리를 해주는 어노테이션으로, 모든 컨트롤러의 예외를 한 곳에서 처리해준다.
- 가장 많이 사용되는 방식이며, 사실상 표준이다.
- 주로 GlobalExceptionHandler 이런식으로 예외처리 클래스를 따로 만들고 @RestControllerAdvice 어노테이션을 붙여줘서 활용한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 🎯 커스텀 비즈니스 예외 처리
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<String> handleIllegalStateException(IllegalStateException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body("요청 오류: " + e.getMessage());
}
}
이렇게 만들어주면 전역적으로 IllegalStateException 에러가 발생한 상황에 대해 모든 API 응답에 공통된 에러 응답을 내려줄 수 있다. 하지만, 잘 생각해보면 "모든 API 응답에 공통된 에러"를 응답으로 내려준다고 했다. 커스텀 에러를 알아보며 다시 얘기해보자,
"커스텀 에러"
우리는 지금까지 IllegalStateException 같은 Java의 기본 예외를 사용해왔다.
하지만 이러한 Java의 기본 예외는 너무 범용적이며 만약 "게시글이 존재하지 않는 상황"같은 경우 이 예외처리로 표현한다면 나중에 코드를 볼 때 어떠한 원인으로 발생했는지 한눈에 파악하기 힘들다.
그렇기 때문에 "커스텀 에러"를 직접 만들어서 상황에 맞게 사용한다.
커스텀 에러를 만들 때의 중요한 포인트는 해당 에러 클래스에 RunTimeException을 상속해줘야한다.
그냥 Exception이 아니다! 꼭 RunTimeException을 상속해야한다.
MovieNotFoundException을 커스텀 에러로 만들어 주기 위해 RunTimeException을 상속해줌
// extends RuntimeException 중요!
public class MovieNotFoundException extends RuntimeException {
public MovieNotFoundException(String message) {
super(message);
}
}
이렇게 하면 MovieNotFoundException 이라는 에러를 직접 만든것이고, 에러를 던지는 코드에서 상황에 맞게 커스텀 에러로 던져주면 된다.
위에 과정을 통해 만들어준 MovieNotFoundException커스텀 에러를 사용
// Service 코드
@Transactional(readOnly = true)
public GetMovieResponse findOne(Long movieId) {
Movie movie = movieRepository.findById(movieId).orElseThrow(
() -> new MovieNotFoundException("없는 영화입니다.")
);
return new GetMovieResponse(
movie.getId(),
movie.getTitle(),
movie.getEmail(),
movie.getPassword(),
movie.getPhoneNumber()
);
}
이런식으로 커스텀한 에러를 직접 던져줘서 사용하면 된다.
그리고 이 커스텀 에러로 위에서 본 GlobalExceptionHandler로 사용할 수 있다.
MovieNotFoundException커스텀 에러를 전역 예외처리 GloblaExceptionHandler 클래스에 넣어줘서 사용
@RestControllerAdvice
public class GlobalExceptionHandler {
// MovieNotFoundException 커스텀 에러 핸들링
@ExceptionHandler(MovieNotFoundException.class)
public ResponseEntity<ErrorResponse> handleMovieNotFoundException(MovieNotFoundException ex) {
ErrorResponse response = new ErrorResponse(ex.getMessage());
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(response);
}
}
하지만, 여기서 문제가 또또 발생한다. 이름이 GlobalExceptionHandler인데, 막상 Globla하지 않다는 것,
GlobalExceptionHandler얘는 전역 예외처리로, 모든 컨트롤러 영역의 예외처리를 담당해주는데, MovieNotFoundException 뿐만 아니라, 앞으로 수백 수천가지 이상의 커스텀 에러가 생길때 마다 GlobalExceptionHandler에 하나하나 따로 만들어줘야 한다. @ExceptionHandler를 사용했을 때와 비슷한 상황인 것이다.
이러한 문제를 해결하기 위해 "공통 부모 커스텀 에러"타입을 만들어 준다!
공통 부모 커스텀 에러 클래스를 만들어주고 얘한테 RunTimeException을 상속해주는 것임,
(커스텀 에러를 만들때 RunTimeException을 상속해줘야 한다고 했었다.)
공통 상위 커스텀 에러 클래스 ServiceException
import lombok.Getter;
@Getter
public class ServiceException extends RuntimeException {
private final HttpStatus status;
public ServiceException(HttpStatus status, String message) {
super(message);
this.status = status;
}
}
이런식으로 커스텀에러의 부모 클래스를 만들어주고, 이것을 내가 만들려고하는 커스텀에러 클래스에 이녀석을 상속해주는 것임,
아까는 MovieNotFounException에 직접 RinTimeException을 상속해줬었는데, 이렇게 만들려고 하는 커스텀에러에다가 RunTimeException을 직접 상속해 주다 보니까 @RestControllerAdvice, 즉 전역 예외처리를 담당하는 클래스에 커스텀 에러가 생길때 마다 계속 추가를 해줘야하는 불편함이 있었음, 하지만 공통 부모 커스텀 클래스를 만들어서 부모클래스에게 RunTimeException을 상속해주고, 이 부모 클래스를 만들려고 하는 커스텀 에러 클래스에다가 상속해주니, 훨씬 편안해짐.
// extends ServerException 중요!
public class MovieNotFoundException extends ServiceException {
public MovieNotFoundException(String message) {
super(HttpStatus.NOT_FOUND, message); // HttpStatus.NOT_FOUND 지정
}
}
공통 부모 커스텀 에러 클래스 SeviceException을 MovieNotFoundException에 상속해줘서 에러를 던지는 코드 부분에는 MovieNotFoundException을 던져주면 된다.
// Service 코드
@Transactional(readOnly = true)
public GetMovieResponse findOne(Long movieId) {
Movie movie = movieRepository.findById(movieId).orElseThrow(
() -> new MovieNotFoundException("없는 영화입니다.")
);
return new GetMovieResponse(
movie.getId(),
movie.getTitle(),
movie.getEmail(),
movie.getPassword(),
movie.getPhoneNumber()
);
}
이렇게되면 GlobalExceptionhHandler에는 공통 부모 커스텀 클래스인 ServiceException만 상속해주고 모두 핸들링 할 수 있음.
@RestControllerAdvice
public class GlobalExceptionHandler {
// MovieNotFoundException 커스텀 에러 핸들링
@ExceptionHandler(ServiceException.class)
public ResponseEntity<String> handleServiceException(ServiceException ex) {
return ResponseEntity
.status(ex.getStatus())
.body(ex.getMessage());
}
}
예를 들어 DuplicateEmailException이라는 새로운 커스텀 에러를 만든다면,
// extends ServerException 중요!
public class DuplicateEmailException extends ServiceException {
public DuplicateEmailException(String message) {
super(HttpStatus.BAD_REQUEST, message);
}
}
이런식으로 공통 부모 커스텀 클래스 ServiceException을 상속해서 만들어주고, 비즈니스 로직에 사용해주기만 하면 된다.
여기서 한 걸음 더 나아가보자,
지금까지 Bean Validation에 대해 배워봤는데, 클라이언트에게 커스텀 에러를 만들어서 구체적인 에러와 자세한 상태코드를 전달할 수 있었지만, 에러 메세지까지는 클라이언트에게 전달 하진 않았다.
예를들면, MovieNotFoundException이라는 커스텀 에러와 404 Not Found 라는 상태코드 까지는 클라이언트에게 전달 했었지만, "없는 영화입니다." 같은 에러 메세지는 전달이 안됐었다.
이 부분은 MethodArgumentNotValidException를 활용하여 에러 메세지까지 전달이 가능하다.
@RestControllerAdvice
public class GlobalExceptionHandelr{
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult().getFieldErrors().stream()
.findFirst() // 첫 번째 에러를 Optional로 가져옴
.map(fieldError -> fieldError.getDefaultMessage()) // 있다면 메시지로 변환
.orElse("입력 값이 올바르지 않습니다."); // 없다면 기본 메시지 사용
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessage);
}