Java

구독 시스템 - 구독 상태 및 Entity 설계

DoRightting 2024. 11. 20. 16:01

 카카오 페이 결제 시스템 구축으로 인해 결제가 가능한 서비스를 개발하게 되었다. 단순 결제를 통해 배운 트랜잭션과 격리 수준에 대해 새롭게 배우고, 결제 시스템 꼭 필요한 멱등성 관리에 대해 학습할 수 있게 되었다.

 

 결제 시스템을 학습하다보니 단순 결제가 아니라 정기 결제도 많이 사용되고 있는데 이건 어떻게 구현할 수 있을까 생각해보다가 구현해보게 되었다. 

 

 먼저 구독은 상태 설계가 중요해보였다. 왜냐하면 상태 관리에 따라 결제가 이루어지기 때문에 현재 어떤 상태인지를 체크하고, 상태를 변경하며 관리할 수 있을까에 대해서 고민해보았다. 

 

 1. 구독 상태를 관리할 수 있는 enum을 만들어 가능한 모든 구독 상태의 경우의 수를 고려해서 설계했다. 

 

public enum SubscriptionStatus {
    PAYMENT_PENDING,  // 결제 대기
    ACTIVE,          // 활성화
    PAYMENT_FAILED,  // 결제 실패
    CANCELLED,       // 취소됨
    EXPIRED         // 만료됨
}

 

 

  • 결제가 수반되는 구독 시스템에서는 결제 상태 관리가 중요
  • 사용자의 명시적인 취소와 시스템에 의한 만료를 구분할 필요가 있음
  • 결제 실패에 대한 별도 상태가 필요함

 

2. 이제 상태 관리를 위한 entity 설계가 필요했다. 

 

@Entity
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Subscription {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String subscriptionId;

    @Column(nullable = false)
    private String memberId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private SubscriptionStatus status;

    @Column(nullable = false)
    private Long currentPrice;

    private LocalDateTime startDate;
    private LocalDateTime endDate;
    private LocalDateTime nextPaymentDate;
    private LocalDateTime cancelledAt;
    private String cancelReason;

    @Column(nullable = false)
    private LocalDateTime createdAt;
    
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL)
    private List<SubscriptionPayment> payments = new ArrayList<>();
}

 

 

  • entity는 결제와 관련되어 있기 때문에 보안을 생각해 UUID를 사용
  • 구독은 정기결제라는 시스템이 반드시 필요했기 때문에 시간 정보를 관리하는 필드들이 필요했음. 그래서 이를 통해 결제 주기를 관리해서 알림을 보내주거나 구독 상태 변경을 추적해서 환불 정책에 사용하기위해 넣음.
  • 결제 이력을 관리할 수 있는 엔티티를 추가로 설계해줘서 결제 이력을 추적하고 결제 실패를 관리할 수 있도록 함.

 

3. 그래서 마지막으로 결제 이력 관리를 위한 SubscriptionPayment 엔티티를 설계를 함.

@Entity
@Table(name = "subscription_payments")
public class SubscriptionPayment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "external_payment_id", nullable = false)
    private String externalPaymentId;  // 외부 결제 시스템의 결제 ID

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "subscription_id", nullable = false)
    private Subscription subscription;

    @Column(nullable = false)
    private Long amount;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private PaymentStatus status;

    private LocalDateTime billingDate;    // 청구일
    private LocalDateTime paidAt;         // 실제 결제일
    private String failureReason;         // 실패 사유
    private Integer retryCount;           // 재시도 횟수
}

 

  • 카카오페이 간편결제랑 연동을 할 수 있도록 외부 결제 시스템의 ID를 저장하는 필드를 만듬.
  • 결제 상태 관리를 위해 만들어 두었던 paymentStatus를 사용해서 결제 상태를 추적하고, 청구일과 결제일을 분리하여 관리함. 이를 통해 결제 지연이나 실패 상황을 추적할 수 있도록 했음.
  • 결제 실패하면 그냥 구독이 만료되는 것보다 재시도 로직을 만들어서 결제가 실패되면 다시 자동으로 결제를 재시도할 수 있는 서비스가 좋을 것 같아서 해당 설계를 해봄.
@PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
        if (retryCount == null) {
            retryCount = 0;
        }
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    public boolean canRetry() {
        return status == PaymentStatus.FAILED && retryCount < 3;
    }

    public void incrementRetryCount() {
        this.retryCount++;
        this.updatedAt = LocalDateTime.now();
    }

 

  • JPA 엔티티의 생명주기 이벤트를 처리하기 위한 콜백 메서드를 지정할 수 있는 @PrePersist와 @PreUpdate를 사용함.
  • @PrePersist는 엔티티가 처음으로 데이터베이스에 저장되기 직전에 실행됨. 그래서 모든 결제가 성공하여 구독이 생성될 때 생성일시와 변경일시를 자동으로 만들고, 재시도 횟수가 null이라면 0으로 초기화 시켜주는 로직이다.
  • @PreUpdate는 엔티티의 변경이 데이터베이스에 반영되기 직전에 실행됨. 변경일시를 최신화해주는 로직을 만들고 여기서 재시도 로직을 만들었다. 만약 결제가 실패했으면서 최대 재시도 로직을 넘지 않았으면 재시도 로직을 실행할 수 있도록 한다.