프로덕션 레디 데이터 마켓플레이스 구축: 아키텍처, 보안 및 교훈
Source: Dev.to
문제 영역
마켓플레이스를 구축하는 것은 코딩을 시작하기 전까지는 간단해 보입니다:
- 결제: Stripe 연동, 웹훅 처리, 체크아웃 세션 관리
- 보안: API 키 암호화, 세션 관리, 공격 방지
- 동시성: 여러 구매자가 마지막 유닛을 놓고 경쟁할 때 초과 판매 방지
- 접근 제어: 토큰 발행, 권한 관리, 폐기 처리
- 테스트: 프로덕션에서 모든 것이 안정적으로 동작하는지 검증
각 항목은 자체적인 미니 프로젝트입니다. UDAM이 이 모든 것을 해결합니다.
아키텍처 개요
UDAM은 고전적인 3계층 아키텍처를 따르지만, 마켓플레이스 요구에 최적화된 설계 선택을 포함합니다:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Frontend │─────▶│ Backend │─────▶│ PostgreSQL │
│ (Next.js) │ │ (Node.js) │ │ Database │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌──────────────┐
│ Stripe │
│ Payments │
└──────────────┘
기술 스택 선택
백엔드: Node.js + Express
- 마켓플레이스 로직을 빠르게 반복 가능
- Stripe SDK 지원이 뛰어남
- 향후 확장을 위한 방대한 생태계
데이터베이스: PostgreSQL
- ACID 트랜잭션 (결제에 필수)
- 행‑레벨 잠금 (경쟁 조건 방지)
- 프로덕션에서 검증된 성숙한 솔루션
프론트엔드: Next.js
- SEO를 위한 SSR 기능 (마켓플레이스 가시성)
- 의도적으로 최소화 – 커스터마이징이 쉬움
- API‑first 설계
심층 탐구: 토큰 암호화
도전 과제
판매자는 구매자가 서비스를 이용하기 위해 필요한 API 키를 제공합니다. 이 키는 다음을 만족해야 합니다:
- 저장 시 암호화 (데이터베이스가 유출돼도 키가 노출되지 않음)
- 정당한 구매자가 복호화 가능 (실제 키가 필요함)
- 평문으로 로그에 남기거나 캐시되지 않음
해결책: AES‑256‑GCM
const crypto = require('crypto');
function encryptToken(apiKey, masterKey) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv);
let encrypted = cipher.update(apiKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString('hex'),
authTag: authTag.toString('hex')
};
}
핵심 포인트
- GCM 모드는 암호화와 인증을 동시에 제공합니다.
- 무작위 IV는 각 암호화마다 고유한 초기화 벡터를 보장합니다.
- Auth tag는 변조 시도를 감지합니다.
- 마스터 키는 환경 변수에 안전하게 저장되며(코드에 절대 포함되지 않음)
이 방식은 데이터베이스에 접근하더라도 마스터 키 없이는 API 키를 복호화할 수 없게 합니다.
동시성 제어: 초과 판매 문제
시나리오
| 시간 | 동작 |
|---|---|
| T=0 | 리스트에 1개 유닛이 $10에 존재 |
| T=1 | 구매자 A가 구매를 시작 |
| T=2 | 구매자 B가 구매를 시작 (여전히 1개가 보임) |
| T=3 | 구매자 A가 구매를 완료 (유닛 → 0) |
| T=4 | 구매자 B가 구매를 완료 (유닛 → -1) ❌ 초과 판매! |
해결책: 행 수준 잠금
BEGIN;
-- 이 트랜잭션을 위해 행을 잠금
SELECT * FROM listings
WHERE id = $1
FOR UPDATE;
-- 가용성 확인
IF available_units >= units_requested THEN
UPDATE listings
SET available_units = available_units - $2
WHERE id = $1;
INSERT INTO orders (...) VALUES (...);
END IF;
COMMIT;
작동 방식
FOR UPDATE는 선택된 행을 트랜잭션이 끝날 때까지 잠급니다.- 다른 트랜잭션은 잠금이 해제될 때까지 대기합니다.
- 한 번에 하나의 트랜잭션만 유닛을 감소시킬 수 있어 초과 판매가 불가능합니다.
우리는 3개의 유닛이 있는 상황에서 5개의 동시 구매 시도를 수행하는 CI 테스트를 통해 검증했습니다 – 정확히 3개의 주문만 성공하고, 2개는 “유닛 부족” 오류로 실패했습니다.
결제 흐름: 즉시 vs. Stripe 체크아웃
즉시 토큰 발행 (소규모 주문)
주문 금액이 설정된 임계값 이하(예: $5)인 경우:
- 주문이 생성됩니다.
- 토큰이 즉시 발행됩니다.
- 결제 확인이 필요하지 않습니다.
왜? 소액일 경우 Stripe 체크아웃의 마찰이 사기 위험보다 전환율에 더 큰 영향을 미칩니다.
Stripe 체크아웃 (대규모 주문)
- Stripe Checkout 세션을 생성합니다.
- 사용자를 Stripe로 리다이렉트합니다.
- 웹훅이 결제를 확인합니다.
- 확인 후 토큰을 발행합니다.
구현
if (totalPrice < SMALL_ORDER_LIMIT) {
// 즉시 토큰 발행
issueToken(orderId);
} else {
// Stripe Checkout 세션 생성
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{price: priceId, quantity: 1}],
mode: 'payment',
success_url: `${BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/cancel`
});
res.json({url: session.url});
}
미들웨어 예시 (인증)
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const session = await db.query(
'SELECT * FROM sessions WHERE token = $1 AND expires_at > NOW()',
[token]
);
if (!session.rows[0]) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.userId = session.rows[0].user_id;
next();
}
테스트: 핵심 흐름을 위한 CI/CD
- name: E2E small-limit flow
run: |
TOKEN=$(curl -X POST /auth/login ...)
LISTING_ID=$(curl -X POST /listings ...)
ORDER=$(curl -X POST /orders ...)
TOKENS=$(curl /tokens ...)
테스트 항목
- ✅ 전체 구매 흐름 (로그인 → 리스트 생성 → 구매 → 토큰 획득)
- ✅ 세션 폐기 (로그아웃 → 보호된 라우트 접근 불가)
- ✅ 동시성 (3개의 유닛에 대해 5개의 동시 구매)
- ✅ 결제 웹훅 (개발 모드)
성능 고려 사항
데이터베이스 인덱스
CREATE INDEX idx_listings_status ON listings(status);
CREATE INDEX idx_orders_buyer ON orders(buyer_id);
CREATE INDEX idx_tokens_buyer ON tokens(buyer_id);
-- 쿼리 패턴에 따라 추가 인덱스 생성