🇰🇷 Kor: 지금 보는 중!
🗺 Eng: Not Yet!
🇯🇵 Jap: まだです。
❓ 커스텀 예외, 어디까지, 얼마나 필요할까?
우리는 프로그램을 작성하다 보면 의도적으로 예외를 만드는 상황들이 있습니다! 🙀 (왜지!)
우리가 실제로 프로그램을 잘못 짜서 발생하는 건 아니구요. 예를 들어 사용자가 가입할 때 중복된 사용자 이름(계정)을 사용하거나, 중복된 닉네임을 사용하는 등 여러 상호작용에서 우리가 의도적으로 예외로 취급하는 것들입니다. (상호작용 대상이 꼭 사용자만 있는 건 아니구요.)
특히 웹은 요청과 응답으로 구성되는데, 응답도 '정상 응답'과 '예외 응답'으로 분류할 수 있습니다.
체계적인 웹 서비스를 위해서는 이 정상 응답과 예외 응답의 목록 관리도 필요할 수 있죠. 예외 클래스를 하나하나 만드는 것이 과도한 작업이라는 점만 뺀다면요.
// Not Suggested 💥
// 이 예시는 예외 상황 하나를 예외 클래스로 작성하고 있습니다.
public class DuplicatedUsernameException extends RuntimeException {
private static final String DEFAULT_MESSAGE = "이미 존재하는 사용자 이름입니다.";
public DuplicatedUsernameException() {
super(DEFAULT_MESSAGE);
}
public DuplicatedUsernameException(Throwalbe cause) {
super(DEFAULT_MESSAGE, cause);
}
public DuplicatedUsernameException(String message) {
super(message);
}
public DuplicatedUsernameException(String message, Throwalbe cause) {
super(message, cause);
}
}
// "후! 이제 예외 케이스 하나 만들었다! 이제 99,999개 남았어!"
이렇게 예외 클래스를 하나하나 만든다면 유사한 코드를 양산하게 되는데, 더 큰 문제는 유지보수입니다. 나중에 이런 걸 몇 백 개 이상 보며 어떨 때 무엇이 필요하다고 파악하며 일한다니, 개선이 필요합니다.
예외 관리에서 택할 수 있는 전략
그래서 보통 유지보수를 위해 예외 클래스는 '추상적인 레벨'에서 작성합니다. 그러면서도 어느 정도 상세 상황에 대한 구분은 가능하길 바라죠. 그러면 설계 전략을 세워 보는 분들은 기본적으로 이런 전략 결정 과정을 떠올리구요.
기본 전략 결정 과정
(전략 1단계) 추상적인 예외 클래스를 만듭니다. (
CustomException
)(전략 2단계) 세세한 유형을 표시할 때는 -> 열거 타입을 사용해서 나눕니다.
(전략 3단계)
CustomException
-(uses)->ErrorCode
(경험을 더한 전략 4단계) "다 경험에서 하는 말인데, Error Code도 적당히 추상적인 정도만 하자."
그렇게 탄생하는 기존의 예외 전략
추상적인 커스텀 예외를 하나 만드는데, 여기에는 ErrorCode
타입 필드를 하나 둡니다. 이 ErrorCode
필드가 세세한 오류 유형을 표현할 열거타입입니다.
CustomException 클래스
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
// ...
}
ErrorCode 열거 타입
@RequiredArgsConstructor // 생성자를 만드는 롬복(lombok) 애노테이션 중 하나입니다.
public enum ErrorCode {
// 이제 각 오류 케이스를 작성합니다.
USER_TOO_HANDSOME("잘생기면 운동하지 마세요.", HttpStatus.BAD_REQUEST),
... // << 새 오류 유형은 여기에 열거 상수만 추가하면 됩니다.
DEFAULT("오류 발생", HttpStatus.INTERNAL_SERVER_ERROR);
// 필요한 필드 목록 예시입니다.
// 이 외에도 식별 코드 등 추가적인 필드를 두기도 합니다.
private final String message;
private final HttpStatus status;
public String message() {
return message;
}
public HttpStatus status() {
return status;
}
}
이후 예외 케이스는 새로운 Exception
클래스를 추가할 필요 없이, ErrorCode
에 새로운 열거상수만 추가하면 되기 때문에 작업이 굉장히 쉽습니다. 👍
이렇게 하면 보통은 CustomException
의 생성자에 이런 유형을 추가하겠죠.
CustomException의 생성자 예시
public CustomException() {
...
}
public CustomException(ErrorCode errorCode) {
...
}
public CustomException(ErrorCode errorCode, Throwable cause) {
...
}
생성자 사용은 이렇게 할 테고요.
new CustomException(ErrorCode.USER_TOO_HANDSOME);
꽤 좋은데 뭐가 문제일까?
이 방식은 충분히 많이 사용되는 방식입니다. 작업에 불편해 보이는 게 별로 없었죠.
단지 분산 환경에서 협업 시 소통 비용을 늘린다는 결점을 발견하기 전까지요. 앞서 '전략 4단계'에 언급한 "에러 코드도 그냥 적당히 추상적으로 쓰자." 하는 경험상의 전략도 무려 금융권 시니어분들의 경험에서 녹여 내어 온 것들입니다. 세세한 구분은 오히려 불편했던 거죠. 특히 에러코드를 한 곳에서 관리하면 여러 피처(feature)에서 발생하는 에러 코드에 대한 협의가 필요할 테니, 추상적으로만 하자는 말씀들이 모두 이해가 됩니다. 그 외에도 여러 불편이 있었을 수 있고요. (실제로 외부 API에서 오류 코드를 받아 보면 다소 추상적이라서 결국 문의를 해야 할 때가 자주 있습니다!)
이처럼 일원화된 에러 코드 제공 시 대략 이런 문제들을 극복할 필요가 있습니다.
서로 다른 리소스/피처의 에러 코드를 한 곳에 묶어서 글로벌하게 관리하게 됩니다.
MSA라면 서로 다른 마이크로 서비스를 개발하고 있는 구성원과 소통 비용이 발생할 수 있습니다.
멀티 모듈 프로젝트라면 공통 모듈이 각 서비스를 구성하는 모듈들에 종속되는 듯한 오묘한 관계가 됩니다. 서비스에서 사용할 여러 세부 에러코드를 공통 모듈이나 그에 인접한 단계의 모듈에 모으게 되기 때문이죠. (멀티모듈을 모른다면, 패키지보다 고도화된 구분이라고 생각하세요.)
\=> 요약하면, 서비스를 구분해서 개발할 때 ErrorCode
의 관리가 이상하고 번거롭다는 것입니다.
분산된 에러 코드 관리를 위한 인터페이스
그래서 선배님들 세대의 고민을 이어받아 추가적인 관리 전략을 고안할 필요가 있었죠. 아키텍처에 대한 관심이 높은 근래에 도움이 되는 방향으로요. 가령 개발자로 근무한다면 요즘은 여러 마이크로 서비스로 분산된 작업 환경을 만날 때가 있습니다(부분적이거나 완전한 MSA).
그럼 마이크로서비스마다 각자 관리하는 에러코드를 독립적으로 관리하고, 필요하면 다른 서비스의 에러 코드도 전달받아서 사용할 수 있어야 편하겠죠.
그래서 우리 만들려는 것은 이렇습니다:
각 리소스나 피처 단위로 분류한 에러 코드들! (구분된
enum
들)각 에러코드는 공통적인 스펙으로 관리합니다. (상위에 인터페이스)
다형성의 이점을 제공할 상위 타입이 있으면 좋습니다. (상위에 인터페이스)
프로젝트를 구성하는 각 모듈은 이 에러코드를 쉽게 취사선택하여 사용합니다. (모듈 간 공유)
자바는 interface
를 통해 거의 모든 것을 확장할 수 있죠. 우린 interface ErrorCode
를 채택합니다.
enum
끼리는 서로 상속할 수 없기 때문에interface
로 확장합니다.에러코드를 리소스(resource) 또는 피처(feature) 단위로 분류하여
enum
으로 만들 때, 이 열거 타입들에 공통interface
를 상위로 두어야겠다고 생각했습니다.
이런 식으로요.
public enum SignUpErrorCode implements ErrorCode { ... }
public enum NotificationErrorCode implements ErrorCode { ... }
에러 코드들의 인터페이스
이 역할을 할 수 있는 에러코드 인터페이스의 예시입니다.
public interface ErrorCode {
String name(); // enum들에게 확장할 거니까 이거 정돈 넣습니다.
String defaultMessage();
HttpStatus defaultHttpStatus(); // 의존성 제외하려면 더 일반적인 타입으로 구성해도 됩니다. (HTTP 상태 코드의 번호와 이름)
RuntimeException defaultException(); // 이 단계에서는 추상적인 반환 타입이 좋습니다.
RuntimeException defaultException(Throwable cause);
}
enum
에서 확장성을 고려할 때는 필드 상수(이하 message
, status
)를 제공하는 메서드를 인터페이스에 작성해 둡니다. 그리고 enum
이 공통으로 갖는 String name()
은 우리가 오버라이딩을 하지 않아도, 자동으로 오버라이딩이 됩니다. (ordinal
등은 불필요하다고 생각하여 제외합니다.)
@RequiredArgsConstructor
public enum SimpleErrorCode implements ErrorCode { // 인터페이스 구현
열거상수("...", HttpStatus.상태코드이름),
열거상수("...", HttpStatus.상태코드이름),
열거상수("...", HttpStatus.상태코드이름),
...,
열거상수("...", HttpStatus.상태코드이름);
private final String message; // 이때 멤버 상수는 `private` 필드
private final HttpStatus status;
@Override
public String defaultMessage() { // 멤버 상수를 메서드로 제공
return message;
}
@Override
public HttpStatus defaultHttpStatus() {
return status;
}
// 반환 타입을 여기서 구체화해도 됩니다. (SignUpException은 뒤에서 만들 거예요.)
@Override
public SignUpException defaultException() {
return new SignUpException(this);
}
@Override
public SignUpException defaultException(Throwable cause) {
return new SignUpException(this, cause);
}
}
커스텀 예외 클래스의 분류
지금 만들 CustomException
은 앞으로 우리가 만들 다른 예외 클래스들의 상위 타입 역할을 합니다.
기본적으로 이 설계는 이런 콘셉트에서 출발하죠.
CustomException
은ErrorCode
를 기본적으로 핸들링합니다.CustomException
을 상속받은 자식 클래스들은 이 클래스에 에러코드를 전달합니다.
참고로 클래스 구현부에서는 다음 항목을 반영해서 간단히 구현했어요.
에러코드를 전달받는 생성자들을 준비합니다. (자식들은 이를
super(...)
로 사용)에러 코드 없는 생성자 사용 시 기본 에러코드를 부여합니다.
예외를 만들 때는 필요하다면
cause
를 전달받습니다.
public class CustomException extends RuntimException {
@Getter
protected final ErrorCode errorCode;
public CustomException() {
super(기본에러코드.defaultMessage());
errorCode = 기본에러코드;
}
public CustomException(String message) {
super(message);
this.ERROR_CODE = 기본에러코드;
}
public CustomException(String message, Throwable cause) {
super(message, cause);
this.ERROR_CODE = 기본에러코드;
}
public CustomException(ErrorCode errorCode) {
super(errorCode.defaultMessage());
this.ERROR_CODE = errorCode;
}
public CustomException(ErrorCode errorCode, Throwable cause) {
super(errorCode.defaultMessage(), cause);
this.ERROR_CODE = errorCode;
}
// ... (추가적인 코드가 올 예정)
}
이렇게 생성자와 에러코드, getter
를 작성했습니다. 이후 저희는 기본 에러 코드에 대해 유일 인스턴스 사용을 위해서 내부 클래스를 활용하고 static
메서드를 통해 제공하는데, 이번 설명에서 중요한 부분은 아니니 자세한 설명은 생략할게요.
public class CustomException extends RuntimException {
// ...
// [1] 기본 에러 코드를 제공할 `static` 메서드입니다.
private static ErrorCode defaultErrorCode() {
return Holder.DEFAULT_ERROR_CODE;
}
// [2] DEFAULT_ERROR_CODE를 보존할 내부 클래스입니다.
private static class Holder {
// DEFAULT_ERROR_CODE입니다. 즉석에서 인터페이스를 구현하여 사용한다 보시면 돼요.
private static final ErrorCode DEFAULT_ERROR_CODE = new ErrorCode() {
@Override
public String name() {
return "SERVER_ERROR";
}
@Override
public String defaultMessage() {
return "오류가 발생했습니다.";
}
@Override
public HttpStatus defaultHttpStatus() {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
@Override
public RuntimeException defaultException() {
return new CustomException(this);
}
@Override
public RuntimeException defaultException(Throwable cause) {
return new CustomException(this, cause);
}
};
}
}
이제 위 생성자들 안에서 '기본에러코드'라고 되어 있던 부분을 defaultErrorCode()
로 변경하면 됩니다. 예를 들면 이렇게요.
public CustomException(String message) {
super(message);
this.ERROR_CODE = CustomException.defaultErrorCode();
}
손쉽게 커스텀 익셉션 상속
커스텀 익셉션을 자식 클래스로 상속시킬 때는 이렇게만 하시면 됩니다.
public class SignUpException extends CustomException {
// 맥에서 인텔리제이 기준으로 `Ctrl` + `O`를 눌러 부모의 생성자를 모두 구현하세요.
// 그러면 아래처럼 작성됩니다.
public SignUpException() {
super();
}
public SignUpException(String message) {
super(message);
}
public SignUpException(String message, Throwable cause) {
super(message, cause);
}
public SignUpException(ErrorCode errorCode) {
super(errorCode);
}
public SignUpException(ErrorCode errorCode, Throwable cause) {
super(errorCode, cause);
}
}
이제 위 에러코드에서 예시로 작성한 SignUpException
을 작성했습니다.
만약 멀티 모듈 구성을 한다면
각 주요 모듈에서 에러코드, 도메인 등은 공유하기 수월하도록 하위 모듈로 구성하면 좋습니다. 참고로 각 모듈의 마지막 이름은 서로 중복이 없도록 관리하는 게 공유하기 편합니다.
그리고 전역적인 예외 핸들러
보통 컨트롤러 어드바이스, 익셉션 핸들러, 글로벌 익셉션 핸들러 등으로 부릅니다.
Bean 스캔 범위에 있어야 합니다. (
scanBasePackages
등)상위 타입으로 작성되어 있어도 실행돼요. (이하
@ExceptionHandler(이곳)
)대신 일치하는 핸들러가 여러 개면 더 구체적인 타입으로 핸들링하는 쪽이 실행돼요.
반환할 바디는 커스텀해서 만드시면 됩니다. (예시에서는
CustomResponseError
라고 썼어요.)
@RestControllerAdvice
public final class GlobalExceptionHandler {
/**
* 커스텀 익셉션에 대한 핸들링
* */
@ExceptionHandler(CustomException.class) // 더 구체적인 타입으로 핸들링하는 쪽이 실행돼요.
public ResponseEntity<CustomResponseError> handleCustomException(CustomException exception) {
exception.printStackTrace();
ErrorCode errorCode = exception.getErrorCode();
HttpStatus httpStatus = errorCode.defaultHttpStatus();
CustomResponseError response = CustomResponseError.of(exception);
return ResponseEntity
.status(httpStatus)
.body(response);
}
/**
* 204 No Content (정상 응답)
*/
@ExceptionHandler(NoContentException.class)
public ResponseEntity<Void> handleNoContentException(NoContentException exception) {
exception.printStackTrace();
return ResponseEntity.noContent().build();
}
}
자료는 모두 DALL·E, excalidraw, Power Point 등을 통해 스스로 만들었습니다. (그래서 영어가 어색할 수 있습니다.) 어색한 영어 문장은 남대문 열렸다고 알려 주듯 살짝 말씀해 주시면 얼른 고쳐 보겠습니다. 그러면 다들 모쪼록 착한 개발 하세요.