[카테고리:] 미분류

  • 데이터 레이어(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)')
    

    마무리

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