결제 로직 구현 중 발생한 Spring Transaction 트러블슈팅

Xylitol311·2026년 1월 15일

Back-end

목록 보기
8/14

게임 플랫폼의 게임 시작 및 종료 로직과 결제 로직을 개발하면서 트랜잭션 관련 문제들을 겪었다. 추후 같은 문제를 겪지 않기 위해 문제 원인과 해결과정을 기록한다. 아래 내용은 Stripe 결제 연동, 게임 세션 관리 과정에서 발생한 문제들이다.

문제 1: "Connection is read-only" 오류

상황

결제 API를 호출하면 Connection is read-only 오류가 발생했다. 크레딧 로그를 INSERT하는 과정에서 DB 연결이 읽기 전용 모드라는 것이다.

원인

서비스 클래스 레벨에 @Transactional(readOnly=true)를 설정해두었는데, charge() 메서드에서 이를 오버라이드하지 않았다. 조회 위주 서비스라 클래스 레벨에 readOnly를 걸어뒀지만, 일부 메서드는 쓰기 작업이 필요했던 것이다.

해결

간단하게 해당 메서드에 @Transactional을 명시적으로 추가했다.

@Override
@Transactional  // readOnly=false (기본값)
public ChargeResponse charge(Long userId, ChargeRequest request) {
    // INSERT/UPDATE 정상 동작
}

교훈: 클래스 레벨 트랜잭션 설정은 메서드 레벨에서 명시적으로 오버라이드하지 않으면 상속된다. 조회 위주 서비스라도 쓰기가 필요한 메서드는 반드시 @Transactional을 붙이는 것이 좋다.


문제 2: 결제 실패 시 기록이 사라지는 문제

상황

Stripe 결제가 실패했을 때 PaymentHistory 테이블에 실패 기록이 남지 않았다. 감사 추적(Audit Trail)이 불가능하고, 사용자가 "결제했는데 왜 안되냐"고 문의해도 확인할 방법이 없었다.

원인

결제 로직이 하나의 트랜잭션으로 묶여 있어서, Stripe API 호출이 실패하면 전체가 롤백되는 구조였다.

해결

PaymentHistoryManager라는 별도 클래스를 만들고, @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용해 독립 트랜잭션으로 분리했다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markFailed(PaymentHistory payment, String reason) {
    payment.markFailed(reason);
    paymentHistoryRepository.save(payment);
    // 즉시 커밋 (메인 트랜잭션과 무관)
}

이제 흐름이 이렇게 바뀌었다:

결과: 성공이든 실패든 모든 결제 시도가 DB에 기록되어 감사 추적이 가능해졌다.

교훈: 감사 기록처럼 반드시 남아야 하는 데이터는 REQUIRES_NEW로 메인 비즈니스 로직과 분리해야 한다.


문제 3: 3DS2 인증 시 Transaction ID 손실

상황

3D Secure 2 인증이 필요한 카드로 결제하면, 사용자가 인증을 완료해도 Stripe 웹훅에서 결제 내역을 찾지 못했다. Transaction ID가 DB에 저장되지 않아서였다.

원인

3DS2 인증이 필요한 경우 Stripe는 requires_action 상태를 반환한다. 이때 Transaction ID를 저장하고 예외를 던져 클라이언트에게 인증을 요청하는데, 예외로 인해 트랜잭션이 롤백되면서 Transaction ID도 사라졌다.

// 문제 코드
if ("requires_action".equals(intent.getStatus())) {
    payment.updateTransactionId(intent.getId());
    throw new ServiceException(...);  // 롤백 발생!
}

해결

Transaction ID 업데이트를 독립 트랜잭션으로 분리했다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateTransactionIdForRequiresAction(PaymentHistory payment, String transactionId) {
    payment.updateTransactionId(transactionId);
    paymentHistoryRepository.save(payment);
    // 즉시 커밋 (예외 발생해도 이미 저장됨)
}

플로우:

sequenceDiagram
    participant Client
    participant API
    participant DB
    participant Stripe

    Client->>API: 결제 요청
    API->>Stripe: PaymentIntent 생성
    Stripe-->>API: requires_action
    API->>DB: Transaction ID 저장 (독립 트랜잭션) ✅
    API-->>Client: 인증 필요 응답
    Client->>Stripe: 3DS2 인증 완료
    Stripe->>API: 웹훅 호출
    API->>DB: Transaction ID로 조회 성공 ✅
    API->>DB: 결제 완료 처리

교훈: 비동기 후속 처리가 있는 경우 식별자는 예외 발생 전에 반드시 커밋되어야 한다.


문제 4: 게임 종료 중복 요청과 최고 점수 오류

상황

게임 종료 API를 중복 호출하면 같은 게임이 여러 번 기록되었다. 게다가 최고 점수를 달성해도 UI에서 축하 메시지가 표시되지 않았다.

원인

  1. 중복 방지 로직 부재: 이미 종료된 세션인지 검증하지 않음
  2. 로직 순서 오류: 게임 기록을 DB에 저장한 후 최고 점수를 확인해서, 방금 저장한 기록이 포함되어 항상 false가 반환됨

해결

1단계: 중복 종료 시 명시적 예외 발생

if (creditLog.getGameSessionEndedAt() != null) {
    throw new SessionAlreadyEndedException();  // HTTP 409
}

2단계: 최고 점수 확인을 저장 전으로 이동

// 저장 전에 최고 점수 확인
boolean isNewHighScore = userGameService.isNewHighScore(userId, gameId, score);

// 이후 게임 기록 저장
userGameRepository.save(userGame);

결과:

  • 중복 요청 시 409 Conflict 반환으로 멱등성 보장
  • 최고 점수 판정 정확도 100% 달성

교훈: 계산 결과가 저장에 영향받는 경우 로직 순서가 중요하다. 조회 → 검증 → 상태 변경 순서를 지켜야 한다.


정리: 트랜잭션 설계 원칙

이번 트러블슈팅을 통해 배운 원칙들을 정리하면:

1. 감사 기록은 독립 트랜잭션으로

REQUIRES_NEW를 사용해 메인 로직 실패와 무관하게 저장한다.

2. 클래스 레벨 트랜잭션 주의

readOnly=true는 메서드에서 명시적으로 오버라이드해야 한다.

3. 트랜잭션 경계 최소화

외부 API 호출과 DB 트랜잭션을 분리해 필요한 부분만 롤백한다.

4. 멱등성 보장

중복 요청은 명시적으로 검증하고 적절한 HTTP 상태 코드(409)를 반환한다.

5. 로직 순서 최적화

조회 → 검증 → 상태 변경 순서를 지키고, 검증 실패 시 빠르게 실패한다.


Spring 트랜잭션은 강력하지만, 제대로 이해하지 않으면 이번처럼 예상치 못한 문제를 만날 수 있다. 특히 외부 API 연동이나 비동기 처리가 있는 경우 트랜잭션 전파 수준과 경계를 신중하게 고려하고 설계해야 한다는 것을 체감했다.

profile
문제에 도전하고 성장하는 백엔드 개발자입니다.

0개의 댓글