앞선 프로젝트 일지를 통해 @Valid 및 RuntimeException에 대해서 간략히나마 알아봤다.
🧩 23.12.26. 유효성 검증 및 에러 핸들링 시작
이제 실제 유효성 검증 코드와 이를 보기 좋게 확인하기 위한 응답 메시지를 출력하는 코드를 보이고자 한다.
@Valid 어노테이션은 Bean Validation 스펙에 기반한 데이터 유효성 검증을 지원한다. 이 어노테이션은 주로 컨트롤러 메서드의 매개변수에 사용되며, 해당 매개변수에 대한 유효성 검증을 활성화한다. 이러한 @Valid 어노테이션을 사용하기 위해서는 build.gradle에 의존성을 추가해주어야 한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
이때 @Valid 어노테이션이 주로 @RequestBody 앞에 붙이는 이유는 해당 어노테이션이 HTTP 요청의 본문을 파싱하고 해당 객체로 변환하는 작업을 수행하기 때문이다. 이렇게 파싱된 객체에 대해 유효성 검사를 수행하고자 할 때 @Valid를 사용할 수 있다.
반면 메서드의 매개변수로 HTTP 요청의 헤더나 파라미터를 받아올 때 사용되는 @RequestHeader와 @RequestParam은 주로 간단한 값들을 받아오는 용도로 쓰이며, 객체로의 변환이 필요하지 않기 때문에 @Valid 어노테이션은 일반적으로 이러한 단순한 값들을 받아오는 경우에는 적용되지 않는다. 즉, 간단한 값을 받아올 때는 @Valid를 사용하지 않아도 되지만, 복잡한 객체를 받아올 때는 @RequestBody를 통해 해당 객체로 변환된 후에 @Valid를 사용하여 유효성을 검사할 수 있다.
@Valid 적용은 아래 코드와 같이 할 수 있다.
@PutMapping("/api/myprofile")
public ResponseEntity<MyProfileViewReponse> editMyProfile(@Valid @RequestBody ProfileEditRequest request,
@RequestHeader("userUuid") String userUuid) {
if (userUuid.isEmpty()) {
throw new NoSuchElementException(messageUtil.getUserUuidEmptyMessage());
}
/** 생략 **/
return ResponseEntity.ok(myProfileViewResponse);
}
그럼 이제 유효성 검증 테스트를 하기 위해 응답 메시지를 어떻게 출력하는지 알아보자.
응답 메시지는 error.properties 또는 messages.properties를 통해 메시지 내용을 리터럴 변수와 같이 읽어올 수 있다. 그리고 이 *.properties 파일을 인식하기 위해 application.yaml에 설정을 추가해야 한다.
error.properties 예시는 다음과 같다.
request.required=필수값 입니다.
request.empty.userUuid=요청으로 들어온 userUuid가 존재하지 않습니다.
request.empty.mentoringUuid=요청으로 들어온 mentoringUuid가 존재하지 않습니다.
request.nosuch.userUuid=해당하는 유저가 존재하지 않습니다. [ userUuid = [{0}] ]
request.overlap.schedule.create=해당 시간에 다른 스케줄이 있습니다.
request.error.file-upload.unsupported=지원되지 않는 확장자입니다. png, jpg, jpeg 확장자만 지원 가능합니다.
runtime.error.profile.create=프로필 생성 중 에러가 발생했습니다.
runtime.error.profile.update=프로필 상태 변경 중 에러가 발생했습니다.
runtime.error.calendar.create=캘린더 생성 중 에러가 발생했습니다.
runtime.error.schedule.create=스케줄 생성 중 에러가 발생했습니다.
runtime.error.schedule.cancel=스케줄 취소 중 에러가 발생했습니다.
messages.properties도 마찬가지로 위의 방식과 같이 작성해주면 된다. 그리고 application.yaml에는 다음과 같이 설정을 추가해주면 된다.
spring:
messages:
basename: errors, messages
이어서 해당 메시지를 유틸리티 객체로서 사용할 수 있게 하기 위해 MessageUtil을 만들었다.
@RequiredArgsConstructor
public class MessageUtil {
private final MessageSource messageSource;
/** 생략 **/
public String getUserUuidEmptyMessage() {
return getMessage("request.empty.userUuid");
}
public String getMentoringUuidEmptyMessage() {
return getMessage("request.empty.mentoringUuid");
}
public String getUserUuidNoSuchMessage(String userUuid) {
return getMessage("request.nosuch.userUuid", new String[] {userUuid});
}
public String getScheduleCreateOverlapMessage() {
return getMessage("request.overlap.schedule.create");
}
/** 생략 **/
private String getMessage(String messageCode) {
return messageSource.getMessage(messageCode, new String[]{}, Locale.KOREA);
}
private String getMessage(String messageCode, String[] parameters) {
return messageSource.getMessage(messageCode, parameters, Locale.KOREA);
}
}
참고로 MessageSource.java는 Spring의 내장 인터페이스로서 다음 메소드들을 갖고 있다.
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
그럼 이제 @RestControllerAdvice에 대해 알아보자.
@RestControllerAdvice는 컨트롤러에서 발생하는 예외를 전역적으로 처리하는 데 사용된다. 주로 예외 처리 관련 로직을 효율적으로 구현하고 중복을 줄이기 위해 활용된다. 일반적으로 예외 처리는 각각의 컨트롤러에서 처리되지만, @RestControllerAdvice를 사용하면 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있다. 이를 통해 예외 처리 로직을 중앙 집중화하여 유지보수성을 높이고 일관된 방식으로 예외를 처리할 수 있다.
주요 특징은 다음과 같다.
1. 전역 예외 처리
:@RestControllerAdvice를 사용하면 모든 컨트롤러에서 발생하는 예외를 처리할 수 있다.
2.@ExceptionHandler메서드 사용
:@RestControllerAdvice클래스 내부에@ExceptionHandler어노테이션이 부여된 메서드를 정의하여 특정 예외 타입에 대한 처리 로직을 작성한다.
3. 특정 패키지나 어노테이션 기반의 적용
:basePackages또는basePackageClasses속성을 사용하여 특정 패키지나 어노테이션이 부여된 컨트롤러에만 적용할 수 있다.
이어서 @ExceptionHandler 어노테이션에 대해서도 알아보자.
@ExceptionHandler는 Spring 프레임워크에서 예외 처리를 담당하는 메서드에 부여되는 어노테이션으로, 이를 사용하면 특정 예외가 발생했을 때 해당 메서드가 실행되어 예외를 처리할 수 있다. 일반적으로 @ExceptionHandler는 @ControllerAdvice 또는 @RestControllerAdvice 어노테이션이 부여된 클래스 내부의 메서드에서 사용된다.
주요 특징은 다음과 같다.
1. 특정 예외 타입에 대한 처리
:@ExceptionHandler는 특정 예외 타입을 매개변수로 받아 처리하는 메서드를 지정한다.
2. 전역 예외 처리 또는 특정 컨트롤러에만 적용
:@ControllerAdvice또는@RestControllerAdvice어노테이션이 부여된 클래스에@ExceptionHandler를 사용하면 모든 컨트롤러에서 발생하는 예외를 처리할 수 있다. 또한 특정 컨트롤러 클래스 내에@ExceptionHandler를 사용하여 해당 컨트롤러에서 발생한 예외만을 처리할 수도 있다.
3. HTTP 응답 상태 및 메시지 지정
:@ExceptionHandler메서드 내에서 처리된 예외에 대한 HTTP 응답 상태 코드나 메시지 등을 지정할 수 있다.
그럼 이제는 @RestControllerAdvice와 @ExceptionHandler를 사용한 다음 코드를 살펴보자.
@RestControllerAdvice(basePackages = "com.goorm.devlink.profileservice.controller")
public class ControllerExceptionAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResult> getHandler(MethodArgumentNotValidException exception, HttpServletRequest request) {
return new ResponseEntity<>(ErrorResult.getInstance(getMethodArgumentNotValidMessage(exception), request.getRequestURL().toString()),
HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MissingRequestHeaderException.class)
public ResponseEntity<ErrorResult> getHandler(MissingRequestHeaderException exception, HttpServletRequest request) {
return new ResponseEntity<>(ErrorResult.getInstance(exception.getMessage(), request.getRequestURL().toString()), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResult> getHandler(MissingServletRequestParameterException exception, HttpServletRequest request) {
return new ResponseEntity<>(ErrorResult.getInstance(exception.getMessage(), request.getRequestURL().toString()), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ErrorResult> getHandler(NoSuchElementException exception, HttpServletRequest request) {
return new ResponseEntity<>(ErrorResult.getInstance(exception.getMessage(), request.getRequestURL().toString()), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResult> getHandler(RuntimeException exception, HttpServletRequest request) {
return new ResponseEntity<>(ErrorResult.getInstance(exception.getMessage(), request.getRequestURL().toString()), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(UnsupportedMediaTypeStatusException.class)
public ResponseEntity<ErrorResult> getHandler(UnsupportedMediaTypeStatusException exception, HttpServletRequest request) {
return new ResponseEntity<>(ErrorResult.getInstance(exception.getMessage(), request.getRequestURL().toString()), HttpStatus.BAD_REQUEST);
}
private List<String> getMethodArgumentNotValidMessage(MethodArgumentNotValidException ex){
ArrayList<String> errorMessages = new ArrayList<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errorMessages.add("[" + fieldError.getField() +"]는(은) " + fieldError.getDefaultMessage() +
" [ 입력된 값 : " + fieldError.getRejectedValue() + " ]");
}
return errorMessages;
}
}
MethodArgumentNotValidException, MissingRequestHeaderException 등 예외 클래스를 @ExceptionHandler로 잡아서 해당 객체를 파라미터로 넘겨주고 ErrorReuslt 객체에 메시지와 요청 URL, HTTP 상태코드를 넣어주면 에러 또는 메시지가 출력되는 적절한 이벤트가 발생했을 때 ErrorResult 객체 형태로 응답값을 반환한다.
ErrorResult 객체의 코드는 아래와 같다.
@Builder
@Getter
public class ErrorResult {
private String requestUrl;
private LocalDateTime timestamp;
private String errorMessage;
private List<String> errorMessages;
public static ErrorResult getInstance(String errorMessage, String requestUrl) {
return ErrorResult.builder()
.requestUrl(requestUrl)
.errorMessage(errorMessage)
.timestamp(LocalDateTime.now())
.build();
}
public static ErrorResult getInstance(List<String> errorMessages, String requestUrl) {
return ErrorResult.builder()
.requestUrl(requestUrl)
.errorMessages(errorMessages)
.timestamp(LocalDateTime.now())
.build();
}
}
메시지 출력 결과를 Postman을 통해 확인하면 다음과 같이 확인할 수 있다.
