[Java] 표준 예외 vs 커스텀 예외, 예외 처리 전략
공통 예외 처리와 관련한 개발을 담당하게 되면서, 예외를 어떻게 처리할 것인 지에 대해 고민했다. 유지보수성을 고려한 코드를 작성하기 위해서는 팀원 모두가 일관성있게 예외를 처리할 필요가 있었고, JVM에서 제공하는 표준 예외를 사용하는 방법과 커스텀 예외를 만들어 사용하는 방법을 생각해보게 되었다. 각 방식의 장단점에 대해 생각해보았고 학습한 내용을 정리하고자 한다.
표준 예외
Java API에서 제공하는 예외이다. 말 그대로 표준이기 때문에 Java를 사용하는 다른 개발자들이 내 코드를 읽더라도 예외의 의미를 쉽게 이해할 수 있어서, 코드의 가독성이 높아진다. 각종 비즈니스 예외 상황에서 사용할만한 예외 클래스를 표준 예외로 충분히 제공하고 있으며, 클래스 생성 비용이 적은 덕분에 메모리 사용량이 줄고 클래스를 적재하는 시간이 적게 소요되어 성능상 이점이 있다.
자주 사용되는 표준 예외로는 IllegalArgumentException, IllegalStateException, NullPointerException, IndexOutOfBoundsException 등이 있다. 우리가 자주 마주치는 예외들이기 때문에, 예외 상황을 이해하는 데 조금 더 편할 수 있다.
표준 예외는 Exception, RuntimeException, Throwable, Error 와 같은 상위 타입 예외를 갖는데, 이러한 상위 타입 예외를 재사용하는 것은 권장되지 않는다. 여러 성격의 예외들을 포괄하고 있기 때문에 안정적인 테스트가 어렵고, 표준 예외의 장점인 '가독성'을 해치기 때문에, 상위 예외 클래스는 사용하지 않는 것이 좋다.
실제 이펙티브 자바라는 책에서는 예외의 맥락에 부합하는 적절한 표준 예외를 재사용할 것을 권장하고 있다고 한다. 이에 나 또한 표준 예외를 사용하려 했었다.
String 값으로 관리되는 표준 예외로 인한 혼란
표준 예외를 생성하려고 하니, 문제가 있었다. 표준 예외는 생성자 파라미터로 예외의 message 값을 설정할 수 있지만, 이를 강제할 방법이 없었다. 표준 예외는 파라미터가 없는 생성자도 지원하기 때문에, 일관되지 않은 방식으로 예외를 생성할 여지가 있었다.
Member member = memberRepository.findByEmail(email)
.orElseThrow(new EntityNotFoundException("해당하는 회원이 존재하지 않습니다."));
Member member = memberRepository.findByEmail(email)
.orElseThrow(EntityNotFoundException::new);
message를 지정하지 않은 채로 예외를 생성하는 것이 가능해져, 예외 생성 방식의 일관성이 떨어지게 된다.
예외 생성 시마다 message를 반드시 입력하는 것으로 팀원들과 약속한다고 한들, 다른 문제가 발생할 수도 있었다. message의 type은 String이며, 그렇기 때문에 예외가 발생할 때마다 String 값을 넣어줘야하는 번거로움이 예상되었으며, 이 String 값은 팀원들의 메시징 스타일에 전적으로 의존할 여지가 있었다.
Member member = memberRepository.findByEmail(email)
.orElseThrow(new EntityNotFoundException("해당하는 회원이 존재하지 않습니다."));
Member member = memberRepository.findByEmail(email)
.orElseThrow(new EntityNotFoundException("회원을 찾을 수 없습니다."));
내가 표준 예외를 사용하려 했던 것은 코드의 '일관성'과 '가독성'을 챙기기 위함이었다. 하지만, 이렇게 된다면 예외 생성 시 입력하는 message의 값이 일관적이지 않을 가능성이 있었다. 가령, 나는 '해당하는 회원이 존재하지 않습니다.'라는 message를 넣어줬는데 같은 예외 상황에서 다른 팀원은 '회원을 찾을 수 없습니다.'라는 message를 입력한다면, 예외 상황에 대한 일관성을 놓치게 될 것이었고, 같은 예외 상황임에도 이를 쉽게 확인할 방법이 없다.
Member member = memberRepository.findByEmail(email)
.orElseThrow(new EntityNotFoundException("해당하는 회원이 존재하지 않습니다."));
Item item = itemRepository.findById(itemId)
.orElseThrow(new EntityNotFoundException("해당하는 상품이 존재하지 않습니다."));
또한, 같은 예외 클래스임에도 맥락이 다른 경우가 생길 수 있다. EntityNotFoundException를 예로 들면, 어떤 엔티티가 존재하지 않느냐에 따라 다른 예외 상황을 가리킬 수 있는 문제가 있었다. 회원을 찾을 수 없는 경우에도, 상품을 찾을 수 없는 경우에도 같은 EntityNotFoundException을 생성해야 하기 때문이다. 이 경우에는 예외 상황의 판단을 String 값인 예외 message에 의존할 수 밖에 없을 것이다.
이에 나는 커스텀 예외 클래스를 만들고 예외 message 값이 아닌 일관된 type으로 예외들을 관리하는 방법을 고민했다.
커스텀 예외를 어떻게 만들 것인가?
커스텀 예외를 도입하는 데 드는 비용은 만만치 않다. 표준 예외에 비해 많은 클래스를 생성해야 하고, 이 많은 클래스는 곧 성능 저하로 이어진다. 그래서 나는 성능 저하를 최소화하면서 예외 상황을 파악하는 과정에서 일관되지 않게 작성될 여지가 있는 예외 message 값에 의존하지 않는 방법을 찾으려 했다.
우선, message에 의존하지 않기 위해 한 프로젝트에서 쓰이는 모든 예외를 전역적으로 관리하는 enum 클래스를 만들었다.
@Getter
public enum ErrorCode {
// Member
MEMBER_NOT_FOUND("M-001", "Member Not Found"),
// Valid
ARGUMENT_NOT_VALID("V-001", "Method Argument Not Valid"),
// Unexpected Exception
UNEXPECTED_ERROR("D-001", "Unexpected error ");
private final String code;
private final String message;
ErrorCode(final String code, final String message) {
this.code = code;
this.message = message;
}
}
일부만을 작성한 것이지만, 대략적으로 이런 방식이다. enum 내부에 code와 message 필드를 정의하고, 예외 상황을 표현하는 enum 값을 정의할 때마다 우리가 의도한 code와 message 값이 자동으로 필드에 주입되도록 했다.
@Getter
public class BusinessException extends RuntimeException{
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super("[" + errorCode.getCode() + "] " + errorCode.getMessage());
this.errorCode = errorCode;
}
@Override
public synchronized Throwable fillInStackTrace() {
// StackTrace를 채우지 않도록 처리
return this;
}
}
또한, 커스텀 예외 방식을 선택할 경우 클래스 생성 비용이 높아지는 것을 고려해 비즈니스와 관련된 예외는 BusinessException 이라는 하나의 클래스에서 관리하도록 했다. 다만, 해당 Exception의 필드에는 위에서 정의한 enum 값인 ErrorCode를 넣어주어 같은 클래스임에도 예외 상황을 일관적인 code를 통해 파악할 수 있도록 구성했다.
또한, 예외 생성 시 많은 비용을 차지하는 fillInStackTrace 메서드를 오버라이딩하여, 성능을 향상시켰다. 해당 메서드는 예외가 발생했을 때 StackTrace를 채우는 역할을 하는 메서드이다. 보통 Stack의 depth 10 정도일 때 4000ns의 시간이 소요되는데, StackTrace를 채우지 않도록 메서드를 오버라이딩해서 80ns의 시간 정도로 단축하였다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponseDto> businessExceptionHandler(
HttpServletRequest request, BusinessException businessException) {
printException(request, businessException.getErrorCode());
return new ResponseEntity<>(ErrorResponseDto
.builder()
.code(ResponseCode.S)
.message(businessException.getErrorCode().getMessage())
.build(), BAD_REQUEST);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponseDto> runtimeExceptionHandler(
HttpServletRequest request, RuntimeException runtimeException) {
printDangerousException(request, ErrorCode.UNEXPECTED_ERROR);
runtimeException.printStackTrace(); // 예상하지 못한 에러는 서버 쪽에서 stacktrace 확인 필요
return new ResponseEntity<>(ErrorResponseDto
.builder()
.code(ResponseCode.S)
.message(ErrorCode.UNEXPECTED_ERROR.getMessage())
.build(), INTERNAL_SERVER_ERROR);
}
// 예측한 예외 logging
private void printException(HttpServletRequest request, ErrorCode errorCode) {
log.info("[" + errorCode.getCode() + "] " + request.getRequestURI());
}
// 예측하지 못한 예외 logging
private void printDangerousException(HttpServletRequest request, ErrorCode errorCode) {
log.error("[" + errorCode.getCode() + "] " + request.getRequestURI());
}
}
예외 핸들러에서는 우리가 지정했던 예외 code와 요청 URI를 조합해 로그를 출력한 후, 에러 응답 객체에 담아 반환하도록 구성했다. 예외와 관련한 message를 로그에 포함하지 않은 것은 짧지만 명확하게 code를 통해 소통하는 것이 더 효율적일 것 같았기 때문이다. 다만, 로그를 출력할 때 우리가 적절하게 예외 처리를 했다는 것이 보장된 BusinessException에 대해서는 info 레벨의 로그를 출력하도록 했고, 예측하지 못한 예외에 대해서는 error 레벨의 로그를 출력하도록 했다.
물론 사전에 예측하지 못한 예외는 절대 발생하지 않아야 하지만, 개발 과정에서는 이러한 상황이 빈번하게 일어날 수 있다고 생각했기 때문에 개발 편의성을 고려해, error 레벨의 로그를 출력하는 경우에 한해서는 StackTrace도 함께 출력하도록 했다.
정답은 없다.
나는 위와 같은 방식으로 예외를 처리했다. 하지만, 이 방식이 꼭 정답이라고 할 수는 없다. 더 좋은 방식이 있을 수도 있고 그러한 방법을 찾는다면 더 개선해 볼 의향도 있다. BusinessException 하나로 모든 에러를 관리하는 것이 좋은 방법은 아니라는 생각도 든다. 우리 프로젝트에서는 예외 별로 분기 처리를 해야 하는 부분이 없었지만, 만약 비즈니스가 커지게 되면 예외 상황 별로 다른 처리를 해야할 수도 있다. 그런 경우에는 커스텀 예외 클래스도 여러 개를 만들어 관리하는 편이 효율적일 수 있다.