글의 발단
저는 확장성과 편의성, 더 나은 설계 방향(특히 설계상 의존성의 방향을 바로잡는 것)을 위해 인터페이스 에러 코드를 사용해 왔습니다. 그리고 관련해서 포스팅도 한두 차례 해 본 적이 있죠. 그런데 글의 이해를 위해 필요한 부연 설명, 서두의 배경 설명이 모두 길다 보니 글을 읽는 것이 부담이 되어 보였습니다.
그래서 이를 축약하여 정리해 봅니다.
ErrorCode와 Custom Exception
기존 enum Error Code 방식
기존에는 사람들이 enum
으로 된 ErrorCode
에 프로젝트 전체의 예외 종류를 관리했습니다.
그러나 이 방식은 다음과 같은 이슈를 포함합니다.
enum
은 상속되지 않습니다. 따라서 모든 에러 코드를 하나의 파일에 모아 두게 됩니다.과도한 계획성을 요구합니다. 이
ErrorCode
열거 타입에 의존성을 갖고 있는 각 서비스들이 어떤 에러를 계획하고 있는지 이곳에 명시되어야 합니다.결과적으로 의존성의 방향이 '구현'에서와 '계획'에서 상이합니다.
의도는 각 세부 서비스가
ErrorCode
에 의존하는 것입니다.세부 서비스 →
ErrorCode
(구현 시)계획된 것은
ErrorCode
에 각 세부 서비스에 대한 계획상 의존이 발생합니다.ErrorCode
→ 세부 서비스 (계획 시)
공통 부분이기 때문에 여러 작업자의 커밋이 이 파일에 몰릴 수 있습니다.
이는 빈번한 충돌로 연결될 수 있으며, 필요하다면 별도 작업 방식을 정해야 합니다.
한 파일로 커버하는 영역이 너무 넓기 때문에, 너무 비대한 파일이 되거나 유지보수 편의상 추상적 에러 코드 목록만 관리하게 됩니다.
분류되지 않은 에러 코드는 이처럼 설계 관점에 어긋나 있고, 작업의 불편을 초래합니다.
인터페이스를 통해 확장된 Error Code
그래서 저는 최상위에 인터페이스로 된 에러 코드를 두었습니다. 그리고 각 리소스나 피처 단위에서 에러 코드를 관리하고, 그 단계에서 enum
을 사용하여 이 인터페이스 ErrorCode
를 구현하도록 정했습니다.
Interface ErrorCode 예시
저는 우선 다음 메서드들이 공통적으로 필요하다고 보았습니다.
그 외 메서드는 필요에 따라 추가하실 수 있다고 생각합니다.
String name()
: 각 열거 상수의 이름을 문자열로 반환해 주는 메서드로, 오버라이딩 하지 않아도 하위enum
에서 자동으로 구현됩니다.💡 (참고)
enum
에는name()
메서드와ordinal()
메서드 등이 자동으로 오버라이딩 됩니다. 에러 코드는 나열 순서와 무관하기 때문에ordinal()
은 애초에 배제했습니다.String defaultMessage()
: 각 에러 코드가 제공할 예외 메시지입니다.HttpStatus defaultHttpStatus()
: 각 에러 코드가 제공할 HTTP 상태 코드입니다.RuntimeException defaultException()
: 각 에러 코드가 기본적으로 제공할 예외입니다.RuntimeException defaultException(Throwable cause)
: 에러 콜스택을 추가할 수 있습니다.
import org.springframework.http.HttpStatus;
public interface ErrorCode {
String name(); // automatically overridden at enum
String defaultMessage();
HttpStatus defaultHttpStatus();
RuntimeException defaultException();
RuntimeException defaultException(Throwable cause);
}
리소스 및 피처 단위로 분류한 Error Code 구현 예시
implements ErrorCode
: 에러 코드 인터페이스를 구현합니다.CustomException
: 우리가 에러 코드와 긴밀하게 사용할 커스텀 예외입니다.
public enum SignUpErrorCode implements ErrorCode {
// 열거_상수("message", status)
CONFLICTED_USERNAME("이미 존재하는 아이디입니다.", HttpStatus.CONFLICT),
CONFLICTED_NICKNAME("이미 사용 중인 닉네임입니다.", HttpStatus.CONFLICT),
// ...
DEFAULT(
"회원 가입 오류입니다. 오류가 지속되면 문의하시기 바랍니다.",
HttpStatus.INTERNAL_SERVER_ERROR
);
private final String message; // final이지만 static이 아니기 때문에 camel case
private final HttpStatus status;
// 생성자
SignUpErrorCode() {
this.message = message;
this.status = status;
}
@Override
public String defaultMessage() {
return message;
}
@Override
public HttpStatus defaultHttpStatus() {
return status;
}
@Override
public CustomException defaultException() {
// 에러 코드를 받는 생성자를 사용합니다. (this가 곧 에러 코드 본인이니까)
return new CustomException(this);
}
@Override
public CustomException defaultException(Throwable cause) {
// 에러 코드를 받는 생성자를 사용합니다. (this가 곧 에러 코드 본인이니까)
return new CustomException(this, cause);
}
}
CustomException Type A
간단한 커스텀 예외 예시입니다. 이 예시에서는 모든 생성자가 에러 코드를 반드시 받도록 되어 있습니다.
public class CustomException extends RuntimeException {
protected final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.defaultMessage());
}
public CustomException(ErrorCode errorCode, Throwable cause) {
super(errorCode.defaultMessage(), cause);
}
}
CustomException Type B
기본 에러 코드를 추가한 겁니다. 사실 생각보다 쓸 일은 없지만, 설계상 미리 추가해 두는 개념입니다.
이 경우 일부 생성자는 외부에서 ErrorCode를 받지 않는다면 기본 에러 코드를 사용하게 됩니다.
DEFAULT_ERROR_CODE
: Lazy load와 Thread-safe를 보장하기 위해 내부 클래스를 통해 유일 객체를 생성했습니다. 자바는 클래스 로드 타임에 동시성을 보장해 주고, 클래스는 실제로 사용될 때 로드되기 때문에 thread-safe와 lazy load를 모두 보장할 수 있습니다.
import org.springframework.http.HttpStatus;
public class CustomException extends RuntimeException {
// ===== statics =====
private static ErrorCode getDefaultErrorCode() {
return DefaultErrorCodeHolder.DEFAULT_ERROR_CODE;
}
// ===== non-static fields =====
protected final ErrorCode errorCode;
// ===== Constructors =====
public CustomException() {
super(getDefaultErrorCode().defaultMessage());
this.errorCode = getDefaultErrorCode();
}
public CustomException(String message) {
super(message);
this.errorCode = getDefaultErrorCode();
}
public CustomException(String message, Throwable cause) {
super(message, cause);
this.errorCode = getDefaultErrorCode();
}
public CustomException(ErrorCode errorCode) {
super(errorCode.defaultMessage());
this.errorCode = errorCode;
}
public CustomException(ErrorCode errorCode, Throwable cause) {
super(errorCode.defaultMessage(), cause);
this.errorCode = errorCode;
}
// ===== Non-static Methods =====
public ErrorCode getErrorCode() {
return errorCode;
}
// ===== Inner Classes =====
private static class DefaultErrorCodeHolder { // 사용할 때 로드 + 스레드 세이프(클래스 로드 타임은 동시성 보장됨.)
private static final ErrorCode DEFAULT_ERROR_CODE = new ErrorCode() {
@Override
public String name() {
return "SERVER_ERROR";
}
@Override
public HttpStatus defaultHttpStatus() {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
@Override
public String defaultMessage() {
return "서버 오류";
}
@Override
public CustomException defaultException() {
return new CustomException(this);
}
@Override
public CustomException defaultException(Throwable cause) {
return new CustomException(this, cause);
}
};
}
}
더 세부적인 커스텀 예외
물론 더 세부적인 커스텀 예외를 만들 수 있습니다. 이때는 생성자 일부 또는 전체를 상속받습니다.
public class SignUpException extends CustomException {
// intellij: Ctrl + O를 눌러 필요한 생성자를 상속받습니다.
// eclipse: 알트 쉬프트 S였나, 하여간 자동생성을 통해 생성자를 만듭니다.
}
Global Exception Handler
앞서 설명한 것은 비교적 독창적이어서 다른 회사들에 자주 보이지 않는 방식이었습니다. 이번에 작성할 controller advice는 일반적인 회사들이 많이 채택하고 있는 방식입니다.
컨트롤러 메서드에서까지 catch 하지 않고 throw 된 예외는 이곳에 옵니다. (지정한 예외만)
이곳에서 예외 응답을 결정할 수 있습니다.
ApiResponseError
: 예외 응답을 할 때 사용할 객체로 제가 따로 만든 클래스입니다. 예외 응답에 사용할 클래스는 각자 만들어서 사용하시거나, 비교적 한정적인 사용을 예상한다면Map
타입으로 사용할 수 있습니다. (Map
은 기피하는 작업자들이 많이 있으니 유념하세요.)
@RestControllerAdvice
public final class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponseError> handleMemberException(CustomException exception) {
HttpStatus httpStatus = exception
.getErrorCode()
.defaultHttpStatus();
ApiResponseError response = ApiResponseError.of(exception);
return ResponseEntity
.status(httpStatus)
.body(response);
}
}
용례
단건 조회에 많이 사용하는 Optional<T>
클래스를 취급할 때, orElseThrow(...)
와 상성이 좋습니다.
Item item = itemQueryRepository
.findById(id)
.orElseThrow(ExampleErrorCode.EXAMPLE::defaultException);