- 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 PXor 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) 마이그레이션(무중단 절차)
- 추가: 새 컬럼/테이블 nullable로 추가
- 백필: 배치 작업(작게 쪼개서)
- 읽기/쓰기: 애플리케이션이 새 필드 함께 사용
- 스왑: 트래픽 전환 확인
- 정리: 구 필드/인덱스 제거(여유 기간 후)
대용량 백필 시
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주 액션 플랜(템플릿)
- 슬로우 쿼리 Top 10 추출→ 인덱스/쿼리 구조 개선
- Redis 캐시-어사이드를 상위 조회 3개 엔드포인트에 적용
- SWR + 분산락으로 캐시 스탬피드 방지
- FTS 도입(tsvector+GIN) → 검색 p95 측정
- Outbox 스키마 + 워커 토대 마련(나중에 Debezium 연동 가능하게)
- Next.js 태그 기반 ISR 적용 및 API ETag/Cache-Control 정착
- 대시보드에 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)')
마무리
- 데이터는 느려지기 시작하면 기하급수로 느려진다 → 초기에 키/인덱스/캐시/검색의 역할분리를 해두면 대규모로 갈수록 유리합니다..