스프링 부트에서 예외 처리를 구조적이고 에러 메시지를 한 눈에 파악할 수 있게끔 에러 처리를 부분을 커스텀해보자.
통일된 Error Response 객체
에러 발생시 에러 코드 및 메시지를 API로 응답 받습니다. 이 때 일관된 구조 속에서 에러 정보들이 담겨져 있길 바라기 때문에 모든 에러는 하나의 ErrorResponse 객체에 담겨서 응답을 합니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorResponse {
private String message;
private String code; //에러에 할당되는 유니크한 코드값입니다.
@JsonInclude(JsonInclude.Include.NON_NULL)
private List<FieldError> errors;
// 요청 값에 대한 field, value, reason 작성합니다. 일반적으로 @Valid 어노테이션으로 JSR 303: Bean Validation에 대한 검증을 진행 합니다.
//만약 errors에 바인인된 결과가 없을 경우 null이 아니라 빈 배열 []을 응답해줍니다. null 객체는 절대 리턴하지 않습니다. null이 의미하는 것이 애매합니다.
...
ControllerAdvice로 모든 예외를 핸들링
ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHander를 적용해줍니다. ExceptionHander는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있습니다. 에러 응답을 자유롭게 다룰 수 있어 유연합니다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final String ERROR_LOG_MESSAGE = "[ERROR] {} : {}";
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error(ERROR_LOG_MESSAGE, e.getClass().getSimpleName(), e.getMessage(), e);
ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
return ResponseEntity
.status(errorCode.getStatus())
.body(ErrorResponse.of(errorCode));
}
ErrorCode 정의
@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ErrorCode {
// Common
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", " 잘못된 입력 값입니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", " 메소드를 사용할 수 없습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C003", " 서버 에러입니다."),
INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "C004", " 잘못된 타입입니다."),
HANDLE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "C005", " 접근 권한이 없습니다."),
...
에러 코드 및 메시지를 곳에 모아 정리합니다.
Business Exception 처리
비즈니스 로직 수행 중 발생한 예외는 직접 Exception 클래스를 만들어서 예외를 처리합니다. 유지 보수하기 좋은 코드를 만들기 위해 검증 단계에서 Exception을 발생시켜야 합니다. 예를 들어 회원 가입 기능에서 email 검증 단계에서 Exception을 처리하면 다음 단계에서는 비즈니스 로직만 계속 이어나가면 됩니다.
public class MemberService {
@Transactional
public Long createMember(SignupRequest signupRequest) {
String email = signupRequest.getEmail();
validateExistEmail(email);
Member member = Member.builder()
.email(new Email(email))
.password(Password.encryptPassword(passwordEncoder, signupRequest.getPassword()))
.nickName(new NickName(signupRequest.getNickName()))
.grade(Grade.BRONZE)
.point(new Point(0L))
.build();
return memberRepository.save(member).getId();
}
private void validateExistEmail(String email) {
if (memberRepository.existsByEmailValue(email)) {
throw new DuplicateEmailException(email, ErrorCode.DUPLICATE_EMAIL);
}
}
...
서비스에서 DuplicateEmailException 발생.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(String message, ErrorCode errorCode) {
super(message);
this.errorCode = errorCode;
}
}
---
public class DuplicateEmailException extends BusinessException {
private static final String ERROR_MESSAGE_FORMAT = "중복된 이메일입니다. 현재 이메일 : {0}";
public DuplicateEmailException(String email, ErrorCode errorCode) {
super(MessageFormat.format(ERROR_MESSAGE_FORMAT, email), errorCode);
}
}
DuplicateEmailException은 BusinessException의 자식이다.
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.warn(ERROR_LOG_MESSAGE, e.getClass().getSimpleName(), e.getMessage(), e);
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity
.status(errorCode.getStatus())
.body(ErrorResponse.of(errorCode));
}
BusinessException이 발생했으니 ExceptionHandler가 붙어 있는 handleBusinessException 메소드에서 에러 반환 처리를 담당한다.
최상위 BusinessException 클래스

최상위 BuisinessException 기준으로 예외를 발생시켜 통일감있게 예외 처리합니다.
Controller Exception
컨트롤러에서 모든 요청에 대한 값을 검증합니다. 이상이 없을 시에 서비스 레이어를 호출해야 합니다.
@Operation(summary = "Signup", description = "signup API")
@PostMapping("/signup")
public ResponseEntity<Void> signUp(@Valid @RequestBody SignupRequest signupRequest) {
memberService.createMember(signupRequest);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SignupRequest {
@Schema(description = "이메일")
@Email(message = "올바른 이메일 형식이 아닙니다.")
@NotBlank(message = "이메일은 비어있을 수 없습니다.")
private String email;
@Schema(description = "비밀번호")
@NotBlank(message = "비밀번호는 비어있을 수 없습니다.")
private String password;
@NotBlank(message = "닉네임은 비어있을 수 없습니다.")
private String nickName;
public SignupRequest(String email, String password, String nickName) {
this.email = email;
this.password = password;
this.nickName = nickName;
}
}
참고
- https://github.com/cheese10yun/spring-guide/blob/master/docs/exception-guide.md
- https://mangkyu.tistory.com/204
'spring' 카테고리의 다른 글
| 동시에 재고 감소 요청이 들어왔을 때의 문제와 해결 방법 (0) | 2023.08.24 |
|---|---|
| nestjs 맛보기 그리고 spring boot (0) | 2023.08.08 |
| github action + s3 + code deploy + docker + nginx 사용해서 배포하기 (0) | 2023.07.30 |
| Spring Boot gradle (0) | 2023.07.29 |