junyeokk
Blog
Stripe·2026. 02. 04

Stripe 결제 통합

온라인이든 오프라인이든 결제를 직접 구현하려면 결제 수단 입력, 금액 검증, 실패 처리, 보안까지 신경 써야 할 게 한두 가지가 아니다. PCI DSS 컴플라이언스만 해도 직접 카드 정보를 다루려면 엄청난 보안 인증 비용이 든다. Stripe는 이 전체 과정을 추상화해서, 개발자가 카드 번호를 직접 만지지 않고도 결제를 처리할 수 있게 해준다.

핵심 아이디어는 서버-클라이언트 분리다. 서버가 "얼마를 받을 건지" 의도(Intent)를 만들고, 클라이언트가 그 의도를 가지고 실제 결제 수단을 수집한다. 카드 정보는 Stripe 서버로 직접 전송되기 때문에 우리 서버를 거치지 않는다.


PaymentIntent 흐름

Stripe 결제의 중심에는 PaymentIntent라는 객체가 있다. 이름 그대로 "결제 의도"를 나타내는 서버 사이드 객체다. 결제 금액, 통화, 상태를 추적하며, 결제의 전체 생명주기를 관리한다.

왜 PaymentIntent인가?

이전에 Stripe는 Charge 객체를 직접 생성하는 방식이었다. 하지만 SCA(Strong Customer Authentication), 3D Secure 같은 추가 인증 단계가 필요해지면서 단순히 "결제 실행"만으로는 부족해졌다. PaymentIntent는 이런 중간 단계(인증 대기, 카드 수집, 확인 등)를 상태 머신으로 관리한다.

상태 흐름

PaymentIntent는 다음과 같은 상태를 거친다:

text
requires_payment_method → requires_confirmation → requires_action → processing → succeeded
                                                                                    ↘ canceled
상태의미
requires_payment_method생성 직후, 결제 수단이 아직 없음
requires_confirmation결제 수단은 있지만 최종 확인 대기
requires_action3D Secure 등 추가 인증 필요
processing결제 처리 중
succeeded결제 완료
canceled취소됨

모든 상태 전이는 Stripe가 관리한다. 우리는 현재 상태를 조회하고 적절히 반응하기만 하면 된다.


서버 사이드: PaymentIntent 생성

서버에서 PaymentIntent를 생성할 때 핵심 파라미터는 amount, currency, payment_method_types다.

typescript
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function createPaymentIntent(amount: number, currency: string) {
  const intent = await stripe.paymentIntents.create({
    amount: convertToStripeAmount(amount, currency),
    currency: currency.toLowerCase(),
    payment_method_types: ['card'],
    metadata: { orderId: 'order-123' },
  });

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

여기서 주의할 점이 두 가지 있다.

client_secret의 역할

client_secret은 PaymentIntent에 대한 제한된 접근 권한을 클라이언트에게 부여하는 토큰이다. 이걸 가지고 있으면 해당 PaymentIntent에 결제 수단을 연결하고 확인할 수 있지만, 금액을 변경하거나 환불하는 건 불가능하다. 그래서 서버에서 생성하고 클라이언트로 전달하는 구조가 안전하다.

metadata 활용

metadata에 주문 ID, 세션 ID 같은 비즈니스 데이터를 넣어두면 나중에 웹훅에서 어떤 주문에 대한 결제인지 추적하기 쉽다. Stripe 대시보드에서도 검색할 수 있어서 운영에도 유용하다.


클라이언트 사이드: Stripe Elements

웹에서 카드 정보를 입력받는 가장 일반적인 방법은 Stripe Elements다. Stripe가 제공하는 iframe 기반 UI 컴포넌트로, 카드 번호가 우리 DOM에 직접 노출되지 않는다.

typescript
import { loadStripe } from '@stripe/stripe-js';

const stripe = await loadStripe('pk_test_...');
const elements = stripe.elements();

// 카드 입력 UI 생성
const cardElement = elements.create('card');
cardElement.mount('#card-element');

// 결제 확인
async function handlePayment(clientSecret: string) {
  const { error, paymentIntent } = await stripe.confirmCardPayment(
    clientSecret,
    {
      payment_method: { card: cardElement },
    }
  );

  if (error) {
    console.error('결제 실패:', error.message);
  } else if (paymentIntent.status === 'succeeded') {
    console.log('결제 성공!');
  }
}

React에서는 @stripe/react-stripe-js 패키지가 Elements Provider와 useStripe, useElements 훅을 제공한다:

tsx
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';

function CheckoutForm({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe();
  const elements = useElements();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    const { error } = await stripe.confirmCardPayment(clientSecret, {
      payment_method: { card: elements.getElement(CardElement)! },
    });

    if (error) {
      // 에러 표시
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <CardElement />
      <button type="submit" disabled={!stripe}>결제</button>
    </form>
  );
}

confirmCardPayment가 호출되면 카드 정보는 Stripe 서버로 직접 전송된다. 3D Secure가 필요하면 자동으로 인증 모달이 뜬다. 이 모든 게 하나의 메서드 호출로 처리된다.


Stripe Terminal: 대면 결제

온라인 결제가 아니라 키오스크나 POS처럼 실물 카드 리더를 사용하는 대면 결제도 Stripe에서 지원한다. 이때 사용하는 게 Stripe Terminal이다.

Terminal은 온라인 결제와 흐름이 비슷하지만, 카드 정보를 수집하는 주체가 브라우저 UI가 아니라 물리적 카드 리더다.

Terminal 초기화

typescript
import { loadStripeTerminal } from '@stripe/terminal-js';

const StripeTerminal = await loadStripeTerminal();

const terminal = StripeTerminal.create({
  onFetchConnectionToken: async () => {
    // 서버에서 connection token을 받아온다
    const response = await fetch('/api/stripe/connection-token');
    const { secret } = await response.json();
    return secret;
  },
  onUnexpectedReaderDisconnect: () => {
    console.error('리더 연결이 끊어졌습니다');
  },
});

onFetchConnectionToken은 Terminal SDK가 카드 리더와 통신할 때 필요한 임시 토큰을 서버에서 발급받는 콜백이다. 서버에서는 다음과 같이 생성한다:

typescript
// 서버 사이드
async function createConnectionToken() {
  const token = await stripe.terminal.connectionTokens.create();
  return token.secret;
}

리더 연결과 결제

typescript
// 1. 리더 검색
const { discoveredReaders } = await terminal.discoverReaders({
  simulated: false,
  location: 'tml_location_id',  // Stripe 대시보드에서 등록한 위치
});

// 2. 리더 연결
await terminal.connectReader(discoveredReaders[0]);

// 3. 카드 수집 (고객이 카드를 대면)
const { paymentIntent } = await terminal.collectPaymentMethod(clientSecret);

// 4. 결제 처리
const { paymentIntent: processed } = await terminal.processPayment(paymentIntent);

if (processed.status === 'succeeded') {
  // 결제 완료 → 서버에 알림
}

collectPaymentMethod는 물리적 카드 리더가 카드 정보를 읽을 때까지 기다리는 비동기 호출이다. 고객이 카드를 탭하거나 삽입하면 resolve된다. processPayment는 실제 결제 승인을 Stripe에 요청한다.

시뮬레이터 모드

개발 환경에서 실제 카드 리더가 없어도 테스트할 수 있다. discoverReaderssimulated: true를 넘기면 가상 리더가 나타난다.

typescript
// 시뮬레이터 테스트 카드 설정
terminal.setSimulatorConfiguration({
  testCardNumber: '4242424242424242',  // Visa 성공
});

const { discoveredReaders } = await terminal.discoverReaders({ simulated: true });
await terminal.connectReader(discoveredReaders[0]);

테스트용 카드 번호로 다양한 시나리오를 재현할 수 있다:

카드 번호시나리오
4242424242424242Visa 성공
5555555555554444Mastercard 성공
4000000000000002거절 (card_declined)
4000000000009995잔액 부족 (insufficient_funds)
4000000000000069만료된 카드 (expired_card)

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

결제가 성공했다는 클라이언트의 말을 그대로 믿으면 안 된다. 클라이언트는 조작될 수 있기 때문이다. 결제 완료 후에는 반드시 서버에서 Stripe API를 직접 호출해서 상태를 확인해야 한다.

typescript
async function verifyPayment(paymentIntentId: string, expectedAmount: number) {
  const intent = await stripe.paymentIntents.retrieve(paymentIntentId);

  // 1. 상태 확인
  if (intent.status !== 'succeeded') {
    throw new Error(`결제 미완료: ${intent.status}`);
  }

  // 2. 금액 일치 확인
  if (intent.amount !== expectedAmount) {
    throw new Error('금액 불일치');
  }

  // 3. PaymentIntent ID가 우리가 생성한 것인지 확인
  // DB에 저장된 externalPaymentId와 대조
  return true;
}

이 패턴을 Query-Driven Verification이라고 부른다. 클라이언트가 보내는 콜백 데이터는 참고용이고, 실제 검증은 서버가 Stripe API를 직접 조회해서 수행한다. 확인해야 할 항목:

  1. PaymentIntent 상태가 succeeded인지 — 가장 기본적인 확인
  2. 금액과 통화가 일치하는지 — 클라이언트가 금액을 조작하는 것을 방지
  3. PaymentIntent ID가 우리가 발급한 것인지 — 다른 사람의 PaymentIntent를 가져오는 것을 방지

웹훅 처리

클라이언트의 콜백만으로는 충분하지 않은 경우가 있다. 네트워크 단절, 브라우저 종료, 비동기 결제(은행 이체 등)처럼 클라이언트가 결과를 받지 못하는 상황이 존재한다. 웹훅은 Stripe가 이벤트 발생 시 우리 서버로 직접 HTTP POST를 보내는 방식이다.

typescript
import express from 'express';

const app = express();

// 웹훅 엔드포인트는 raw body가 필요하다 (서명 검증용)
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'] as string;
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    console.error('웹훅 서명 검증 실패:', err.message);
    return res.status(400).send('Webhook signature verification failed');
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      // 주문 완료 처리
      handlePaymentSuccess(paymentIntent);
      break;

    case 'payment_intent.payment_failed':
      // 실패 처리, 사용자 알림
      break;
  }

  res.json({ received: true });
});

서명 검증이 필수인 이유

웹훅 엔드포인트의 URL을 아는 사람이면 누구나 가짜 이벤트를 보낼 수 있다. stripe.webhooks.constructEvent는 Stripe가 요청에 포함한 서명을 검증해서 진짜 Stripe에서 보낸 것인지 확인한다. 이 검증을 빼면 공격자가 "결제 성공" 이벤트를 위조할 수 있다.

raw body 주의사항

서명 검증은 HTTP 요청의 raw body를 기반으로 한다. Express의 express.json() 미들웨어를 전역으로 적용하면 body가 파싱되면서 원본이 사라지기 때문에, 웹훅 라우트에는 반드시 express.raw()를 사용해야 한다.

멱등성 처리

Stripe는 웹훅 전송에 실패하면 재시도한다 (최대 3일간). 같은 이벤트가 여러 번 올 수 있으므로, 이벤트 처리는 멱등(idempotent)해야 한다:

typescript
async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
  const order = await db.orders.findByPaymentIntentId(paymentIntent.id);

  // 이미 처리된 주문이면 무시
  if (order.status === 'completed') {
    return;
  }

  await db.orders.update(order.id, { status: 'completed' });
}

Zero-Decimal Currency

Stripe API에서 금액은 항상 해당 통화의 최소 단위로 표현된다. 예를 들어 USD 10.00달러는 1000 (센트)으로 넘겨야 한다. 하지만 모든 통화가 소수점 단위를 가지는 건 아니다.

Zero-decimal currency(무소수점 통화)는 이미 최소 단위가 1이라서 변환이 필요 없다. 한국 원(KRW)이 대표적이다. 1000원은 그냥 1000이다.

typescript
const zeroDecimalCurrencies = [
  'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW',
  'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV',
  'XAF', 'XOF', 'XPF',
];

function convertToStripeAmount(amount: number, currency: string): number {
  if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
    return Math.round(amount);  // 변환 없이 그대로
  }
  return Math.round(amount * 100);  // 센트 단위로 변환
}

이 변환을 빼먹으면 USD에서 10를청구하려다10를 청구하려다 0.10이 결제되거나, 반대로 KRW에서 1000원이 100000원으로 청구될 수 있다. Stripe 공식 문서에 전체 목록이 있으니 지원하는 통화가 추가되면 업데이트해야 한다.


결제 재시도 처리

결제가 실패했을 때 단순히 "다시 시도하세요"만으로는 부족하다. 이전에 생성한 PaymentIntent를 정리하지 않으면 상태가 꼬인다.

typescript
async function retryPayment(payment: Payment) {
  // 1. 이전 PaymentIntent 취소
  if (payment.externalPaymentId) {
    try {
      await stripe.paymentIntents.cancel(payment.externalPaymentId);
    } catch (error) {
      // 취소 실패해도 재시도는 진행 (이미 succeeded/canceled 상태일 수 있음)
      console.warn('이전 PaymentIntent 취소 실패:', error);
    }
  }

  // 2. 새 PaymentIntent 생성
  const newIntent = await stripe.paymentIntents.create({
    amount: payment.amount,
    currency: payment.currency,
    payment_method_types: ['card_present'],
  });

  // 3. DB 업데이트
  payment.externalPaymentId = newIntent.id;
  await payment.save();

  return { clientSecret: newIntent.client_secret };
}

cancel()은 PaymentIntent가 이미 succeededcanceled 상태이면 에러를 던진다. 재시도 흐름에서 이 에러를 무시하고 진행하는 게 일반적이다. 중요한 건 새 PaymentIntent를 제대로 생성하고 DB에 반영하는 것이다.


보안 체크리스트

Stripe 통합에서 보안 실수가 나기 쉬운 포인트들을 정리한다.

Secret Key 관리

sk_live_...로 시작하는 Secret Key는 절대 클라이언트에 노출되면 안 된다. 환경 변수로 관리하고 git에 커밋하지 않는다. 클라이언트에서는 pk_live_...(Publishable Key)만 사용한다.

금액 검증

클라이언트가 전달하는 금액을 그대로 PaymentIntent에 넣으면 안 된다. 서버에서 상품 가격을 조회해서 계산한 금액을 사용해야 한다.

typescript
// ❌ 위험: 클라이언트가 보낸 금액 사용
app.post('/create-payment', (req, res) => {
  const intent = await stripe.paymentIntents.create({
    amount: req.body.amount,  // 조작 가능!
    currency: 'krw',
  });
});

// ✅ 안전: 서버에서 금액 계산
app.post('/create-payment', (req, res) => {
  const order = await db.orders.findById(req.body.orderId);
  const amount = calculateTotalPrice(order);  // 서버 사이드 계산

  const intent = await stripe.paymentIntents.create({
    amount,
    currency: 'krw',
  });
});

PaymentIntent ID 대조

결제 완료 시 클라이언트가 보내는 PaymentIntent ID가 우리가 생성해서 DB에 저장한 것과 일치하는지 반드시 확인해야 한다. 다른 사람이 정상 결제한 PaymentIntent ID를 가로채서 사용하는 공격을 방지한다.

typescript
// 클라이언트가 보낸 ID와 DB에 저장된 ID 대조
if (dto.paymentIntentId !== payment.externalPaymentId) {
  throw new BadRequestException('PaymentIntent ID mismatch');
}

전체 아키텍처 요약

text
┌──────────────┐     ① PaymentIntent 생성 요청     ┌──────────────┐
│              │ ──────────────────────────────────→ │              │
│   클라이언트   │     ② clientSecret 반환           │    서버       │
│   (브라우저/   │ ←────────────────────────────────── │   (NestJS/   │
│    키오스크)   │                                    │   Express)   │
│              │     ⑤ 결제 완료 알림 + 검증 요청    │              │
│              │ ──────────────────────────────────→ │              │
│              │     ⑥ 서버가 Stripe API로 직접 확인  │              │
└──────┬───────┘                                    └──────┬───────┘
       │                                                    │
       │ ③ clientSecret으로                                 │ ①② Stripe SDK로
       │   카드 정보 수집/확인                                │    PaymentIntent 생성
       │                                                    │
       │ ④ 카드 정보는 Stripe로                              │ ⑥ retrieve로
       │   직접 전송                                         │   상태 검증
       ↓                                                    ↓
┌─────────────────────────────────────────────────────────────┐
│                        Stripe API                           │
│   PaymentIntent 관리 / 카드 정보 처리 / 웹훅 발송            │
└─────────────────────────────────────────────────────────────┘

핵심은 카드 정보가 우리 서버를 절대 경유하지 않는다는 것이다. 클라이언트에서 Stripe로 직접 전송되고, 우리 서버는 PaymentIntent의 상태만 관리한다. 이 구조 덕분에 PCI DSS 컴플라이언스 부담이 크게 줄어든다.