Stripe Terminal
온라인 결제는 카드 번호를 웹 폼에 입력하는 방식이다. 하지만 오프라인 매장이나 키오스크처럼 고객이 직접 실물 카드를 리더기에 꽂거나 태핑하는 대면(in-person) 결제가 필요한 상황이 있다. 이때 Stripe의 온라인 결제 인프라를 그대로 활용하면서 물리적 카드 리더를 연동할 수 있게 해주는 것이 Stripe Terminal이다.
기존에 대면 결제를 구현하려면 VAN사(밴사)와 계약하고, 각 단말기 제조사의 SDK를 통합하고, PCI DSS 인증을 직접 받아야 했다. Stripe Terminal은 이 복잡한 과정을 추상화해서, Stripe 계정 하나로 온라인/오프라인 결제를 통합 관리할 수 있게 해준다.
전체 아키텍처
Stripe Terminal의 결제 흐름은 크게 세 레이어로 나뉜다.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ 클라이언트 │────▶│ 백엔드 서버 │────▶│ Stripe API │
│ (키오스크 앱) │◀────│ │◀────│ │
└──────┬──────┘ └──────────────┘ └──────────────┘
│
▼
┌─────────────┐
│ 카드 리더기 │
│ (Stripe 인증)│
└─────────────┘
- 백엔드 서버: PaymentIntent를 생성하고, 결제 완료 후 Stripe API로 상태를 검증한다.
- 클라이언트: Stripe Terminal JS SDK를 로드해서 리더기에 연결하고, 카드 수집과 결제 처리를 수행한다.
- 카드 리더기: Stripe에서 인증한 물리적 디바이스. 카드 데이터를 읽어서 Stripe 서버로 직접 전송한다(카드 정보가 클라이언트를 거치지 않는다).
이 구조 덕분에 민감한 카드 데이터는 리더기 → Stripe 서버로 직접 가고, 우리 서버나 클라이언트는 카드 번호를 절대 만지지 않는다. PCI 규정 준수 부담이 대폭 줄어드는 핵심 이유다.
서버 사이드: PaymentIntent 생성
대면 결제도 온라인 결제와 마찬가지로 PaymentIntent를 생성하는 것에서 시작한다. 다만 Terminal 전용으로 payment_method_types에 card_present를 지정하고, capture_method를 automatic으로 설정한다.
// 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이다.
// 백엔드: 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가 필요할 때마다 자동으로 호출한다.
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)
const { discoveredReaders, error } = await terminal.discoverReaders({
simulated: false,
location: 'tml_xxx', // Stripe Location ID
});
location은 Stripe Dashboard에서 생성한 위치 식별자다. 매장별로 리더기를 관리할 수 있게 해준다. 실제 배포 환경에서는 반드시 location을 지정해야 한다.
2단계: 리더 연결 (Connect)
const { reader, error } = await terminal.connectReader(discoveredReaders[0]);
검색된 리더 중 하나를 선택해서 연결한다. 연결이 성공하면 reader 객체가 반환되고, 이후 결제 수집에 사용할 수 있다.
3단계: 연결 해제 (Disconnect)
await terminal.disconnectReader();
더 이상 결제를 받지 않을 때 명시적으로 연결을 해제한다.
결제 수집과 처리
리더기가 연결된 상태에서 실제 결제를 진행하는 흐름이다.
collectPaymentMethod
const collectResult = await terminal.collectPaymentMethod(clientSecret);
이 메서드를 호출하면 리더기가 활성화되어 고객의 카드를 기다린다. 고객이 카드를 삽입하거나 태핑하면, 리더기가 카드 정보를 읽어서 Stripe 서버에 직접 전송한다. 클라이언트는 카드 번호를 볼 수 없다.
clientSecret은 서버에서 생성한 PaymentIntent의 시크릿이다. 이 시크릿으로 어떤 PaymentIntent에 대해 결제를 수집할지 지정한다.
반환값에는 카드 정보가 채워진 paymentIntent 객체가 있다. 아직 결제가 완료된 것은 아니고, 카드 수집만 된 상태다.
processPayment
const processResult = await terminal.processPayment(
collectResult.paymentIntent,
);
수집된 결제 정보를 실제로 처리(승인 요청)한다. 이 단계에서 카드사에 승인 요청이 가고, 성공하면 paymentIntent.status가 succeeded로 바뀐다.
전체 결제 흐름 코드
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,
};
}
collectPaymentMethod → processPayment의 2단계 분리는 의도적인 설계다. 수집과 처리를 분리함으로써 중간에 사용자에게 금액 확인을 받거나, 추가 할인을 적용하거나, 결제를 취소할 수 있는 여지를 만든다.
서버 사이드 검증: Query-Driven Verification
클라이언트에서 "결제 성공했어요"라고 보내는 걸 그대로 믿으면 안 된다. 반드시 서버에서 Stripe API를 직접 조회해서 PaymentIntent의 실제 상태를 확인해야 한다.
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에 직접 물어보는 방식이다. 결제 시스템에서는 이 원칙을 반드시 지켜야 한다.
시뮬레이터 모드
개발 환경에서 실물 리더기 없이 테스트하기 위해 시뮬레이터 모드를 제공한다.
// 시뮬레이터 활성화
const discoverParams = {
simulated: true, // 실물 리더 대신 시뮬레이터 사용
};
// 테스트 카드 번호 설정
terminal.setSimulatorConfiguration({
testCardNumber: '4242424242424242', // 성공하는 Visa 카드
});
simulated: true로 설정하면 가상 리더기가 검색되고, 실제 카드 없이 결제 흐름을 테스트할 수 있다.
주요 테스트 카드 번호
성공 케이스:
| 카드 번호 | 브랜드 |
|---|---|
4242424242424242 | Visa |
4000056655665556 | Visa Debit |
5555555555554444 | Mastercard |
5200828282828210 | Mastercard Debit |
378282246310005 | American 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의 discoverReaders → connectReader API는 동일하다. 추상화가 잘 되어 있어서 리더를 교체해도 코드 변경이 거의 없다.
Location 관리
Stripe Terminal에서 Location은 리더기가 설치된 물리적 장소를 나타낸다.
// 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를 지정하면 해당 위치에 등록된 리더만 검색된다. 매장이 여러 개일 때 각 매장의 리더를 구분하는 데 필수적이다.
const { discoveredReaders } = await terminal.discoverReaders({
simulated: false,
location: 'tml_xxx', // 이 위치의 리더만 검색
});
상태 관리 패턴
결제 과정에서 UI에 현재 상태를 표시하려면 상태 콜백 패턴이 유용하다.
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 A | SAQ C-VT (최소) |
| SDK | Stripe.js | @stripe/terminal-js |
| PaymentIntent 타입 | card | card_present |
| 인증(3DS) | 필요할 수 있음 | 불필요 (카드가 물리적으로 존재) |
| 분쟁(Chargeback) | 비교적 흔함 | 드묾 (카드 실물 확인) |
대면 결제는 카드가 물리적으로 존재하기 때문에 사기 위험이 낮고, 3DS 같은 추가 인증이 필요 없다. 반면 리더기 하드웨어 관리와 네트워크 안정성이라는 새로운 과제가 생긴다.
에러 처리
Terminal 결제에서 발생할 수 있는 주요 에러 상황과 대응 방법이다.
// 리더 검색 실패
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) {
// 카드 거절, 잔액 부족 등
// → 다른 카드로 재시도 안내
}
특히 주의할 점은 연결 해제 후 재연결 패턴이다. 이전 연결이 깔끔하게 정리되지 않으면 다음 연결이 실패할 수 있다.
async connect(): Promise<void> {
// 이미 연결된 리더가 있으면 먼저 해제 (재시도 시나리오)
if (this.reader) {
await this.disconnect();
}
// ... 새로 연결
}
정리
Stripe Terminal의 핵심 흐름을 요약하면 이렇다.
- 서버: PaymentIntent 생성 (
card_present) →client_secret반환 - 클라이언트: Terminal SDK 초기화 (Connection Token)
- 클라이언트: 리더 검색 → 연결
- 클라이언트:
collectPaymentMethod(clientSecret)→ 카드 대기 - 클라이언트:
processPayment(paymentIntent)→ 승인 요청 - 서버: Stripe API로 PaymentIntent 상태 검증 (Query-Driven)
카드 데이터가 우리 시스템을 거치지 않는다는 점, collectPaymentMethod와 processPayment가 분리되어 있다는 점, 서버에서 반드시 최종 검증을 해야 한다는 점. 이 세 가지가 Stripe Terminal을 이해하는 핵심이다.