수정

SaaS 구독 결제를 만들며 이해한 Facade 패턴

모여라는 SaaS를 처음부터 끝까지 만들면서 클라이언트와 서버를 모두 개발했어요. 결제도 처음에는 단순했는데, 요구사항이 하나둘 늘어나면서 코드가 복잡해지고 읽기도 어려워지기 시작했어요.

구독을 갱신하는 코드가 결제수단 조회와 토스페이먼츠 요청 구성은 물론, 승인 결과를 확인해 이용 기간을 반영하는 일까지 직접 챙기고 있었어요. 비슷한 기능을 추가할 때마다 이 순서와 토스의 규격을 다시 알아야 했고, 결제 처리를 바꾸려면 여러 곳을 함께 살펴봐야 했어요.

그래서 구독 갱신을 요청하는 쪽에서 이 과정을 전부 알아야 할지 고민했어요. 필요한 작업은 한곳에서 조합하고, 호출하는 쪽은 갱신을 요청하는 데 집중할 수 있으면 좋겠다고 생각했죠. 그 과정에서 Facade 패턴을 도입했어요. 어떤 코드를 묶고 어떤 책임을 남겼는지, NestJS 예제로 풀어 볼게요.

복잡한 구독 결제 흐름을 하나의 창구로 정리하는 Facade
복잡한 구독 결제 흐름을 하나의 창구로 정리하는 Facade

코드는 설명에 필요한 흐름만 남기고 간추렸어요. 실제 모여 코드와 다른 부분은 해당 위치에서 함께 짚을게요.

처음에는 결제에 성공하면 끝인 줄 알았어요

카드가 등록되어 있고, 무료 체험이 끝나 첫 구독료를 청구하는 상황부터 볼게요.

청구할 금액과 기간을 담은 청구서(Invoice)는 SubscriptionService가 준비해요. 그런데 갱신 작업은 그다음부터 결제수단 조회, 토스 요청 구성, 오류 해석, 승인 기록까지 직접 처리하고 있어요.

@Injectable()
class RenewSubscriptionJob {
  constructor(
    private readonly subscriptionService: SubscriptionService,
    private readonly paymentMethodRepository: TossPaymentMethodRepository,
    // 결제 시도의 중복 실행을 막고, 승인 결과와 실패 사유를 저장해요.
    private readonly paymentStore: FirstChargeStore,
    private readonly tossBillingClient: TossBillingClient,
  ) {}

  async execute(subscriptionId: string): Promise<void> {
    const invoice = await this.subscriptionService.prepareRenewal(subscriptionId);
    if (!invoice) {
      return;
    }

    // 결제 시도를 저장하고, 이 작업이 처음 승인할 요청을 선점해요.
    const attempt = await this.paymentStore.prepareFirstCharge(invoice);
    if (!attempt) {
      return;
    }

    const paymentMethod = await this.paymentMethodRepository.findForAccount(
      invoice.paymentMethodId,
      invoice.accountId,
    );
    if (!paymentMethod) {
      // 이 계정에서 사용할 결제수단이 없어 결제 시도를 실패로 기록해요.
      await this.paymentStore.markPaymentMethodInvalid(attempt.id);
      return;
    }

    let payment: TossPaymentResponse;

    try {
      payment = await this.tossBillingClient.charge({
        billingKey: paymentMethod.billingKey,
        idempotencyKey: attempt.idempotencyKey,
        body: {
          customerKey: paymentMethod.customerKey,
          amount: invoice.amountKrw,
          orderId: attempt.orderId,
          orderName: invoice.description,
        },
      });
    } catch (error) {
      if (error instanceof TossApiError && error.code === "REJECT_CARD_PAYMENT") {
        // 카드 승인이 거절됐다는 사유를 결제 시도에 기록해요.
        await this.paymentStore.markCardDeclined(attempt.id);
        return;
      }

      // 카드 거절로 분류하지 못한 오류는 운영자가 확인할 대상으로 남겨요.
      await this.paymentStore.markForReview(attempt.id);
      throw error;
    }

    const matchesInvoice =
      payment.orderId === attempt.orderId &&
      payment.totalAmount === invoice.amountKrw &&
      payment.currency === "KRW" &&
      payment.type === "BILLING";
    if (!matchesInvoice || payment.status !== "DONE") {
      // 청구 내역과 일치하는 승인을 확인하지 못했으므로 구독을 갱신하지 않아요.
      await this.paymentStore.markForReview(attempt.id);
      return;
    }

    // 토스 승인 내역을 저장하고, 연결된 내부 결제 ID로 구독에 반영해요.
    const paymentId = await this.paymentStore.saveTossApproval(attempt.id, payment);
    await this.subscriptionService.applyPayment(invoice.id, paymentId);
  }
}ts

prepareFirstCharge()는 청구서에 연결된 결제 시도를 저장하고 처음 실행할 작업만 선점해요. 주문번호와 멱등키도 이때 만들어 보관해요. 이미 처리했거나 결과를 확인 중인 요청은 여기서 다시 승인하지 않아요.

HTTP 호출은 Client로 옮겼지만, 구독 갱신 코드는 여전히 토스가 어떤 키를 요구하고, 어떤 상태를 성공으로 보는지 알아야 해요. billingKey와 customerKey로 요청을 만들고, DONE과 REJECT_CARD_PAYMENT를 해석하고 있으니까요. 구독을 갱신하는 로직이 토스의 API 규격에 강하게 묶여 있어요.

이제 카드 변경 후 다시 결제하는 기능을 붙인다고 해 볼게요. 그쪽에서도 키를 조회하고, 토스 요청을 만들고, 같은 오류 코드를 해석해야 해요. 비슷한 코드를 복사하거나, 기존 함수에 조건을 더 붙이게 되기 쉬워요.

구독 갱신 코드에 결제 요청과 이용 권한 처리가 뒤섞인 모습
구독 갱신 코드에 결제 요청과 이용 권한 처리가 뒤섞인 모습

기능이 하나 늘 때마다 이런 연결이 하나씩 더 얽혀요. 결제사를 바꿔야 한다면 더 곤란해지고요. 토스 연동을 준비하면서 카드사 심사가 길어지면 어떡하나 걱정했는데, 실제로 2026년 10월 14일 출시 때까지 심사가 끝나지 않았거든요. 출시 일정에 맞추려고 다른 결제사로 바꿔야 했다면 이 코드는 어디까지 고쳐야 했을까요?

Client만 교체해서 끝나지 않아요. 토스의 상태와 오류를 해석하는 갱신·재결제 코드는 물론, 토스 응답을 기준으로 작성한 테스트까지 전부 찾아봐야 하죠. 지금 잘 동작하는 것과 변경에 유연한 것은 다른 문제였어요. 그래서 결제사에 대한 지식은 한곳에 모으고, 구독 쪽은 필요한 결과만 알도록 바꾸고 싶었어요.

우선 복잡한 사용 과정을 묶어 볼게요

Facade는 복잡한 하위 시스템을 쉽게 사용할 수 있는 인터페이스를 제공하는 패턴이에요. 여기서는 구독과 결제 Service를 조합해, 호출자가 구독 갱신의 세부 순서를 매번 작성하지 않게 만들어요.

외부 연동과 승인 기록을 TossPaymentService로 옮기고, 두 Service를 Facade에서 호출해 볼게요.

@Injectable()
class SubscriptionBillingFacadeV1 {
  constructor(
    private readonly subscriptionService: SubscriptionService,
    private readonly tossPaymentService: TossPaymentService,
  ) {}

  async renewSubscription(subscriptionId: string): Promise<TossPaymentResponse | null> {
    const invoice = await this.subscriptionService.prepareRenewal(subscriptionId);
    if (!invoice) {
      return null;
    }

    const { paymentId, payment } = await this.tossPaymentService.payInvoice(invoice);
    if (payment.status === "DONE") {
      await this.subscriptionService.applyPayment(invoice.id, paymentId);
    }

    return payment;
  }
}ts

이 중간 버전의 TossPaymentService는 승인 결과를 저장한 내부 paymentId와 토스 응답을 함께 반환해요. 호출자는 이제 renewSubscription()만 사용하면 돼요.

await subscriptionBillingFacade.renewSubscription(subscriptionId);ts

여기까지도 의미 있는 개선이에요. 호출자가 청구서를 만들고, 결제하고, 이용 기간을 반영하는 순서를 전부 알 필요가 없어졌으니까요.

그런데 결제 결과를 사용하려고 하면 아직 문제가 남아 있어요.

반환값과 오류는 여전히 토스의 것이에요

앞의 Facade는 내부에서 DONE을 해석하고, 호출자에게도 TossPaymentResponse를 반환해요. 외부 오류도 그대로 올라오고요.

try {
  const payment = await subscriptionBillingFacade.renewSubscription(subscriptionId);
  if (payment?.status === "DONE") {
    // 호출자도 토스의 응답 구조를 알아야 해요.
    console.log(payment.paymentKey, payment.approvedAt);
  }
} catch (error) {
  if (error instanceof TossApiError && error.code === "REJECT_CARD_PAYMENT") {
    // 결제사의 오류 코드가 여기까지 올라와요.
    await paymentFailureNotifier.notify(subscriptionId);
  } else {
    throw error;
  }
}ts

호출은 단순해졌지만 결제사를 바꿀 때 수정할 곳은 아직 남아 있어요. Facade와 호출자 모두 토스의 응답을 알고 있기 때문이에요.

이것만으로 Facade가 틀렸다고 할 수는 없어요. Facade의 기본 목적은 하위 시스템을 쉽게 사용하게 하는 것이니까요. 다만 이 예제에서는 결제사의 응답과 오류까지 내부 계약으로 정리해야 변경 범위를 줄일 수 있어요.

구독 쪽에서 필요한 결과는 다음 세 가지예요.

// contracts.ts
export type PaymentFailureCode = "CARD_DECLINED" | "INVALID_PAYMENT_METHOD" | "PAYMENT_FAILED";

export type PaymentResult =
  | { status: "SUCCEEDED"; paymentId: string }
  | { status: "FAILED"; failureCode: PaymentFailureCode }
  | { status: "PENDING" };ts

SUCCEEDED는 승인을 확인하고 내부 결제 기록까지 저장한 결과예요. paymentId는 우리 DB의 ID이고, 토스의 paymentKey는 결제 모듈 안에 보관해요.

FAILED는 실패가 확인된 경우이고, PENDING은 처리가 진행 중이거나 결과를 아직 확정할 수 없는 경우예요. timeout이 발생해도 실제 승인은 끝났을 수 있으니 둘을 구분해야 해요.

상태 이름도 토스의 DONE을 빌려 오지 않았어요. 결제사와 상관없이 성공·실패·미확정만 표현하도록 우리 계약에서 정한 이름이에요.

상태마다 필요한 필드가 달라서 discriminated union을 사용했어요. status를 확인하면 TypeScript의 narrowing이 타입을 좁혀 주므로, 성공한 경우에만 paymentId를 꺼낼 수 있어요. 모든 필드를 optional로 두는 것보다 성공인데 결제 ID가 없는 데이터를 막기도 쉽고요.

Facade에는 구독 갱신의 순서가 남아요

이제 PaymentService가 토스 응답 대신 PaymentResult를 반환하도록 바꿔 볼게요. 바뀌는 부분부터 보면, 토스의 승인 상태를 해석하던 자리에 내부 결제 결과가 들어와요.

// 구독 갱신 메서드의 결제 처리 부분
const { paymentId, payment } = await this.tossPaymentService.payInvoice(invoice);
if (payment.status === "DONE") {
  await this.subscriptionService.applyPayment(invoice.id, paymentId);
}
const paymentResult = await this.paymentService.payInvoice(invoice);
if (paymentResult.status !== "SUCCEEDED") {
  return;
}

await this.subscriptionService.applyPayment(invoice.id, paymentResult.paymentId);ts

이제 Facade는 DONE이나 paymentKey를 알 필요가 없어요. 전체 코드도 함께 볼게요.

// subscription-billing.facade.ts
import { Injectable } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { SubscriptionService } from "./subscription.service";

@Injectable()
export class SubscriptionBillingFacade {
  constructor(
    private readonly subscriptionService: SubscriptionService,
    private readonly paymentService: PaymentService,
  ) {}

  async renewSubscription(subscriptionId: string): Promise<void> {
    const invoice = await this.subscriptionService.prepareRenewal(subscriptionId);
    if (!invoice) {
      return;
    }

    const paymentResult = await this.paymentService.payInvoice(invoice);
    // 실패했거나 결과가 미확정이면 이용 기간을 늘리지 않아요.
    if (paymentResult.status !== "SUCCEEDED") {
      return;
    }

    await this.subscriptionService.applyPayment(invoice.id, paymentResult.paymentId);
  }

  async reconcilePayment(paymentId: string): Promise<void> {
    // 기존 결제의 결과를 확인해 복구해요. 새 결제를 요청하지는 않아요.
    const { invoiceId, result } = await this.paymentService.reconcile(paymentId);
    if (result.status !== "SUCCEEDED") {
      return;
    }

    await this.subscriptionService.applyPayment(invoiceId, result.paymentId);
  }
}ts

청구할 항목이 없으면 끝내고, 결제에 성공하면 해당 청구서의 이용 기간을 반영해요. 위에서 아래로 읽었을 때 구독 갱신의 흐름이 그대로 보이죠. reconcilePayment()도 결과를 놓친 결제를 확인한 뒤 같은 반영 경로를 사용해요.

이 Facade는 백그라운드 작업을 처리하는 Worker에서 실행해요. Worker는 renewSubscription(subscriptionId)를 호출하고 청구 시도를 추적할 수 있도록 작업을 기록해 둬요. 메서드 실행이 끝났다고 결제까지 성공한 것은 아니므로, 화면에서는 저장된 결제·구독 상태를 조회해요.

Facade가 여러 Service를 조합하는 경우는 흔해요. 그렇다고 Service를 몇 개 이상 주입해야 Facade가 되는 것은 아니에요. 제가 본 기준은 호출자가 복잡한 사용 순서를 반복하지 않아도 되는지였어요. Facade의 기본 설명도 하위 시스템을 쉽게 사용하는 데 초점을 맞추고 있어요.

Facade가 구독과 결제 Service를 연결하고, 성공한 결제만 구독에 반영하는 흐름
Facade가 구독과 결제 Service를 연결하고, 성공한 결제만 구독에 반영하는 흐름

호출하는 쪽은 Facade에 갱신을 한 번 요청할 뿐이에요. 결제가 성공했을 때만 구독 쪽으로 이어지고, 실패하거나 미확정인 결제는 이용 기간에 닿지 않아요.

각 객체의 역할은 이 정도로 나누면 충분해요.

구성 요소담당하는 일
SubscriptionBillingFacade청구 준비 → 결제 → 구독 반영 순서 조합
SubscriptionService구독 조회, 청구서 확정, 결제된 기간 저장
Subscription청구 가능 여부, 요금·기간, 이용 권한 판단
PaymentService결제 시도 관리, 실행·조회, 결과 기록
TossPaymentGateway내부 결제 계약과 토스 규격 사이의 변환
TossBillingClientHTTP 요청과 외부 데이터 검증

호출 방향으로 이어 보면 Worker에서 토스 API까지 한 방향으로 내려가요.

flowchart TD
    J["갱신 Worker"] --> F["SubscriptionBillingFacade"]
    F --> S["SubscriptionService"]
    F --> P["PaymentService"]
    S --> E["Subscription Entity"]
    P --> G["TossPaymentGateway<br/>PaymentGateway 구현"]
    G --> C["TossBillingClient<br/>HTTP · Zod"]
    C --> T["토스페이먼츠 API"]

실제 모여 코드에서는 이 Facade 자리를 Use Case 세 개가 나눠 맡아요. 청구 기간이 오면 ProcessWorkspaceSubscriptionPeriod가 청구서를 만들고, ProcessBillingInvoiceCharge가 결제를 요청하고, 결과를 놓친 결제는 ReconcileBillingPayment가 조회로 확인해요. 토스 연동은 모두 PaymentGateway port 뒤에 있고요. 글에서는 패턴이 잘 보이도록 이 흐름을 SubscriptionBillingFacade 하나로 모았어요.

예제로 돌아오면, Facade에는 구독 갱신의 순서가 남고 요금 계산과 결제 시도 관리는 각각의 객체가 맡아요. 아래 구현을 펼쳐 보면 이 책임이 어떻게 나뉘는지 확인할 수 있어요.

구독 Entity와 Service 구현

예제에서 사용할 구독 조건은 아래처럼 정할게요.

항목예제 조건
월간 구독한 달 10,000원(예시 금액)
연간 구독12개월 100,000원 일괄 결제(예시 금액)
무료 체험월간·연간 모두 가입 후 14일
첫 청구체험 종료 시 선택한 요금 청구
만료한국 시간 자정, 갱신 승인 미확인 시 무료 전환

금액은 계산을 쉽게 보여 주려고 정한 예시예요. 실제 모여는 워크스페이스 단위로 채널 수만큼 청구하고 유료는 3채널부터 팔아요. 2026년 10월 7일부터 Pro는 채널당 월 5,900원·연 59,000원, Team은 채널당 월 9,900원·연 99,000원이에요. 체험 14일은 실제 정책과 같아요.

이 규칙은 구독 자신의 상태를 보고 판단하는 동작이므로 Subscription Entity에 모을게요. NestJS나 ORM의 decorator 없이 작성한 클래스예요.

// subscription.ts
export class Subscription {
  #state: SubscriptionState;

  constructor(state: SubscriptionState) {
    // 전달받은 객체를 나중에 수정해도 구독 상태가 바뀌지 않도록 복사해요.
    this.#state = structuredClone(state);
  }

  get id(): string {
    return this.#state.id;
  }

  createRenewalInvoice(now: Date): InvoiceDraft | null {
    if (!this.#state.autoRenewalEnabled || now < this.#state.trialEndsAt) {
      return null;
    }

    const plan = PLAN_PRICES[this.#state.billingCycle];
    // 첫 청구일에서 이미 반영한 유료 기간만큼 건너뛰어요.
    const monthsAfterFirstBilling = this.#state.completedBillingPeriods * plan.months;
    const periodStartsAt = getBillingDate(this.#state.firstBillingDate, monthsAfterFirstBilling);
    const periodEndsAt = getBillingDate(
      this.#state.firstBillingDate,
      monthsAfterFirstBilling + plan.months,
    );
    // 청구 시점이 아직 오지 않았거나 해당 이용 기간이 모두 지났으면 청구하지 않아요.
    if (now < periodStartsAt || now >= periodEndsAt) {
      return null;
    }

    return {
      subscriptionId: this.#state.id,
      accountId: this.#state.accountId,
      paymentMethodId: this.#state.paymentMethodId,
      billingPeriodIndex: this.#state.completedBillingPeriods,
      amountKrw: plan.amountKrw,
      description: plan.description,
      periodStartsAt,
      periodEndsAt,
    };
  }

  applyPaidInvoice(invoice: Invoice): void {
    const isExpectedInvoice =
      invoice.subscriptionId === this.#state.id &&
      invoice.billingPeriodIndex === this.#state.completedBillingPeriods;
    if (!isExpectedInvoice) {
      throw new Error("현재 구독 기간에 해당하는 청구서가 아닙니다.");
    }

    // 결제 확인이 늦어져도 이용 종료일은 청구서에 정한 날짜를 유지해요.
    this.#state.paidUntil = new Date(invoice.periodEndsAt);
    this.#state.completedBillingPeriods += 1;
  }

  getAccessLevel(now: Date): AccessLevel {
    if (now < this.#state.trialEndsAt) {
      return "TRIAL";
    }

    if (this.#state.paidUntil && now < this.#state.paidUntil) {
      return "PAID";
    }

    return "FREE";
  }

  // 저장용 데이터도 사본으로 반환해 내부 객체의 참조를 노출하지 않아요.
  toState(): SubscriptionState {
    return structuredClone(this.#state);
  }
}ts

Entity는 외부 API를 호출하거나 DB에 저장하지 않아요. 현재 구독 상태와 전달받은 시각으로 청구서를 만들 수 있는지 판단하고, 결제가 확인되면 그 청구서의 기간을 적용해요.

상태는 JavaScript의 private field인 #state에 보관해요. id는 조회만 가능한 getter로 공개하고, 이용 기간은 applyPaidInvoice()로만 바꿀 수 있어요. paidUntil에 원하는 날짜를 바로 대입하는 setter는 두지 않았어요. getAccessLevel(now)는 전달받은 시각으로 권한을 판단하므로 getter 대신 메서드로 유지해요.

필드를 숨기는 것만으로 객체의 참조까지 분리되는 것은 아니에요. 생성자로 받은 상태와 toState()가 반환하는 상태는 structuredClone()으로 복사해요. 이 상태는 일반 객체와 Date로 구성되어 있어서, 중첩된 날짜를 수정해도 Entity 내부에는 영향을 주지 않아요.

completedBillingPeriods는 반영이 끝난 유료 기간의 개수예요. 첫 결제에서는 0이고, 결제된 기간을 반영할 때 하나씩 증가해요. firstBillingDate는 가입일에 14일을 더한 체험 종료일이에요. 가입할 때 한 번 정해 두고, 이후 청구일은 모두 이 날짜에서 계산해요.

한 청구 기간이 통째로 지난 구독은 밀린 요금을 자동으로 청구하지 않아요. 다시 구독하려면 새 청구 기간을 정하는 별도 흐름이 필요해요.

// subscription.ts
import { getBillingDate, type BillingDate } from "./billing-date";
import type { Invoice, InvoiceDraft } from "./contracts";

export type BillingCycle = "MONTHLY" | "YEARLY";
export type AccessLevel = "TRIAL" | "PAID" | "FREE";

export interface SubscriptionState {
  id: string;
  accountId: string;
  paymentMethodId: string;
  billingCycle: BillingCycle;
  firstBillingDate: BillingDate;
  completedBillingPeriods: number;
  autoRenewalEnabled: boolean;
  trialEndsAt: Date;
  paidUntil: Date | null;
}

const PLAN_PRICES = {
  MONTHLY: { amountKrw: 10_000, months: 1, description: "월간 구독" },
  YEARLY: { amountKrw: 100_000, months: 12, description: "연간 구독" },
} as const;ts
// contracts.ts
export interface InvoiceDraft {
  subscriptionId: string;
  accountId: string;
  paymentMethodId: string;
  billingPeriodIndex: number;
  amountKrw: number;
  description: string;
  periodStartsAt: Date;
  periodEndsAt: Date;
}

export interface Invoice extends InvoiceDraft {
  id: string;
  status: "OPEN" | "PAID";
}ts
// billing-date.ts
export interface BillingDate {
  year: number;
  month: number;
  day: number;
}

export function getBillingDate(firstBillingDate: BillingDate, monthsAfter: number): Date {
  const targetMonth = new Date(
    Date.UTC(firstBillingDate.year, firstBillingDate.month - 1 + monthsAfter, 1),
  );
  const year = targetMonth.getUTCFullYear();
  const month = targetMonth.getUTCMonth();
  const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
  const day = Math.min(firstBillingDate.day, lastDay);

  // 한국 시간 자정을 UTC로 변환해 저장해요.
  return new Date(Date.UTC(year, month, day, -9));
}ts

첫 청구일에서 매번 계산하기 때문에 1월 31일에 첫 청구한 구독은 2월 말일 다음에 3월 31일로 돌아와요. 한 달을 30일로 바꾸거나, 보정된 2월 날짜에 계속 한 달씩 더하지 않아요.

trialEndsAt은 가입 시 저장하고 체험 사용 이력도 따로 보존해요. 결제수단을 다시 등록했다고 무료 체험이 시작되어서는 안 돼요. 예제에서는 가입 후 요금제 변경은 다루지 않아요.

구독 Service에서는 조회와 저장을 연결해요.

// subscription.service.ts
import { Injectable } from "@nestjs/common";
import type { Invoice } from "./contracts";
import { SubscriptionRepository } from "./subscription.repository";

@Injectable()
export class SubscriptionService {
  constructor(private readonly subscriptionRepository: SubscriptionRepository) {}

  async prepareRenewal(subscriptionId: string): Promise<Invoice | null> {
    const subscription = await this.subscriptionRepository.getById(subscriptionId);
    const invoiceDraft = subscription.createRenewalInvoice(new Date());
    if (!invoiceDraft) {
      return null;
    }

    // 같은 구독 기간의 청구서가 이미 있으면 저장된 금액과 기간을 그대로 사용해요.
    return this.subscriptionRepository.getOrCreateInvoice(invoiceDraft);
  }

  async applyPayment(invoiceId: string, paymentId: string): Promise<void> {
    await this.subscriptionRepository.transaction(async (transaction) => {
      const invoice = await transaction.getInvoiceForUpdate(invoiceId);

      // 이 청구서와 연결된 결제인지, 금액·통화가 맞고 성공했는지 확인해요.
      await transaction.assertSuccessfulPayment(invoice, paymentId);

      // 복구 작업이 반복돼도 같은 이용 기간을 두 번 반영하지 않아요.
      if (invoice.status === "PAID") {
        return;
      }

      const subscription = await transaction.getSubscriptionForUpdate(invoice.subscriptionId);
      subscription.applyPaidInvoice(invoice);

      // 이용 기간 변경과 청구서의 결제 완료 처리를 함께 저장해요.
      await transaction.saveSubscription(subscription);
      await transaction.markInvoicePaid(invoice.id, paymentId);
    });
  }
}ts

prepareRenewal()은 Entity의 판단을 받아 같은 구독 기간의 청구서를 한 번만 만들어요. 금액과 이용 기간은 청구서에 저장한 값을 사용해요. 나중에 가격표가 바뀌어도 이미 만든 청구서의 금액이 바뀌지는 않아요.

applyPayment()는 실제 성공 기록이 이 청구서와 일치하는지 확인한 다음 구독을 변경해요. 청구서와 구독을 잠그고, 구독 저장과 청구서의 결제 완료 처리를 하나의 transaction으로 묶어요. 외부 결제를 기다리는 동안 DB transaction을 열어 두지는 않아요.

// subscription.repository.ts
import type { Invoice, InvoiceDraft } from "./contracts";
import type { Subscription } from "./subscription";

export interface SubscriptionTransaction {
  getInvoiceForUpdate(invoiceId: string): Promise<Invoice>;
  getSubscriptionForUpdate(subscriptionId: string): Promise<Subscription>;
  assertSuccessfulPayment(invoice: Invoice, paymentId: string): Promise<void>;
  saveSubscription(subscription: Subscription): Promise<void>;
  markInvoicePaid(invoiceId: string, paymentId: string): Promise<void>;
}

export abstract class SubscriptionRepository {
  abstract getById(subscriptionId: string): Promise<Subscription>;
  abstract getOrCreateInvoice(draft: InvoiceDraft): Promise<Invoice>;
  abstract transaction<T>(work: (transaction: SubscriptionTransaction) => Promise<T>): Promise<T>;
}ts

getOrCreateInvoice()는 구독 ID와 기간 번호의 unique constraint를 사용해요. assertSuccessfulPayment()는 내부 결제 ID, 청구서 연결, 금액·통화, 성공 상태를 확인해요. 이미 결제된 청구서라면 연결된 결제 ID도 같아야 해요.

이 계약의 잠금과 원자성은 실제 DB 구현이 보장해야 해요. Entity만으로 동시 실행을 막을 수 있는 것은 아니에요.

결제 Service와 Repository 구현
// payment.service.ts
import { Inject, Injectable } from "@nestjs/common";
import {
  PAYMENT_GATEWAY,
  type Invoice,
  type PaymentGateway,
  type PaymentResult,
  type GatewayPaymentResult,
} from "./contracts";
import { PaymentRepository, type PaymentExecution } from "./payment.repository";

@Injectable()
export class PaymentService {
  constructor(
    private readonly paymentRepository: PaymentRepository,
    @Inject(PAYMENT_GATEWAY) private readonly paymentGateway: PaymentGateway,
  ) {}

  async payInvoice(invoice: Invoice): Promise<PaymentResult> {
    // 저장된 결제 시도를 확인해 새 승인, 기존 결제 조회, 결과 반환 중 하나를 정해요.
    const execution = await this.paymentRepository.prepare(invoice);

    return this.execute(execution);
  }

  async reconcile(paymentId: string): Promise<{ invoiceId: string; result: PaymentResult }> {
    const payment = await this.paymentRepository.getById(paymentId);
    const execution = await this.paymentRepository.prepareReconciliation(paymentId);
    const result = await this.execute(execution);

    return { invoiceId: payment.invoiceId, result };
  }

  private async execute(execution: PaymentExecution): Promise<PaymentResult> {
    if (execution.action === "RETURN_RESULT") {
      return execution.result;
    }

    const payment = execution.payment;
    let result: GatewayPaymentResult;

    try {
      if (execution.action === "LOOKUP") {
        // 승인 응답을 놓쳤을 수 있으므로 저장된 주문번호로 기존 결제를 조회해요.
        result = await this.paymentGateway.findPayment(payment.request);
      } else {
        result = await this.paymentGateway.charge(payment.request);
      }
    } catch (error) {
      // 자동으로 처리할 수 없는 연동 오류는 운영 확인 대상으로 기록해요.
      await this.paymentRepository.markForReview(payment);
      throw error;
    }

    // 승인 후 저장 실패를 외부 결제 실패로 바꾸지 않아요.
    return this.paymentRepository.saveResult(payment, result);
  }
}ts

처리한 결과가 있으면 그대로 반환하고, 결과를 놓친 요청은 조회해요. 새 결제를 실행할 때만 charge()를 호출해요.

action은 실행할 동작이고 status는 결제 결과예요. LOOKUP은 기존 결제를 조회하라는 지시이고, PENDING은 아직 결과가 확정되지 않았다는 뜻이에요.

saveResult()를 외부 호출의 try/catch 밖에 둔 것도 이유가 있어요. 승인 후 DB 저장에 실패했다고 결제까지 실패한 것은 아니에요. 그 경우 기존 결제를 조회해 복구해야 해요.

// payment.repository.ts
import type { GatewayPaymentResult, Invoice, PaymentRequest, PaymentResult } from "./contracts";

export interface PaymentAttempt {
  id: string;
  invoiceId: string;
  lockToken: string;
  request: PaymentRequest;
}

export type PaymentExecution =
  | { action: "RETURN_RESULT"; result: PaymentResult }
  | { action: "CHARGE"; payment: PaymentAttempt }
  | { action: "LOOKUP"; payment: PaymentAttempt };

export abstract class PaymentRepository {
  abstract prepare(invoice: Invoice): Promise<PaymentExecution>;
  abstract getById(paymentId: string): Promise<PaymentAttempt>;
  abstract prepareReconciliation(
    paymentId: string,
  ): Promise<Exclude<PaymentExecution, { action: "CHARGE" }>>;
  abstract saveResult(
    payment: PaymentAttempt,
    result: GatewayPaymentResult,
  ): Promise<PaymentResult>;
  abstract markForReview(payment: PaymentAttempt): Promise<void>;
}ts

prepare()는 청구서와 연결된 결제 시도를 저장하고 실행을 선점해요. 이미 완료된 시도는 저장된 결과를, 다른 작업이 실행 중이면 PENDING을 반환해요. 응답을 놓쳤거나 실행이 중단된 시도는 LOOKUP으로 넘겨요.

금액·주문번호·멱등키·결제수단 ID는 처음 저장한 값을 유지해요. 시도마다 내부 결제 ID를 미리 발급하고, 승인 기록에도 같은 ID를 사용해요. 카드가 바뀌어도 진행 중인 시도는 기존 결제수단을 참조해요.

선점에는 만료 가능한 잠금과 lockToken을 사용해요. 저장 시에도 token을 검사해, 오래된 작업이 최신 결과를 덮어쓰지 못하게 해요. 승인 요청을 보낸 적이 있는 작업은 잠금이 만료됐다고 바로 다시 청구하지 않아요.

카드 거절이 확인된 뒤 다시 청구할 때는 별도의 시도를 만들어요. 이때도 기존 청구서에 성공한 결제가 없는지 먼저 확인해야 하죠. 예제에서는 재청구를 생략했지만, 실제 모여는 첫 실패 뒤 7일 유예를 두고 그 안에서 1일·3일·6일째에 자동으로 다시 청구해요.

모든 실패를 다시 청구하지는 않아요. 정지되거나 만료된 카드처럼 결제수단을 바꿔야 하는 거절은 같은 카드로 몇 번을 시도해도 성공하지 않으니 자동 재청구에서 빼요. 실패 사유를 CARD_DECLINED와 INVALID_PAYMENT_METHOD로 나눠 둔 이유이기도 해요.

토스의 응답은 결제 모듈 안에서 정리해요

PaymentService는 토스를 직접 호출하는 대신 PaymentGateway 계약을 사용해요. 토스 연동을 담당하는 TossPaymentGateway가 이 계약을 구현하고요.

// contracts.ts
export interface PaymentGateway {
  charge(request: PaymentRequest): Promise<GatewayPaymentResult>;
  findPayment(request: PaymentRequest): Promise<GatewayPaymentResult>;
}

export const PAYMENT_GATEWAY = Symbol("PAYMENT_GATEWAY");ts

Gateway는 내부 요청을 토스 규격으로 바꾸고, 응답도 내부 결과로 돌려줘요. 예를 들어 승인 응답은 주문번호·금액·통화와 카드 승인 정보를 확인한 뒤 다음처럼 변환해요.

// toss-payment.mapper.ts
return {
  status: "SUCCEEDED",
  providerPaymentId: response.paymentKey,
  approvedAt: new Date(response.approvedAt),
};ts

토스의 DONE이나 오류 코드는 여기서 해석해요. providerPaymentId는 결제 모듈에서 보관하고, PaymentService가 결과를 저장한 뒤 내부 paymentId를 반환해요. 구독 쪽에서는 토스의 거래 ID를 사용할 필요가 없어요.

외부 요청과 응답은 Zod로 검증해요. 응답의 형식이 올바른지 확인하는 것과, 그 결제가 우리 청구서와 일치하는지 판단하는 것은 구분해 두었어요.

토스 연동 흐름, Zod 검증과 Gateway 구현

먼저 실제 자동결제 흐름을 확인해 볼게요. 카드 등록과 구독료 청구는 별개의 과정이에요. 무료 체험은 앞서 정한 서비스 규칙대로 가입 시 시작해요.

sequenceDiagram
    participant U as 사용자
    participant W as 웹앱
    participant S as 우리 서버
    participant T as 토스페이먼츠

    U->>W: 카드 등록 선택
    W->>S: 등록 시작 요청
    S-->>W: customerKey · successUrl(state 포함) · 브라우저 cookie
    W->>T: requestBillingAuth
    T-->>U: 카드 인증 후 successUrl로 redirect
    U->>S: successUrl 요청(authKey · customerKey · state)
    S->>S: 로그인 세션 · cookie · state 확인
    S->>T: 빌링키 발급 요청
    T-->>S: billingKey
    S->>S: 결제수단 저장
    S-->>U: 303 redirect로 웹 화면 복귀
    Note over S,T: 체험 중에는 승인 요청 없음
    S->>S: 청구일에 갱신 Worker 실행
    S->>T: 자동결제 승인 요청
    T-->>S: 승인 결과 또는 오류
    S->>S: 결제 기록 · 성공한 기간 반영

토스 자동결제 연동 가이드처럼 서버에서 빌링키를 발급받아 고객과 연결해 두고, 청구할 때 이 키를 사용해요. 자동결제는 별도 계약이 필요한 기능이고, 이 예제는 카드 자동결제를 기준으로 해요.

customerKey는 서버에서 UUID처럼 추측하기 어려운 값으로 생성해요. successUrl도 웹 화면이 아니라 서버 API 주소(/api/v1/billing/payment-method-authorizations/{id}/callbacks/toss-payments/success?state=...)로 정했어요. 토스가 브라우저를 이 주소로 보내면 서버가 로그인 세션, 등록을 시작한 브라우저에 심어 둔 cookie, 주소의 state를 저장된 등록 기록과 대조해요. 모두 맞을 때만 빌링키를 발급받고, 끝나면 웹 화면으로 303 redirect해요.

웹앱이 authKey를 받아 서버로 넘기는 방식도 있지만, 모여는 서버가 콜백을 직접 받아 확인까지 한 번에 끝내요. 브라우저가 전달한 값만 보고 다른 계정에 카드를 연결하는 일이 없어야 하니까요.

목적요청핵심 입력
인증한 카드 등록POST /v1/billing/authorizations/issueauthKey, customerKey
등록된 카드로 청구POST /v1/billing/{billingKey}customerKey, amount, orderId, orderName
기존 결제 조회GET /v1/payments/orders/{orderId}저장된 orderId

토스가 구독 요금이나 청구 일정을 관리해 주는 것은 아니에요. 무료 체험, 월간·연간 요금, 다음 청구일은 우리 서버가 결정하고 그 시점에 승인 API를 호출해요.

Gateway가 주고받는 내부 계약과, 토스 요청·응답을 검증하는 Zod 스키마예요. PaymentGateway는 앞에서 본 그대로라 생략했어요.

// contracts.ts
export interface PaymentRequest {
  accountId: string;
  paymentMethodId: string;
  orderId: string;
  amountKrw: number;
  description: string;
  idempotencyKey: string;
}

export type GatewayPaymentResult =
  | { status: "SUCCEEDED"; providerPaymentId: string; approvedAt: Date }
  | { status: "FAILED"; failureCode: PaymentFailureCode }
  | { status: "PENDING" };ts
// toss.schema.ts
import { z } from "zod";

export const tossBillingBodySchema = z.object({
  // 2~50자이고, 특수문자(-, _, =, ., @)를 하나 이상 포함해야 해요.
  customerKey: z.string().regex(/^(?=.{2,50}$)(?=.*[-_=.@])[-A-Za-z0-9_=.@]+$/),
  amount: z.number().int().min(100),
  orderId: z.string().regex(/^[A-Za-z0-9_-]{6,64}$/),
  orderName: z.string().min(1).max(100),
});

export const tossChargeRequestSchema = z.object({
  billingKey: z.string().min(1).max(200),
  idempotencyKey: z.string().min(1).max(300),
  body: tossBillingBodySchema,
});

export const tossErrorSchema = z.object({
  code: z.string().min(1),
  message: z.string(),
});

export const tossPaymentSchema = z.object({
  paymentKey: z.string().min(1).max(200),
  orderId: z.string().min(1),
  type: z.string(),
  currency: z.string(),
  totalAmount: z.number().int().nonnegative(),
  status: z.string(),
  approvedAt: z.iso.datetime({ offset: true }).nullable(),
  card: z.object({}).nullable(),
  failure: tossErrorSchema.nullish(),
});

export type TossChargeRequest = z.infer<typeof tossChargeRequestSchema>;
export type TossPaymentResponse = z.infer<typeof tossPaymentSchema>;ts

요청에서는 카드 결제의 최소 금액, 주문번호와 키의 형식을 확인해요. 응답에서는 우리가 사용하는 필드만 검증해요. 새로운 필드가 추가됐다고 실패시키지는 않지만, 금액이 문자열로 오거나 날짜가 잘못되면 그대로 사용하지 않아요.

card는 카드 자동결제 승인인지 확인할 때 사용해요. 여기서는 상세 카드 정보를 읽지 않으므로 객체 여부만 확인해요. Zod의 safeParse()로 검증 결과를 분기하고, ZodError를 구독 쪽으로 그대로 넘기지 않아요.

Gateway는 필요한 값만 변환해요.

// toss-payment.gateway.ts
import { Injectable } from "@nestjs/common";
import type { GatewayPaymentResult, PaymentGateway, PaymentRequest } from "./contracts";
import { TossBillingClient } from "./toss-billing.client";
import { TossPaymentMethodRepository } from "./toss-payment-method.repository";
import { mapTossError, mapTossPayment } from "./toss-payment.mapper";
import type { TossPaymentResponse } from "./toss.schema";

@Injectable()
export class TossPaymentGateway implements PaymentGateway {
  constructor(
    private readonly tossBillingClient: TossBillingClient,
    private readonly paymentMethodRepository: TossPaymentMethodRepository,
  ) {}

  async charge(request: PaymentRequest): Promise<GatewayPaymentResult> {
    const paymentMethod = await this.paymentMethodRepository.findForAccount(
      request.paymentMethodId,
      request.accountId,
    );
    if (!paymentMethod) {
      // 이 계정의 결제수단을 찾지 못했으므로 토스에 승인 요청을 보내지 않아요.
      return { status: "FAILED", failureCode: "INVALID_PAYMENT_METHOD" };
    }

    return this.send(request, "CHARGE", () =>
      this.tossBillingClient.charge({
        billingKey: paymentMethod.billingKey,
        idempotencyKey: request.idempotencyKey,
        body: {
          customerKey: paymentMethod.customerKey,
          amount: request.amountKrw,
          orderId: request.orderId,
          orderName: request.description,
        },
      }),
    );
  }

  findPayment(request: PaymentRequest): Promise<GatewayPaymentResult> {
    return this.send(request, "LOOKUP", () =>
      this.tossBillingClient.findByOrderId(request.orderId),
    );
  }

  private async send(
    request: PaymentRequest,
    operation: "CHARGE" | "LOOKUP",
    sendRequest: () => Promise<TossPaymentResponse>,
  ): Promise<GatewayPaymentResult> {
    let response: TossPaymentResponse;

    try {
      response = await sendRequest();
    } catch (error) {
      return mapTossError(error, operation);
    }

    return mapTossPayment(response, request);
  }
}ts

이 안에서는 billingKey와 customerKey를 사용해도 돼요. 실제 토스 API에 맞추는 위치니까요. 반대로 구독 Entity나 Facade까지 이 필드를 전달할 필요는 없어요.

TossPaymentMethodRepository는 내부 결제수단 ID와 계정 ID로 키를 조회해요. 카드 변경 시에는 새 결제수단 레코드를 만들고 기존 결제 시도의 참조는 보존해요.

// toss-payment.mapper.ts
import {
  PaymentIntegrationError,
  type GatewayPaymentResult,
  type PaymentFailureCode,
  type PaymentRequest,
} from "./contracts";
import {
  TossApiError,
  TossInvalidRequestError,
  TossUnconfirmedResponseError,
} from "./toss-billing.client";
import type { TossPaymentResponse } from "./toss.schema";

const CARD_FAILURE_CODES = new Map<string, PaymentFailureCode>([
  ["REJECT_CARD_PAYMENT", "CARD_DECLINED"],
  ["REJECT_CARD_COMPANY", "CARD_DECLINED"],
  ["INVALID_REJECT_CARD", "CARD_DECLINED"],
  ["INVALID_STOPPED_CARD", "INVALID_PAYMENT_METHOD"],
  ["INVALID_CARD_EXPIRATION", "INVALID_PAYMENT_METHOD"],
]);

export function mapTossPayment(
  response: TossPaymentResponse,
  request: PaymentRequest,
): GatewayPaymentResult {
  const matchesInvoice =
    response.orderId === request.orderId &&
    response.totalAmount === request.amountKrw &&
    response.currency === "KRW" &&
    response.type === "BILLING";
  if (!matchesInvoice) {
    throw new PaymentIntegrationError("승인 내역이 청구서와 일치하지 않습니다.");
  }

  switch (response.status) {
    case "DONE": {
      if (!response.approvedAt || !response.card) {
        throw new PaymentIntegrationError("카드 승인 정보를 확인할 수 없습니다.");
      }

      return {
        status: "SUCCEEDED",
        providerPaymentId: response.paymentKey,
        approvedAt: new Date(response.approvedAt),
      };
    }
    case "ABORTED":
      return {
        status: "FAILED",
        failureCode: CARD_FAILURE_CODES.get(response.failure?.code ?? "") ?? "PAYMENT_FAILED",
      };
    case "EXPIRED":
      return { status: "FAILED", failureCode: "PAYMENT_FAILED" };
    case "READY":
    case "IN_PROGRESS":
      return { status: "PENDING" };
    default:
      // 취소·부분 취소·예상하지 못한 상태는 운영 확인 대상으로 남겨요.
      throw new PaymentIntegrationError("별도 확인이 필요한 결제 상태입니다.");
  }
}

export function mapTossError(error: unknown, operation: "CHARGE" | "LOOKUP"): GatewayPaymentResult {
  if (error instanceof TossUnconfirmedResponseError) {
    // timeout이나 응답 해석 실패만으로 실제 승인까지 실패했다고 판단하지 않아요.
    return { status: "PENDING" };
  }

  if (error instanceof TossInvalidRequestError) {
    throw new PaymentIntegrationError("결제 요청 값이 올바르지 않습니다.", { cause: error });
  }

  if (!(error instanceof TossApiError)) {
    throw error;
  }

  const failureCode = CARD_FAILURE_CODES.get(error.code);
  if (operation === "CHARGE" && failureCode) {
    return { status: "FAILED", failureCode };
  }

  const needsConfirmation =
    error.httpStatus >= 500 ||
    error.code === "IDEMPOTENT_REQUEST_PROCESSING" ||
    error.code === "DUPLICATED_ORDER_ID" ||
    (operation === "LOOKUP" && error.code === "NOT_FOUND_PAYMENT");
  if (needsConfirmation) {
    return { status: "PENDING" };
  }

  throw new PaymentIntegrationError(undefined, { cause: error });
}ts

DONE인지 보기 전에 주문번호·금액·통화·결제 유형을 대조해요. 실패가 확인된 ABORTED·EXPIRED를 미확정 상태로 바꾸지 않고, 취소·부분 취소는 이 예제의 승인 흐름에서 성공 처리하지 않아요. Payment 상태 정의를 기준으로 별도 확인 대상으로 남겨요.

시크릿 키 오류, 고객 키 불일치, INVALID_BILL_KEY_REQUEST는 카드 거절로 단정하지 않아요. 특히 마지막 코드는 빌링 인증이나 거래의 유효성 문제도 뜻하므로 연동을 먼저 확인해야 해요. 분류는 자동결제 오류 목록을 기준으로 했어요.

API의 5xx나 읽을 수 없는 응답은 보수적으로 조회 대상으로 남겨요. 알 수 없는 거래 상태와 연동 오류는 내부 PaymentIntegrationError로 전달하고, 해당 결제 시도를 운영 확인 대상으로 기록해요.

// contracts.ts
export class PaymentIntegrationError extends Error {
  constructor(message = "결제 연동을 확인해야 합니다.", options?: ErrorOptions) {
    super(message, options);
    this.name = "PaymentIntegrationError";
  }
}ts
// toss-payment-method.repository.ts
export interface TossPaymentMethod {
  billingKey: string;
  customerKey: string;
}

export abstract class TossPaymentMethodRepository {
  abstract findForAccount(
    paymentMethodId: string,
    accountId: string,
  ): Promise<TossPaymentMethod | null>;
}ts

운영 로그에는 내부 결제 ID와 오류 코드를 남기고, 인증 헤더나 빌링키가 들어간 URL 전체는 기록하지 않아요. 내부 오류의 cause도 API 응답으로 직렬화하지 않아요.

승인 요청 timeout을 60초로 둔 이유

토스의 자동결제 승인 문서는 승인에 최대 60초가 걸릴 수 있으니 timeout을 최소 60초로 설정하라고 안내해요.

그래서 승인 요청은 60초로 맞췄어요. 모여의 토스 Adapter는 승인 timeout이 60초보다 짧게 설정되면 생성 단계에서 거절해요. 누군가 값을 줄여도 문서의 하한 아래로 내려가지 않게 하려는 장치예요. 조회는 별도 요청이므로 10초로 정했어요.

Worker의 제한 시간도 승인 대기와 결과 저장을 마칠 수 있게 잡아야 해요. 일반 HTTP 요청 전체에 같은 timeout을 적용하거나, 브라우저가 60초 동안 연결을 유지해야 한다고 가정하지는 않아요.

// toss-billing.client.ts
import { Inject, Injectable } from "@nestjs/common";
import {
  tossBillingBodySchema,
  tossChargeRequestSchema,
  tossErrorSchema,
  tossPaymentSchema,
  type TossChargeRequest,
  type TossPaymentResponse,
} from "./toss.schema";

export class TossApiError extends Error {
  constructor(
    public httpStatus: number,
    public code: string,
  ) {
    super("Toss API request failed");
  }
}

export class TossInvalidRequestError extends Error {}
export class TossUnconfirmedResponseError extends Error {}

export const TOSS_SECRET_KEY = Symbol("TOSS_SECRET_KEY");
const TOSS_API_URL = "https://api.tosspayments.com";
const TOSS_CHARGE_TIMEOUT_MS = 60_000;
const TOSS_LOOKUP_TIMEOUT_MS = 10_000;

@Injectable()
export class TossBillingClient {
  constructor(@Inject(TOSS_SECRET_KEY) private readonly secretKey: string) {}

  async charge(request: TossChargeRequest): Promise<TossPaymentResponse> {
    const validation = tossChargeRequestSchema.safeParse(request);
    if (!validation.success) {
      throw new TossInvalidRequestError();
    }

    const { billingKey, idempotencyKey, body } = validation.data;

    return this.request(
      `/v1/billing/${encodeURIComponent(billingKey)}`,
      {
        method: "POST",
        headers: { "Idempotency-Key": idempotencyKey },
        body: JSON.stringify(body),
      },
      TOSS_CHARGE_TIMEOUT_MS,
    );
  }

  async findByOrderId(orderId: string): Promise<TossPaymentResponse> {
    const validation = tossBillingBodySchema.shape.orderId.safeParse(orderId);
    if (!validation.success) {
      throw new TossInvalidRequestError();
    }

    return this.request(
      `/v1/payments/orders/${encodeURIComponent(validation.data)}`,
      {
        method: "GET",
      },
      TOSS_LOOKUP_TIMEOUT_MS,
    );
  }

  private async request(
    path: string,
    options: RequestInit,
    timeoutMs: number,
  ): Promise<TossPaymentResponse> {
    const headers = new Headers(options.headers);
    const credentials = Buffer.from(`${this.secretKey}:`).toString("base64");

    headers.set("Authorization", `Basic ${credentials}`);
    headers.set("Content-Type", "application/json");

    let response: Response;
    let body: unknown;

    try {
      response = await fetch(`${TOSS_API_URL}${path}`, {
        ...options,
        headers,
        signal: AbortSignal.timeout(timeoutMs),
      });
      body = await response.json();
    } catch {
      throw new TossUnconfirmedResponseError();
    }

    if (!response.ok) {
      const validation = tossErrorSchema.safeParse(body);
      if (!validation.success) {
        throw new TossUnconfirmedResponseError();
      }

      throw new TossApiError(response.status, validation.data.code);
    }

    const validation = tossPaymentSchema.safeParse(body);
    if (!validation.success) {
      throw new TossUnconfirmedResponseError();
    }

    return validation.data;
  }
}ts

Client는 시크릿 키 뒤에 콜론을 붙여 Basic 인증 헤더를 만들어요. 요청 후 timeout이 발생해도 결제 실패로 단정하지 않고, 결과를 확정할 수 없다는 오류로 변환해요.

// billing.module.ts
import { Module } from "@nestjs/common";
import { PAYMENT_GATEWAY } from "./contracts";
import { PaymentService } from "./payment.service";
import { SubscriptionService } from "./subscription.service";
import { SubscriptionBillingFacade } from "./subscription-billing.facade";
import { TossBillingClient, TOSS_SECRET_KEY } from "./toss-billing.client";
import { TossPaymentGateway } from "./toss-payment.gateway";
import { BillingPersistenceModule } from "./billing-persistence.module";

@Module({
  imports: [BillingPersistenceModule],
  providers: [
    SubscriptionBillingFacade,
    SubscriptionService,
    PaymentService,
    TossBillingClient,
    { provide: PAYMENT_GATEWAY, useClass: TossPaymentGateway },
    {
      provide: TOSS_SECRET_KEY,
      useFactory: () => {
        const secretKey = process.env.TOSS_SECRET_KEY;
        if (!secretKey) {
          throw new Error("TOSS_SECRET_KEY is required");
        }

        return secretKey;
      },
    },
  ],
  exports: [SubscriptionBillingFacade],
})
export class BillingModule {}ts

BillingPersistenceModule은 구독·결제·토스 결제수단 Repository의 DB 구현을 제공하는 모듈이에요. 외부에는 Facade를 공개하고, Gateway는 NestJS Custom Provider로 연결해요.

결제사를 바꿔도 구독 갱신 흐름은 유지돼요

처음에 던진 질문으로 돌아가서, 신규 결제를 포트원 연동으로 전환한다고 해 볼게요. 새 Gateway가 PaymentGateway를 구현하도록 만들고, 주입할 구현을 바꿀 수 있어요.

// BillingModule에서 신규 결제에 사용할 Gateway를 교체해요.
const paymentGatewayProvider = {
  provide: PAYMENT_GATEWAY,
  useClass: TossPaymentGateway,
  useClass: PortOnePaymentGateway,
};ts

새 연동의 요청과 응답 변환은 Gateway와 Client에서 구현해요. Facade의 청구 준비·결제·구독 반영 순서와 Entity의 요금·기간 계산은 유지할 수 있고요.

기존 거래는 원래 결제사에서 조회해야 해요. 그래서 전환할 때는 결제 시도에 결제사를 함께 저장하고, 그에 맞는 Gateway로 보내야 해요. 기존 빌링키가 그대로 이전되는 것도 아니라서 결제수단을 다시 등록받아야 할 수도 있고요.

실제로 바뀐 것은 결제사가 아니었어요

다행히 모여는 결제사를 바꾸지 않았어요. 심사를 기다리는 동안 바뀐 것은 계약 뒤쪽이었어요.

먼저 9월 25일에 토스 Adapter를 정리했어요. 청구, 카드 인증, 빌링키 발급과 해지, 결제 조회로 나뉘어 있던 Adapter 다섯 개를 toss-payment.adapter.ts의 TossPaymentGateway 하나로 합쳤어요. 이제 이 클래스가 PaymentGateway port 전체를 구현해요.

결제 환경도 live와 test로 나눴어요. PaymentGatewayResolver가 환경마다 다른 토스 키로 만든 Gateway를 골라 주는데, 운영자나 Tester가 소유한 워크스페이스는 test 환경으로 결제해요. 덕분에 카드사 심사관도 실제 청구 없이 카드 등록부터 해지까지 확인할 수 있어요.

이때 호출하는 쪽은 요청 값이 아니라 저장된 결제 기록의 환경을 넘겨요. 다른 환경의 키로 조회하거나 청구하는 일을 막으려는 선택이에요.

가장 큰 변화는 결제를 닫은 채로 출시한 일이에요. 앞에서 말한 카드사 심사가 출시일까지 끝나지 않았거든요. 결제 기능은 모두 만들어 뒀지만 BILLING_CHECKOUT_ENABLED의 기본값이 false라서 live 환경의 결제 시작은 잠겨 있어요.

모여 요금제 화면의 Free, Pro, Team 카드와 결제는 곧 열린다는 안내
모여 요금제 화면의 Free, Pro, Team 카드와 결제는 곧 열린다는 안내

지금 모여 요금제 화면이에요. 유료 요금제 버튼은 결제가 아니라 “Pro 신청”, “Team 신청”이고, 아래에는 “결제는 곧 열려요. 가입한 뒤 워크스페이스에서 신청하면 확인하고 열어 드려요.”라는 안내가 붙어 있어요.

결제가 닫혀 있는 동안 유료 요금제는 신청으로 받아요. RequestWorkspacePlan이 신청을 남기면 운영자가 확인해 이행하거나 거절하는 식이에요.

결제를 시작할 수 있는지는 CheckoutAvailability 한 곳에서만 판단해요. 구독 갱신 흐름은 이 판단을 몰라도 되니, 심사가 끝나면 코드를 고치지 않고 플래그만 켜서 결제를 열 수 있어요.

테스트에서는 결제 결과를 Stub으로 바꿔요

Facade 테스트에 토스의 Payment 객체를 만들 필요는 없어요. 결제 Service와 같은 계약을 따르는 Stub을 넣고, 성공·실패·미확정 중 어떤 결과를 반환할지만 정하면 돼요.

// payment-service.stub.ts
import type { Invoice, PaymentResult } from "./contracts";
import type { PaymentService } from "./payment.service";

export class PaymentServiceStub implements Pick<PaymentService, "payInvoice"> {
  constructor(private result: PaymentResult | Error) {}

  async payInvoice(_invoice: Invoice): Promise<PaymentResult> {
    if (this.result instanceof Error) {
      throw this.result;
    }

    return this.result;
  }
}ts

PaymentServiceStub은 갱신에 필요한 payInvoice()만 제공해요. 전달받은 결과를 그대로 반환하고, 오류를 전달하면 그 오류를 던져요. 토스의 키나 응답 구조는 전혀 필요하지 않아요.

구독 쪽은 실제 SubscriptionService와 Entity를 사용하고, 저장소는 데이터를 메모리에 보관하는 Fake로 바꿀게요. Stub과 Mock의 차이는 어떤 도구로 만들었는지보다 무엇을 대신하고 어떻게 검증하는지에 있어요. 여기서는 메서드의 호출 순서 대신, 갱신 후 청구서 상태와 이용 가능한 기간을 확인해요.

Vitest로 Given/When/Then을 나누면 다음처럼 읽혀요. createTestContext()는 결제 Stub과 메모리 저장소를 연결하는 테스트 준비 함수예요.

// subscription-billing.facade.spec.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PaymentResult } from "./contracts";
import { createTestContext } from "./subscription-billing.fixture";

const renewalDate = new Date("2026-10-05T00:00:00+09:00");
const periodEndsAt = new Date("2026-11-05T00:00:00+09:00");

beforeEach(() => {
  // 날짜에 따라 결과가 달라지지 않도록 현재 시각을 고정해요.
  vi.useFakeTimers();
  vi.setSystemTime(renewalDate);
});

afterEach(() => {
  vi.useRealTimers();
});

describe("구독 갱신", () => {
  it("무료 체험 중에는 청구하지 않는다", async () => {
    // Given
    vi.setSystemTime(new Date("2026-10-04T23:59:59+09:00"));
    // 결제를 시도하면 즉시 실패하는 Stub으로 불필요한 청구를 잡아요.
    const { facade, repository } = await createTestContext(
      new Error("무료 체험 중에는 결제를 요청하면 안 됩니다."),
    );

    // When
    await facade.renewSubscription("subscription_001");

    // Then
    expect(repository.invoice).toBeNull();
    const subscription = await repository.getById("subscription_001");
    expect(subscription.getAccessLevel(new Date())).toBe("TRIAL");
  });

  it("결제에 성공하면 청구한 한 달 동안 유료 기능을 이용할 수 있다", async () => {
    // Given
    const { facade, repository } = await createTestContext({
      status: "SUCCEEDED",
      paymentId: "payment_001",
    });

    // When
    await facade.renewSubscription("subscription_001");

    // Then
    const subscription = await repository.getById("subscription_001");
    expect(repository.invoice?.status).toBe("PAID");
    expect(subscription.getAccessLevel(renewalDate)).toBe("PAID");
    expect(subscription.getAccessLevel(new Date(periodEndsAt.getTime() - 1))).toBe("PAID");
    expect(subscription.getAccessLevel(periodEndsAt)).toBe("FREE");
  });

  it.each<PaymentResult>([
    { status: "FAILED", failureCode: "CARD_DECLINED" },
    { status: "PENDING" },
  ])("결과가 $status이면 유료 이용 기간을 부여하지 않는다", async (paymentResult) => {
    // Given
    const { facade, repository } = await createTestContext(paymentResult);

    // When
    await facade.renewSubscription("subscription_001");

    // Then
    const subscription = await repository.getById("subscription_001");
    expect(repository.invoice?.status).toBe("OPEN");
    expect(subscription.getAccessLevel(renewalDate)).toBe("FREE");
  });
});ts

성공하면 청구서가 결제 완료 상태가 되고, 청구한 기간 동안 유료 기능을 이용할 수 있어요. 실패하거나 결과를 확정하지 못했다면 무료 상태를 유지해요. 무료 체험 중에는 결제를 요청하면 오류가 나는 Stub을 넣어, 불필요한 청구도 잡아내요.

이렇게 계약이 정리되어 있으면 AI로 테스트 초안을 만들 때도 편해요. Facade 코드와 PaymentResult를 보여 주고, 결제 결과별로 어떤 상태가 되어야 하는지 알려 주면 돼요. 토스의 복잡한 응답을 매번 설명할 필요 없이, Stub의 반환값을 바꾸며 성공·거절·미확정 시나리오를 작성할 수 있어요.

생성된 테스트를 읽는 기준도 분명해져요. 결제에 성공한 기간만 유료로 열리는지, 미확정 결제에 권한을 부여하지 않는지 보면 되니까요. 계약과 기대 결과가 분명할수록 AI가 만든 테스트도 검토하고 고치기 쉬워요.

vi는 현재 시각을 고정하는 데만 사용했어요. vi.fn()이나 호출 횟수 검증 없이도 구독 갱신의 결과를 확인할 수 있어요. 요금과 날짜 계산은 Entity 테스트에서 따로 검증해요.

Gateway와 Client에서는 승인·거절·미확정 응답, Zod 검증, 금액 불일치, 토스 오류 변환을 따로 확인해요. 저장소의 잠금과 unique constraint는 실제 DB를 사용하는 integration test가 필요해요. 메모리 Fake로 검증하는 범위에는 DB의 동시성이 포함되지 않아요.

Stub 연결, 메모리 저장소와 Vitest 설정

SubscriptionService는 실제 객체를 사용하고 PaymentService만 Stub으로 주입해요. 성공 시나리오에는 성공한 결제 기록도 미리 준비해요. SUCCEEDED가 내부 결제 기록까지 저장된 상태라는 계약에 맞추기 위해서예요.

// subscription-billing.fixture.ts
import { Test } from "@nestjs/testing";
import { onTestFinished } from "vitest";
import type { Invoice, InvoiceDraft, PaymentResult } from "./contracts";
import { PaymentService } from "./payment.service";
import { PaymentServiceStub } from "./payment-service.stub";
import { Subscription } from "./subscription";
import { SubscriptionService } from "./subscription.service";
import { SubscriptionRepository, type SubscriptionTransaction } from "./subscription.repository";
import { SubscriptionBillingFacade } from "./subscription-billing.facade";

interface SavedPayment {
  id: string;
  invoiceId: string;
  amountKrw: number;
  currency: "KRW";
}

// 상태를 메모리에 보관하는 Fake예요. DB의 잠금과 transaction은 재현하지 않아요.
class InMemorySubscriptionRepository extends SubscriptionRepository {
  invoice: Invoice | null = null;

  constructor(
    private subscription: Subscription,
    private successfulPayment: SavedPayment | null,
  ) {
    super();
  }

  async getById(subscriptionId: string): Promise<Subscription> {
    if (subscriptionId !== this.subscription.id) {
      throw new Error("구독을 찾을 수 없습니다.");
    }

    return this.subscription;
  }

  async getOrCreateInvoice(draft: InvoiceDraft): Promise<Invoice> {
    this.invoice ??= { ...draft, id: "invoice_001", status: "OPEN" };

    return this.invoice;
  }

  async transaction<T>(work: (transaction: SubscriptionTransaction) => Promise<T>): Promise<T> {
    const transaction: SubscriptionTransaction = {
      getInvoiceForUpdate: async (invoiceId) => {
        if (!this.invoice || this.invoice.id !== invoiceId) {
          throw new Error("청구서를 찾을 수 없습니다.");
        }

        return this.invoice;
      },
      getSubscriptionForUpdate: (subscriptionId) => this.getById(subscriptionId),
      assertSuccessfulPayment: async (invoice, paymentId) => {
        const payment = this.successfulPayment;
        if (
          !payment ||
          payment.id !== paymentId ||
          payment.invoiceId !== invoice.id ||
          payment.amountKrw !== invoice.amountKrw ||
          payment.currency !== "KRW"
        ) {
          throw new Error("청구서와 일치하는 성공한 결제가 없습니다.");
        }
      },
      saveSubscription: async (subscription) => {
        this.subscription = subscription;
      },
      markInvoicePaid: async (invoiceId, paymentId) => {
        if (
          !this.invoice ||
          this.invoice.id !== invoiceId ||
          this.successfulPayment?.id !== paymentId
        ) {
          throw new Error("청구서에 결제를 연결할 수 없습니다.");
        }

        this.invoice.status = "PAID";
      },
    };

    return work(transaction);
  }
}

export async function createTestContext(paymentResult: PaymentResult | Error) {
  const subscription = new Subscription({
    id: "subscription_001",
    accountId: "account_001",
    paymentMethodId: "method_001",
    billingCycle: "MONTHLY",
    // 9월 21일 가입, 14일 체험 뒤 10월 5일이 첫 청구일이에요.
    firstBillingDate: { year: 2026, month: 10, day: 5 },
    completedBillingPeriods: 0,
    autoRenewalEnabled: true,
    trialEndsAt: new Date("2026-10-05T00:00:00+09:00"),
    paidUntil: null,
  });

  // SUCCEEDED는 내부 결제 기록까지 저장한 결과이므로 해당 기록도 준비해요.
  const successfulPayment: SavedPayment | null =
    !(paymentResult instanceof Error) && paymentResult.status === "SUCCEEDED"
      ? {
          id: paymentResult.paymentId,
          invoiceId: "invoice_001",
          amountKrw: 10_000,
          currency: "KRW",
        }
      : null;
  const repository = new InMemorySubscriptionRepository(subscription, successfulPayment);
  const module = await Test.createTestingModule({
    providers: [
      SubscriptionBillingFacade,
      SubscriptionService,
      { provide: SubscriptionRepository, useValue: repository },
      { provide: PaymentService, useValue: new PaymentServiceStub(paymentResult) },
    ],
  }).compile();
  onTestFinished(() => module.close());

  return {
    repository,
    facade: module.get(SubscriptionBillingFacade),
  };
}ts

메모리 저장소는 이번 시나리오에 필요한 조회·저장만 구현했어요. transaction의 rollback이나 동시 실행까지 흉내 내지는 않아요.

NestJS의 Vitest 안내에 따라 SWC로 decorator metadata를 처리해요. 타입 검사는 별도로 실행하고요.

// vitest.config.ts
import { defineConfig } from "vitest/config";
import swc from "unplugin-swc";

export default defineConfig({
  test: {
    environment: "node",
    globals: false,
  },
  plugins: [
    swc.vite({
      jsc: {
        parser: { syntax: "typescript", decorators: true },
        transform: { legacyDecorator: true, decoratorMetadata: true },
      },
      module: { type: "es6" },
    }),
  ],
});ts

이 예제는 작성 당시(2026년 9월) NestJS 12.0.1과 Vitest로 확인했어요. 지금 모여는 NestJS 12.1.3과 Vitest 5.0.4를 써요. reflect-metadata는 NestJS가 내부에서 불러오므로 테스트마다 import할 필요는 없어요. 패키지 의존성과 SWC의 decorator metadata 설정은 유지해요.

빠르게 만드는 것과 계속 빠르게 만드는 것

AI로 개발하면서 이 차이를 자주 생각해요. 눈에 보이는 기능을 만드는 일은 이제 AI가 저보다 빠를 때가 많아요. 다만 제가 구조를 이해하지 못한 채 결과만 받아들이면, 서비스가 커졌을 때 어디를 고쳐야 할지 모르는 순간이 올 것 같아요. 당장의 구현 속도에 만족하는 사이 기술 부채도 빠르게 쌓일 수 있고요.

초기 스타트업은 PMF를 찾으려고 빨리 만들고 검증해야 해요. 처음부터 완벽한 설계를 하는 건 불가능하고, 무엇을 완벽하다고 보는지도 사람과 서비스마다 다를 거예요.

그래도 결제처럼 돈과 이용 권한이 걸린 로직은 흐름을 설명하고 고칠 수 있을 만큼은 구조를 잡아 둬야 한다고 생각해요. 요구사항이 바뀌거나 결제에서 장애가 났을 때 원인을 찾고 영향 범위를 확인해 수정할 수 있어야 하니까요. 처음에 구조를 고민하는 시간은 조금 더 들어도, 이후의 변경과 장애 대응에 드는 시간은 줄어든다고 봐요.

시간이 흐르며 달라지는 두 개발 방식의 누적 구현량
시간이 흐르며 달라지는 두 개발 방식의 누적 구현량

곡선의 기울기는 개발 속도, 높이는 그동안 구현한 양이에요. 구조를 미룬 쪽은 처음에 빠르게 올라가지만, 변경 비용이 쌓이면서 점점 평평해져요. 실제로 측정한 값이 아니라 이 차이를 설명하려고 그린 개념도예요.

이 예제에서는 그 출발점으로 Facade를 도입했어요. AI가 코드를 빠르게 작성해 주더라도, 무엇이 바뀔 때 어디를 고쳐야 하는지는 제가 이해하고 싶었어요. 언젠가는 이런 판단까지 AI가 더 잘할지도 모르겠지만, 지금은 제가 설명할 수 있는 구조 안에서 함께 개발하려고 해요.

Facade를 만들 때 놓치기 쉬운 것들

외부 API를 그대로 옮겨 놓기

issueBillingKey(), approveBillingPayment()를 같은 클래스에 모아도 호출자가 실행 순서를 알아야 한다면 단순화한 범위가 작아요. 이 글에서는 renewSubscription()처럼 호출자가 하려는 일을 표현했어요.

관련 기능을 전부 넣기

구독 갱신에 필요하다고 할인, 이메일, 통계, 환불까지 한 Facade에 넣을 필요는 없어요. 이 Facade는 청구와 구독 반영이라는 응집된 흐름을 맡고, 계산과 저장은 해당 객체에 남겨요.

꼭 구분할 차이까지 숨기기

카드 거절과 timeout을 모두 false로 반환하면 다음 행동을 정할 수 없어요. 내부 계약을 단순하게 만들더라도 확인된 실패와 미확정 결과는 구분해야 해요.

그래서 PENDING을 따로 두었어요. 승인 결과를 놓쳤다면 기존 결제를 조회하고, 성공을 확인한 뒤 같은 구독 반영 경로로 보내요. 이때 이용 기간은 확인한 시각이 아니라 원래 청구서를 기준으로 적용해요.

이용 기간 만료와 미확정 결제의 복구

예를 들어 9월 21일에 가입했다면 14일 체험은 10월 5일 00:00에 끝나요. 월간 결제가 성공하면 11월 5일 00:00까지, 연간 결제가 성공하면 다음 해 10월 5일 00:00까지 이용할 수 있어요.

결제 시각부터 정확히 30일을 더하는 방식은 아니에요. 예제에서는 첫 청구일의 한국 시간 자정을 기준으로 정했으므로 화면에도 종료 시각을 안내해요.

자정 배치가 유료 여부를 바꿀 때까지 기다리는 대신, 서버의 권한 검사에서 subscription.getAccessLevel(now)로 종료 시각을 확인해요. <로 비교하므로 종료 시각에 도달하면 기존 권한은 끝나요.

예제는 유예 없이 단순화했으므로, 갱신 승인을 아직 확인하지 못했다면 무료 플랜을 적용해요. 그래서 승인 확인 전까지 잠깐 무료로 보일 수 있어요. 실제 모여는 결제가 실패해도 바로 낮추지 않고 7일 유예를 둬요. 유예 동안은 유료 한도를 유지하면서 자동으로 다시 청구하고, 그래도 결제되지 않은 채 유예가 끝나면 이용을 제한해요.

이용 권한이 끝났다고 진행 중인 결제까지 실패한 것은 아니에요. 미확정 결제는 별도 Worker가 조회하고, 확인된 승인은 같은 구독 반영 경로로 보내요.

// reconcile-payment.job.ts
// 미확정 결제를 조회하는 Worker에서 호출해요.
await subscriptionBillingFacade.reconcilePayment(paymentId);ts

앞서 만든 reconcilePayment()를 Worker가 내부 결제 ID로 호출해요. 갱신을 해제했거나 기존 이용 기간이 끝났어도 이미 보낸 결제의 확인은 계속되어야 해요.

결제 결과는 토스가 보내는 webhook으로도 들어와요. webhook 주소에는 환경별 endpoint token을 넣어 두고, 컨트롤러가 이 token부터 검증해요. 결제 상태 변경(PAYMENT_STATUS_CHANGED)과 빌링키 삭제(BILLING_DELETED)는 각각의 Use Case가 provider event inbox에 넣고, 같은 주문·상태로 다시 온 알림은 inbox에서 한 건으로 묶어요.

결제 상태 변경 알림에는 서명이 없어요. 그래서 알림에 적힌 상태를 그대로 믿지 않고, Worker가 토스 조회로 사실을 확인한 뒤에만 결제 기록을 바꿔요.

반대로 승인 기록은 저장됐는데 구독 반영만 실패했다면, 성공한 결제와 미반영 청구서를 찾아 SubscriptionService.applyPayment()를 다시 실행해요. 결제를 다시 요청할 필요는 없어요.

복구된 권한의 종료일은 확인한 시각에 한 달을 더한 값이 아니라 원래 청구서의 periodEndsAt이에요. 이미 그 기간까지 지났다면 권한을 새로 늘리지 않고, 결제·서비스 제공 내역을 운영자가 확인해요.

토스의 멱등키 규칙에 따르면 같은 키로 동일한 요청을 재전송할 수 있고, 키는 최초 사용일부터 15일간 유효해요. 이 예제는 미확정 요청을 먼저 조회하는 방식으로 처리해요. 새 키로 바로 재청구하지 않아요.

DUPLICATED_ORDER_ID도 기존 승인·취소 거래가 있다는 신호이므로 먼저 내역을 확인해요. 조회 한 번에 결제가 없다고 나왔다고 해서 즉시 미승인으로 확정하지 않아요. 조회 횟수와 간격을 제한하고 계속 확인되지 않는 시도는 운영 확인 대상으로 남겨요.

마지막으로, Facade와 Adapter는 목적이 달라요

둘 다 다른 객체를 감싸지만 사용하는 목적은 달라요.

패턴목적이 예제에서 하는 일
Facade복잡한 사용 과정을 단순하게 제공구독 Service와 결제 Service를 조합해 갱신 기능 제공
Adapter서로 다른 인터페이스를 맞춤토스 규격을 PaymentGateway 계약에 맞게 변환

코드로 보면 차이가 더 분명해요. Facade를 사용하는 쪽에서는 다음 호출 하나로 구독을 갱신해요.

// 청구 준비 → 결제 → 구독 반영 순서는 Facade가 조합해요.
await subscriptionBillingFacade.renewSubscription(subscriptionId);ts

반면 Adapter는 호출자가 기대하는 인터페이스에 다른 객체를 맞춰 줘요. 앞서 만든 TossPaymentGateway에서 조회 기능만 간추려 볼게요.

// adapter-example.ts
class TossPaymentGateway {
  constructor(private readonly tossBillingClient: TossBillingClient) {}

  async findPayment(request: PaymentRequest): Promise<GatewayPaymentResult> {
    let response: TossPaymentResponse;

    try {
      // 내부 요청에서 주문번호를 꺼내 토스 Client의 조회 방식에 맞춰요.
      response = await this.tossBillingClient.findByOrderId(request.orderId);
    } catch (error) {
      // 토스의 오류도 내부 결제 결과나 연동 오류로 변환해요.
      return mapTossError(error, "LOOKUP");
    }

    // 토스의 응답을 검증한 뒤 우리 결제 계약으로 바꿔요.
    return mapTossPayment(response, request);
  }
}ts

PaymentService는 findPayment(request)라는 내부 계약을 사용해요. Gateway는 그 요청을 받아 토스 Client의 findByOrderId(orderId)를 호출하고, 응답과 오류를 우리 계약에 맞춰 돌려줘요. 이처럼 메서드와 입력·출력의 차이를 맞추는 것이 Adapter의 역할이에요.

예를 들어 토스의 DONE은 SUCCEEDED로, paymentKey는 결제 모듈에서 사용하는 providerPaymentId로 바뀌어요. 앞서 작성한 mapTossPayment()가 주문번호·금액을 대조하고 이 변환을 처리해요. 구독 기간을 계산하거나 갱신 순서를 조합하지는 않아요.

그래서 TossPaymentGateway는 Adapter 역할을 한다고 볼 수 있어요. 실제 모여 코드에서도 이 클래스는 toss-payment.adapter.ts에 있어요. 결제사를 바꾸면 새 연동도 같은 PaymentGateway 계약에 맞추고, Facade는 청구 준비·결제·구독 반영이라는 흐름을 그대로 사용해요.

정리하면 Facade는 사용 과정을 단순하게 만들고, Adapter는 서로 다른 인터페이스를 맞춰요. 함께 쓸 수 있지만 목적이 다른 패턴이에요.

모여의 결제는 지금도 카드사 심사를 기다리며 닫혀 있어요. 그사이 토스 Adapter를 합치고 live와 test 환경을 나눴지만, 구독을 다루는 Use Case는 여전히 PaymentGateway 계약만 알아요. 기능이 늘어도 사용하는 쪽은 내부 사정을 몰라도 되게 하는 것, Facade로 얻고 싶었던 것도 그거였어요.