예외 처리에 대해 고민한 내용들

현민·2024년 2월 14일

서론

농산물 직거래 플랫폼, 중고 경매 플랫폼, 그리고 현재 진행중인 구미 페이먼츠 프로젝트에서도 꾸준히 개선해보려고 노력하고 있는 예외 처리에 대해 고민했던 내용들을 작성해 보려고 한다.


고민한 내용

농산물 직거래 플랫폼

해당 프로젝트에서 예외를 처리했던 방식은 다음과 같다.

  1. 모든 예외에 대해 각각의 예외 클래스를 만든다.
  2. 예외는 한 패키지에 모아 놓는다.
  3. 예외의 응답으로는 HttpStatus와 예외 메세지만 보낸다.

@RestControllerAdvice
@RequiredArgsConstructor
public class ExControllerAdvice {

    private final ValidatorMessageUtils validatorMessageUtils;

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(BindException.class)
    public ErrorRes BindException(BindException ex) {
        BindingResult bindingResult = ex.getBindingResult();
        return new ErrorRes(HttpStatus.BAD_REQUEST.value(), validatorMessageUtils.getValidationMessage(bindingResult));
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(FileSaveException.class)
    public ErrorRes FileSaveException(FileSaveException ex) {
        return new ErrorRes(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
    }

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(LoginAuthenticationException.class)
    public ErrorRes loginAuthExHandle(LoginAuthenticationException ex) {
        return new ErrorRes(HttpStatus.UNAUTHORIZED.value(), ex.getMessage());
    }
	...
}

프로젝트가 끝나고 고민해보니 다음과 같은 문제가 있다고 생각했다.

  1. 모든 예외에 대해 각각 예외 클래스를 만드니 예외 클래스가 너무 많아진다.
  2. controller advice에서 처리하는 예외가 너무 많아서 복잡해진다.
  3. 예외를 한 패키지에 모두 모아 놓으니 어디서 어떤 예외가 발생하는지 알기 어렵다.
  4. 응답으로 상태코드와 메세지만 보내니 어떤 예외인지 파악하기 어렵다.

중고 경매 플랫폼

해당 프로젝트에서는 이전 프로젝트의 4가지 문제들을 개선해보려고 했다.

문제를 개선하기 위해 예외를 에러 코드 클래스를 통해 관리하기로 했다.

  • 하나의 에러 코드 클래스에서 모든 예외들을 관리하게 되면 어디서 어떤 예외가 발생하는지 알기 어렵고, 충돌이 발생할 가능성이 높아지므로 각 도메인 별로 에러 코드를 분리했다. ↓
@Getter
@AllArgsConstructor
public enum ChatErrorCode implements ErrorCode{
    CHAT_ROOM_DUPLICATED(HttpStatus.BAD_REQUEST, "채팅방이 이미 존재합니다."),
    CHAT_ROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 채팅방을 찾을 수 없습니다."),
    INVALID_ROOM_ID(HttpStatus.BAD_REQUEST, "잘못된 채팅방 아이디 입니다.");

    private final HttpStatus status;
    private final String message;
}

@Getter
@AllArgsConstructor
public enum UserErrorCode implements ErrorCode {

    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 사용자를 찾을 수 없습니다."),
    INVALID_USER(HttpStatus.BAD_REQUEST, "올바르지 않은 사용자입니다."),
    DUPLICATE_USER(HttpStatus.CONFLICT, "사용자가 이미 존재합니다."),
    WRONG_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."),

    EMAIL_AUTH_FAIL(HttpStatus.BAD_REQUEST, "이메일 인증 실패"),
    SELLER_NOT_BIDDING(HttpStatus.BAD_REQUEST, "판매자는 입찰할 수 없습니다.");
    private final HttpStatus status;
    private final String message;
}
...
  • 이전 방식처럼 응답으로 상태 코드와 메세지만 보내는 방식보다는, 에러 코드도 포함해 어떤 종류의 예외인지 쉽게 알 수 있도록 하려고 했다. ↓
@Getter
@AllArgsConstructor
@Builder
public class ErrorRes {

    private final HttpStatus status;
    private final String code;
    private final String msg;

    public ErrorRes(ErrorCode errorCode) {
        this.status = errorCode.getStatus();
        this.code = errorCode.name();
        this.msg = errorCode.getMessage();
    }

    public static ResponseEntity<ErrorRes> error(CustomException e) {
        return ResponseEntity
                .status(e.getErrorCode().getStatus())
                .body(ErrorRes.builder()
                        .status(e.getErrorCode().getStatus())
                        .code(e.getErrorCode().name())
                        .msg(e.getErrorCode().getMessage())
                        .build());
    }
  • 에러 코드를 통해 예외를 생성하도록 해 controller advice에서 너무 많은 예외를 처리하는 것을 해결하고자 했다. ↓
// 예외 생성
memberRepository.findByLoginId(loginId)
                .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));

// controller advice
@ExceptionHandler(value = CustomException.class)
public ResponseEntity<ErrorRes> handleCustomException(CustomException e) {
    return ErrorRes.error(e);
}

결론적으로 예외 클래스가 너무 많아지는 점은 해결되고, controller advice에서 처리하는 예외도 대부분 하나의 exceptionhandler에서 처리하게 되었다.

또한 각 도메인 별 에러 코드 클래스에 들어가보면 어떤 도메인에서 어떤 예외가 발생하는지 알 수 있게 되었다.

하지만 프로젝트가 끝나고 고민해보니 위 방식도 여러 문제가 존재한다고 생각했다.

  1. 에러 코드는 presentation 계층에 존재한다. 에러 코드를 통해 예외를 생성하는 방식을 사용하게 되면, 하위 계층에서 상위 계층에 의존하게 된다. 상위 계층에서 하위 계층으로의 단방향성을 유지하지 못하게 된다.
    • ex) domain 계층에서 예외를 생성할 때 presentation 계층에 존재하는 에러 코드에 의존하게 된다.
  2. 에러 코드 클래스를 도메인 별로 분리하고 각 상황에 맞는 에러 코드와 메세지를 매핑해 놓는 방식은 사용자를 헷갈리게 한다.
    • 에러 코드가 많아지면 관리가 힘들어진다.
    • 에러 코드가 많아지면 이미 존재하는 에러 코드도 없다고 착각해 새로 만들게 될 수 있고, 각 도메인 별 에러 코드 클래스 별로 중복되는 에러 코드들과 더 이상 사용되지 않는 에러 코드들이 많아진다.
  3. 예외를 한 곳에 모아두는 게 아니라, 특정 계층에서 발생하는 예외는 그 계층에 두어 어떤 계층에서 어떤 예외가 발생할 수 있는지 알 수 있게 하는 게 좋지 않을까?
  4. 굳이 모든 예외를 controller advice에서 공통적으로 처리할 필요가 있을까?
    • 특정 비즈니스 로직에서만 발생하는 예외는 해당 컨트롤러에서 처리하고, 공통적으로 발생하는 예외들만 controller advice에서 처리하면 되지 않을까?
  5. 비즈니스 상황에서 충분히 발생할 수 있는 예외와, 절대 발생해서는 안되는 예외들을 구분할 수 있지 않을까?
    • ex) 주문을 할 때, 재고가 없는 상황은 비즈니스 상황에서 충분히 발생할 수 있다. 하지만 주문을 하는데 계정이 존재하지 않는 상황은 절대 발생해서는 안되는 예외다.

구미 페이먼츠

해당 프로젝트에서도 마찬가지로 이전 프로젝트에서 고민했던 내용들을 개선해보려고 했다.

비즈니스 예외와 시스템 예외를 구분하기로 했다.

  • public class DuplicateSystemException extends RuntimeException implements SystemException {
    • 위와 같이 시스템 예외는 SystemException을 구현하도록 해서 시스템 예외임을 표시하도록 했다.
  • 에러 코드 또한 비즈니스 에러 코드와 시스템 에러 코드로 구분했다.

  • 이렇게 비즈니스 상황에서 충분히 발생할 수 있는 예외와 절대 발생해서는 안되는 예외를 구분함으로써 각 예외 상황을 이해하기 쉽도록 했다.

예외를 한 곳에 모아두지 않고, 예외가 발생하는 계층에 두도록 했다.

  • 예외가 발생하는 계층에 예외를 둠으로써 어떤 계층에서 어떤 예외가 발생하는지 쉽게 알 수 있도록 했다.

에러 코드는 사용하되, 메세지를 매핑해두지는 않기로 했다.

public enum BusinessErrorCode implements ErrorCode{

    NOT_FOUND,
    TRY_AGAIN,
    INVALID_STATUS,
    TIMEOUT,
	...
}
  • 위와 같이 공통적인 상황에 대한 코드만 만들어두고, 메세지는 예외를 생성할 때 받도록 했다.
  • 이렇게 함으로써 에러 코드가 너무 많아져서 중복되거나 더 이상 사용되지 않는 에러코드들을 줄여 관리하기 쉽도록 했다.

에러 코드로 예외를 생성하는 방식은 사용하지 않기로 했다.

  • throw new SignupAcceptTimeoutException("만료 시간이 지났습니다.");
  • 에러 코드를 통해 예외를 생성하지 않고, 위와 같이 예외가 발생하는 계층에 둔 예외를 던지게 했다.
  • 던져진 예외는 아래와 같이 exceptionhandler를 통해 에러 코드와 함께 응답하도록 했다.
    @ExceptionHandler(value = SignupAcceptTimeoutException.class)
    public ResponseEntity<ExceptionResponse> signupAcceptTimeoutExceptionHandler(SignupAcceptTimeoutException e) {
    		return ExceptionResponse.of(BusinessErrorCode.TIMEOUT, HttpStatus.BAD_REQUEST, e.getMessage());
    }
  • 이렇게 함으로써 상위 계층에서 하위 계층으로의 단방향성을 유지하도록 했다.

모든 예외를 controller advice에서 공통으로 처리하지 않기로 했다.

  • 가입 인증 timeout 예외 같이 특정 비즈니스 로직에서만 발생하는 예외는 해당 컨트롤러에서 처리하도록 했다.
  • controller advice에서는 바인딩 예외 같이 공통적으로 발생하는 예외만 처리하도록 했다.
  • 이렇게 함으로써 controller advice에서 너무 많은 예외를 처리해 복잡해지는 문제를 해결하려고 했다.

결론

프로젝트들을 거치면서 예외 처리 방식에 대해 고민하고 개선하려고 여러 시도를 해보고 있다.
아직 프로젝트가 진행중이고, 개선한 방식에도 문제가 있을 가능성이 많다고 생각한다...
꾸준히 고민해볼 예정이다.

0개의 댓글