우선 해당 어노테이션을 사용하기 전에 기존의 프로젝트들은 모두 if,else 또는 try,catch 등을 사용하여 예외 처리를 했었다. 이런 경우 제공하는 서비스의 규모가 커질수록 복잡해지고 코드도 길어져서 가독성이 떨어지고 수정하기 매우 힘들었다. 해당 문제점들을 해결하기위해 에러 핸들링 방법들을 찾아보던 중 @ControllerAdvice를 찾아 사용하게되었다.
@ControllerAdvice & @RestControllerAdvice
Spring은 전역적으로 예외처리를 할 수 있는 @ControllerAdvice를 Spring 3.2 부터, @RestControllerAdvice를 Spring4.3 부터 제공하고 있다. 두 어노테이션의 차이는 @Controller와 @RestController의 차이와 같다. 즉 @RestControllerAdvice는 @ControllerAdvice + @ResponseBody의 기능을 가지고 있어 Json으로 응답을 한다.
@ControllerAdvice
ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다.
위의 ControllerAdvice 인터페이스에 보이듯이 @Component 어노테이션이 있기 때문에 ControllerAdvice 어노테이션이 선언된 클래스는 Spring Bean으로 등록된다. 따라서 아래와 같이 에러를 핸들링하는 클래스를 만들어 어노테이션을 붙임으로써 에러 처리를 위임할 수 있다.
장점
- 모든 컨트롤러에 대한 예외처리를 하나의 클래스로 할 수 있다.
- 일관성있는 에러 응답을 보내줄 수 있다.
- if,else 또는 try,catch를 사용하지 않아 코드의 가독성이 좋아지고 수정하기 용이하다.
주의점
ControllerAdvice를 여러개 사용하면 Spring이 임의의 순서로 에러를 처리할 수 있다. 이를 해결하기 위해 @Order 어노테이션으로 순서 지정이 가능하다. 하지만 일관된 예외 처리를 위해서는 다음과 같이 하는 것이 좋다.
- 한 프로젝트당 하나의 ControllerAdvice만 관리하기
- 여러 ControllerAdvice가 필요하다면 basePackages나 어노테이션 등을 지정하기
- 직접 구현한 Exception 클래스들을 한 공간에서 관리하기
@RestControllerAdvice로 예외 처리하기
에러 코드 정의하기
먼저 클라이언트에게 보내줄 에러 코드를 정의해야 한다. 에러 이름, HttpStatus code, 에러 메세지를 가지고 있는 에러 코드 클래스를 만들어 보겠다.
에러 코드는 공통 에러와 특정 도메인에 대한 에러로 나누고 인터페이스를 이용해서 추상화하여 작성해보았다.
exception/ErrorCode(member 모듈)
package com.seungh1024.exception;
import org.springframework.http.HttpStatus;
public interface ErrorCode {
String name();
HttpStatus getHttpStatus();
int getCode();
String getMessage();
}
name()은 아래의 enum으로 정의한 요소의 값을 문자열로 리턴해줄 것이다.
나머지 메소드들은 각각 status, status code, message를 리턴해줄 것이다.
exception/common/CommonErrorCode
package com.seungh1024.exception.common;
import com.seungh1024.exception.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode {
INVALID_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, 400, "올바르지 않은 파라미터입니다."),
INVALID_FORMAT_ERROR(HttpStatus.BAD_REQUEST,400, "올바르지 않은 포맷입니다."),
INVALID_TYPE_ERROR(HttpStatus.BAD_REQUEST, 400, "올바르지 않은 타입입니다."),
ILLEGAL_ARGUMENT_ERROR(HttpStatus.BAD_REQUEST, 400, "필수 파라미터가 없습니다"),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 500, "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
;
private final HttpStatus httpStatus;
private final int code;
private final String message;
}
공통적으로 사용되는 에러 코드이다.
요청이 들어왔을 때 잘못된 경우들이나 서버 에러 등을 정의할 수 있다.
httpStatus와 message를 필드로 가지고 @Getter 어노테이션을 사용함으로써 implements한 메소드들을 굳이 코드로 재정의하지 않았다. → 상속한 인터페이스를 getter메소드가 네이밍하는 것과 같이 만들었기 때문
exception/member/MemberErrorCode
package com.seungh1024.exception.member;
import com.seungh1024.exception.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public enum MemberErrorCode implements ErrorCode {
MEMBER_NOT_FOUND_ERROR(HttpStatus.NOT_FOUND,404,"존재하지 않는 사용자입니다"),
MEMBER_ALREADY_EXISTS_ERROR(HttpStatus.CONFLICT,409, "는 이미 존재하는 회원입니다"),
INACTIVE_USER_ERROR(HttpStatus.FORBIDDEN, 403,"권한이 없는 사용자입니다"),
;
private final HttpStatus httpStatus;
private final int code;
private final String message;
}
사용자 관련 에러코드이다.
필요한 것들을 추가적으로 생성하며 사용하면 된다.
Checked Exception, Unchecked Exception
추가적으로 코드를 작성하기에 앞서 Checked, Unchecked Exception이 뭔지 알고 가야한다.
Checked Exception
Exception 클래스의 하위 클래스들이다. 복구 가능성이 있는 예외 이므로 예외를 처리할 코드를 함께 작성해야 한다. try catch 또는 throws를 통해 메소드 밖으로 던져서 처리할 수 있다.
만약 예외처리를 하지 않으면 컴파일 에러가 발생한다.
예시로는
- IOException
- SQLException
Unchecked Exception
RuntimeException의 하위 클래스들을 의미한다.
이 RuntimeException 클래스를 상속받는 예외 클래스들은 복구 가능성이 없는 예외들이므로 컴파일러가 예외처리를 강제하지 않는다. 에러 처리를 하지 않아도 컴파일 에러가 발생하지 않는다. 즉 런타임 에러는 예상못한 상황에서 발생한 것이 아니기 때문에 굳이 예외 처리를 강제하지 않는다.
예시로는
- NullPointerException
- IllegalArgumentException
- SystemException
차이
Spring이 내부적으로 발생한 예외를 확인해서 Unchecked Exception은 자동으로 롤백시키도록 처리한다.
Checked Exception을 롤백하지 않는 이유는 얘는 처리가 강제되기 때문에 개발자가 무엇인가를 처리할 것이라는 기대 때문이다.
위의 표를 보면 Checked Exception의 대표적인 예시로 SQLException이 있는데 얘는 롤백되지 않으므로 나중에 여러 쿼리를 동시에 사용할 때 이런 에러 처리를 잘 해야한다.
직접 에러 코드 정의하기
IllegalArgumentException 처럼 기존에 있는 에러와 달리 발생한 예외를 처리해줄 예외 클래스를 추가해서 사용할 수도 있다.
package com.seungh1024.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException{
private final ErrorCode errorCode;
}
여기서 Unchecked Exception을 상속받도록 한 이유는 일반적인 비즈니스 로직들은 따로 try, catch를 사용하여 처리할 것이 없기 때문이다.
과거에는 체크 예외를 많이 사용했지만 최근에는 거의 모든 경우에 언체크 예외를 사용한다고 한다.
에러 응답 클래스 생성
{
"success": false,
"httpStatus": "CONFLICT",
"code": 409,
"message": "test123324@naver.com는 이미 존재하는 회원입니다"
}
클라이언트에게 위와 같은 포맷의 에러를 던져주고 싶다.
success는 클라이언트가 if(data.success) 이렇게 접근하면 조금 더 도움이 될 것 같아 넣었다.
아래는 위와 같은 형식의 응답을 보내기 위한 응답 클래스이다.
ErrorResponse(common 모듈)
package com.seungh1024;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import java.util.List;
@Getter
@RequiredArgsConstructor
@Builder
public class ErrorResponse {
private final boolean success = false;
private final HttpStatus httpStatus;
private final int code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_NULL)
private final List<ValidationError> errors;
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;
private ValidationError(FieldError fieldError){
this.field = fieldError.getField();
this.value = fieldError.getRejectedValue() == null? "" :fieldError.getRejectedValue().toString() ;
this.message = fieldError.getDefaultMessage();
}
public static List<ValidationError> of(final BindingResult bindingResult){
return bindingResult.getFieldErrors().stream()
.map(ValidationError :: new)
.toList();
}
}
}
이전에 하지 않았던 valid 체크를 할 예정이기 때문에 @Valid를 사용했을 때 에러가 발생한 경우 어느 필드에서 에러가 발생했는지 응답을 하기 위한 ValidationError를 내부 정적 클래스로 추가했다.
Spring에서 제공하는 BindingResult를 이용하면 요청으로 들어온 data에 대한 오류를 간단하게 담을 수 있다. BindingResult는 MethodArgumentNotValidException에 속한다.
BindingResult는 내부의 errors 개수만큼 반복하여 해당 객체를 FieldError라는 형태로 List에 담아 반환하고 있다. 그래서 BindingResult.getFieldErrors()를 하면 FieldError로 이루어진 List를 반환받는다. 해당 에러들을 전부 new ValidationError를 해주고 List형태로 반환하는 것이다.
그리고 erros가 없다면 응답으로 내려가지 않도록 JsonInclude 어노테이션을 추가했다. null로 들어오는 값들을 보고싶지 않을 때 사용하여 조절할 수 있다.
아래는 JsonInclude의 속성들이다
- JsonInclude.Include.ALWAYS
- 모든 데이터를 JSON으로 변환한다.
- JsonInclude.Include.NON_NULL
- null인 데이터는 제외한다.
- JsonInclude.Include.NON_ABSENT
- null인 데이터를 제외한다.
- 참조 유형참조 유형 (Java 8 'Optional'또는 {link java.utl.concurrent.atomic.AtomicReference})의 "absent"값; 즉, null이 아닌 값은 제외한다 → 뭔소린지 잘 모르겠다..
- JsonInclude.Include.NON_EMPTY
- null인 데이터는 제외한다.
- 3의 absent는 제외한다
- Collection, Map의 isEmpty() == true 이면 제외한다.
- Array의 길이가 0이면 제외한다.
- String의 length()가 0이면 제외한다.
- JsonInclude.Include.NON_DEFAULT
- Collection, Map의 isEmpty() == true 이면 제외한다.
- primitive타입이 default 값이면 제외한다. (int → 0, boolean →false 등의 기본값이면 제외)
- Date의 timestampe가 0L 이면 제외한다.
@RestControllerAdvice 구현하기
마지막으로 전역적으로 에러를 처리해줄 클래스를 생성하고 @RestControllerAdvice를 붙여주면 된다.
exception/ExceptionHandlerAdvice
package com.seungh1024.exception;
import com.seungh1024.ErrorResponse;
import com.seungh1024.exception.common.CommonErrorCode;
import com.seungh1024.exception.custom.DuplicateMemberException;
import com.seungh1024.exception.custom.RestApiException;
import com.seungh1024.exception.member.MemberErrorCode;
import jakarta.persistence.EntityNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class ExceptionHandlerAdvice {
//모든 에러 -> 하위 에러에서 못받을 때
@ExceptionHandler(Exception.class)
public ResponseEntity handleException(Exception e){
// NestedExceptionUtils.getMostSpecificCause() -> 가장 구체적인 원인, 즉 가장 근본 원인을 찾아서 반환
log.error("[Exception] cause: {} , message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(), errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
@ExceptionHandler(RestApiException.class)
public ResponseEntity handleSystemException(RestApiException e){
log.error("[SystemException] cause: {}, message: {}",NestedExceptionUtils.getMostSpecificCause(e),e.getMessage());
ErrorCode errorCode = e.getErrorCode();
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(),errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
//메소드가 잘못되었거나 부적합한 인수를 전달했을 경우 -> 필수 파라미터 없을 때
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e){
log.error("[IlleagalArgumentException] cause: {} , message: {}",NestedExceptionUtils.getMostSpecificCause(e),e.getMessage());
ErrorCode errorCode = CommonErrorCode.ILLEGAL_ARGUMENT_ERROR;
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(),errorCode.getCode(),
String.format("%s %s", errorCode.getMessage(), NestedExceptionUtils.getMostSpecificCause(e).getMessage()));
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
//@Valid 유효성 검사에서 예외가 발생했을 때 -> requestbody에 잘못 들어왔을 때
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
log.error("[MethodArgumentNotValidException] cause: {}, message: {}",NestedExceptionUtils.getMostSpecificCause(e),e.getMessage());
ErrorCode errorCode = CommonErrorCode.INVALID_ARGUMENT_ERROR;
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(),
errorCode.getCode(),
errorCode.getMessage(),
e.getBindingResult());
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
//잘못된 포맷 요청 -> Json으로 안보내다던지
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e){
log.error("[HttpMessageNotReadableException] cause: {}, message: {}",NestedExceptionUtils.getMostSpecificCause(e),e.getMessage());
ErrorCode errorCode = CommonErrorCode.INVALID_FORMAT_ERROR;
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(), errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
//중복 회원 예외처리
@ExceptionHandler(DuplicateMemberException.class)
public ResponseEntity handleHttpClientErrorException(DuplicateMemberException e){
log.error("[DuplicateMemberException : Conflict] cause: {}, message: {}",NestedExceptionUtils.getMostSpecificCause(e),e.getMessage());
ErrorCode errorCode = MemberErrorCode.MEMBER_ALREADY_EXISTS_ERROR;
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(),errorCode.getCode(), e.getMessage()+ errorCode.getMessage());
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity handleEntityNotFoundException(EntityNotFoundException e){
log.error("[EntityNotFoundException] cause:{}, message: {}", NestedExceptionUtils.getMostSpecificCause(e),e.getMessage());
ErrorCode errorCode = MemberErrorCode.MEMBER_NOT_FOUND_ERROR;
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(),errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse);
}
}
이제 @RestControllerAdvice가 해당 클래스에 정의된 에러들과 일치하는 에러가 있다면 해당 메소드를 실행한다.
@ExceptionHandler 어노테이션을 사용하고 원하는 Exception클래스를 value로 넘겨주면 해당 Exception이 발생했을 때 메소드가 실행된다. Exception은 포괄적인 Exception이 아닌 최대한 구체적인 Exception을 명시해 주는 것이 좋다.
우선 맨 위의 두 메소드는 Exception, RestApiException에 대한 예외처리를 하는데 Exception의 경우 아래의 다른 예외들과 일치하지 않을 경우 로그를 찍어서 보기 위해 작성했다. 또한 RestApiException은 RuntimeException을 상속받았기 때문에 해당 관련 예외 처리를 했다.
메소드의 내용은 대부분 동일하며 NestedExceptionUtils.getMostSpecificCause(e) 는 해당 Exception에서 가장 구체적인 원인을 알려준다.
MethodArgumentNotValidException의 경우 e.bindingResult()를 ErrorResponse에 넣어주어서 유효성 검사에서 어떤 것들이 잘못되었는지 볼 수 있게 하였다. 이건 나중에 유효성 검사를 추가하고 테스트해야겠다.
형식은 모두 같은 형식으로 ErrorCode만든 것을 처리할 에러 코드로 불러오고 ErrorResponse객체를 생성해주고 리턴하는 것이다.
DuplicateMemberException의 경우 중복된 회원 예외 처리이다 해당 예외는 따로 클래스로 만들었다.
RestApiException 외에 커스텀 Exception이 생겨 커스텀 Exception의 경우 custom 패키지를 생성하여 하위에 넣었다.
exception/custom/DuplicateMemberException
package com.seungh1024.exception.custom;
import com.seungh1024.exception.ErrorCode;
import lombok.Getter;
import org.springframework.dao.DuplicateKeyException;
@Getter
public class DuplicateMemberException extends DuplicateKeyException {
public DuplicateMemberException(String msg) {
super(msg);
}
}
추가로 @Slf4j 어노테이션은 lombok에서 제공하는 로깅을 하게 해주는 어노테이션이다. application.yml에 설정을 아래와 같이 추가한다
logging:
level:
com.seungh1024: error
옵션으로 level말고 file도 줄 수 있다. 로그를 기록할 파일 위치, 삭제 주기 등을 설정할 수 있다.
그리고 로그 레벨이 있는데 (많은 로깅) trace → warn → info → debug → error(적은 로깅) 순이다.
많은 로깅이 적은 로깅들을 모두 포함하여 출력한다.
저기 로그 레벨을 trace로하면 해당 패키지의 로그가 별의 별 것이 다 찍힐 것이다.
보통 개발 서버는 debug, 운영 서버는 info를 사용한다고 한다.
로그 메세지는 log.error(”data = “ + data)가 아니라 log.debug(”data={}”,data)와 같은 방식을 사용하는데 이는 로그 레벨을 info로 설정해도 “data=”+data는 실행이되어 문자열이 생성된다. 즉 불필요한 연산이 발생한다.
하지만 뒤의 방식은 info로 설정해도 아무일도 발생하지 않는다. 연산이 발생하지 않아 더 효율적인 방법이다.
이제 서비스클래스를 수정해보자
service/MemberService
public Member createMember(MemberDto.MemberJoinRequest memberDto){
int emailCheck = memberRepository.countMemberByMemberEmail(memberDto.getMemberEmail());
if(emailCheck > 0){ // 1이상이면 중복되는 누군가 있는 것
throw new DuplicateMemberException(memberDto.getMemberEmail());
}
Member member = Member.builder()
.memberEmail(memberDto.getMemberEmail())
.memberPassword(memberDto.getMemberPassword())
.memberName(memberDto.getMemberName())
.build();
member = memberRepository.save(member);
return member;
}
회원가입 메소드만 살펴보겠다.
사실 이게 맞는 방식인지는 잘 모르겠다. try,catch가 없으니 확실히 깔끔해 보이긴 하지만 만약 체크할게 많아서 if문이 많아진다면 또 문제가 되지 않을까? 라는 생각이 있다.
코드는 countMemberByMemberEmail이라는 메소드를 만들어서 이메일로 검색한걸 카운트하게 했다.
→ Integer countMemberByMemberEmail(String memberEmail); 이거 한 줄을 MemberRepository에 추가하면 된다.
1이상이면 중복되는 사람이 있다는 뜻이니 throw new를 통해 에러를 던졌다. 이러면 던진 에러가 내가 작성한 에러 핸들링 클래스로 가서 처리해줄 것이다.
이제 테스트를 해보면
이렇게 내가 작성한 에러메세지가 잘 나오는 것을 확인할 수 있다.
우선 여러 에러들로 에러 핸들링을 작성했는데 테스트를 많이 해보며 추가적으로 발생하는 에러들을 이렇게 생길 때마다 추가해주며 처리하면 된다.
다음엔 유효성 검사를 추가하고 해당 예외처리도 잘 되는지 확인해야겠다.
'Java > Spring boot 프로젝트' 카테고리의 다른 글
@Valid를 이용한 객체 유효성 검증 - 게시판 만들기(8) (0) | 2023.03.17 |
---|---|
Spring Entity와 DTO 구분하기 -게시판 만들기(7) (0) | 2023.03.16 |
Spring 공통 응답 만들기(Enum, 제네릭 타입) - 게시판 만들기(5) (0) | 2023.02.20 |
JPA 레포지터리, Member CRUD - 게시판 만들기(4) (0) | 2023.02.20 |
Spring Boot JPA 설정, 테이블 생성(Entity) - 게시판 만들기(3) (2) | 2023.02.19 |