기능 구현은 끝났는데 예외처리가 엉망이었다.
IllegalArgumentException, IllegalStateException이 뒤섞여 있어서
어떤 에러인지 파악하기 어려웠다. 도메인 전용 예외 클래스를 만들었다.
1. 왜 도메인 전용 예외가 필요한가
기존 코드는 이런 상태였다.
throw new IllegalArgumentException("결제를 찾을 수 없습니다.");
throw new IllegalArgumentException("결제 금액이 일치하지 않습니다.");
throw new IllegalStateException("승인된 결제만 환불할 수 있습니다.");
문제점이 여러 가지다.
- 모든 에러가 동일한 400 응답으로 처리된다
- "결제를 찾을 수 없습니다"는 404여야 하고, "본인의 결제가 아닙니다"는 403이어야 한다
- 에러 코드가 없어서 클라이언트가 원인을 파악하기 어렵다
2. PaymentErrorCode
공통 모듈의 ErrorCode 인터페이스를 구현했다.
@Getter
@RequiredArgsConstructor
public enum PaymentErrorCode implements ErrorCode {
PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "결제를 찾을 수 없습니다."),
PAYMENT_ALREADY_SUCCESS(HttpStatus.BAD_REQUEST, "이미 승인된 결제입니다."),
PAYMENT_INVALID_STATUS(HttpStatus.BAD_REQUEST, "현재 상태에서 처리할 수 없습니다."),
PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "결제 금액이 일치하지 않습니다."),
PAYMENT_CONFIRM_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "토스 결제 승인에 실패했습니다."),
PAYMENT_CANCEL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "토스 결제 취소에 실패했습니다."),
PAYMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 결제만 접근할 수 있습니다.");
private final HttpStatus status;
private final String message;
}
각 에러 코드에 적절한 HTTP 상태 코드를 매핑했다.
PAYMENT_NOT_FOUND→ 404PAYMENT_FORBIDDEN→ 403PAYMENT_CONFIRM_FAILED→ 500
3. PaymentException
공통 모듈의 BusinessException을 상속받아 구현했다.
public class PaymentException extends BusinessException {
public PaymentException(PaymentErrorCode errorCode) {
super(errorCode);
}
}
BusinessException은 공통 모듈에서 GlobalExceptionHandler가 처리하도록 설계되어 있다.
4. PaymentSuccessCode
성공 응답도 도메인별로 의미있는 코드로 분리했다.
@Getter
@RequiredArgsConstructor
public enum PaymentSuccessCode implements SuccessCode {
PAYMENT_CREATED(HttpStatus.CREATED, "결제가 생성되었습니다."),
PAYMENT_CONFIRMED(HttpStatus.OK, "결제가 승인되었습니다."),
PAYMENT_REFUNDED(HttpStatus.OK, "결제가 환불되었습니다."),
PAYMENT_FOUND(HttpStatus.OK, "결제를 조회했습니다."),
PAYMENT_LIST_FOUND(HttpStatus.OK, "결제 목록을 조회했습니다.");
private final HttpStatus status;
private final String message;
}
5. Payment 엔티티 상태 전이 가드
상태 전이 유효성 검증을 엔티티 내부로 이동했다.
// 결제 승인
public void confirm(String paymentKey, LocalDateTime approvedAt) {
if (this.status != PaymentStatus.PENDING && this.status != PaymentStatus.FAILED) {
throw new PaymentException(PaymentErrorCode.PAYMENT_INVALID_STATUS);
}
this.paymentKey = paymentKey;
this.status = PaymentStatus.SUCCESS;
this.approvedAt = approvedAt;
}
// 결제 실패
public void fail(int retryTtlSeconds) {
if (this.status != PaymentStatus.PENDING) {
throw new PaymentException(PaymentErrorCode.PAYMENT_INVALID_STATUS);
}
this.status = PaymentStatus.FAILED;
this.retryExpiredAt = LocalDateTime.now().plusSeconds(retryTtlSeconds);
}
// 환불
public void refund() {
if (this.status != PaymentStatus.SUCCESS) {
throw new PaymentException(PaymentErrorCode.PAYMENT_INVALID_STATUS);
}
this.status = PaymentStatus.REFUNDED;
}
서비스 레이어에서 상태를 직접 검증하지 않아도 된다. 엔티티가 잘못된 상태 전이를 스스로 막는다.
6. 서비스 예외 교체
기존 IllegalArgumentException → PaymentException으로 교체했다.
// Before
.orElseThrow(() -> new IllegalArgumentException("결제를 찾을 수 없습니다."));
// After
.orElseThrow(() -> new PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND));
// Before
throw new IllegalArgumentException("결제 금액이 일치하지 않습니다.");
// After
throw new PaymentException(PaymentErrorCode.PAYMENT_AMOUNT_MISMATCH);
7. 코드래빗 리뷰 대응
예외 타입 분리 요청
코드래빗이 PaymentQueryService에서 미존재(404)와 권한 없음(403)을 둘 다 IllegalArgumentException으로 처리한다고 지적했다.
PaymentErrorCode 구현 이슈에서 PaymentException으로 통합 처리할 예정입니다.
현재는 임시로 IllegalArgumentException을 사용합니다.이번 이슈에서 전부 PaymentException으로 교체했다.
마무리
예외처리를 도메인 전용으로 분리하면서 세 가지를 얻었다.
첫째, HTTP 상태 코드가 의미있어졌다. 404, 403, 500이 각각 다른 상황을 표현한다.
둘째, 상태 전이 가드가 엔티티 내부로 들어갔다. 서비스가 아닌 도메인이 스스로를 보호한다.
셋째, 에러 코드가 생겨서 클라이언트가 원인을 파악하기 쉬워졌다.
다음 편에서는 Config Server 연동 삽질기를 다룰 예정이다.
'Spring 개발일지' 카테고리의 다른 글
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 아웃박스 패턴 구현 (0) | 2026.05.08 |
|---|---|
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - Config Server 연동 삽질기 (0) | 2026.05.07 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 환불 및 결제 조회 API 구현 (2) | 2026.05.04 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 토스 ACL + 결제 승인 API 구현 삽질기 (0) | 2026.05.01 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 생성 API 구현 (1) | 2026.04.30 |