원래 예외 처리를 할 때 가장 기본적인 방식인 if, else 또는 try, catch를 이용하여 서비스단에서 아래와 같이 처리했다.
하지만 이런 경우 서비스의 규모가 커지면 코드가 길어져서 가독성이 떨어지게 된다. 이런 문제점을 해결하기 위하여 @RestControllerAdvice를 이용하는 방법을 찾아 공부하게 됐다.
@RestControllerAdvice과 @ControllerAdvice의 차이점
두 어노테이션의 주요 차이점은 @ResponseBody의 적용 여부이다. @ControllerAdvice는 이 어노테이션을 직접 적용해야 하지만, @RestControllerAdvice는 기본적으로 @ResponseBody가 적용되어 있다.
@ResponseBody는 메소드가 반환하는 값을 HTTP 응답 본문에 포함시키는 역할을 하는데, 이를 통해 JSON이나 XML과 같은 형식으로 데이터를 클라이언트에게 직접 전달할 수 있다.
따라서 @RestControllerAdvice는 주로 RESTful 웹 서비스에서 예외 처리를 위해 사용되며, @ControllerAdvice는 뷰를 반환하는 웹 애플리케이션에서 주로 사용된다.
// 회원 생성
@Transactional
public CreateResponse create(CreateRequest request) {
// 같은 이름의 사용자가 존재할 경우 예외 처리
Optional<EntitySample> exist = repository.findByName(request.getName());
if (exist.isPresent()) {
throw new MemberNameDuplicateException("이미 같은 이름의 사용자가 존재합니다.");
}
// / 예외처리
EntitySample entity = new EntitySample();
entity.setName(request.getName());
return new CreateResponse(repository.save(entity).getId());
}
public class MemberNameDuplicateException extends RuntimeException {
public MemberNameDuplicateException(String message) {
super(message);
}
}
-> 기존의 코드
@ControllerAdvice
@ControllerAdvice를 이용하면 전역 예외 처리를 할 수 있으며 아래와 같은 장점이 있다.
- 하나의 클래스로 모든 컨트롤러에 대한 예외 처리를 할 수 있다.
- 에러의 응답을 일관성 있게 해준다.
- if, else 또는 try, catch를 사용하지 않아 코드의 가독성이 좋아지고 수정하기 용이하다.
하지만 이 어노테이션을 여러 개 사용하게 되면 Spring이 임의의 순서로 에러를 처리할 수 있다. 이를 경우 @Order 어노테이션으로 순서 지정이 가능하다. 하지만 일관된 예외 처리를 위해 다음과 같이 하는 것이 좋다.
- 한 프로젝트당 하나의 ControllerAdvice만 관리하기
- 여러 ControllerAdvice가 필요하다면 basePackages나 어노테이션 등을 지정하기
- 직접 구현한 Exception 클래스들을 한 공간에서 관리하기
@ControllerAdvice를 이용해 예외 처리하기
에러 코드 정의하기
클라이언트에게 전달할 에러 코드를 정의한다.
public interface ErrorCode {
String name();
HttpStatus getHttpStatus();
int getCode();
String getMessage();
}
에러 코드를 상속받아 세밀한 에러 코드를 정의한다.
이때 enum을 이용해 상수를 그룹화하여 더 간편하게 정의해 준다.
@RequiredArgsConstructor을 이용해 final 키워드가 붙은 필드로 구성된 생성자를 자동으로 생성해준다.생성해 준다. -> 각 상수가 생성될 때 HttpStatus, code, message 필드 값을 받는 생성자를 생성해 준다.
따라서 아래와 같은 코드는 USER_NOT_FOUND_ERROR라는 상수를 생성하고, 이 상수의 httpStatus는 HttpStatus.NOT_FOUND, code는 404, message는 "존재하지 않는 사용자입니다."로 초기화한다.
@Getter // interface의 메소드가 get 메소드만 있기 때문에 굳이 재정의 하지 않기 위해 사용
@RequiredArgsConstructor // 상수가 생성될 때 HttpStatus, code, message 필드 값을 받는 생성자를 생성
public enum UserErrorCode implements ErrorCode {
// 상수
USER_NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, 404, "존재하지 않는 사용자입니다."),
USER_ALREADY_EXIST_ERROR(HttpStatus.CONFLICT, 409, "이미 존재하는 이름입니다."),
;
// 필드값
private final HttpStatus httpStatus;
private final int code;
private final String message;
}
예외 응답 클래스 생성
package com.example.demo.customException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.*;
@Getter
@RequiredArgsConstructor
@Builder
public class ErrorResponse {
private final boolean success = false; // error 응답이므로 항상 false
private final HttpStatus httpStatus;
private final int code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_NULL) // errors 필드가 null일 때 JSON 응답에서 제외
private final List<ValidationError> errors;
// ErrorResponse 객체를 생성하는 팩토리 메소드
public static ErrorResponse of(HttpStatus httpStatus, int code, String message) {
return ErrorResponse.builder()
.httpStatus(httpStatus)
.code(code)
.message(message)
.build();
}
public static ErrorResponse of(HttpStatus httpStatus,int code, String message, BindingResult bindingResult){
return ErrorResponse.builder()
.httpStatus(httpStatus)
.code(code)
.message(message)
.errors(ValidationError.of(bindingResult))
.build();
}
@Getter
public static class ValidationError {
private final String field;
private final String value;
private final String message;
// Spring의 FieldError 객체를 받아서 ValidationError 객체를 생성하는 생성자
// FieldError 객체는 필드 검증에 실패한 정보를 담고 있음
private ValidationError(FieldError fieldError) {
this.field = fieldError.getField();
this.value = fieldError.getRejectedValue() == null ? "" : fieldError.getRejectedValue().toString();
this.message = fieldError.getDefaultMessage();
}
// BindingResult 객체를 받아서 ValidationError 객체의 리스트를 생성
// BindingResult 객체는 필드 검증 결과를 담고 있음
// 이 메소드를 통해 필드 검증에 실패한 모든 정보를 ValidationError 객체 리스트로 변환
public static List<ValidationError> of(final BindingResult bindingResult) {
return bindingResult.getFieldErrors().stream().map(ValidationError :: new).toList();
}
}
}
@Valid를 사용했을 때 에러가 발생한 경우, 어느 필드에서 에러가 발생했는지 응답을 위한 ValidationError를 내부 정적 클래스로 추가했다.
Spring에서 제공하는 BindingResult를 이용하면 요청으로 들어온 data에 대한 오류를 간단하게 담을 수 있다. BindingResult는 MethodArgumentNotValidException에 속한다.
BindingResult는 내부의 errors 개수만큼 반복하여 해당 객체를 FieldError라는 형태로 List에 담아 반환하고 있다. 그래서 BindingResult.getFieldErrors()를 하면 FieldError로 이루어진 List를 반환받는다. 해당 에러들을 전부 new ValidationError를 해주고 List형태로 반환하는 것이다.
erros가 없다면 응답으로 내려가지 않도록 JsonInclude 어노테이션을 추가했다. null로 들어오는 값들을 보고 싶지 않을 때 사용하여 조절할 수 있다.
@RestControllerAdvice 구현하기
package com.example.demo.customException;
import javax.persistence.EntityNotFoundException;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.example.demo.customException.common.CommonErrorCode;
import com.example.demo.customException.users.UserErrorCode;
import lombok.extern.slf4j.Slf4j;
// 기존 예외 처리에서 ControllerAdvice를 이용한 로깅 방식으로 변경
@RestControllerAdvice
@Slf4j // 로거 객체 생성 어노테이션
public class ExceptionHandlerAdvice {
// Common
// 모든 에러 -> 하위에서 에러 못 받았을 때
@ExceptionHandler(Exception.class)
public ResponseEntity handleException(Exception e) {
// NestedExceptionUtils.getMostSpecificCause() -> 가장 구체적인 원인, 즉 가장 근본 원인을 찾아서 반환
log.error("[Exception] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorCode code = CommonErrorCode.INTERNAL_SERVER_ERROR;
ErrorResponse response = ErrorResponse.of(code.getHttpStatus(), code.getCode(), code.getMessage());
return ResponseEntity.status(code.getHttpStatus()).body(response);
}
// 메소드가 잘못되었거나 부적합한 인수를 전달했을 경우 -> 필수 파라미터 없을 때
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity HandleIllegalArgumentException(IllegalArgumentException e) {
log.error("[IllegalArgumentException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorCode code = CommonErrorCode.ILLEGAL_ARGUMENT_ERROR;
ErrorResponse response = ErrorResponse.of(code.getHttpStatus(), code.getCode(),
String.format("%s %s", code.getMessage(), NestedExceptionUtils.getMostSpecificCause(e)));
return ResponseEntity.status(code.getHttpStatus()).body(response);
}
//잘못된 포맷 요청 ex)Json으로 안 보냈을 때
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
log.error("[HttpMessageNotReadableException] cause: {}, message: {}", e);
ErrorCode code = CommonErrorCode.INVALID_FORMAT_ERROR;
ErrorResponse response = ErrorResponse.of(code.getHttpStatus(), code.getCode(), code.getMessage());
return ResponseEntity.status(code.getHttpStatus()).body(response);
}
// Users
// 중복 회원 예외 처리 (Custom Exception)
@ExceptionHandler(DuplicateMemberException.class)
public ResponseEntity handleDuplicateMemberException(DuplicateMemberException e) {
log.error("[DuplicateMemberException: Conflict] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorCode code = UserErrorCode.USER_ALREADY_EXIST_ERROR;
ErrorResponse response = ErrorResponse.of(code.getHttpStatus(), code.getCode(), e.getMessage() + code.getMessage());
return ResponseEntity.status(code.getHttpStatus()).body(response);
}
// 해당 멤버가 없을 때
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity handleEntityNotFoundException(EntityNotFoundException e) {
log.error("[EntityNotFoundException] cause: {}, message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorCode code = UserErrorCode.USER_NOT_FOUND_ERROR;
ErrorResponse response = ErrorResponse.of(code.getHttpStatus(), code.getCode(), code.getMessage());
return ResponseEntity.status(code.getHttpStatus()).body(response);
}
}
이제 @RestControllerAdvice가 해당 클래스에 정의된 에러들과 일치하는 에러가 있다면 해당 메소드를 실행한다.
@ExceptionHandler 어노테이션을 사용하고 원하는 Exception클래스를 value로 넘겨주면 해당 Exception이 발생했을 때 메소드가 실행된다. Exception은 포괄적인 Exception이 아닌 최대한 구체적인 Exception을 명시해 주는 것이 좋다.
application.yml에서 로그 레벨을 아래와 같이 설정할 수 있다.
logging:
level:
root: info
com:
example:
demo: debug
(많은 로깅) trace → warn → info → debug → error(적은 로깅)
많은 로깅이 적은 로깅들을 모두 포함하여 출력한다.
보통 개발 서버는 debug, 운영 서버는 info를 사용한다고 한다.
로그 메시지는 log.error(”data = “ + data)가 아니라 log.debug(”data={}”, data)와 같은 방식을 사용하는데 이는 로그 레벨을 info로 설정해도 “data=”+data는 실행이 되어 문자열이 생성된다. 즉 불필요한 연산이 발생한다.
하지만 뒤의 방식은 info로 설정해도 아무 일도 발생하지 않는다. 연산이 발생하지 않아 더 효율적인 방법이다.
서비스단 수정
// 회원 생성
@Transactional
public CreateResponse create(CreateRequest request) {
// 같은 이름의 사용자가 존재할 경우 예외 처리
Optional<EntitySample> exist = repository.findByName(request.getName());
if (exist.isPresent()) {
throw new DuplicateMemberException(request.getName()); // request.getName은 로그에 남음
// 사용자에게 보여지는 오류 메시지는 UserErrorCode에서 정의된 부분
}
EntitySample entity = new EntitySample();
entity.setName(request.getName());
return new CreateResponse(repository.save(entity).getId());
}
// id로 회원 검색
public GetResponse getSample(Long id) {
return new GetResponse(repository.findById(id).orElseThrow(() -> new EntityNotFoundException("해당 회원이 존재하지 않습니다.")).getName());
}
위에서 만들어준 예외를 던져준다.
정리
1. 클라이언트에게 전달할 ErrorCode interface를 정의한다.
2. 이 에러 코드를 상속받아 상세하게 구현한다. ex) CommonErrorCode, UserErrorCode
3. 예외 응답 클래스를 생성한다(ErrorResponse). 클라이언트에게 에러를 원하는 포맷으로 던져주기 위한 클래스이다.
4. @RestControllerAdvice를 구현한다.
5. 로직의 원하는 부분에서 적절하게 예외를 던져준다.
참고 자료
'공부' 카테고리의 다른 글
[JAVA] JWT (JSON Web Token) - Cookie, Session, Token (1) (0) | 2023.11.09 |
---|---|
[Spring] MapStruct 사용하기 (ModelMapper -> mapstruct 변환) (0) | 2023.11.08 |
[QueryDSL] Projections.bean과 fields의 차이 (0) | 2023.11.02 |
[API] Kakao Login 구현하기 (1) (0) | 2023.10.20 |
[SQL] Index (0) | 2023.09.30 |