junyeokk
Blog
Stripe·2026. 02. 06

Stripe Terminal

온라인 결제는 카드 번호를 웹 폼에 입력하는 방식이다. 하지만 오프라인 매장이나 키오스크처럼 고객이 직접 실물 카드를 리더기에 꽂거나 태핑하는 대면(in-person) 결제가 필요한 상황이 있다. 이때 Stripe의 온라인 결제 인프라를 그대로 활용하면서 물리적 카드 리더를 연동할 수 있게 해주는 것이 Stripe Terminal이다.

기존에 대면 결제를 구현하려면 VAN사(밴사)와 계약하고, 각 단말기 제조사의 SDK를 통합하고, PCI DSS 인증을 직접 받아야 했다. Stripe Terminal은 이 복잡한 과정을 추상화해서, Stripe 계정 하나로 온라인/오프라인 결제를 통합 관리할 수 있게 해준다.


전체 아키텍처

Stripe Terminal의 결제 흐름은 크게 세 레이어로 나뉜다.

text
┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  클라이언트   │────▶│   백엔드 서버  │────▶│  Stripe API  │
│ (키오스크 앱) │◀────│              │◀────│              │
└──────┬──────┘     └──────────────┘     └──────────────┘


┌─────────────┐
│  카드 리더기  │
│ (Stripe 인증)│
└─────────────┘
  1. 백엔드 서버: PaymentIntent를 생성하고, 결제 완료 후 Stripe API로 상태를 검증한다.
  2. 클라이언트: Stripe Terminal JS SDK를 로드해서 리더기에 연결하고, 카드 수집과 결제 처리를 수행한다.
  3. 카드 리더기: Stripe에서 인증한 물리적 디바이스. 카드 데이터를 읽어서 Stripe 서버로 직접 전송한다(카드 정보가 클라이언트를 거치지 않는다).

이 구조 덕분에 민감한 카드 데이터는 리더기 → Stripe 서버로 직접 가고, 우리 서버나 클라이언트는 카드 번호를 절대 만지지 않는다. PCI 규정 준수 부담이 대폭 줄어드는 핵심 이유다.


서버 사이드: PaymentIntent 생성

대면 결제도 온라인 결제와 마찬가지로 PaymentIntent를 생성하는 것에서 시작한다. 다만 Terminal 전용으로 payment_method_typescard_present를 지정하고, capture_methodautomatic으로 설정한다.

typescript
// stripe-client.service.ts
async createPaymentIntent(params: {
  amount: number;
  currency: string;
  sessionId: string;
}) {
  const paymentIntent = await this.stripe.paymentIntents.create({
    amount: params.amount,
    currency: params.currency.toLowerCase(),
    payment_method_types: ['card_present'],
    capture_method: 'automatic',
    metadata: {
      sessionId: params.sessionId,
    },
  });

  return {
    id: paymentIntent.id,
    clientSecret: paymentIntent.client_secret,
  };
}

card_present는 "실물 카드가 리더기에 제시된다"는 의미다. 온라인 결제의 card와 구분되는 Terminal 전용 결제 수단이다.

생성된 PaymentIntent의 client_secret을 클라이언트에 전달한다. 이 시크릿은 클라이언트가 리더기로 결제를 수집할 때 필요하다.


Connection Token

클라이언트의 Terminal SDK가 Stripe 서버와 통신하려면 인증이 필요하다. 이때 사용하는 것이 Connection Token이다.

typescript
// 백엔드: Connection Token 발급 엔드포인트
@Post('stripe/connection-token')
async createConnectionToken() {
  const token = await this.stripe.terminal.connectionTokens.create();
  return { secret: token.secret };
}

Connection Token은 수명이 짧은 일회성 토큰이다. Terminal SDK를 초기화할 때 토큰 fetch 함수를 등록해두면, SDK가 필요할 때마다 자동으로 호출한다.

typescript
const config = {
  onFetchConnectionToken: async () => {
    const response = await api.createStripeConnectionToken();
    return response.data.secret;
  },
  onUnexpectedReaderDisconnect: () => {
    // 리더기가 예기치 않게 연결 해제됐을 때 처리
    handleDisconnect();
  },
};

const terminal = StripeTerminal.create(config);

onFetchConnectionToken은 SDK 초기화 시점과 토큰 만료 시점에 호출된다. 매번 서버에서 새 토큰을 받아와야 하므로, 캐싱하면 안 된다.


리더기 연결

Terminal SDK로 리더기를 찾고 연결하는 과정은 세 단계다.

1단계: 리더 검색 (Discover)

typescript
const { discoveredReaders, error } = await terminal.discoverReaders({
  simulated: false,
  location: 'tml_xxx', // Stripe Location ID
});

location은 Stripe Dashboard에서 생성한 위치 식별자다. 매장별로 리더기를 관리할 수 있게 해준다. 실제 배포 환경에서는 반드시 location을 지정해야 한다.

2단계: 리더 연결 (Connect)

typescript
const { reader, error } = await terminal.connectReader(discoveredReaders[0]);

검색된 리더 중 하나를 선택해서 연결한다. 연결이 성공하면 reader 객체가 반환되고, 이후 결제 수집에 사용할 수 있다.

3단계: 연결 해제 (Disconnect)

typescript
await terminal.disconnectReader();

더 이상 결제를 받지 않을 때 명시적으로 연결을 해제한다.


결제 수집과 처리

리더기가 연결된 상태에서 실제 결제를 진행하는 흐름이다.

collectPaymentMethod

typescript
const collectResult = await terminal.collectPaymentMethod(clientSecret);

이 메서드를 호출하면 리더기가 활성화되어 고객의 카드를 기다린다. 고객이 카드를 삽입하거나 태핑하면, 리더기가 카드 정보를 읽어서 Stripe 서버에 직접 전송한다. 클라이언트는 카드 번호를 볼 수 없다.

clientSecret은 서버에서 생성한 PaymentIntent의 시크릿이다. 이 시크릿으로 어떤 PaymentIntent에 대해 결제를 수집할지 지정한다.

반환값에는 카드 정보가 채워진 paymentIntent 객체가 있다. 아직 결제가 완료된 것은 아니고, 카드 수집만 된 상태다.

processPayment

typescript
const processResult = await terminal.processPayment(
  collectResult.paymentIntent,
);

수집된 결제 정보를 실제로 처리(승인 요청)한다. 이 단계에서 카드사에 승인 요청이 가고, 성공하면 paymentIntent.statussucceeded로 바뀐다.

전체 결제 흐름 코드

typescript
async requestPayment(request: PaymentRequest): Promise<PaymentResult> {
  const terminal = await this.ensureTerminal();

  if (!this.reader) {
    await this.connect();
  }

  // 1. 카드 수집 대기
  this.emitStatus('waiting_card');
  const collectResult = await terminal.collectPaymentMethod(
    request.clientSecret,
  );

  if (collectResult.error) {
    this.emitStatus('failed');
    return { success: false, message: collectResult.error.message };
  }

  // 2. 결제 처리 (승인 요청)
  this.emitStatus('processing');
  const processResult = await terminal.processPayment(
    collectResult.paymentIntent,
  );

  if (processResult.error) {
    this.emitStatus('failed');
    return { success: false, message: processResult.error.message };
  }

  // 3. 결과 반환
  const intent = processResult.paymentIntent;
  const charge = intent.charges?.data?.[0];
  const cardPresent = charge?.payment_method_details?.card_present;

  return {
    success: intent.status === 'succeeded',
    gateway: 'stripe',
    paymentIntentId: intent.id,
    cardBrand: cardPresent?.brand,
    last4: cardPresent?.last4,
    receiptUrl: charge?.receipt_url,
  };
}

collectPaymentMethodprocessPayment의 2단계 분리는 의도적인 설계다. 수집과 처리를 분리함으로써 중간에 사용자에게 금액 확인을 받거나, 추가 할인을 적용하거나, 결제를 취소할 수 있는 여지를 만든다.


서버 사이드 검증: Query-Driven Verification

클라이언트에서 "결제 성공했어요"라고 보내는 걸 그대로 믿으면 안 된다. 반드시 서버에서 Stripe API를 직접 조회해서 PaymentIntent의 실제 상태를 확인해야 한다.

typescript
async complete(context: CompletePaymentContext): Promise<CompletePaymentResult> {
  const { payment, dto } = context;

  // 1. 클라이언트가 보낸 ID와 서버에 저장된 ID가 일치하는지 확인
  if (dto.paymentIntentId !== payment.externalPaymentId) {
    throw new BadRequestException('PaymentIntent ID mismatch');
  }

  // 2. Stripe API에서 직접 PaymentIntent 상태 조회
  const paymentIntent = await this.stripeClient.retrievePaymentIntent(
    dto.paymentIntentId,
  );

  const isSuccess = paymentIntent.status === 'succeeded';

  return {
    success: isSuccess,
    externalPaymentId: paymentIntent.id,
    receiptData: isSuccess ? this.extractReceiptData(paymentIntent) : undefined,
  };
}

이 패턴을 Query-Driven Verification이라고 부른다. 클라이언트의 콜백을 "힌트"로만 사용하고, 실제 진위 여부는 서버가 Stripe API에 직접 물어보는 방식이다. 결제 시스템에서는 이 원칙을 반드시 지켜야 한다.


시뮬레이터 모드

개발 환경에서 실물 리더기 없이 테스트하기 위해 시뮬레이터 모드를 제공한다.

typescript
// 시뮬레이터 활성화
const discoverParams = {
  simulated: true, // 실물 리더 대신 시뮬레이터 사용
};

// 테스트 카드 번호 설정
terminal.setSimulatorConfiguration({
  testCardNumber: '4242424242424242', // 성공하는 Visa 카드
});

simulated: true로 설정하면 가상 리더기가 검색되고, 실제 카드 없이 결제 흐름을 테스트할 수 있다.

주요 테스트 카드 번호

성공 케이스:

카드 번호브랜드
4242424242424242Visa
4000056655665556Visa Debit
5555555555554444Mastercard
5200828282828210Mastercard Debit
378282246310005American Express

실패 케이스:

카드 번호실패 사유
4000000000000002카드 거절 (card_declined)
4000000000009995잔액 부족 (insufficient_funds)
4000000000009987분실 카드 (lost_card)
4000000000000069만료된 카드 (expired_card)
4000000000000119처리 오류 (processing_error)

실패 케이스를 테스트하는 건 선택이 아니라 필수다. "카드가 거절됐을 때 UI가 어떻게 반응하는가"를 검증하지 않으면 프로덕션에서 고객이 당황한다.


리더 디바이스 종류

Stripe Terminal은 여러 종류의 물리적 리더기를 지원한다.

  • BBPOS WisePOS E: 안드로이드 기반 독립형 단말기. 자체 화면이 있어서 금액 표시와 PIN 입력이 가능하다. 키오스크나 POS에 적합하다.
  • BBPOS Chipper 2X BT: 블루투스 연결되는 소형 리더. 모바일 앱과 함께 사용한다.
  • Stripe Reader S700: Stripe 자체 제작 단말기. 터치스크린 내장.
  • BBPOS WisePad 3: NFC + 칩 + 마그네틱 스와이프를 모두 지원하는 범용 리더.

리더 종류에 따라 연결 방식(인터넷/블루투스)이 다르지만, SDK의 discoverReadersconnectReader API는 동일하다. 추상화가 잘 되어 있어서 리더를 교체해도 코드 변경이 거의 없다.


Location 관리

Stripe Terminal에서 Location은 리더기가 설치된 물리적 장소를 나타낸다.

typescript
// Stripe Dashboard 또는 API로 Location 생성
const location = await stripe.terminal.locations.create({
  display_name: '강남 1호점',
  address: {
    line1: '서울특별시 강남구 테헤란로 123',
    city: '서울',
    country: 'KR',
    postal_code: '06134',
  },
});
// location.id → 'tml_xxx'

Location은 단순히 주소를 저장하는 게 아니다. 리더기를 검색할 때 Location ID를 지정하면 해당 위치에 등록된 리더만 검색된다. 매장이 여러 개일 때 각 매장의 리더를 구분하는 데 필수적이다.

typescript
const { discoveredReaders } = await terminal.discoverReaders({
  simulated: false,
  location: 'tml_xxx', // 이 위치의 리더만 검색
});

상태 관리 패턴

결제 과정에서 UI에 현재 상태를 표시하려면 상태 콜백 패턴이 유용하다.

typescript
type PaymentStatus =
  | 'idle'          // 대기 중
  | 'connecting'    // 리더 연결 중
  | 'waiting_card'  // 카드 대기 중
  | 'processing'    // 결제 처리 중
  | 'success'       // 성공
  | 'failed'        // 실패
  | 'cancelled';    // 취소됨

class StripeGateway implements PaymentGateway {
  private statusCallback: ((status: PaymentStatus) => void) | null = null;

  onStatusChange(callback: (status: PaymentStatus) => void): void {
    this.statusCallback = callback;
  }

  private emitStatus(status: PaymentStatus): void {
    this.statusCallback?.(status);
  }
}

클라이언트에서는 이 콜백을 구독해서 "카드를 넣어주세요", "처리 중입니다" 같은 안내 메시지를 표시할 수 있다. 키오스크처럼 사용자가 화면만 보는 환경에서는 이 상태 표시가 특히 중요하다.


온라인 결제와의 차이점

온라인 결제 (card)대면 결제 (card_present)
카드 입력웹 폼에 번호 직접 입력리더기에 삽입/태핑
카드 데이터 경로브라우저 → Stripe.js → Stripe리더기 → Stripe (직접)
PCI 부담SAQ A-EP 또는 SAQ ASAQ C-VT (최소)
SDKStripe.js@stripe/terminal-js
PaymentIntent 타입cardcard_present
인증(3DS)필요할 수 있음불필요 (카드가 물리적으로 존재)
분쟁(Chargeback)비교적 흔함드묾 (카드 실물 확인)

대면 결제는 카드가 물리적으로 존재하기 때문에 사기 위험이 낮고, 3DS 같은 추가 인증이 필요 없다. 반면 리더기 하드웨어 관리와 네트워크 안정성이라는 새로운 과제가 생긴다.


에러 처리

Terminal 결제에서 발생할 수 있는 주요 에러 상황과 대응 방법이다.

typescript
// 리더 검색 실패
const { error, discoveredReaders } = await terminal.discoverReaders(params);
if (error || !discoveredReaders?.length) {
  // 네트워크 확인 → Location ID 확인 → 리더 전원 확인
  throw new Error('리더를 찾을 수 없습니다.');
}

// 리더 연결 실패
const connectResult = await terminal.connectReader(reader);
if (connectResult.error) {
  // 리더가 다른 세션에 이미 연결되어 있을 수 있음
  // → 기존 연결 해제 후 재시도
  throw new Error(connectResult.error.message);
}

// 카드 수집 실패
const collectResult = await terminal.collectPaymentMethod(clientSecret);
if (collectResult.error) {
  // 카드 읽기 실패, 타임아웃, 취소 등
  // → 사용자에게 재시도 안내
}

// 결제 처리 실패
const processResult = await terminal.processPayment(paymentIntent);
if (processResult.error) {
  // 카드 거절, 잔액 부족 등
  // → 다른 카드로 재시도 안내
}

특히 주의할 점은 연결 해제 후 재연결 패턴이다. 이전 연결이 깔끔하게 정리되지 않으면 다음 연결이 실패할 수 있다.

typescript
async connect(): Promise<void> {
  // 이미 연결된 리더가 있으면 먼저 해제 (재시도 시나리오)
  if (this.reader) {
    await this.disconnect();
  }
  // ... 새로 연결
}

정리

Stripe Terminal의 핵심 흐름을 요약하면 이렇다.

  1. 서버: PaymentIntent 생성 (card_present) → client_secret 반환
  2. 클라이언트: Terminal SDK 초기화 (Connection Token)
  3. 클라이언트: 리더 검색 → 연결
  4. 클라이언트: collectPaymentMethod(clientSecret) → 카드 대기
  5. 클라이언트: processPayment(paymentIntent) → 승인 요청
  6. 서버: Stripe API로 PaymentIntent 상태 검증 (Query-Driven)

카드 데이터가 우리 시스템을 거치지 않는다는 점, collectPaymentMethod와 processPayment가 분리되어 있다는 점, 서버에서 반드시 최종 검증을 해야 한다는 점. 이 세 가지가 Stripe Terminal을 이해하는 핵심이다.