TuBrief
Subscribed Channels
Videos
Community

30분 만에 배포한 앱이 실제 결제에서 터지는 이유와 해결책

TuBrief Editorial
August 10, 2026
0
컴퓨터/소프트웨어

Written with AI assistance from the source video. The video is the authority.

한국어English中文العربيةहिन्दीEspañolDeutschFrançaisPortuguêsРусскийBahasa Indonesia日本語

Related Video

Ship 26 NYC - 워크숍 - 30분 만에 init부터 배포까지11:26

Ship 26 NYC - 워크숍 - 30분 만에 init부터 배포까지

Vercel

More from the community

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

September 13, 2026

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

September 13, 2026

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

September 13, 2026

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

September 13, 2026

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

September 12, 2026

Apple Won the AI Race

September 12, 2026

Comments (0)

Log in to leave a comment

No posts yet

© 2026 . All rights reserved.

TuBrief
Subscribed Channels
Videos
Community
Log in

30분 만에 배포한 앱이 실제 결제에서 터지는 이유와 해결책

CLI로 30분 만에 서비스를 올리고 첫 결제 버튼을 붙였을 때 느끼는 도파민은 오래 가지 않습니다. 진짜 문제는 실제 유저가 결제 창을 닫거나, 카드사 통신에 지연이 생기거나, 해외 유저가 결제를 시도할 때 터집니다. 클라이언트의 리다이렉트만 믿고 데이터베이스를 업데이트하는 구조는 network timeout 한 번에 바로 깨집니다. 돈은 나갔는데 이용권은 지급되지 않는 상황이 생기는 겁니다.

이런 사고를 막으려면 결제 성공 여부를 클라이언트가 아닌 서버 대 서버의 비동기 이벤트(웹훅)로 받아야 합니다.

1. Stripe CLI로 로컬에서 결제 웹훅 디버깅하기

로컬 개발 환경에서는 Stripe 서버가 내 컴퓨터(localhost)로 요청을 보낼 수 없습니다. 배포해서 디버깅하겠다는 생각은 버려야 합니다. Stripe CLI를 쓰면 내 터널링으로 이벤트를 그대로 받아올 수 있습니다.

GitGuardian 발표를 보면 결제 장애의 40% 이상이 비동기 이벤트 처리 실패에서 나온다고 합니다. 로컬에서 실패 시나리오까지 직접 실행해봐야 합니다.

먼저 터널링을 엽니다.

stripe listen --events payment_intent.succeeded,payment_intent.payment_failed,checkout.session.completed --forward-to localhost:4242/webhook

명령어를 치면 터미널에 whsec_로 시작하는 웹훅 시크릿이 출력됩니다. 이 값을 환경 변수에 넣고, 다른 터미널 창에서 강제로 이벤트를 발생시킵니다.

stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed

서버에서는 이벤트 서명을 검증하고 멱등성(Idempotency)을 보장해야 합니다. Stripe이 네트워크 불안정으로 같은 이벤트를 두 번 보낼 때, 유저에게 재화가 두 번 지급되면 안 되니까요. Redis에 event.id를 저장해서 중복 실행을 막는 Express 예제입니다.

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const Redis = require('ioredis');

const app = express();
const redis = new Redis(process.env.REDIS_URL);

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  const eventId = event.id;
  const isProcessed = await redis.get(`webhook:processed:${eventId}`);

  if (isProcessed) {
    return res.status(200).json({ received: true, status: 'already_processed' });
  }

  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        await handlePaymentSuccess(event.data.object);
        break;
      case 'payment_intent.payment_failed':
        await handlePaymentFailure(event.data.object);
        break;
    }

    await redis.set(`webhook:processed:${eventId}`, 'true', 'EX', 604800);
    return res.status(200).json({ received: true });
  } catch (dbError) {
    return res.status(500).send('Internal Server Error');
  }
});

async function handlePaymentSuccess(paymentIntent) {}
async function handlePaymentFailure(paymentIntent) {}

app.listen(4242, () => console.log('Stripe 웹훅 모니터링 가동'));

2. API 키 유출을 빌드 단계에서 막는 사전 검증 스크립트

GitGuardian의 2025년 시크릿 상태 보고서에 따르면, 2024년 한 해 동안 GitHub 퍼블릭 저장소에 유출된 비밀키는 2,860만 건입니다. 스캔 봇들은 저장소가 공개되고 5분 이내에 키를 긁어갑니다.

AI 코딩 툴을 쓰다 보면 나도 모르게 .env 내역을 코드에 집어넣거나 .gitignore를 빠뜨리곤 합니다. 아예 빌드 명령어(npm run build)를 실행할 때 스크립트로 검증하는 게 확실합니다.

scripts/preflight-security.js 파일로 다음 로직을 작성합니다.

const fs = require('fs');
const path = require('path');

const rootDir = path.resolve(__dirname, '..');
const gitignorePath = path.join(rootDir, '.gitignore');

if (!fs.existsSync(gitignorePath)) {
  console.error('.gitignore 파일이 없습니다.');
  process.exit(1);
}

const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
if (!gitignoreContent.split('\n').map(l => l.trim()).includes('.env')) {
  console.error('.gitignore에 .env가 누락되었습니다.');
  process.exit(1);
}

const secretPatterns = [
  /sk_live_[0-9a-zA-Z]{24,}/g,
  /sk_test_[0-9a-zA-Z]{24,}/g
];

function scanDirectory(dir) {
  const files = fs.readdirSync(dir);
  for (const file of files) {
    const fullPath = path.join(dir, file);
    if (['node_modules', '.git', '.next', 'build'].includes(file)) continue;

    const stat = fs.statSync(fullPath);
    if (stat.isDirectory()) {
      scanDirectory(fullPath);
    } else if (stat.isFile() && (file.endsWith('.js') || file.endsWith('.ts'))) {
      const content = fs.readFileSync(fullPath, 'utf8');
      secretPatterns.forEach(pattern => {
        if (pattern.test(content)) {
          console.error(`하드코딩된 API 키 발견: ${fullPath}`);
          process.exit(1);
        }
      });
    }
  }
}

scanDirectory(rootDir);
console.log('보안 검증 완료.');

이걸 package.json에 주입합니다.

"scripts": {
  "prebuild": "node scripts/preflight-security.js",
  "build": "next build"
}

이제 실제 시크릿은 Vercel이나 AWS의 환경변수 설정창에서 직접 주입합니다. 만약 키가 유출되었다면 망설이지 말고 Stripe 대시보드의 'Roll Key'로 이전 키를 즉시 무효화해야 합니다.

3. Stripe Tax로 국가별 세금 계산 자동화하기

해외 유저에게 SaaS를 팔기 시작하면 EU VAT나 미국 각 주의 Sales Tax 같은 간접세 문제가 따라옵니다. 처음엔 몰라도 매출이 늘어나면 국세청이나 해외 세무 당국의 연락을 받게 됩니다.

Stripe Tax를 쓰면 결제 시점에 고객의 위치를 파악해서 세금을 알아서 붙여줍니다.

  1. Stripe 대시보드 Settings > Tax에서 사업자 주소를 등록합니다.
  2. Product Catalog에서 제품 세금 코드를 SaaS 표준인 txcd_10000000으로 잡습니다.
  3. 세션 생성 API에 자동 세금 옵션을 켜줍니다.
const express = require('express');
const app = express();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/create-checkout-session', async (req, res) => {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: 'price_1NXXXXXXXXXXXXXX', quantity: 1 }],
    automatic_tax: { enabled: true },
    customer_update: { address: 'auto', name: 'auto' },
    billing_address_collection: 'required',
    success_url: 'https://yourdomain.com/success?session_id={CHECKOUT_SESSION_ID}',
    cancel_url: 'https://yourdomain.com/cancel',
  });

  res.redirect(333, session.url);
});

automatic_tax: { enabled: true }와 billing_address_collection: 'required' 두 줄만 추가해도 국가별 주소 수집과 부가세 계산이 처리됩니다. 세무 관련 데이터는 매월 Reports 메뉴에서 CSV로 뽑아서 회계 처리에 쓰면 됩니다.