프로덕션 레디 데이터 마켓플레이스 구축: 아키텍처, 보안 및 교훈

발행: (2025년 12월 5일 오전 05:48 GMT+9)
7 min read
원문: Dev.to

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 체크아웃 (대규모 주문)

  1. Stripe Checkout 세션을 생성합니다.
  2. 사용자를 Stripe로 리다이렉트합니다.
  3. 웹훅이 결제를 확인합니다.
  4. 확인 후 토큰을 발행합니다.

구현

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);
-- 쿼리 패턴에 따라 추가 인덱스 생성
Back to Blog

관련 글

더 보기 »

core.async: 심층 탐구 — 온라인 밋업

이벤트 개요: 12월 10일 GMT+1 기준 18:00에 Health Samurai가 온라인 밋업 “core.async: Deep Dive”를 주최합니다. 이번 강연은 clojure.core의 내부를 파헤칩니다....