# Spring: interface ErrorCode 요약 정리

# 글의 발단

저는 확장성과 편의성, 더 나은 설계 방향(특히 설계상 의존성의 방향을 바로잡는 것)을 위해 인터페이스 에러 코드를 사용해 왔습니다. 그리고 관련해서 포스팅도 한두 차례 해 본 적이 있죠. 그런데 글의 이해를 위해 필요한 부연 설명, 서두의 배경 설명이 모두 길다 보니 글을 읽는 것이 부담이 되어 보였습니다.

그래서 이를 축약하여 정리해 봅니다.

# 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)`: 에러 콜스택을 추가할 수 있습니다.
    

```java
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`: 우리가 에러 코드와 긴밀하게 사용할 커스텀 예외입니다.
    

```java
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

간단한 커스텀 예외 예시입니다. 이 예시에서는 모든 생성자가 에러 코드를 반드시 받도록 되어 있습니다.

```java
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를 모두 보장할 수 있습니다.
    

```java
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);
            }
        };
    }
}
```

### 더 세부적인 커스텀 예외

물론 더 세부적인 커스텀 예외를 만들 수 있습니다. 이때는 생성자 일부 또는 전체를 상속받습니다.

```java
public class SignUpException extends CustomException {
    // intellij: Ctrl + O를 눌러 필요한 생성자를 상속받습니다.
    // eclipse: 알트 쉬프트 S였나, 하여간 자동생성을 통해 생성자를 만듭니다.
}
```

# Global Exception Handler

앞서 설명한 것은 비교적 독창적이어서 다른 회사들에 자주 보이지 않는 방식이었습니다. 이번에 작성할 controller advice는 일반적인 회사들이 많이 채택하고 있는 방식입니다.

컨트롤러 메서드에서까지 catch 하지 않고 throw 된 예외는 이곳에 옵니다. (지정한 예외만)

이곳에서 예외 응답을 결정할 수 있습니다.

* `ApiResponseError`: 예외 응답을 할 때 사용할 객체로 제가 따로 만든 클래스입니다. 예외 응답에 사용할 클래스는 각자 만들어서 사용하시거나, 비교적 한정적인 사용을 예상한다면 `Map` 타입으로 사용할 수 있습니다. (`Map`은 기피하는 작업자들이 많이 있으니 유념하세요.)
    

```java
@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(...)`와 상성이 좋습니다.

```java
Item item = itemQueryRepository
        .findById(id)
        .orElseThrow(ExampleErrorCode.EXAMPLE::defaultException);
```
