관리 메뉴

백엔드 엔지니어 이재혁

결제 시스템: Enum을 활용한 유한 상태 기계 만들기 본문

케어매칭 서비스 배포

결제 시스템: Enum을 활용한 유한 상태 기계 만들기

alex00728 2025. 11. 11. 16:04

잘못된 결제 처리를 막자

결제가 완료되었는데 결제 대기 상태로 변경된다면? 사용자는 2번 결제하게 될 수도 있다.

이런 문제는 당연히 막아야 한다. 그런데, 어떻게 효과적으로 막을 것인가?

 

서비스 레이어의 모든 비즈니스 로직에서 상태 변화를 모두 추적할 것인가?

도메인에 유한 상태 기계 로직을 표현하면 도메인 스스로 잘못된 상태 변경을 막아줄 수 있다.

 

결제 상태 관리

 

우리 시스템에서 결제 시스템은 위와 같은 상태 변화를 갖는다. `SUCCESS` 였는데 `PENDING`이 되는 것은 불가능하다. 위와 같은 상태 변화 규칙을 도메인에 로직으로 담아보자.

 

TransactionStatus Enum

public enum TransactionStatus {
    PENDING,  // 결제 대기 중 (사용자가 결제 진행 중)
    PENDING_RETRY, // 결제 재시도 대기 중 (PG사 복구 후 자동 재시도 예정)
    SUCCESS,  // 결제 성공
    FAILED,   // 결제 실패
    CANCELED, // 결제 취소
    REFUNDED; // 환불 완료

    // 상태 전이 규칙을 정의하는 맵
    // Key: 현재 상태, Value: 전이 가능한 다음 상태의 Set
    private static final Map<TransactionStatus, Set<TransactionStatus>> allowedTransitions = new EnumMap<>(TransactionStatus.class);

    static {
        // PENDING 상태에서 변경 가능한 상태들
        allowedTransitions.put(PENDING, EnumSet.of(
            SUCCESS,
            FAILED,
            CANCELED,
            PENDING_RETRY
        ));

        // SUCCESS 상태에서 변경 가능한 상태들
        allowedTransitions.put(SUCCESS, EnumSet.of(
            REFUNDED
        ));

        // PENDING_RETRY 상태에서 변경 가능한 상태들
        allowedTransitions.put(PENDING_RETRY, EnumSet.of(
            SUCCESS,
            FAILED,
            CANCELED
        ));

        // FAILED, CANCELED, REFUNDED는 '최종 상태'로, 다른 상태로 변경 불가
        allowedTransitions.put(FAILED, EnumSet.noneOf(TransactionStatus.class));
        allowedTransitions.put(CANCELED, EnumSet.noneOf(TransactionStatus.class));
        allowedTransitions.put(REFUNDED, EnumSet.noneOf(TransactionStatus.class));
    }

    /**
     * 현재 상태(this)에서 nextState로 변경이 가능한지 확인
     */
    public boolean canTransitionTo(TransactionStatus nextState) {
        // 같은 상태로의 변경은 항상 허용
        if (this == nextState) {
            return true;
        }

        // 맵에 정의된 규칙 확인
        return allowedTransitions.get(this).contains(nextState);
    }
}

 

`TransactionStatus` 안에 상태 전이 규칙을 정의했다. 이제는 규칙을 적용해보자.

 

Transaction 엔티티

@Table(name = "transaction")
public class Transaction {
    // ...
    
    @NotNull
    @Enumerated(EnumType.STRING)
    @Column(name = "STATUS", nullable = false)
    @Setter(AccessLevel.NONE)
    private TransactionStatus transactionStatus = TransactionStatus.PENDING;
    
    /**
     * 트랜잭션의 상태를 변경합니다.
     * 이 메서드는 정의된 상태 전이 규칙(State Machine)을 따릅니다.
     * @param nextState 변경하려는 다음 상태
     * @throws IllegalTransactionStateException 규칙에 어긋나는 상태 변경 시
     */
    public void changeTransactionStatus(TransactionStatus nextState) {
        if (!this.transactionStatus.canTransitionTo(nextState)) {
            throw new IllegalTransactionStateException(
                "잘못된 상태 변경입니다: " + this.transactionStatus + " -> " + nextState +
                    " (OrderId: " + this.orderId + ")"
            );
        }

        // 규칙 통과 시 상태 변경
        this.transactionStatus = nextState;
    }
}

 

`@Setter(AccessLevel.NONE)`를 설정하고, `changeTransactionStatus` 메서드를 정의하였다.

`Transaction` 엔티티의 `TransactionStatus`를 변경하고 싶으면, `setTransactionStatus`가 아니라 `changeTransactionStatus`를 호출하도록 강제한다.

 

비즈니스 로직에서는 이제 `changeTransactionStatus`를 호출한다.

잘못된 상태 변경이 일어나면 작업이 취소되며 로그가 남는다.

 

예시

아래는 승인에 실패한 결제를 재시도 대기 상태로 바꿔주는 메서드다.

@Transactional
protected TransactionDetailDTO fallbackForConfirm(PaymentConfirmRequestDTO paymentConfirmRequestDTO) {
    Transaction transaction = transactionRepository.findByOrderId(paymentConfirmRequestDTO.getOrderId())
            .orElseThrow(() -> new IllegalStateException("Fallback: 존재하지 않는 주문 ID에 대한 승인 요청입니다. orderId=" + paymentConfirmRequestDTO.getOrderId()));
    transaction.changeTransactionStatus(TransactionStatus.PENDING_RETRY);
    
    // ...

    log.warn("{} confirm fallback: 결제 재시도 상태로 전환. orderId={}",
        provider, request.getOrderId());
}

 

만약 어떤 실수로 TransactionStatus가 `SUCCESS`인 transaction을 조회했다고 가정해보자.

TransactionStatus 상태 전이 규칙이 없는 경우: `changeTransactionStatus(PENDING_RETRY)` 정상 실행

TransactionStatus 상태 전이 규칙이 있는 경우: `changeTransactionStatus(PENDING_RETRY)` 실행 거부

 

개발자의 실수나 사용자의 잘못된 접근을 꽤나 효과적으로 막아줄 수 있다. 명백히 잘못된 상태 변화는 도메인 스스로 막음으로서 개발자가 신경써야 할 예외가 크게 줄어든다. 올바른 상태 전이에 대한 고민만 하면 된다.

 

한계

물론 이게 모든 문제를 해결해주지는 않는다.

 

`PENDING` 상태에서 결제 승인에 성공했는데 `SUCCESS` 상태로 바꾸지 않고 `FAILED` 상태로 바꾸는 코드를 작성한다면? 못막는다.

 

그건 개발자가 책임져야 할 문제...

테스트 코드가 필요한 때이다.