구독 청구 및 갱신 시스템 설계

1. 개요

구독 갱신은 간단하게 들릴지도 모르지만: 기간이 끝나면 고객으로부터 요금을 청구합니다. 실제로는 여러 함정이 있습니다. 요금을 두 번 청구하거나, 한 달을 건너뛰거나, 매월 1일에 결제 게이트웨이를 마비시킬 수 있습니다.

이 글은 전체 설계를 다룹니다: 요구사항, 데이터 모델, API, 처리 흐름, 그리고 어려운 부분들입니다. 각 어려운 부분에는 Java 21, Spring Boot 3.x, PostgreSQL, Redis 및 Kafka를 사용하는 코드 예제가 포함됩니다. 이 글을 읽으면 실제 시스템에 적용할 수 있는 충분히 견고한 설계 프레임워크를 갖게 될 것입니다.

2. 요구사항

2.1. 기능 요구사항

  • 고객이 월간 또는 연간 요금제(Plan)에 가입합니다.
  • 현재 기간이 끝나면 시스템이 자동으로 갱신하고 요금을 청구합니다.
  • 고객은 즉시 취소하거나 기간 끝에 취소할 수 있습니다.
  • 고객은 기간 중에 요금제를 변경할 수 있습니다. 요금은 남은 시간에 따라 비례 배분됩니다(proration).
  • 결제 실패 시 시스템은 일정에 따라 재시도합니다(dunning). 재시도 횟수가 끝나면 구독을 취소합니다.
  • 다른 시스템(이메일, 권한 부여)은 InvoicePaid와 SubscriptionCanceled 이벤트를 수신합니다.

2.2. 규모

  • 활성 구독 5백만 개.
  • 일일 평균 약 17만 건의 갱신.
  • 프로모션 캠페인으로 인해 매월 1일 최대 100만 건, 가입 시작일이 집중됨.
  • 결제 게이트웨이는 200 TPS로 제한됨. 100만 건의 경우, 청구 부분만 약 1.4시간 소요.

2.3. 제약사항

  • 같은 기간에 요금을 두 번 청구하면 안 됨.
  • 어떤 기간도 건너뛰지 말아야 함, 인스턴스가 도중에 다운되더라도.
  • 금액은 최소 단위의 정수로 저장(BIGINT). double 사용 금지.
  • 시간은 UTC(TIMESTAMPTZ)로 저장.
  • 서비스는 로드 밸런서 뒤에 여러 인스턴스로 실행됨.

3. 데이터 모델

시스템에는 세 개의 주요 테이블과 하나의 아웃박스 테이블이 있습니다:

CREATE TABLE plan (
    id               BIGSERIAL PRIMARY KEY,
    code             VARCHAR(50) UNIQUE NOT NULL,
    price_amount     BIGINT      NOT NULL,
    currency         CHAR(3)     NOT NULL,
    billing_interval VARCHAR(10) NOT NULL          -- MONTH | YEAR
);

CREATE TABLE subscription (
    id                   BIGSERIAL PRIMARY KEY,
    customer_id          BIGINT      NOT NULL,
    plan_id              BIGINT      NOT NULL REFERENCES plan(id),
    status               VARCHAR(20) NOT NULL,     -- ACTIVE | PAST_DUE | CANCELED
    current_period_end   TIMESTAMPTZ NOT NULL,
    cancel_at_period_end BOOLEAN     NOT NULL DEFAULT FALSE,
    version              BIGINT      NOT NULL DEFAULT 0
);
CREATE INDEX idx_sub_due ON subscription (current_period_end) WHERE status = 'ACTIVE';

CREATE TABLE invoice (
    id              BIGSERIAL PRIMARY KEY,
    subscription_id BIGINT      NOT NULL REFERENCES subscription(id),
    period_start    TIMESTAMPTZ NOT NULL,
    period_end      TIMESTAMPTZ NOT NULL,
    amount          BIGINT      NOT NULL,
    status          VARCHAR(20) NOT NULL,          -- OPEN | PAID | FAILED
    attempt_count   INT         NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ,
    UNIQUE (subscription_id, period_start)
);
CREATE INDEX idx_inv_retry ON invoice (next_attempt_at) WHERE status = 'OPEN';

CREATE TABLE outbox_event (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT       NOT NULL,
    event_type   VARCHAR(50)  NOT NULL,
    payload      JSONB        NOT NULL,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

몇 가지 주목할 만한 결정사항이 있습니다:

  • UNIQUE (subscription_id, period_start)는 최후의 안전망입니다. 코드에 버그가 있어도 데이터베이스는 같은 기간에 대해 두 개의 송장을 생성할 수 없습니다.
  • Partial index idx_sub_due는 ACTIVE 상태의 구독만 포함합니다. 갱신 기한이 된 구독을 찾는 쿼리는 따라서 항상 작고 빠릅니다.
  • version 컬럼은 API의 요금제 변경 또는 취소와 같은 작업을 위한 낙관적 잠금(optimistic locking)에 사용됩니다.

4. API

Method Endpoint 설명
POST /subscriptions 구독 생성. Idempotency-Key 헤더 필수
GET /subscriptions/{id} 상태 및 현재 기간 확인
POST /subscriptions/{id}/cancel 본문 {"atPeriodEnd": true}
POST /subscriptions/{id}/change-plan 본문 {"planCode": "PRO_MONTHLY"}
POST /webhooks/payments 결제 게이트웨이 비동기 결과 통보

모든 쓰기 작업은 멱등성이 있어야 합니다. 모바일 네트워크나 재시도 요청이 있을 수 있으며, 고객이 “가입” 버튼을 두 번 누르는 경우도 있지만 여전히 구독이 하나만 생성되어야 합니다.

5. 갱신 흐름

갱신 흐름은 두 가지 독립적인 단계로 나뉩니다:

  1. 송장 생성: 갱신 기한이 된 구독을 찾고, OPEN 상태로 invoice를 생성한 후, current_period_end를 새 기간으로 이동합니다. 이 단계는 데이터베이스에서만 작동합니다.
  2. 요금 청구: OPEN 상태이고 next_attempt_at <= now인 송장을 찾아 결제 게이트웨이를 호출한 후 결과를 기록합니다.

분리해야 하는 이유: 결제 게이트웨이를 호출하는 데 몇 초가 걸릴 수 있습니다. 행 잠금을 유지하는 트랜잭션 내에서 즉시 호출하면 커넥션 풀이 매우 빠르게 고갈됩니다.

먼저 다음 의존성이 필요합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

6. 어려운 부분과 해결책

6.1. 경합: 여러 인스턴스가 동시에 작업 실행

각 인스턴스에는 @Scheduled가 있습니다. 모두 함께 갱신 기한이 된 구독을 SELECT하면 같은 구독이 여러 번 처리될 것입니다. 해결책은 FOR UPDATE SKIP LOCKED를 사용하는 것입니다:

public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {

    @Query(value = """
            SELECT * FROM subscription
            WHERE status = 'ACTIVE' AND current_period_end <= :now
            ORDER BY current_period_end
            LIMIT :batchSize
            FOR UPDATE SKIP LOCKED
            """, nativeQuery = true)
    List<Subscription> lockDueBatch(Instant now, int batchSize);
}

FOR UPDATE는 선택된 행을 잠금니다. SKIP LOCKED는 다른 인스턴스가 해당 행을 건너뛰고 다음 배치를 가져가도록 합니다. 대기하는 대신 각 인스턴스는 별도의 배치를 받습니다. 처리량을 높이려면 인스턴스를 추가하면 됩니다.

갱신 서비스는 각 배치를 하나의 트랜잭션에서 처리합니다:

@Service
@RequiredArgsConstructor
public class RenewalService {

    private final SubscriptionRepository subscriptions;
    private final InvoiceRepository invoices;

    @Transactional
    public int createDueInvoices(Instant now) {
        List<Subscription> due = subscriptions.lockDueBatch(now, 500);
        for (Subscription sub : due) {
            if (sub.isCancelAtPeriodEnd()) {
                sub.cancel();
                continue;
            }
            Instant start = sub.getCurrentPeriodEnd();
            Instant end = sub.getPlan().nextPeriodEnd(start);
            invoices.save(Invoice.open(sub, start, end, sub.getPlan().getPriceAmount(), now));
            sub.advancePeriod(end);
        }
        return due.size();
    }
}

루프에서 구독이 cancelAtPeriodEnd 플래그를 가지고 있으면 갱신 대신 취소됩니다. 나머지 구독들은 OPEN 상태로 next_attempt_at = now인 송장이 생성되고, 기간이 새로운 지점으로 이동합니다. 두 변경사항 모두 같은 트랜잭션에 있으므로 “송장이 생성되었지만 기간이 아직 이동하지 않음” 같은 상태는 없습니다.

스케줄러는 작업이 없을 때까지 서비스를 반복 호출합니다:

@Scheduled(fixedDelay = 10_000)
public void renew() {
    while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}

요금제 변경 또는 취소와 같은 API의 작업도 갱신 작업과 충돌할 수 있습니다. version 컬럼과 JPA의 @Version annotation이 이를 처리합니다. 나중에 커밋되는 쪽은 OptimisticLockException을 받게 됩니다. 클라이언트는 상태를 다시 읽고 재시도하면 됩니다.

6.2. 요금을 두 번 청구하지 않기: 멱등성 키

가장 어려운 부분입니다. 결제 게이트웨이를 호출했는데 타임아웃이 났습니다. 고객이 이미 청구되었는지 여부를 알 수 없습니다. 무작정 재시도하면 두 번 청구할 수 있습니다.

해결책은 멱등성 키를 함께 전송하는 것입니다. 결제 게이트웨이가 동일한 키를 다시 받으면 이전 결과를 반환하며, 추가로 청구하지 않습니다:

public void chargeInvoice(long invoiceId) {
    Invoice inv = invoices.findById(invoiceId).orElseThrow();
    if (inv.getStatus() != InvoiceStatus.OPEN) {
        return;
    }
    String key = "inv-%d-try-%d".formatted(inv.getId(), inv.getAttemptCount());
    PaymentResult result = paymentGateway.charge(inv.getCustomerId(), inv.getAmount(), key);
    billingTx.recordResult(invoiceId, result);
}

키는 invoiceId와 attemptCount로 구성되며, 단순히 invoiceId만이 아닙니다. 많은 결제 게이트웨이는 키당 실패 결과도 캐시합니다. 하나의 고정 키를 사용하면 3일 뒤(고객이 이미 입금한 후)에도 여전히 이전의 “카드 거부됨” 오류가 반환됩니다.

attemptCount 증가 규칙:

  • 성공: 송장이 PAID 상태로 변함.
  • 명확한 거부(잔액 부족, 카드 만료): attemptCount 증가 및 재시도 예약.
  • 결과 불명확(타임아웃, 5xx 오류): attemptCount 유지. 다음 재시도는 동일한 키를 사용하므로 결제 게이트웨이가 이전 시도의 실제 결과를 반환합니다.

주의: 결제 게이트웨이로부터의 웹훅은 먼저 도착할 수도, 나중에 도착할 수도, 여러 번 도착할 수도 있습니다. 웹훅 핸들러는 업데이트하기 전에 송장 상태를 확인해야 합니다. 이미 PAID 상태인 송장은 무시합니다.

6.3. 결제 실패 시 재시도 일정(dunning)

재시도 일정은 순수 함수이므로 매우 쉽게 테스트할 수 있습니다:

public final class DunningPolicy {

    private static final List<Duration> SCHEDULE =
            List.of(Duration.ofDays(1), Duration.ofDays(3), Duration.ofDays(5));

    public static Optional<Instant> nextAttempt(int failedAttempts, Instant now) {
        if (failedAttempts > SCHEDULE.size()) {
            return Optional.empty();
        }
        long jitterMinutes = ThreadLocalRandom.current().nextLong(0, 120);
        return Optional.of(now.plus(SCHEDULE.get(failedAttempts - 1)).plusSeconds(jitterMinutes * 60));
    }
}

첫 번째 실패 후 시스템은 1일 후 재시도합니다. 두 번째는 3일 후, 세 번째는 5일 후입니다. 네 번째 시도 후 함수는 Optional.empty()를 반환합니다. 이 시점에서 송장은 FAILED로 변하고 구독은 CANCELED로 변합니다. 회수 기간 동안 구독은 PAST_DUE 상태이며 고객은 여전히 서비스를 사용할 수 있습니다(유예 기간).

0–120분의 무작위 지터는 재시도를 같은 시간에 몰리지 않도록 합니다.

6.4. 데이터베이스와 Kafka 간의 일관성: 아웃박스 패턴

송장을 PAID로 표시한 후 InvoicePaid 이벤트를 전송해야 합니다. DB 커밋 후 Kafka를 전송하면 인스턴스가 사이에 다운될 수 있고 이벤트가 손실됩니다. Kafka를 먼저 전송하고 DB 롤백하면 다른 시스템이 “가짜” 이벤트를 받습니다.

아웃박스 패턴은 이벤트를 같은 트랜잭션 내에서 outbox_event 테이블에 기록하여 해결합니다:

@Transactional
public void recordResult(long invoiceId, PaymentResult result) {
    Invoice inv = invoices.findById(invoiceId).orElseThrow();
    switch (result.status()) {
        case SUCCESS -> {
            inv.markPaid();
            outbox.save(OutboxEvent.of(inv.getId(), "InvoicePaid", inv.toJson()));
        }
        case DECLINED -> inv.recordFailure(Instant.now());
        case UNKNOWN -> inv.retryLater(Instant.now().plus(Duration.ofMinutes(15)));
    }
}

이 함수는 결제 게이트웨이 호출과 분리된 자체 트랜잭션에서 실행됩니다. 릴레이는 주기적으로 아웃박스를 읽고 Kafka로 푸시합니다:

@Scheduled(fixedDelay = 1_000)
@Transactional
public void publishOutbox() {
    for (OutboxEvent e : outbox.lockUnpublished(200)) {
        kafka.send("billing-events", String.valueOf(e.getAggregateId()), e.getPayload()).join();
        e.markPublished(Instant.now());
    }
}

lockUnpublished도 6.1의 FOR UPDATE SKIP LOCKED를 사용합니다. join() 호출은 Kafka가 메시지를 수신한 후에야 published_at를 표시하도록 보장합니다. 메시지의 키는 aggregateId이므로 같은 송장의 이벤트는 항상 같은 파티션으로 가고 순서를 유지합니다.

이 접근법은 최소한 한 번 배송을 보장합니다. 릴레이가 전송 후 커밋 전에 다운되면 이벤트가 다시 전송됩니다. 따라서 컨슈머는 멱등성이 있어야 하며, 예를 들어 처리된 eventId를 저장해야 합니다.

6.5. 매월 1일의 트래픽 스파이크

매월 1일에는 최대 100만 건의 송장이 청구되어야 합니다. 결제 게이트웨이는 200 TPS만 처리할 수 있습니다. 세 가지 보호 계층이 있습니다:

  1. 청구 시점 분산: current_period_end는 고객이 가입한 실제 시점(00:00이 아닌 정확한 시간 분 포함)을 기반으로 합니다. 이것만으로도 전체 24시간 동안 부하가 분산됩니다.
  2. 자연스러운 큐: 송장은 next_attempt_at과 함께 DB에 있습니다. 워커는 능력에 따라 점진적으로 처리합니다. 아무것도 손실되지 않으며 단지 느려질 뿐입니다.
  3. Redis를 사용한 전역 레이트 제한: 모든 인스턴스가 공유 카운터를 사용합니다.
@Component
@RequiredArgsConstructor
public class GatewayRateLimiter {

    private final StringRedisTemplate redis;
    private final int limitPerSecond = 200;

    public boolean tryAcquire() {
        String key = "rl:gateway:" + Instant.now().getEpochSecond();
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofSeconds(2));
        }
        return count != null && count <= limitPerSecond;
    }
}

각 초는 고유한 키입니다. INCR은 원자적 작업이므로 10개의 인스턴스가 있더라도 1초 동안의 총 호출 수는 200을 초과하지 않습니다. false를 받은 워커는 다음 라운드까지 송장을 남겨둡니다. 이것은 고정 윈도우 알고리즘입니다. 간단하고 여기서는 충분하지만, 두 초 사이의 경계에서 약간의 오버슈팅이 가능합니다.

주의: Redis에 접근할 수 없으면 레이트 제한을 우회하는 대신 fail-closed(청구 일시 중지)를 선택하세요. 몇 분 지연되는 것이 머천트 계정이 결제 게이트웨이에 의해 잠기는 것보다 낫습니다.

7. 기간 중 요금제 변경 시 요금 및 테스트

고객이 기간 중에 업그레이드하면 시스템은 이전 요금제의 미사용 부분에 대해 환불하고 새 요금제의 남은 부분에 대해 청구합니다:

public final class Proration {

    public static long amountDue(long oldPrice, long newPrice,
                                 Instant periodStart, Instant periodEnd, Instant changeAt) {
        long total = Duration.between(periodStart, periodEnd).toSeconds();
        long remaining = Duration.between(changeAt, periodEnd).toSeconds();
        long credit = Math.floorDiv(oldPrice * remaining, total);
        long charge = Math.floorDiv(newPrice * remaining, total);
        return charge - credit;
    }
}

함수는 나눗셈 전에 곱하여 정밀도 손실을 피합니다. 모든 계산은 long을 사용합니다. 음수 결과는 고객이 요금제를 내렸고 그 금액이 다음 기간의 크레딧으로 기록됨을 의미합니다.

JUnit 5와 AssertJ로 테스트합니다:

class BillingRulesTest {

    private final Instant start = Instant.parse("2026-10-01T00:00:00Z");
    private final Instant end = Instant.parse("2026-10-31T00:00:00Z");

    @Test
    void upgradeAtHalfPeriodChargesHalfOfDifference() {
        Instant mid = Instant.parse("2026-10-16T00:00:00Z");

        long due = Proration.amountDue(100_000, 300_000, start, end, mid);

        assertThat(due).isEqualTo(100_000);
    }

    @Test
    void stopsRetryingAfterThreeScheduledAttempts() {
        Instant now = Instant.parse("2026-10-01T00:00:00Z");

        assertThat(DunningPolicy.nextAttempt(1, now)).get()
                .satisfies(t -> assertThat(t).isBetween(now.plus(Duration.ofDays(1)),
                        now.plus(Duration.ofDays(1)).plus(Duration.ofHours(2))));
        assertThat(DunningPolicy.nextAttempt(4, now)).isEmpty();
    }
}

첫 번째 테스트는 정확히 30일 기간의 중간에서 요금제를 100,000에서 300,000으로 변경합니다. 크레딧은 50,000, 청구는 150,000이므로 고객은 추가 100,000을 지불합니다. 두 번째 테스트는 두 가지를 확인합니다: 첫 재시도는 1일 더하기 지터 범위 내에 있고, 네 번째 실패 후에는 재시도 일정이 없습니다.

BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED

6.1의 경합 부분에 대해 Testcontainers PostgreSQL을 사용하여 통합 테스트를 추가로 작성하세요. 두 개의 스레드가 동시에 createDueInvoices를 호출한 후 송장 개수가 정확히 갱신 기한이 된 구독 개수와 같은지 확인하세요.

8. 흔한 실수

  • @Transactional 내에서 결제 게이트웨이 호출: 트랜잭션이 네트워크 대기 중에 잠금과 연결을 유지합니다. 읽기 단계, 외부 호출 단계, 결과 기록 단계로 나누세요.
  • 여러 인스턴스에서 실행할 때 @Scheduled에 잠금 메커니즘 없음: 중복 처리를 초래합니다.
  • LocalDateTime을 서버 시간대로 사용하여 날짜 계산: 시간대 오류 및 31일 관련 오류가 발생합니다. Instant를 사용하세요. 월 기간의 경우 고객의 시간대에 따라 ZonedDateTime.plusMonths(1)을 사용하세요. 이 메서드는 자동으로 1월 31일을 2월 28일 또는 29일로 고정합니다.
  • 모든 재시도에 단일 멱등성 키 사용: 이전 오류가 캐시되고 계속 반환됩니다.
  • 고객이 몇 천 명이고 가격 모델이 단순한 경우 전체 시스템을 직접 구축해서는 안 됩니다. 결제 게이트웨이의 기본 제공 청구 서비스가 대개 유지 비용보다 저렴합니다. 사용자 정의 가격 로직, 다중 게이트웨이 또는 엄격한 데이터 제어가 필요한 경우에만 직접 구축하세요.

9. 결론

이 글에서는 수백만 고객 규모를 처리할 수 있는 구독 청구 및 갱신 시스템을 설계했습니다. 주요 내용은 다음과 같습니다:

  • 송장 생성과 요금 청구를 두 단계로 분리합니다.
  • FOR UPDATE SKIP LOCKED를 사용하여 여러 인스턴스 간에 작업을 분배하고, 제약으로 보호합니다.
  • 멱등성 키를 재시도마다 사용하여 요금이 두 번 청구되지 않도록 합니다.
  • 아웃박스 패턴을 사용하여 데이터베이스와 Kafka 간의 일관성을 유지합니다.
  • 청구 시점 분산, DB의 큐, Redis 레이트 제한을 결합하여 트래픽 스파이크를 극복합니다.

이 프레임워크에서 세금, 쿠폰 또는 사용량 기반 청구를 추가하여 핵심 흐름을 변경하지 않고 확장할 수 있습니다.

system-designspring-bootbillingkafkaredis