동시 사용자 500명에서 5만 명까지: 실제로 확장되는 아키텍처 결정

발행: (2026년 6월 10일 PM 01:47 GMT+9)
7 분 소요
원문: Dev.to

출처: Dev.to

Shubham Kansal

TL;DR: 2백만 제품 플랫폼에서 맞이한 블랙프라이데이. 서버를 하나도 추가하기 전에 EXPLAIN ANALYZE가 순차 스캔을 수행하는 3개의 쿼리를 찾아냈고, 인덱스를 추가해 쿼리 시간을 800 ms에서 12 ms로 단축했습니다. PgBouncer가 8 000개의 연결을 150개로 다중화해 처리량을 4배 늘렸고, Redis는 정확히 세 가지만 캐시했습니다. 전체 내용은 다음과 같습니다.

블랙프라이데이에 12 000명의 동시 사용자가 몰리는 플랫폼은 6개월 전 Magento에서 마이그레이션한 뒤라면 또 다른 종류의 스트레스 테스트가 됩니다. 이전 팀은 부하가 급증하면 앱 서버를 늘리라는 직관적인 판단을 했지만, 그 직관은 거의 항상 틀리며 저는 이벤트 3주 전까지 이를 증명했습니다.

데이터베이스부터 시작하라, 앱 서버가 아니라

스케일링 이야기가 나오면 대부분 수평적인 앱 서버 확장을 먼저 떠올립니다. 하지만 데이터베이스가 거의 항상 첫 번째 병목입니다. 다른 작업을 하기 전에 다음 쿼리를 실행하세요.

-- Find slow queries in production (pg_stat_statements must be enabled)
SELECT
  query,
  calls,
  mean_exec_time,
  total_exec_time,
  rows
FROM pg_stat_statements
WHERE mean_exec_time > 50   -- anything over 50ms is a candidate
ORDER BY mean_exec_time DESC
LIMIT 20;

우리는 세 개의 느린 쿼리를 발견했습니다. 그 중 하나는 카테고리와 재고를 조인하는 제품 목록 쿼리였습니다.

-- Before: full sequential scan, 800ms at scale
EXPLAIN ANALYZE
SELECT p.*, c.name AS category_name, i.quantity
FROM products p
JOIN categories c ON c.id = p.category_id
JOIN inventory i ON i.product_id = p.id
WHERE p.status = 'active'
  AND c.slug = 'electronics'
ORDER BY p.created_at DESC
LIMIT 50;

-- Result: Seq Scan on products (cost=0.00..48234.12 rows=2089432)
--         Execution Time: 847.392 ms

해결책은 필터와 정렬을 동시에 만족하는 복합 인덱스를 만드는 것이었습니다.

-- Covering index for the status + category_id + created_at access pattern
CREATE INDEX CONCURRENTLY idx_products_status_category_created
  ON products (status, category_id, created_at DESC)
  INCLUDE (id, title, price, slug);

-- Separate index for the categories join on slug
CREATE INDEX CONCURRENTLY idx_categories_slug ON categories (slug);
-- After: index scan, 12ms
-- Result: Index Scan using idx_products_status_category_created
--         Execution Time: 11.843 ms

800 ms → 12 ms. 새로운 서버도, 코드 변경도, 비용 증가도 없었습니다. 블랙프라이데이 전에 인덱스 분석을 하는 것은 기본 중의 기본입니다.

커넥션 풀링은 선택 사항이 아니다

12 000명의 동시 사용자를 지원하려면 Node.js 앱 서버마다 커넥션 풀을 유지합니다. ECS 태스크 10개에 각각 100개의 연결이라면 이미 Postgres에 1 000개의 연결이 걸려 있는 셈이죠. 부하가 걸리면 태스크를 20개로 늘리게 되고, PostgreSQL은 곧 연결을 거부하기 시작합니다—활성 연결이 약 200개를 넘으면 급격히 성능이 저하됩니다.

해결책은 앱 계층과 Postgres 사이에 커넥션 풀러를 두는 것입니다. 우리는 transaction pooling mode 로 PgBouncer를 사용했습니다.

# pgbouncer.ini
[databases]
production = host=your-rds-endpoint port=5432 dbname=production

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 5432
pool_mode = transaction         # key: connection released after each transaction
max_client_conn = 10000         # app-side connections (generous ceiling)
default_pool_size = 150         # actual Postgres connections (stay under 200)
reserve_pool_size = 10
reserve_pool_timeout = 5
server_idle_timeout = 600
log_connections = 0
log_disconnections = 0

PgBouncer 적용 전: 8 000개의 앱‑측 연결 → Postgres가 버벅임.
PgBouncer 적용 후: 8 000개의 앱‑측 연결 → 150개의 Postgres 연결 → 처리량 4배 상승.

단, transaction pooling mode에서는 SET 문, advisory lock, LISTEN/NOTIFY 등을 트랜잭션 간에 사용할 수 없습니다—반환되는 연결이 원래와 다를 수 있기 때문입니다. 세션 수준 상태에 의존한다면 유의하세요.

Redis: 정확히 필요한 데이터만 캐시하고 그 외는 캐시하지 말라

Redis는 일반적인 캐시 레이어가 아닙니다. 잘못된 데이터를 캐시하면 분산 일관성 문제가 발생해 디버깅이 훨씬 어려워집니다.

우리는 정확히 세 가지만 캐시했습니다.

1. Product page data          TTL: 5 minutes
2. User session data          TTL: 30 minutes (sliding)
3. Rate limit counters        TTL: 1 minute (sliding window)

재고 수량은 절대 캐시하지 않았습니다. 이유는 다음과 같습니다.

// Wrong: cached inventory leads to overselling
async function getProductPage(productId) {
  const cached = await redis.get(`product:${productId}`);
  if (cached) return JSON.parse(cached); // inventory count may be stale

  const product = await db.query(
    `SELECT p.*, i.quantity FROM products p
     JOIN inventory i ON i.product_id = p.id
     WHERE p.id = $1`,
    [productId]
  );

  await redis.setex(`product:${productId}`, 300, JSON.stringify(product.rows[0]));
  return product.rows[0];
}

// Right: cache everything except the inventory count
async function getProductPage(productId) {
  const [cachedProduct, liveInventory] = await Promise.all([
    redis.get(`product:${productId}`),
    db.query(`SELECT quantity FROM inventory WHERE product_id = $1`, [productId])
  ]);

  const product = cachedProduct
    ? JSON.parse(cachedProduct)
    : await fetchAndCacheProduct(productId); // separate function

  return { ...product, quantity: liveInventory.rows[0].quantity };
}

오래된 재고 수는 전환율을 떨어뜨고 과잉 판매를 초래합니다. 재고는 반드시 DB에서 직접 읽고, 인덱스로 최적화된 inventory 조회는 3 ms 정도 걸리니 정확성을 위한 충분히 작은 비용입니다.

스파이크에는 Serverless, 베이스라인에는 Stateful

모든 워크로드가 지속적인 부하를 견뎌야 하는 것은 아닙니다. 트래픽 형태에 따라 작업을 나누었습니다.

ECS(프리워밍 인스턴스)에서 유지할 서비스

  • 결제 파이프
0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...