JVM/Spring

[Spring] Exception 어떻게 처리할까?

Hyo Kim 2022. 6. 26. 15:00
728x90
반응형

🤔 서론

Exception Handler 를 구현하다 리펙토링 한 경험을 작성합니다.


🫢 본론

Custom Exception

기존 Exception 은 상황에 맞게 각각 Custom Exception 을 만들어서 처리를 했습니다.

 

문제점

Exception Handler

Exception 마다 각각의 Handler 를 만들어주어야 했습니다.

클래스는 통합해서 작성해도 되지만, 여기서 중요한 건

만든 각각의 Custom Exception 별로 별도의 Handler를 추가로 계속 작성해주어야만 했습니다.

 

중복된 코드

private static final String MESSAGE = "패스워드가 일치하지 않습니다";

@ExceptionHandler(value = {PasswordMismatchException.class, BadCredentialsException.class})
protected ResponseEntity<ErrorResponseDTO> handleException(RuntimeException e) {
    ErrorResponseDTO errorDTO = ErrorResponseDTO.of(e.getClass().getSimpleName(), List.of(MESSAGE));
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorDTO);
}

@ExceptionHandler(value = EmailNotFoundException.class)
protected ResponseEntity<ErrorResponseDTO> handleException(EmailNotFoundException e) {
    ErrorResponseDTO errorDTO = ErrorResponseDTO.of(e.getClass().getSimpleName(), List.of(e.getMessage()));
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorDTO);
}

거의 모든 코드가 동일하지만, 클라이언트에 내려주어야 하는 Http Status Code 가 다르기 때문입니다. (단지, 그 뿐..)

 

이 방식을 계속 유지하면서 진행하면, 만든 Exception 에 대해서

Handler를 중복해서 만들게 될 것 같아 리펙토링을 진행했습니다.


리펙토링 진행

ErrorCode 정의

@Getter
@AllArgsConstructor
public enum ErrorCode {

	/* 400 BAD_REQUEST : 잘못된 요청 */
	MISMATCH_PASSWORD(BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
	TOKEN_TYPE(BAD_REQUEST, "토큰 타입이 올바르지 않습니다."),
	UNAVAILABLE_REFRESH_TOKEN(BAD_REQUEST, "사용할 수 없는 토큰 입니다."),

	/* 404 NOT_FOUND : Resource 를 찾을 수 없음 */
	EMAIL_NOT_FOUND(NOT_FOUND, "해당 이메일을 찾을 수 없습니다."),
	REFRESH_TOKEN_NOT_FOUND(NOT_FOUND, "리프레쉬 토큰을 찾을 수 없습니다."),

	/* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */
	DUPLICATE_EMAIL(CONFLICT, "이메일이 이미 존재합니다."),
	DELETED_EMAIL(CONFLICT, "이미 삭제된 이메일 입니다.");

	private final HttpStatus httpStatus;
	private final String detail;

}

각각의 Custom Exception 을 만들었던 이유는

어떤 예외인지 Class 명을 보고 빠르게 판단하기 위해
MESSAGE를 내부에서 정의하여 재사용성을 높이기 위해

 

이렇게 두 가지가 있습니다.

 

장점을 포함하며, 단점이었던 HttpStatus 를 개별 처리해야하는 걸 enum 으로 한 번에 묶었습니다.

이렇게 진행하게 되면 Custom Exception 을 만든 이유와 HttpStatus 세 개 다 포함할 수 있게 됩니다.

 

어디서 사용할까?

@Getter
@AllArgsConstructor
public class TicketingException extends RuntimeException {

	private final ErrorCode errorCode;

}

Custom Exception 을 공통으로 하나를 만듭니다.

ErrorCode 를 품은 Exception 입니다.

 

Exception 을 던질 때 TicketingException 을 공통으로 사용하고,

상황에 맞게 ErrorCode를 정의해주는 형식으로 변경했습니다.

 

Handler 정의

@ExceptionHandler(value = TicketingException.class)
protected ResponseEntity<ErrorResponse> ticketingException(TicketingException ex) {
    log.error("TicketingException :: ", ex);

    ErrorCode errorCode = ex.getErrorCode();
    return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.toErrorResponse(errorCode));
}

짠!

이젠 공통으로 정의한 TicketingException 에 해당하는 하나의 Handler만을 정의해주면 됩니다!

ErrorCode에 status code와 message 가 있기 때문에 불필요하게 handler를 중복해서 만들 필요가 없어졌습니다!

 

클라이언트에게 통일된 규격으로 내려주기

@Getter
public class ErrorResponse {

	private final HttpStatus status;
	private final String message;
	private final List<String> errors;

	public ErrorResponse(HttpStatus status, String message, List<String> errors) {
		this.status = status;
		this.message = message;
		this.errors = errors;
	}

	public ErrorResponse(HttpStatus status, String message, String error) {
		this.status = status;
		this.message = message;
		this.errors = List.of(error);
	}

	public static ErrorResponse toErrorResponse(ErrorCode errorCode) {
		return new ErrorResponse(
			errorCode.getHttpStatus(),
			errorCode.name(),
			errorCode.getDetail());
	}

}

저희는 현재 이렇게 정의했습니다.

  • status: httpStatus name
  • message: enum name
  • errors: enum 에서 정의한 detail 혹은 별도 Exception 에서 받은 messages

 

Custom Exception 을 통일하긴 했지만, 저희가 만든 Exception 외에도 Spring 에서 만든 Exception 들이 많습니다.

모두 공통으로 정의된 ErrorResponse 를 내려주도록 해보겠습니다.

 

ResponseEntityExceptionHandler

Spring MVC에서 발생할 수 있는 Exception 을 미리 Handler 로 구현하고,

각각의 Exception 별로 메서드를 구현해놓은 클래스로 우린 해당 클래스를 상속받아 메서드를 재정의하면 됩니다.

 

ResponseEntityExceptionHandler
재정의할 메서드

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

	/**
	 * Valid 유효성 검사 실패
	 */
	@Override
	protected ResponseEntity<Object> handleMethodArgumentNotValid(
		MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
		log.error("MethodArgumentNotValidException :: ", ex);

		List<String> errors = generateErrors(ex);
		ErrorResponse response = new ErrorResponse(BAD_REQUEST, ex.getLocalizedMessage(), errors);
		return handleExceptionInternal(ex, response, headers, response.getStatus(), request);
	}
    
...
}

 

이처럼 ResponseEntityExceptionHandler 에 있는 메서드들을 재정의하여

저희가 만든 ErrorResponse 를 만들어서 보내주면 동일한 규격으로 진행할 수 있게 됩니다.


😁 결론

Custom Exception 을 각각 만드는 방식도 좋은 경우가 있습니다.

Exception 클래스 내에서 별도 메서드를 진행한다던지, 메세지를 조합한다던지 등등..

 

하지만, 제가 지금까지 만든 클래스에서는 단지 Message 만 정의하고 있었고,

ErrorCode로 관리하는 게 더 가독성과 유지보수성이 좋게 느껴졌습니다.

 

리펙토링 후 장점

- ErrorCode 한 곳에서 관리할 수 있다.

- handler를 개별로 만들 필요가 없어졌다.

- 유지보수성 업 확장성 업!

 

자세한 코드는 현재 진행 중인 프로젝트에서 확인할 수 있습니다. (링크)

 

참고

728x90
반응형