[Spring] - 공통 예외 처리 적용하기(1)

공통 예외 처리 적용하기(1)

현재 개발 중인 프로젝트에서 기존에 @RestControllerAdvice, @ExceptionHandler 를 통해 CustomApiException 이라는 커스텀 예외를 만들어서 처리하도록 정의해놓은게 있었는데, 모든 도메인의 비즈니스 로직에서 예외 상황에 CustomApiException 예외를 던지도록 하니 유지보수와 가독성 측면에서 불편한 것 같아 도메인 별로 공통 예외 처리를 적용하기로 했습니다.

1. 현재 예외처리 구조

도메인 별 공통 예외

현재는 RuntimeException 을 상속한 CustomApiException 을 사용하고 있으며, 비즈니스 로직에서 throw new CustomApiException() 을 통해 예외를 던지면, 이를 CustomApiExceptionHandler 가 잡아 클라이언트에게 전달하는 구조입니다.

여기에 description 필드를 새로 추가하여, 기존 message 는 응답 enum 의 이름으로, description 은 메시지 설명으로 활용하려고 합니다.


2. 도메인 별로 예외 분리

도메인별로 분리하기 위해서 RuntimeException 을 상속받는 추상 클래스 BaseException 을 만들고, 각 도메인별 예외 클래스는 이 BaseException 을 상속받도록 구조를 변경합니다.

 

❓ 왜 추상 클래스를 사용하는지?

더보기

자바에서 예외를 던지기 위해서는 반드시 Throwable 을 상속해야 합니다.
즉, throw new CustomApiException() 처럼 사용하려면, 해당 클래스는 Throwable (또는 그 하위 클래스)을 상속받아야 하죠.

만약 BaseException 을 인터페이스로 만들 경우, RuntimeException 을 동시에 상속받는 것은 문법적으로 불가능합니다.

interface BaseException extends RuntimeException // 문법적으로 불가능

또한, 스프링의 @ExceptionHandler 는 클래스 타입으로 예외를 매핑하기 때문에 인터페이스 기반으로는 예외 처리가 정상적으로 동작하지 않습니다.

@ExceptionHandler(BaseException.class) // 인터페이스는 불가능

이러한 이유로 BaseException 을 추상 클래스로 설계하고, 공통 예외 처리 로직은 BaseExceptionHandler 에서 처리할 수 있도록 구조를 개선하려고 합니다.

BaseException 을 적용하여 구조를 아래와 같이 변경하였습니다.

도메인별 예외클래스 분리


3. 변경된 소스 코드

3.1. BaseException

@Getter
public abstract class BaseException extends RuntimeException {
    private final ResCode resCode;

    protected BaseException(ResCode resCode) {
        super(resCode.getMessage());
        this.resCode = resCode;
}

BaseException 은 RuntimeException 을 상속받는 추상 클래스입니다.
생성자에서는 ResCode 를 전달받아, 해당 코드의 메시지를 super() 를 통해 예외 메시지로 설정합니다.
이를 통해 공통된 예외 처리 구조를 만들고, 도메인별 예외 클래스들이 BaseException 을 확장해 일관된 방식으로 동작하도록 합니다.


3.2. PaymentConfirmException

@Getter
public class PaymentConfirmException extends BaseException {
    public PaymentConfirmException(ResCode resCode) {
   	 super(resCode);
    }
}

PaymentConfirmExceptionBaseException 을 상속받는 도메인별 예외 클래스입니다.
생성자에서는 ResCode 를 전달받아, 상위 클래스인 BaseException 의 생서자를 호출함으로써 예외 메시지와 코드 정보를 설정합니다.

이제 결제 승인 비즈니스 로직에서는 예외 상황 발생 시 throw new PaymentConfirmException() 을 통해 결제 승인 예외를 발생시킬 수 있습니다.

이제 예외에서 공통적으로 사용하는 ResCode 인터페이스를 작성합니다.


3.3. ResCode

public interface ResCode {
    HttpStatusCode getHttpStatusCode();
    Integer getCode();
    String getMessage();
    String getDescription(); // 기존에 없던 description 필드 추가
}

ResCode 는 예외 응답에 필요한 정보를 제공하기 위해 모든 응답 코드 Enum이 구현해야 하는 인터페이스 입니다.
기존에는 getMessage() 만 사용했지만, 이번 변경을 통해 getDescription() 메서드를 추가하여 실제 응답 메시지로 사용할 수 있도록 했습니다.

앞으로는 messageenum 의 이름(예: INVALID_REQUEST)으로 사용하고, 실제 사용자에게 전달되는 설명 메시지는 description 필드를 통해 제공할 예정입니다.


3.4. PaymentErrorCode

@Getter
@RequiredArgsConstructor
public enum PaymentErrorCode implements ResCode {
    ALREADY_PROCESSED_PAYMENT(HttpStatus.BAD_REQUEST, 10000, "이미 처리된 결제 입니다."),
    PROVIDER_ERROR(HttpStatus.BAD_REQUEST, 10000, "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
    EXCEED_MAX_CARD_INSTALLMENT_PLAN(HttpStatus.BAD_REQUEST, 10000, "설정 가능한 최대 할부 개월 수를 초과했습니다."),
    INVALID_REQUEST(HttpStatus.BAD_REQUEST, 10000, "잘못된 요청입니다."),
    NOT_ALLOWED_POINT_USE(HttpStatus.BAD_REQUEST, 10000, "포인트 사용이 불가한 카드로 카드 포인트 결제에 실패했습니다."),
    INVALID_API_KEY(HttpStatus.BAD_REQUEST, 10000, "잘못된 시크릿키 연동 정보 입니다."),
    INVALID_REJECT_CARD(HttpStatus.BAD_REQUEST, 10000, "카드 사용이 거절되었습니다. 카드사 문의가 필요합니다."),
    BELOW_MINIMUM_AMOUNT(HttpStatus.BAD_REQUEST, 10000, "신용카드는 결제금액이 100원 이상, 계좌는 200원이상부터 결제가 가능합니다."),
    INVALID_CARD_EXPIRATION(HttpStatus.BAD_REQUEST, 10000, "카드 정보를 다시 확인해주세요. (유효기간)"),
    INVALID_STOPPED_CARD(HttpStatus.BAD_REQUEST, 10000, "정지된 카드 입니다."),
    EXCEED_MAX_DAILY_PAYMENT_COUNT(HttpStatus.BAD_REQUEST, 10000, "하루 결제 가능 횟수를 초과했습니다."),
    NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT(HttpStatus.BAD_REQUEST, 10000, "할부가 지원되지 않는 카드 또는 가맹점 입니다."),
    INVALID_CARD_INSTALLMENT_PLAN(HttpStatus.BAD_REQUEST, 10000, "할부 개월 정보가 잘못되었습니다."),
    NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN(HttpStatus.BAD_REQUEST, 10000, "할부가 지원되지 않는 카드입니다."),
    EXCEED_MAX_PAYMENT_AMOUNT(HttpStatus.BAD_REQUEST, 10000, "하루 결제 가능 금액을 초과했습니다."),
    NOT_FOUND_TERMINAL_ID(HttpStatus.BAD_REQUEST, 10000, "단말기번호(Terminal Id)가 없습니다. 토스페이먼츠로 문의 바랍니다."),
    INVALID_AUTHORIZE_AUTH(HttpStatus.BAD_REQUEST, 10000, "유효하지 않은 인증 방식입니다."),
    INVALID_CARD_LOST_OR_STOLEN(HttpStatus.BAD_REQUEST, 10000, "분실 혹은 도난 카드입니다."),
    RESTRICTED_TRANSFER_ACCOUNT(HttpStatus.BAD_REQUEST, 10000, "계좌는 등록 후 12시간 뒤부터 결제할 수 있습니다. 관련 정책은 해당 은행으로 문의해주세요."),
    INVALID_CARD_NUMBER(HttpStatus.BAD_REQUEST, 10000, "카드번호를 다시 확인해주세요."),
    INVALID_UNREGISTERED_SUBMALL(HttpStatus.BAD_REQUEST, 10000, "등록되지 않은 서브몰입니다. 서브몰이 없는 가맹점이라면 안심클릭이나 ISP 결제가 필요합니다."),
    NOT_REGISTERED_BUSINESS(HttpStatus.BAD_REQUEST, 10000, "등록되지 않은 사업자 번호입니다."),
    EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT(HttpStatus.BAD_REQUEST, 10000, "1일 출금 한도를 초과했습니다."),
    EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT(HttpStatus.BAD_REQUEST, 10000, "1회 출금 한도를 초과했습니다."),
    CARD_PROCESSING_ERROR(HttpStatus.BAD_REQUEST, 10000, "카드사에서 오류가 발생했습니다."),
    EXCEED_MAX_AMOUNT(HttpStatus.BAD_REQUEST, 10000, "거래금액 한도를 초과했습니다."),
    INVALID_ACCOUNT_INFO_RE_REGISTER(HttpStatus.BAD_REQUEST, 10000, "유효하지 않은 계좌입니다. 계좌 재등록 후 시도해주세요."),
    NOT_AVAILABLE_PAYMENT(HttpStatus.BAD_REQUEST, 10000, "결제가 불가능한 시간대입니다"),
    UNAPPROVED_ORDER_ID(HttpStatus.BAD_REQUEST, 10000, "아직 승인되지 않은 주문번호입니다."),
    EXCEED_MAX_MONTHLY_PAYMENT_AMOUNT(HttpStatus.BAD_REQUEST, 10000, "당월 결제 가능금액인 1,000,000원을 초과 하셨습니다."),
    UNAUTHORIZED_KEY(HttpStatus.UNAUTHORIZED, 10000, "인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다."),
    REJECT_ACCOUNT_PAYMENT(HttpStatus.FORBIDDEN, 10000, "잔액부족으로 결제에 실패했습니다."),
    REJECT_CARD_PAYMENT(HttpStatus.FORBIDDEN, 10000, "한도초과 혹은 잔액부족으로 결제에 실패했습니다."),
    REJECT_CARD_COMPANY(HttpStatus.FORBIDDEN, 10000, "결제 승인이 거절되었습니다."),
    FORBIDDEN_REQUEST(HttpStatus.FORBIDDEN, 10000, "허용되지 않은 요청입니다."),
    REJECT_TOSSPAY_INVALID_ACCOUNT(HttpStatus.FORBIDDEN, 10000, "출금이체 등록이 안 된 계좌입니다. 계좌를 다시 등록해주세요."),
    EXCEED_MAX_AUTH_COUNT(HttpStatus.FORBIDDEN, 10000, "최대 인증 횟수를 초과했습니다."),
    EXCEED_MAX_ONE_DAY_AMOUNT(HttpStatus.FORBIDDEN, 10000, "일일 한도를 초과했습니다."),
    NOT_AVAILABLE_BANK(HttpStatus.FORBIDDEN, 10000, "은행 서비스 시간이 아닙니다."),
    INVALID_PASSWORD(HttpStatus.FORBIDDEN, 10000, "결제 비밀번호가 일치하지 않습니다."),
    INCORRECT_BASIC_AUTH_FORMAT(HttpStatus.FORBIDDEN, 10000, "잘못된 요청입니다. ':' 를 포함해 인코딩해주세요."),
    FDS_ERROR(HttpStatus.FORBIDDEN, 10000, "위험거래가 감지되어 결제가 제한됩니다. 문자 내 링크로 본인 인증이 필요합니다."),
    NOT_FOUND_PAYMENT(HttpStatus.NOT_FOUND, 10000, "존재하지 않는 결제 정보 입니다."),
    NOT_FOUND_PAYMENT_SESSION(HttpStatus.NOT_FOUND, 10000, "결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다."),
    FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING(HttpStatus.INTERNAL_SERVER_ERROR, 10000, "결제가 완료되지 않았어요. 다시 시도해주세요."),
    FAILED_INTERNAL_SYSTEM_PROCESSING(HttpStatus.INTERNAL_SERVER_ERROR, 10000, "내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요."),
    UNKNOWN_PAYMENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 10000, "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."),

    PAYMENT_CONFIRM_ERROR_MISMATCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 10000, "결제 과정에서 서버 에러가 발생했습니다. 관리자에게 문의해주세요.");

    private final HttpStatusCode httpStatusCode;
    private final Integer code;
    private final String description;

    public static PaymentErrorCode findByName(String name) {
        return Arrays.stream(values())
            .filter(v -> v.name().equals(name))
            .findAny()
            .orElse(PAYMENT_CONFIRM_ERROR_MISMATCH_ERROR);
    }

    @Override
    public String getMessage() {
        return this.name();
    }
}

이 클래스는 토스페이먼츠 결제 승인 요청 시 발생할 수 있는 오류 코드들을 정의한 enum 입니다. 토스페이먼츠 결제승인 에러코드 API문서
각 항목은 HTTP 상태 코드, 내부 코드(code), 사용자 설명 메시지(description)을 포함하고 있으며, ResCode 인터페이스를 구현하여 공통 예외 응답 포맷을 따르도록 설계되었습니다.

  • code 는 아직 확정되지 않은 내부 정의 코드로, 현재는 임의로 10000을 지정해두었습니다.
  • @Getter 어노테이션을 사용해 httpStatusCode, code, description 에 대한 getter 메서드는 자동 생성됩니다.
  • getMessage()enum 이름 자체를 반환하도록 별도록 구현했으며, 응답 메시지의 식별자로 활용됩니다.
  • findByName() 는 비즈니스 로직에서 문자열로 enum 을 조회할 수 있도록 도와주는 유틸성 메서드입니다.

💡 (참고) findByName() 메서드는 별도의 유틸 클래스로 추출하였습니다.

다른 enum 클래스에서도 빈번히 사용될 것 같아, 해당 메서드는 별도의 유틸 클래스로 추출하였으니 참고 부탁드립니다.

public class EnumUtils {
    public static <T extends Enum<T>> T findByNameOrThrow(Class<T> enumClass, String name) {
        return Arrays.stream(enumClass.getEnumConstants())
            .filter(e -> e.name().equals(name))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("알수없는 Enum 명입니다. " + name));
    }
}

3.5. BaseExceptionHandler

이 클래스는 앞서 정의한 PaymentConfirmException 을 포함해, 추후 추가될 모든 BaseException 의 하위 예외들을 공통으로 처리하기 위한 예외 핸들러 입니다.

@Slf4j(topic = "BaseExceptionHandler")
@RestControllerAdvice
@Order(value = Integer.MIN_VALUE)
public class BaseExceptionHandler {

    @ExceptionHandler(BaseException.class)
    public ResponseEntity<CustomApiResponse<Void>> handleBaseExceptionHandler(BaseException baseException) {
        log.error("BaseException exception occurred: {}", baseException.getMessage(), baseException);

        ResCode errorCode = baseException.getResCode();
        return ResponseEntity
            .status(errorCode.getHttpStatusCode())
            .body(CustomApiResponse.ERROR(errorCode, errorCode.getDescription()));
    }
}

@ExceptionHandler(BaseException.class) 를 통해 BaseException 또는 그 자식 클래스의 예외가 발생했을 때 이 메서드가 호출되도록 설정했습니다.
예외 발생 시 ResCode 를 추출해 로그를 남기고, CustomApiResponse.ERROR() 정적 메서드를 통해 공통된 에러 응답 형식으로 클라이언트에게 전달합니다.

 

// CustomApiResponse의 일부
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL) // Null 필드 제외
@NoArgsConstructor
public class CustomApiResponse<T> {

    private HttpStatusCode httpStatusCode;
    private Integer code;
    private String message;
    private String description;
    private List<String> errorList;
    private T data;

    private CustomApiResponse(ResCode resCode, List<String> errorList, T data) {
        this.httpStatusCode = resCode.getHttpStatusCode();
        this.code = resCode.getCode();
        this.message = resCode.getMessage();
        this.description = resCode.getDescription() != null ? resCode.getDescription() : null;
        this.errorList = errorList;
        this.data = data;
    }

    // ...
    
    public static CustomApiResponse<Void> ERROR(ResCode resCode) {
        return new CustomApiResponse<>(resCode, null, null);
    }
    
    // ...
}

CustomApiResponse 는 프로젝트에서 사용하는 공통 응답 포맷입니다.
에러 응답을 생성할 때는 ERROR() 정적 메서드를 통해 간편하게 생성할 수 있으며, 필요 시 에러 메시지(description)나 에러 목록(errorList)도 포함할 수 있습니다.


4. 최종 구조 및 테스트 결과

도메인별 예외 분리 최종 구조

도메인별 예외를 분리한 최종 구조는 외와 같습니다.
각 도메인의 책임에 맞게 예외 클래스를 정의함으로써, 예외 처리의 명확성과 확장성을 확보했습니다.

 

4.1. 토스페이먼츠 결제 승인 API 예외 처리 테스트

변경한 예외 처리 구조가 실제로 잘 동작하는지 확인하기 위해, 토스페이먼츠 결제 승인 API 연동 로직에서 PaymentConfirmException 예외를 발생시키고, 클라이언트에게 의도한 예외 메시지가 전달되는지 테스트해 보았습니다.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final RestClient restClient;
    private final PaymentProperties paymentProperties;
    private final ObjectMapper objectMapper;

    public PaymentConfirmResponse confirmPayment(PaymentConfirmRequest confirmRequest) {
        return restClient.method(HttpMethod.POST)
            .uri(paymentProperties.getConfirmEndpoint())
            .contentType(MediaType.APPLICATION_JSON)
            .body(confirmRequest)
            .retrieve()
            .onStatus(HttpStatusCode::isError, (request, response) -> {
                throw new PaymentConfirmException(getPaymentConfirmErrorCode(response)); // 여기서 예외 발생!!!
            })
            .body(PaymentConfirmResponse.class);
    }

    private PaymentErrorCode getPaymentConfirmErrorCode(ClientHttpResponse response) throws IOException {
        PaymentConfirmFailOutput paymentConfirmFailOutput = objectMapper.readValue(response.getBody(),
            PaymentConfirmFailOutput.class);
        return PaymentErrorCode.findByName(paymentConfirmFailOutput.code);
    }

    private record PaymentConfirmFailOutput(String code, String message, String data){}

}

결제 승인 API 요청 시 에러가 발생하면 onStatus() 의 로직이 실행되고, 응답 본문에 포함된 에러 코드 문자열(예: NOT_FOUND_PAYMENT_SESSION)을 기반으로 해당하는 PaymentErrorCode enum을 찾아 PaymentConfirmException 을 던지게 됩니다.

이 예외는 BaseExceptionHandler 에서 처리되어, 아래와 같이 의도한 에러 메시지가 클라이언트에게 전달됩니다.

테스트 및 결과

 

4.2. 테스트 및 결과 요약

이번 작업을 통해, 기존에 하나의 CustomApiException 으로 통합 처리하던 구조에서 도메인별로 예외를 명확히 분리하여, 다음과 같은 효과를 얻을 수 있었습니다.

  • 예외의 의도와 책임이 명확해짐
  • 코드의 가독성 및 유지보수성이 향상
  • 추후 새로운 도메인이 추가될 때도 확장하기 쉬운 구조

프로젝트의 규모와 무관하게 예외를 도메인 단위로 설계해 두는 것은 디버깅 편의성, 모듈화된 책임관리, API 일관성 유지 측면에서도 더 나은 선택이 되지 않을까 생각합니다. (제 개인적인 생각...😂)

 

다음 글...

[Spring] - 공통 예외 처리 적용하기(2)

'Spring' 카테고리의 다른 글

[Spring] AOP 동작 원리  (1) 2025.05.23
[Spring] - 공통 예외 처리 적용하기(2)  (0) 2025.04.06