데이터 레이어(Postgres/Redis/Search)

  • Postgres: 키 전략(ULID/UUIDv7), 인덱스 설계(BTREE+GIN/TRGM), PgBouncer+statement_timeout, 슬로우쿼리 Top-N 튜닝
  • Redis: 캐시-어사이드 + SWR(stale-while-revalidate), 키 버저닝, 스탬피드 방지(뮤텍스/확률갱신)
  • Search: 1단계 FTS(Postgres)2단계 OpenSearch/Elastic(CDC로 색인 일관성 확보)
  • 일관성: Outbox + 소비자(워커), 아이템포턴시 키
  • Next.js: ISR/태그 리밸리데이트, Route Handler 결과 캐싱 + ETag
  • 가시성: 캐시 히트율/DB p95/슬로우로그/색인 지연을 대시보드로 고정 관찰

1) 스키마·키·인덱스: “처음부터 빨라지게”

키 선택

  • 단조 증가가 필요하면: UUIDv7 또는 ULID (정렬·샤딩 친화)
  • 외부 노출 ID는 내부 PK와 분리(보안/URL 안정성)
-- uuid-ossp 또는 pg_uuidv7 확장 사용 가정
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE orders (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), -- 내부 PK
  public_id uuid UNIQUE NOT NULL DEFAULT uuid_generate_v4(), -- 외부용
  user_id uuid NOT NULL,
  status text NOT NULL,
  total_cents int NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON orders (user_id, created_at DESC);

인덱스 기본기

  • 정확 매칭/정렬: BTREE
  • 부분 문자열/유사검색: pg_trgm + GIN
  • 전문검색: tsvector + GIN
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING gin (name gin_trgm_ops);

슬로우 쿼리 루틴

-- pg_stat_statements 필수
SELECT total_exec_time, mean_exec_time, calls, query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
  • 인덱스 미스 → 커버링 인덱스 추가
  • 조인 키 타입 불일치 → 타입 통일
  • 대용량 업데이트 → 배치 + 파티셔닝 검토

2) 연결·풀링·타임아웃 (FastAPI + SQLAlchemy)

# db.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(
  DB_URL,
  pool_size=20, max_overflow=20,
  pool_timeout=10, pool_recycle=1800, pool_pre_ping=True,
)
Session = async_sessionmaker(engine, expire_on_commit=False)

서버/DB 공통: statement_timeout(예: 5s), idle_in_transaction_session_timeout(예: 10s)로 좀비 세션 제거.
PgBouncer: transaction pooling + 최대 연결 수를 DB보다 작게.


3) 트랜잭션·동시성·아이템포턴시

안전한 업서트

INSERT INTO inventory (sku, qty) VALUES ($1, $2)
ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty;

아이템포턴시(중복 요청 차단)

# idempotency.py (Redis)
async def once(key: str, ttl=300):
    ok = await redis.setnx(f"idem:{key}", "1")
    if ok: await redis.expire(f"idem:{key}", ttl)
    return ok

큐 처리 시 경합 억제

-- 작업 큐: SKIP LOCKED로 병렬 안전
DELETE FROM job_queue
WHERE id = (
  SELECT id FROM job_queue
  WHERE run_at <= now()
  ORDER BY priority DESC, run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *;

4) 캐싱 전략(응답/데이터/계산 결과)

패턴 맛보기

  • Cache-Aside(권장): 미스 시 DB→캐시 세팅. 가장 유연
  • Write-Through: 쓰기마다 캐시에 즉시 반영(지연↑)
  • Write-Back: 캐시에 적고 나중에 DB 반영(복잡/장애시 위험)

스탬피드 방지

  • 확률적 조기 갱신: TTL의 10~20% 남았을 때 일부 요청만 갱신 시도
  • 분산 락: SET key val NX PX or Redlock(간단 용도)
  • 버전 키: user:{id}:v{ver} → 무효화는 버전 증가로 O(1)
async def get_user(user_id: str):
    ver = await redis.get(f"user:{user_id}:ver") or "1"
    ck = f"user:{user_id}:v{ver}"
    cached = await redis.get(ck)
    if cached: return json.loads(cached)

    # stampede 방지 락
    if not await redis.setnx(ck+":lock", "1"):
        await asyncio.sleep(0.05); return json.loads(await redis.get(ck))
    try:
        data = await db_fetch_user(user_id)
        await redis.setex(ck, 300, json.dumps(data))
        return data
    finally:
        await redis.delete(ck+":lock")

5) Redis 자료구조 실전

  • String: 일반 캐시/카운터
  • Hash: 프로필 같은 필드 부분 업데이트
  • ZSET: 랭킹/시간순 스트림 인덱스
  • SET: 유니크 토큰/피처 플래그
  • Bloom 필터: 존재 가능성 빠른 체크(미스 폭발 완화)

6) Postgres FTS → OpenSearch로 성장

1단계: Postgres FTS

ALTER TABLE articles ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);

SELECT id, ts_rank(tsv, plainto_tsquery('simple', $1)) AS rank
FROM articles
WHERE tsv @@ plainto_tsquery('simple', $1)
ORDER BY rank DESC
LIMIT 20 OFFSET $2;
  • 한글은 형태소 분석 이슈 → ngram 기반 TRGM도 병행 고려
CREATE INDEX idx_articles_body_trgm ON articles USING gin (body gin_trgm_ops);

2단계: OpenSearch/Elastic로 분리

  • 색인 경로
    A) 앱 이중쓰기(쓰기 시 DB+ES 동시) → 간단하지만 불일치 위험
    B) Outbox + CDC(권장): DB에 이벤트 행 기록 → Debezium/Kafka → 색인 워커
-- outbox 테이블
CREATE TABLE outbox_events(
  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
  aggregate_type text NOT NULL, -- e.g. "article"
  aggregate_id uuid NOT NULL,
  event_type text NOT NULL,     -- "created"|"updated"|"deleted"
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
  • 색인 지연(SLA) 목표: p95 < 3~5초
  • 리인덱스 전략: 새 인덱스 생성 → 앨리어스 스위치(무중단)

7) 벡터 검색(옵션)

  • 간단/소규모: pgvector 확장 + ANN 인덱스(HNSW)
  • 대규모/멀티모달: 전용 벡터 스토어(초저지연, 대량 업서트)
  • 임베딩 관리: 버전 필드 포함(embed_v=2), 재학습 시 백필 파이프라인 준비

8) Next.js 캐싱/리밸리데이션

  • ISR: 콘텐츠성 페이지는 revalidate 또는 태그 기반 무효화
  • 사용자 맞춤(세션/쿠키 의존) → SSR + CDN 캐시 비활성/단기
  • Route Handler(BFF)에서 ETag/Cache-Control 헤더 적극 사용
// app/api/articles/[id]/route.ts
import { NextResponse } from 'next/server';
export async function GET(_: Request, { params }: { params: { id: string }}) {
  const data = await fetchFromAPI(params.id);
  const etag = await hashJSON(data);
  const res = NextResponse.json(data);
  res.headers.set('ETag', etag);
  res.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
  return res;
}

9) 마이그레이션(무중단 절차)

  1. 추가: 새 컬럼/테이블 nullable로 추가
  2. 백필: 배치 작업(작게 쪼개서)
  3. 읽기/쓰기: 애플리케이션이 새 필드 함께 사용
  4. 스왑: 트래픽 전환 확인
  5. 정리: 구 필드/인덱스 제거(여유 기간 후)

대용량 백필 시 SET LOCAL statement_timeout 낮추고 소량 커밋 반복


10) 관측성(꼭 넣을 항목)

  • DB: p50/p95/p99, 커넥션 사용률, 슬로우 쿼리 수, 캐시 적중률
  • Redis: 히트율, 키 수/메모리, 실패율, 록 경합
  • Search: 색인 지연, 쿼리 지연, 에러율, 리트라이율
  • 캐시 KPI: 캐시 없을 때 p95 vs 있을 때 p95(절대 수치로 기록)

11) 보안/컴플라이언스(요지)

  • PII 필드: 컬럼 수준 암호화(KMS) 또는 앱 레벨 토큰화
  • 행 수준 보안(RLS): 멀티테넌시 시 Postgres RLS 고려
  • 백업/DR: PITR(최신 시점 복구), 정기 복구 리허설 필수

12) 2주 액션 플랜(템플릿)

  1. 슬로우 쿼리 Top 10 추출→ 인덱스/쿼리 구조 개선
  2. Redis 캐시-어사이드를 상위 조회 3개 엔드포인트에 적용
  3. SWR + 분산락으로 캐시 스탬피드 방지
  4. FTS 도입(tsvector+GIN) → 검색 p95 측정
  5. Outbox 스키마 + 워커 토대 마련(나중에 Debezium 연동 가능하게)
  6. Next.js 태그 기반 ISR 적용 및 API ETag/Cache-Control 정착
  7. 대시보드에 DB/Redis/Search 핵심 지표 고정 배치

13) 바로 복붙하는 스니펫 묶음

A. Redis SWR 윈도우

import random, time
def should_refresh(ttl_remaining: int, base_ttl: int):
    # TTL 남은 비율이 20% 이하이면서 확률적으로만 갱신 트리거
    return ttl_remaining < base_ttl*0.2 and random.random() < 0.2

B. Postgres FTS + TRGM 혼합 검색

SELECT id, title,
  ts_rank(tsv, plainto_tsquery('simple', :q)) AS rank_fts,
  similarity(title, :q) AS sim
FROM articles
WHERE tsv @@ plainto_tsquery('simple', :q)
   OR title % :q
ORDER BY (rank_fts*0.7 + sim*0.3) DESC
LIMIT 20;

C. Alembic 무중단 마이그레이션 예시

def upgrade():
    op.add_column('orders', sa.Column('note', sa.Text(), nullable=True))
    # 인덱스는 동시 생성
    op.execute('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created ON orders (created_at DESC)')

마무리

  • 데이터는 느려지기 시작하면 기하급수로 느려진다 → 초기에 키/인덱스/캐시/검색의 역할분리를 해두면 대규모로 갈수록 유리합니다..

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다