왜 구독 모델인가
썸블링은 외모가 아닌 내면으로 연결하는 AI 매칭 앱입니다. 이 철학을 지속 가능하게 유지하려면 수익 모델이 필요합니다. 광고는 사용자 경험을 해치고, 단순 인앱 결제는 과금 심리를 자극합니다. 그래서 선택한 것이 구독 모델입니다.
구독은 사용자가 서비스에 지속적으로 투자하게 만들고, 우리는 그에 걸맞은 가치를 제공해야 한다는 책임을 지게 합니다. FREE 티어는 기본 매칭을 경험하고, PLUS와 PREMIUM은 더 깊은 연결을 원하는 사용자를 위한 것입니다.
이 글에서는 그 구독 시스템이 기술적으로 어떻게 구현되었는지를 다룹니다.
데이터베이스 스키마
구독 시스템은 두 개의 핵심 테이블로 구성됩니다.
subscriptions (회원 구독 현황)
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL REFERENCES members(id),
tier VARCHAR NOT NULL, -- FREE, PLUS, PREMIUM
status VARCHAR NOT NULL, -- ACTIVE, EXPIRED, CANCELLED
start_date TIMESTAMP NOT NULL,
end_date TIMESTAMP NOT NULL,
price INTEGER NOT NULL, -- 결제 금액 (원)
duration_months INTEGER NOT NULL, -- 구독 기간 (개월)
payment_id VARCHAR, -- 토스페이먼츠 결제 키
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_subscriptions_member_id ON subscriptions(member_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
subscription_plans (플랜 정의)
CREATE TABLE subscription_plans (
id BIGSERIAL PRIMARY KEY,
tier VARCHAR NOT NULL, -- PLUS, PREMIUM
duration_months INTEGER NOT NULL, -- 1, 3, 6, 12
base_price INTEGER NOT NULL, -- 기본가 (원)
discount_percent INTEGER NOT NULL DEFAULT 0,
final_price INTEGER NOT NULL, -- 실결제가 (원)
is_active BOOLEAN NOT NULL DEFAULT true,
seasonal_tag VARCHAR, -- "봄 할인", "발렌타인" 등
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
subscription_plans는 관리자가 플랜 가격과 할인율을 동적으로 조정할 수 있도록 분리했습니다. 예를 들어 발렌타인 시즌에 PREMIUM 3개월을 20% 할인하는 이벤트를 코드 변경 없이 DB 수정만으로 적용할 수 있습니다.
구독 생명주기
구독은 네 가지 상태를 가집니다.
stateDiagram-v2
[*] --> ACTIVE : 결제 완료 (subscribe)
ACTIVE --> EXPIRED : 만료일 도래 (Scheduler)
ACTIVE --> CANCELLED : 사용자 해지 (cancel)
CANCELLED --> ACTIVE : 재구독 (subscribe)
EXPIRED --> ACTIVE : 재구독 (subscribe)
EXPIRED --> [*]
CANCELLED --> [*]
note right of ACTIVE
end_date까지 모든 기능 이용 가능
(해지해도 end_date까지는 유지)
end note
note right of CANCELLED
해지 요청됨
하지만 end_date까지는 ACTIVE와 동일
end note
중요한 점은 CANCELLED 상태에서도 end_date까지는 기능이 유지된다는 것입니다. 사용자가 해지를 요청해도 남은 기간은 보장합니다. 이는 사용자 신뢰에 직결됩니다.
상태 전환 규칙
| 전환 | 조건 | 처리 주체 |
|---|---|---|
[*] → ACTIVE | 결제 성공 | SubscriptionService.subscribe() |
ACTIVE → CANCELLED | 사용자 요청 | SubscriptionService.cancel() |
ACTIVE → EXPIRED | end_date 경과 | SubscriptionScheduler (매일 00:10) |
CANCELLED → EXPIRED | end_date 경과 | SubscriptionScheduler (매일 00:10) |
EXPIRED/CANCELLED → ACTIVE | 재결제 | SubscriptionService.subscribe() |
SubscriptionService 구현
aro-matching 모듈에 위치한 핵심 서비스입니다.
/**
* 구독 관리 서비스.
* 구독 시작, 취소, 만료 처리, 티어 조회를 담당합니다.
*
* @author Rojae
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SubscriptionService {
private final SubscriptionRepository subscriptionRepository;
private final SubscriptionPlanRepository planRepository;
private final MemberRepository memberRepository;
/**
* 구독을 시작하거나 갱신합니다.
* 이미 활성 구독이 있으면 end_date를 연장합니다.
*
* @param memberId 구독 신청 회원 ID
* @param planId 구독 플랜 ID
* @param paymentId 토스페이먼츠 결제 키 (멱등성 키로도 사용)
* @return 생성 또는 갱신된 구독 정보
*/
@Transactional
public Subscription subscribe(Long memberId, Long planId, String paymentId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
SubscriptionPlan plan = planRepository.findById(planId)
.orElseThrow(() -> new BusinessException(ErrorCode.SUBSCRIPTION_NOT_FOUND));
// 멱등성 검증: 동일 paymentId로 이미 구독이 존재하면 그대로 반환
subscriptionRepository.findByPaymentId(paymentId)
.ifPresent(existing -> { throw new BusinessException(ErrorCode.ALREADY_SUBSCRIBED); });
// 기존 활성 구독 만료일 계산 (연장 vs 신규)
LocalDateTime startDate = calculateStartDate(memberId);
LocalDateTime endDate = startDate.plusMonths(plan.getDurationMonths());
Subscription subscription = Subscription.builder()
.member(member)
.tier(plan.getTier())
.status(SubscriptionStatus.ACTIVE)
.startDate(startDate)
.endDate(endDate)
.price(plan.getFinalPrice())
.durationMonths(plan.getDurationMonths())
.paymentId(paymentId)
.build();
return subscriptionRepository.save(subscription);
}
/**
* 구독을 해지합니다.
* 즉시 비활성화하지 않고 CANCELLED 상태로 변경합니다.
* end_date까지는 기능이 유지됩니다.
*
* @param memberId 해지 요청 회원 ID
*/
@Transactional
public Subscription cancel(Long memberId) {
Subscription subscription = subscriptionRepository
.findActiveByMemberId(memberId)
.orElseThrow(() -> new BusinessException(ErrorCode.SUBSCRIPTION_NOT_FOUND));
subscription.cancel();
return subscription;
}
/**
* 회원의 현재 유효한 구독 티어를 반환합니다.
* 활성 구독이 없으면 FREE를 반환합니다.
*
* @param memberId 조회할 회원 ID
* @return 현재 구독 티어
*/
public SubscriptionTier getCurrentTier(Long memberId) {
return subscriptionRepository
.findActiveOrCancelledByMemberId(memberId)
.map(Subscription::getTier)
.orElse(SubscriptionTier.FREE);
}
/**
* 신규 구독의 시작일을 계산합니다.
* 기존 활성 구독이 있으면 그 end_date를, 없으면 현재 시각을 반환합니다.
*/
private LocalDateTime calculateStartDate(Long memberId) {
return subscriptionRepository
.findActiveOrCancelledByMemberId(memberId)
.map(Subscription::getEndDate)
.orElse(LocalDateTime.now());
}
}
Subscription 엔티티의 상태 변경 메서드
@Entity
@Table(name = "subscriptions")
public class Subscription extends BaseEntity {
// ... 필드 생략 ...
/**
* 구독을 취소 상태로 변경합니다.
* end_date는 변경되지 않으므로 남은 기간은 보장됩니다.
*/
public void cancel() {
if (this.status == SubscriptionStatus.EXPIRED) {
throw new BusinessException(ErrorCode.SUBSCRIPTION_NOT_FOUND);
}
this.status = SubscriptionStatus.CANCELLED;
}
/**
* 구독을 만료 상태로 변경합니다. Scheduler에서 호출합니다.
*/
public void expire() {
this.status = SubscriptionStatus.EXPIRED;
}
/**
* 현재 구독이 유효한지 확인합니다.
* ACTIVE 또는 CANCELLED 상태이고 end_date가 현재 시각 이후여야 합니다.
*/
public boolean isValid() {
return (this.status == SubscriptionStatus.ACTIVE
|| this.status == SubscriptionStatus.CANCELLED)
&& this.endDate.isAfter(LocalDateTime.now());
}
}
자동 만료 처리 — SubscriptionScheduler
매일 자정 이후 만료된 구독을 일괄 처리합니다.
/**
* 구독 만료 스케줄러.
* 매일 00:10에 실행되어 end_date가 경과한 구독을 EXPIRED로 변경합니다.
*
* @author Rojae
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class SubscriptionScheduler {
private final SubscriptionRepository subscriptionRepository;
/**
* 만료된 구독을 일괄 처리합니다.
* Native Query 벌크 업데이트로 N+1 없이 처리합니다.
*/
@Scheduled(cron = "0 10 0 * * *") // 매일 00:10
@Transactional
public void expireSubscriptions() {
LocalDateTime now = LocalDateTime.now();
int expiredCount = subscriptionRepository.bulkExpireByEndDate(now);
log.info("[SubscriptionScheduler] 구독 만료 처리 완료 — {}건", expiredCount);
}
}
// SubscriptionRepository
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
/**
* end_date가 경과한 ACTIVE/CANCELLED 구독을 EXPIRED로 일괄 변경합니다.
*
* @param now 기준 시각
* @return 업데이트된 레코드 수
*/
@Modifying
@Query("""
UPDATE Subscription s
SET s.status = 'EXPIRED', s.updatedAt = :now
WHERE s.status IN ('ACTIVE', 'CANCELLED')
AND s.endDate < :now
""")
int bulkExpireByEndDate(@Param("now") LocalDateTime now);
Optional<Subscription> findByPaymentId(String paymentId);
@Query("""
SELECT s FROM Subscription s
WHERE s.member.id = :memberId
AND s.status = 'ACTIVE'
ORDER BY s.endDate DESC
LIMIT 1
""")
Optional<Subscription> findActiveByMemberId(@Param("memberId") Long memberId);
@Query("""
SELECT s FROM Subscription s
WHERE s.member.id = :memberId
AND s.status IN ('ACTIVE', 'CANCELLED')
AND s.endDate > CURRENT_TIMESTAMP
ORDER BY s.endDate DESC
LIMIT 1
""")
Optional<Subscription> findActiveOrCancelledByMemberId(@Param("memberId") Long memberId);
}
벌크 업데이트를 사용하는 이유는 명확합니다. 구독 만료 대상이 수천 건이 될 수 있는데, 각 엔티티를 하나씩 로드해 업데이트하면 DB 왕복이 N번 발생합니다. @Modifying + JPQL UPDATE는 단 한 번의 쿼리로 처리합니다.
티어별 기능 비교
| 기능 | FREE | PLUS | PREMIUM |
|---|---|---|---|
| 일일 매칭 카드 | 3장 | 5장 | 10장 |
| 매칭 수락 후 채팅 | 1회 (무료) | 무제한 | 무제한 |
| 프로필 언블러 | ✗ | 5회/월 | 무제한 |
| Soul 리포트 (궁합 분석) | ✗ | 1회/월 | 3회/월 |
| Super Like | ✗ | 3회/월 | 10회/월 |
| 추가 매칭 카드 구매 | ✓ | ✓ | ✓ |
| AI 대화 추천 주제 | ✗ | ✓ | ✓ |
| 미니 소울 테스트 | 1회/월 | 1회/월 | 1회/월 |
| 비디오 프롬프트 | 1개 | 3개 | 8개 |
| 만남 신청 | ✓ | ✓ | ✓ |
티어 게이팅 구현
기능 게이팅은 aro-api 모듈의 서비스 레이어에서 처리합니다.
/**
* 현재 사용자의 구독 티어를 기반으로 기능 접근을 제어합니다.
*/
@Component
@RequiredArgsConstructor
public class SubscriptionGate {
private final SubscriptionService subscriptionService;
/**
* 최소 요구 티어 이상인지 검증합니다.
*
* @param memberId 검증할 회원 ID
* @param minimumTier 최소 요구 티어
* @throws BusinessException 티어 조건 미달 시
*/
public void requireMinimumTier(Long memberId, SubscriptionTier minimumTier) {
SubscriptionTier current = subscriptionService.getCurrentTier(memberId);
if (current.getLevel() < minimumTier.getLevel()) {
throw new BusinessException(ErrorCode.SUBSCRIPTION_TIER_REQUIRED);
}
}
}
// SubscriptionTier enum — 티어 간 비교를 위한 level 필드
public enum SubscriptionTier {
FREE(0),
PLUS(1),
PREMIUM(2);
private final int level;
SubscriptionTier(int level) {
this.level = level;
}
public int getLevel() {
return level;
}
}
실제 사용 예시입니다. Soul 리포트 생성 서비스에서 PLUS 이상 티어를 요구합니다.
// CompatibilityReportService (aro-matching 모듈)
@Transactional
public CompatibilityReport generate(Long memberId, Long matchId, boolean force) {
// 1. 티어 검증 (PLUS 이상 필요)
subscriptionGate.requireMinimumTier(memberId, SubscriptionTier.PLUS);
// 2. SOUL_REPORT 인벤토리 차감
inventoryService.consume(memberId, ProductType.SOUL_REPORT, 1);
// 3. 리포트 생성 로직 ...
}
토스페이먼츠 결제 연동
결제 플로우
sequenceDiagram
participant Client as 클라이언트
participant API as aro-api
participant Toss as 토스페이먼츠
participant DB as PostgreSQL
Client->>Toss: 결제 위젯 초기화 (clientKey)
Client->>Toss: 결제 요청 (orderId, amount)
Toss-->>Client: paymentKey 발급 + 결제 성공 리다이렉트
Client->>API: POST /api/v1/payments/confirm<br/>(paymentKey, orderId, amount)
API->>Toss: POST /v1/payments/confirm<br/>(secretKey 인증)
Toss-->>API: 결제 승인 완료 응답
API->>DB: subscriptions INSERT (paymentId = paymentKey)
API->>DB: member_inventories UPDATE (보너스 지급)
API-->>Client: 구독 정보 반환
결제 확인 서비스
/**
* 토스페이먼츠 결제 확인 및 구독 처리를 담당합니다.
*
* @author Rojae
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {
private final SubscriptionService subscriptionService;
private final TossPaymentsClient tossPaymentsClient;
@Value("${toss.payments.secret-key}")
private String secretKey;
/**
* 토스페이먼츠 결제를 최종 승인하고 구독을 활성화합니다.
*
* <p>멱등성 보장: paymentId(=paymentKey)가 이미 처리된 경우
* 기존 구독 정보를 그대로 반환합니다.</p>
*
* @param memberId 결제 회원 ID
* @param request 결제 확인 요청 (paymentKey, orderId, amount)
* @return 처리 결과 응답
*/
@Transactional
public PaymentConfirmResponse confirm(Long memberId, PaymentConfirmRequest request) {
// 1. 토스페이먼츠 서버에 최종 승인 요청
TossPaymentResponse tossResponse = tossPaymentsClient.confirm(
request.getPaymentKey(),
request.getOrderId(),
request.getAmount(),
secretKey
);
if (!"DONE".equals(tossResponse.getStatus())) {
log.error("[PaymentService] 결제 승인 실패 — paymentKey={}, status={}",
request.getPaymentKey(), tossResponse.getStatus());
throw new BusinessException(ErrorCode.PAYMENT_CONFIRM_FAILED);
}
// 2. orderId에서 planId 파싱 (예: "PLAN-12-1709123456789")
Long planId = parsePlanId(request.getOrderId());
// 3. 구독 처리 (내부에서 멱등성 검증)
Subscription subscription = subscriptionService.subscribe(
memberId,
planId,
request.getPaymentKey()
);
log.info("[PaymentService] 구독 처리 완료 — memberId={}, tier={}, endDate={}",
memberId, subscription.getTier(), subscription.getEndDate());
return PaymentConfirmResponse.of(request.getOrderId(), tossResponse.getApprovedAt());
}
private Long parsePlanId(String orderId) {
// orderId 형식: "PLAN-{planId}-{timestamp}"
String[] parts = orderId.split("-");
if (parts.length < 3 || !"PLAN".equals(parts[0])) {
throw new BusinessException(ErrorCode.PAYMENT_CONFIRM_FAILED);
}
return Long.parseLong(parts[1]);
}
}
멱등성 보장
결제 시스템에서 멱등성은 선택이 아닌 필수입니다. 네트워크 재시도나 사용자의 새로고침으로 동일 결제가 두 번 처리될 수 있습니다.
썸블링은 세 단계로 멱등성을 보장합니다.
-
토스페이먼츠 레벨:
paymentKey는 토스페이먼츠에서 한 번만 발급됩니다. 동일paymentKey로/v1/payments/confirm을 두 번 호출하면 토스페이먼츠가 에러를 반환합니다. -
서비스 레벨:
subscriptionRepository.findByPaymentId(paymentId)조회로 이미 처리된 건이면 예외를 던집니다. -
DB 레벨:
payment_id컬럼에 유니크 인덱스를 걸어 최후의 방어선을 유지합니다.
프론트엔드의 구독 상태 관리
React Native 앱의 useSubscriptionStore (Zustand)가 구독 상태를 관리합니다.
// stores/useSubscriptionStore.ts
import { create } from 'zustand';
import { api } from '@/lib/api';
interface SubscriptionState {
currentTier: 'FREE' | 'PLUS' | 'PREMIUM';
subscription: Subscription | null;
plans: SubscriptionPlan[];
isLoading: boolean;
fetchMySubscription: () => Promise<void>;
subscribe: (planId: number) => Promise<void>;
cancelSubscription: () => Promise<void>;
}
export const useSubscriptionStore = create<SubscriptionState>((set, get) => ({
currentTier: 'FREE',
subscription: null,
plans: [],
isLoading: false,
fetchMySubscription: async () => {
set({ isLoading: true });
try {
const res = await api.get('/subscriptions/me');
const sub: Subscription = res.data.data;
set({
subscription: sub,
currentTier: sub?.status === 'ACTIVE' || sub?.status === 'CANCELLED'
? sub.tier
: 'FREE',
});
} catch {
set({ currentTier: 'FREE', subscription: null });
} finally {
set({ isLoading: false });
}
},
subscribe: async (planId: number) => {
// 토스페이먼츠 결제 위젯 호출 → confirm API 호출
// ... 결제 플로우 후
await get().fetchMySubscription(); // 구독 상태 즉시 갱신
},
cancelSubscription: async () => {
await api.post('/subscriptions/cancel');
await get().fetchMySubscription();
},
}));
컴포넌트에서 티어 게이팅은 다음처럼 간단하게 사용합니다.
// 구독 게이트 컴포넌트
function FeatureGate({
requiredTier,
children,
}: {
requiredTier: 'PLUS' | 'PREMIUM';
children: React.ReactNode;
}) {
const { currentTier } = useSubscriptionStore();
const tierLevel = { FREE: 0, PLUS: 1, PREMIUM: 2 };
if (tierLevel[currentTier] < tierLevel[requiredTier]) {
return <UpgradePrompt requiredTier={requiredTier} />;
}
return <>{children}</>;
}
// 사용 예시
<FeatureGate requiredTier="PLUS">
<SoulReportButton matchId={matchId} />
</FeatureGate>
운영 고려 사항
결제 실패 시 보상 트랜잭션
토스페이먼츠 승인 성공 후 DB 저장이 실패하면? @Transactional이 롤백을 보장합니다. 하지만 이미 결제된 금액은 토스페이먼츠에 남아있습니다. 이 경우 관리자 대시보드에서 paymentId로 결제 상태를 확인하고 수동으로 처리할 수 있습니다.
구독 갱신 알림
만료 3일 전에 Firebase FCM으로 알림을 발송합니다. SubscriptionScheduler에 추가 스케줄이 있습니다.
@Scheduled(cron = "0 0 10 * * *") // 매일 10:00
public void notifyExpirationSoon() {
LocalDateTime threeDaysLater = LocalDateTime.now().plusDays(3);
List<Subscription> expiringSoon = subscriptionRepository
.findExpiringBetween(LocalDateTime.now(), threeDaysLater);
expiringSoon.forEach(sub ->
pushNotificationService.sendSubscriptionExpiringSoon(
sub.getMember().getId(),
sub.getEndDate()
)
);
}
Scheduler 중복 실행 방지
다중 인스턴스 배포 환경에서는 스케줄러가 여러 서버에서 동시에 실행될 수 있습니다. 현재는 ShedLock을 활용해 분산 락을 적용하고 있습니다.
@Scheduled(cron = "0 10 0 * * *")
@SchedulerLock(name = "expireSubscriptions", lockAtMostFor = "PT10M")
@Transactional
public void expireSubscriptions() {
// ...
}
마치며
썸블링의 구독 시스템은 단순해 보이지만 세부 구현에서 많은 것을 고민했습니다.
- CANCELLED 상태 보존: 해지해도 기간은 보장
- 멱등성 3단계 방어: 토스페이먼츠 → 서비스 → DB
- 벌크 만료 처리: Scheduler의 N+1 없는 배치
- 플랜 외부화: 코드 변경 없이 가격/할인 조정 가능
작은 결정 하나하나가 사용자 신뢰와 운영 안정성을 만든다고 생각합니다. 결제 시스템은 단 한 번의 버그도 사용자를 잃게 만들 수 있으니까요.
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.