프로덕션 준비된 데이터 마켓플레이스 구축: 아키텍처, 보안 및 배운 교훈
Source: Dev.to
죄송합니다, 번역하려는 전체 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다. 현재는 링크만 포함되어 있어 실제 기사 내용이 없으므로 번역이 불가능합니다. 기사 본문을 복사해서 보내 주시면 바로 번역해 드리겠습니다.
문제 영역
마켓플레이스를 구축하는 것은 코딩을 시작하기 전까지는 간단해 보입니다:
- Payments: Stripe 통합, 웹훅 처리, 체크아웃 세션 관리
- Security: API 키 암호화, 세션 관리, 공격 방지
- Concurrency: 여러 구매자가 마지막 제품을 놓고 경쟁할 때 초과 판매 방지
- Access Control: 토큰 발급, 권한 관리, 폐기 처리
- Testing: 프로덕션에서 모든 것이 안정적으로 동작하도록 보장
이 각각은 자체적인 작은 프로젝트입니다. UDAM이 이 모든 문제를 해결합니다.
아키텍처 개요
UDAM은 고전적인 3계층 아키텍처를 따르지만, 마켓플레이스 요구에 최적화된 특정 설계 선택을 적용합니다:
기술 스택 선택
백엔드: Node.js + Express
- 마켓플레이스 로직에 대한 빠른 반복
- 뛰어난 Stripe SDK 지원
- 향후 확장을 위한 방대한 생태계
데이터베이스: PostgreSQL
- ACID 트랜잭션 (결제에 중요)
- 행 수준 잠금 (경쟁 조건 방지)
- 성숙하고 실전 검증된
프론트엔드: Next.js
- SEO를 위한 SSR 기능 (마켓플레이스 가시성)
- 의도적으로 최소화 – 커스터마이징이 쉬움
- API‑우선 설계
Source: …
심층 분석: 토큰 암호화
과제
판매자는 구매자가 서비스에 접근하기 위해 필요한 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 | 리스트에 $10에 1개의 유닛이 남아 있음 |
| T=1 | 구매자 A가 구매를 시작 |
| T=2 | 구매자 B가 구매를 시작 (여전히 1개의 유닛을 봄) |
| T=3 | 구매자 A가 구매를 완료 (유닛 → 0) |
| T=4 | 구매자 B가 구매를 완료 (유닛 → -1) ❌ 초과 판매! |
해결책: 행 수준 잠금
BEGIN;
-- Lock the row for this transaction
SELECT * FROM listings
WHERE id = $1
FOR UPDATE;
-- Check availability
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 Checkout
즉시 토큰 발행 (소액 주문)
설정 가능한 임계값 이하 주문(예: $5) :
- 주문이 생성됩니다.
- 토큰이 즉시 발행됩니다.
- 결제 확인이 필요하지 않습니다.
왜? 소액 주문에서는 Stripe Checkout의 마찰이 사기 위험보다 전환율에 더 큰 영향을 미칩니다.
Stripe Checkout (대규모 주문)
임계값 이상의 주문에 대해:
- Stripe Checkout 세션을 생성합니다.
- 사용자를 Stripe로 리디렉션합니다.
- 웹훅이 결제를 확인합니다.
- 확인 후 토큰이 발행됩니다.
구현
if (totalPrice 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);
-- Additional indexes as needed for query patterns