30분 만에 배포한 앱이 실제 결제에서 터지는 이유와 해결책
TuBrief 편집팀
2026년 8월 10일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
CLI로 30분 만에 서비스를 올리고 첫 결제 버튼을 붙였을 때 느끼는 도파민은 오래 가지 않습니다. 진짜 문제는 실제 유저가 결제 창을 닫거나, 카드사 통신에 지연이 생기거나, 해외 유저가 결제를 시도할 때 터집니다. 클라이언트의 리다이렉트만 믿고 데이터베이스를 업데이트하는 구조는 network timeout 한 번에 바로 깨집니다. 돈은 나갔는데 이용권은 지급되지 않는 상황이 생기는 겁니다.
이런 사고를 막으려면 결제 성공 여부를 클라이언트가 아닌 서버 대 서버의 비동기 이벤트(웹훅)로 받아야 합니다.
로컬 개발 환경에서는 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 웹훅 모니터링 가동'));
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'로 이전 키를 즉시 무효화해야 합니다.
해외 유저에게 SaaS를 팔기 시작하면 EU VAT나 미국 각 주의 Sales Tax 같은 간접세 문제가 따라옵니다. 처음엔 몰라도 매출이 늘어나면 국세청이나 해외 세무 당국의 연락을 받게 됩니다.
Stripe Tax를 쓰면 결제 시점에 고객의 위치를 파악해서 세금을 알아서 붙여줍니다.
txcd_10000000으로 잡습니다.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로 뽑아서 회계 처리에 쓰면 됩니다.