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는 엔티티의 변경이 데이터베이스에 반영되기 직전에 실행됨. 변경일시를 최신화해주는 로직을 만들고 여기서 재시도 로직을 만들었다. 만약 결제가 실패했으면서 최대 재시도 로직을 넘지 않았으면 재시도 로직을 실행할 수 있도록 한다.