ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • PgBouncer 검토 및 Redis Checkpoint Sync 비교
    이코에코(Eco²)/Agent 2026. 1. 27. 10:50

    https://www.pgbouncer.org/

    Date: 2026-01-24
    Author: Claude Code, mangowhoiscloud
    Agenda: PostgreSQL Connection Pooling, PgBouncer, LangGraph Checkpoint 아키텍처 비교
    Target: Eco² 프로젝트의 LangGraph 기반 Chat Pipeline에서 고빈도 checkpoint write 처리
    Related: https://rooftopsnow.tistory.com/242, https://rooftopsnow.tistory.com/243


    1. 문제 정의

    1.1 LangGraph Checkpoint의 특성

    LangGraph는 각 노드 실행 후 상태를 checkpoint로 저장합니다.

    이는 장애 복구(fault tolerance)와 대화 이력 보존을 위한 핵심 메커니즘입니다.

    Eco² Chat Pipeline (17 Nodes):
    ┌─────────────────────────────────────────────────────────────┐
    │  intent_classifier                                          │
    │       ↓                                                     │
    │  router (Send API - 병렬 분기)                              │
    │       ├─→ waste_node ────→ checkpoint write                │
    │       ├─→ weather_node ──→ checkpoint write                │
    │       └─→ location_node ─→ checkpoint write                │
    │       ↓                                                     │
    │  aggregator ─────────────→ checkpoint write                │
    │       ↓                                                     │
    │  answer_generator ───────→ checkpoint write                │
    │       ↓                                                     │
    │  ... (총 17개 노드)                                         │
    └─────────────────────────────────────────────────────────────┘

    1.2 병목의 본질

    문제 시나리오:
    ┌────────────────────────────────────────────────────────────┐
    │  동시 사용자: 50명                                          │
    │  Send API 병렬 노드: 3개                                    │
    │  노드당 checkpoint: 1회                                     │
    │                                                            │
    │  → 순간 최대 checkpoint write: 50 × 3 = 150회              │
    │  → PostgreSQL pool_size=5, max_overflow=10 → 최대 15 연결  │
    │  → 135개 요청이 연결 대기 큐에서 직렬화                      │
    │  → PoolTimeout 발생 또는 LLM 파이프라인 지연                │
    └────────────────────────────────────────────────────────────┘

    1.3 PostgreSQL 연결 = 프로세스

    ┌─────────────────────────────────────────────────────────────┐
    │  PostgreSQL Server                                          │
    │                                                             │
    │  postmaster (main process)                                  │
    │       │                                                     │
    │       ├─→ backend process 1 (client connection 1)          │
    │       │     └─ private memory: 10-20MB                     │
    │       ├─→ backend process 2 (client connection 2)          │
    │       │     └─ private memory: 10-20MB                     │
    │       ├─→ backend process 3 (client connection 3)          │
    │       │     └─ private memory: 10-20MB                     │
    │       └─→ ...                                               │
    │                                                             │
    │  Shared Memory: shared_buffers, WAL buffers, etc.          │
    └─────────────────────────────────────────────────────────────┘
    
    연결 생성 비용:
    - fork() 시스템콜
    - private memory 할당 (work_mem, temp_buffers 등)
    - 인증 handshake
    - 총 비용: 수 ms + 10-20MB RAM

     

     

    PostgreSQL은 프로세스 기반 아키텍처를 채택하고 있습니다.

    • 100 연결 = 100 프로세스 = 1-2GB RAM 추가 소비
    • Context switch 오버헤드 (커널 레벨)
    • 내부 Lock contention (ProcArrayLock, WALWriteLock 등)

    PostgreSQL 공식 권장 연결 수:

    max_connections ≈ (CPU 코어 수 × 2) + 유효 디스크 수
    
    4코어 서버 → ~10개가 최적 throughput 구간
    100개로 늘리면 → throughput 오히려 하락

    2. PgBouncer 심층 분석

    2.1 개요 및 아키텍처

    PgBouncer는 PostgreSQL 전용 경량 Connection Pooler입니다. C로 작성되어 연결당 ~2KB만 사용합니다.

    ┌──────────────────────────────────────────────────────────────┐
    │                        PgBouncer                             │
    │                                                              │
    │   Client Connections          Server Connections             │
    │   (앱에서 오는 연결)           (실제 PG 연결)                 │
    │                                                              │
    │   ┌─────────┐                 ┌─────────────────────┐       │
    │   │ Client 1│─┐               │                     │       │
    │   └─────────┘ │               │  ┌───────────────┐  │       │
    │   ┌─────────┐ │  ┌─────────┐  │  │ Server Conn 1 │──┼──→ PG │
    │   │ Client 2│─┼─→│  Pool   │──┼─→│ (SV_ACTIVE)   │  │       │
    │   └─────────┘ │  │ Manager │  │  └───────────────┘  │       │
    │   ┌─────────┐ │  └─────────┘  │  ┌───────────────┐  │       │
    │   │ Client 3│─┤       ↓       │  │ Server Conn 2 │──┼──→ PG │
    │   └─────────┘ │  ┌─────────┐  │  │ (SV_IDLE)     │  │       │
    │       ...     │  │ Waiting │  │  └───────────────┘  │       │
    │   ┌─────────┐ │  │  Queue  │  │         ...         │       │
    │   │Client N │─┘  └─────────┘  │                     │       │
    │   └─────────┘                 └─────────────────────┘       │
    │                                                              │
    │   max_client_conn=1000        default_pool_size=20          │
    └──────────────────────────────────────────────────────────────┘

    2.2 Pool Mode 상세

    PgBouncer의 핵심은 언제 서버 연결을 클라이언트에서 분리하는가입니다.

    Session Mode (기본값)

    Client A: ══════════════════════════════════════ Server Conn 1
              │ connect │ BEGIN │ query │ COMMIT │ idle │ disconnect │
              └─────────────────────────────────────────────────────┘
                               연결 해제까지 점유
    
    특징:
    - 세션 기능 전부 사용 가능 (SET, PREPARE, LISTEN, temp table 등)
    - 다중화 효과 없음
    - 적합: 장기 연결, 세션 상태 필요한 앱

    Transaction Mode (Eco² 권장)

    Client A: ─────[TX1]───────────────────[TX2]─────
                    │                        │
    Server Conn 1: ═[TX1]═══[B:TX1]═══════[TX2]════
                            ↑
                      다른 클라이언트가 사용
    
    동작 원리:
    1. Client A가 BEGIN 전송 → PgBouncer가 idle server conn 할당
    2. TX 실행 중 → 해당 conn 점유
    3. COMMIT/ROLLBACK → 즉시 conn을 pool에 반환
    4. Client A는 여전히 연결 상태이지만 server conn 없음
    5. 다음 TX 시작 → 다른 server conn 할당될 수 있음
    
    제약사항:
    - SET 명령어: ❌ (TX 끝나면 conn 바뀜)
      → 대안: SET LOCAL (TX 범위에서만 유효)
    - PREPARE: ❌ (conn 바뀌면 prepared statement 사라짐)
      → 대안: PgBouncer 1.21+ max_prepared_statements
    - LISTEN/NOTIFY: ❌ (세션에 바인딩됨)
    - Temp Table: ❌ (세션 종속)
    - Advisory Lock: ❌ (세션 종속)

    Statement Mode

    Client A: ─[Q1]─[Q2]─[Q3]─
                 │    │    │
    Server:    ═[Q1]═[B]═[Q3]═
                      ↑
                다른 클라이언트
    
    특징:
    - 쿼리 단위로 conn 재할당
    - 트랜잭션 불가 (autocommit only)
    - 극단적 다중화
    - 적합: 읽기 전용 분석 쿼리

    2.3 내부 동작: 연결 상태 머신

    ┌───────────────────────────────────────────────────────────────┐
    │  Client States                  Server States                 │
    │                                                               │
    │  CL_FREE ──→ CL_LOGIN           SV_FREE                      │
    │      │           │                  │                         │
    │      ↓           ↓                  ↓                         │
    │  CL_ACTIVE ←── CL_WAITING ←───→ SV_LOGIN                     │
    │      │                              │                         │
    │      │ (transaction mode)           ↓                         │
    │      │  TX 완료 시                SV_ACTIVE ←─→ CL_ACTIVE     │
    │      │  server conn 분리              │    (linked pair)      │
    │      ↓                              ↓                         │
    │  CL_WAITING ──────────────────→ SV_IDLE                      │
    │      │                              │                         │
    │      └──── 다음 TX 시작 시 ─────────┘                         │
    │            새 server conn과 link                              │
    └───────────────────────────────────────────────────────────────┘

    2.4 Prepared Statement 처리 (PgBouncer 1.21+)

    Transaction mode에서 prepared statement를 지원하기 위한 메커니즘입니다:

    ┌───────────────────────────────────────────────────────────────┐
    │  Client → PgBouncer                PgBouncer → PostgreSQL     │
    │                                                               │
    │  PARSE "stmt1" AS "SELECT..."      (intercept & rename)       │
    │       │                                   │                   │
    │       ↓                                   ↓                   │
    │  PgBouncer internal:               PARSE "PGBOUNCER_xxxx"     │
    │  - stmt1 → PGBOUNCER_xxxx mapping        AS "SELECT..."      │
    │  - query text cached (global)                                 │
    │                                                               │
    │  EXECUTE "stmt1" (on conn B)       (conn B에 stmt 없으면)     │
    │       │                            → auto PARSE + EXECUTE     │
    │       ↓                                                       │
    │  Check: conn B has PGBOUNCER_xxxx?                           │
    │  No → send PARSE first                                        │
    │  Yes → send EXECUTE directly                                  │
    └───────────────────────────────────────────────────────────────┘
    
    설정:
    max_prepared_statements = 200  # 0이면 기능 비활성화
    
    제약:
    - Protocol-level prepared statement만 지원
    - SQL PREPARE 명령어는 지원 불가

    2.5 asyncpg 호환성

    asyncpg는 기본적으로 aggressive prepared statement caching을 사용합니다:

    # asyncpg 기본 동작
    conn.execute("SELECT * FROM users WHERE id = $1", user_id)
    # 내부적으로:
    # 1. statement_cache에 쿼리 존재? → 캐시된 prepared statement 사용
    # 2. 없으면 → PREPARE → 캐시 → EXECUTE
    
    # Transaction mode 문제:
    # TX1: conn A에서 PREPARE → statement_cache에 기록
    # TX2: conn B 할당됨 → conn B에는 해당 prepared statement 없음
    # → "prepared statement does not exist" 에러

    해결책:

    # 방법 1: prepared statement 완전 비활성화
    pool = asyncpg.create_pool(
        dsn="postgresql://...@pgbouncer:6432/db",
        statement_cache_size=0,
        max_cached_statement_lifetime=0,
    )
    
    # 방법 2: PgBouncer 1.21+ 사용
    # pgbouncer.ini
    max_prepared_statements = 200
    
    # asyncpg는 그대로 사용, PgBouncer가 중재

     

    2.6 고가용성 구성

    ┌─────────────────────────────────────────────────────────────────┐
    │  Multi-PgBouncer Architecture                                   │
    │                                                                 │
    │                    ┌─────────────────┐                          │
    │                    │  Load Balancer  │                          │
    │                    │  (HAProxy/NLB)  │                          │
    │                    └────────┬────────┘                          │
    │                             │                                   │
    │           ┌─────────────────┼─────────────────┐                │
    │           │                 │                 │                │
    │           ▼                 ▼                 ▼                │
    │   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐        │
    │   │ PgBouncer 1  │  │ PgBouncer 2  │  │ PgBouncer 3  │        │
    │   │ (Active)     │  │ (Active)     │  │ (Active)     │        │
    │   └──────┬───────┘  └──────┬───────┘  └──────┬───────┘        │
    │          │                 │                 │                 │
    │          └─────────────────┼─────────────────┘                 │
    │                            │                                   │
    │                            ▼                                   │
    │                    ┌──────────────┐                             │
    │                    │  PostgreSQL  │                             │
    │                    │   Primary    │                             │
    │                    └──────────────┘                             │
    └─────────────────────────────────────────────────────────────────┘
    
    이점:
    - SPOF 제거
    - 수평 확장 (CPU bound 해소)
    - Rolling restart 가능
    
    [peers] 섹션으로 cancel request 전파 설정 필요

    3. 캐싱 패턴: Write-Through vs Write-Behind

    3.1 Write-Through

    ┌─────────────────────────────────────────────────────────────────┐
    │  Write-Through Pattern                                          │
    │                                                                 │
    │  App ──write──→ Cache ──sync write──→ Database                 │
    │        │          │                        │                    │
    │        │          └── 동기적으로 DB 쓰기 ──┘                    │
    │        │                완료될 때까지 대기                       │
    │        └── 응답 반환                                            │
    │                                                                 │
    │  특징:                                                          │
    │  - Strong Consistency (Cache = DB)                              │
    │  - Write latency = Cache write + DB write                       │
    │  - DB 장애 시 write 실패                                        │
    └─────────────────────────────────────────────────────────────────┘

    3.2 Write-Behind (Write-Back)

    ┌─────────────────────────────────────────────────────────────────┐
    │  Write-Behind Pattern                                           │
    │                                                                 │
    │  App ──write──→ Cache ──queue──→ Background ──batch──→ Database│
    │        │          │       │       Worker          │             │
    │        │          │       │                       │             │
    │        │          └─ 즉시 반환                    └─ 비동기 동기화
    │        └── 응답 반환 (~1ms)                         (~배치 주기)│
    │                                                                 │
    │  특징:                                                          │
    │  - Eventual Consistency                                         │
    │  - Write latency = Cache write only (~1ms)                      │
    │  - DB 장애 시에도 write 성공 (queue에 축적)                      │
    │  - 데이터 유실 위험 (cache crash before sync)                   │
    └─────────────────────────────────────────────────────────────────┘

    3.3 Read-Through

    ┌─────────────────────────────────────────────────────────────────┐
    │  Read-Through Pattern                                           │
    │                                                                 │
    │  App ──read──→ Cache ──hit?──→ Yes: 즉시 반환                  │
    │                  │                                              │
    │                  └── No (miss)                                  │
    │                        │                                        │
    │                        ▼                                        │
    │                    Database ──read──→ Cache ──promote──→ 반환  │
    │                                         │                       │
    │                                    (write-back)                 │
    │                                                                 │
    │  특징:                                                          │
    │  - Lazy loading: 요청 시점에 캐시 적재                          │
    │  - Temporal locality 활용: 최근 참조 데이터 재참조 가능성 높음   │
    └─────────────────────────────────────────────────────────────────┘

    4. Eco² 현재 구현: Write-Behind + Read-Through

    4.1 아키텍처 개요

    ┌──────────────────────────────────────────────────────────────────────────┐
    │  Eco² LangGraph Checkpoint Architecture                                  │
    │                                                                          │
    │  ┌─────────────────────────────────────────────────────────────────────┐│
    │  │  Chat Worker (LangGraph Pipeline)                                   ││
    │  │                                                                      ││
    │  │  graph.ainvoke()                                                     ││
    │  │       │                                                              ││
    │  │       ▼                                                              ││
    │  │  ┌─────────────────────────────────────┐                            ││
    │  │  │  ReadThroughCheckpointer             │                            ││
    │  │  │                                      │                            ││
    │  │  │  Write Path:                         │                            ││
    │  │  │    aput() → SyncableRedisSaver       │                            ││
    │  │  │              ├─ Redis SET (~1ms)     │                            ││
    │  │  │              └─ LPUSH sync_queue     │                            ││
    │  │  │                                      │                            ││
    │  │  │  Read Path:                          │                            ││
    │  │  │    aget_tuple()                      │                            ││
    │  │  │      ├─ Redis GET (hit) → return     │                            ││
    │  │  │      └─ Redis miss                   │                            ││
    │  │  │           └─ PG read → Redis promote │                            ││
    │  │  └─────────────────────────────────────┘                            ││
    │  └─────────────────────────────────────────────────────────────────────┘│
    │                          │                                               │
    │                          │ sync_queue (Redis List)                       │
    │                          ▼                                               │
    │  ┌─────────────────────────────────────────────────────────────────────┐│
    │  │  Checkpoint Sync Service (별도 프로세스)                            ││
    │  │                                                                      ││
    │  │  while True:                                                         ││
    │  │    batch = BRPOP + drain (max 50, 2s timeout)                       ││
    │  │    deduplicate by thread_id                                          ││
    │  │    for event in batch:                                               ││
    │  │      checkpoint = Redis GET                                          ││
    │  │      PostgreSQL UPSERT (batch)                                       ││
    │  │                                                                      ││
    │  │  PG Pool: min=1, max=5 (batch write이므로 충분)                      ││
    │  └─────────────────────────────────────────────────────────────────────┘│
    │                                                                          │
    │  ┌─────────────┐              ┌─────────────┐                           │
    │  │   Redis     │              │ PostgreSQL  │                           │
    │  │  (Primary)  │  ══════════▶ │  (Archive)  │                           │
    │  │  TTL: 24h   │   async sync │  Durable    │                           │
    │  └─────────────┘              └─────────────┘                           │
    └──────────────────────────────────────────────────────────────────────────┘

    4.2 핵심 컴포넌트 상세

    SyncableRedisSaver

    # /apps/chat_worker/infrastructure/orchestration/langgraph/sync/syncable_redis_saver.py
    
    class SyncableRedisSaver(PlainAsyncRedisSaver):
        """Redis checkpoint 저장 + sync queue 이벤트 발행."""
    
        async def aput(self, config, checkpoint, metadata, new_versions):
            # 1. Redis에 checkpoint 저장 (부모 클래스)
            result_config = await super().aput(config, checkpoint, metadata, new_versions)
    
            # 2. Sync queue에 이벤트 발행 (best-effort)
            event = json.dumps({
                "thread_id": config["configurable"]["thread_id"],
                "checkpoint_ns": config["configurable"].get("checkpoint_ns", ""),
                "checkpoint_id": checkpoint["id"],
            })
            await self._redis.lpush(SYNC_QUEUE_KEY, event)  # O(1)
    
            return result_config
    
        async def aput_no_sync(self, ...):
            """Sync queue 없이 저장 (Read-Through promote용).
    
            PG에서 읽어온 checkpoint를 Redis에 적재할 때 사용합니다.
            이미 PG에 존재하므로 sync queue에 넣으면 불필요한 upsert가 발생합니다.
            """
            return await super().aput(...)

    ReadThroughCheckpointer

    # /apps/chat_worker/infrastructure/orchestration/langgraph/sync/read_through_checkpointer.py
    
    class ReadThroughCheckpointer(BaseCheckpointSaver):
        """Redis Primary + PostgreSQL Cold Start Fallback.
    
        Temporal Locality (시간적 지역성) 활용:
        - Redis TTL(24h) 만료 후 재접속한 세션 → PG에서 복원
        - 복원된 checkpoint를 Redis에 promote (LRU write-back)
        - 이후 동일 세션 요청은 Redis에서 직접 서빙
        """
    
        async def aget_tuple(self, config) -> Optional[CheckpointTuple]:
            # 1. Redis 조회 (hot path, ~1ms)
            result = await self._redis_saver.aget_tuple(config)
            if result is not None:
                return result
    
            # 2. Redis miss → PostgreSQL fallback (cold start, ~10-50ms)
            pg_result = await self._pg_saver.aget_tuple(config)
            if pg_result is None:
                self._miss_count += 1
                return None
    
            # 3. Redis에 promote (LRU write-back)
            # aput_no_sync: 이미 PG에 존재하므로 sync queue bypass
            await self._redis_saver.aput_no_sync(
                config=pg_result.config,
                checkpoint=pg_result.checkpoint,
                metadata=pg_result.metadata,
                new_versions={},
            )
            self._promote_count += 1
    
            return pg_result

    CheckpointSyncService

    # /apps/chat_worker/infrastructure/orchestration/langgraph/sync/checkpoint_sync_service.py
    
    class CheckpointSyncService:
        """Redis → PostgreSQL 비동기 동기화.
    
        전략:
        1. BRPOP으로 sync queue 대기 (blocking, timeout=5s)
        2. 배치 수집 (max 50개 또는 2초)
        3. thread_id별 deduplicate (최신 checkpoint만)
        4. PostgreSQL batch upsert
        5. 실패 시 DLQ로 이동
        """
    
        async def _collect_batch(self, stop_event) -> list[dict]:
            batch = []
    
            # 첫 이벤트: blocking 대기
            result = await self._redis.brpop(SYNC_QUEUE_KEY, timeout=5)
            if result is None:
                return batch
            batch.append(self._parse_event(result[1]))
    
            # 추가 이벤트: non-blocking drain
            deadline = time.monotonic() + self._drain_timeout  # 2초
            while len(batch) < self._batch_size:  # 50개
                if time.monotonic() > deadline:
                    break
                raw = await self._redis.rpop(SYNC_QUEUE_KEY)
                if raw is None:
                    break
                batch.append(self._parse_event(raw))
    
            return batch
    
        async def _sync_batch(self, batch: list[dict]) -> int:
            # thread_id별 deduplicate (동일 thread의 최신 checkpoint만)
            latest = {}
            for event in batch:
                key = (event["thread_id"], event["checkpoint_ns"])
                latest[key] = event  # 마지막이 최신
    
            for event in latest.values():
                await self._sync_one(event)
    
        async def _sync_one(self, event: dict) -> bool:
            # Redis에서 checkpoint 읽기
            tuple_data = await self._redis_saver.aget_tuple(config)
            if tuple_data is None:
                return False  # TTL 만료
    
            # PostgreSQL에 upsert
            await self._pg_saver.aput(
                config=tuple_data.config,
                checkpoint=tuple_data.checkpoint,
                metadata=tuple_data.metadata,
                new_versions={},
            )
            return True

    4.3 설계 결정의 근거

    왜 Redis Primary인가?

    LangGraph 노드 실행 특성:
    ┌─────────────────────────────────────────────────────────────┐
    │  Node 1: LLM API 호출 (3~8초)                               │
    │     └─ checkpoint write (~5ms with PG, ~1ms with Redis)     │
    │  Node 2: LLM API 호출 (3~8초)                               │
    │     └─ checkpoint write                                     │
    │  Node 3: ...                                                 │
    │                                                              │
    │  총 파이프라인: 20~60초                                       │
    │  총 checkpoint write: 10~17회                                │
    │  checkpoint 시간 비중: (17 × 5ms) / 30s = 0.3%               │
    │                                                              │
    │  → Checkpoint latency 자체는 병목이 아닙니다                  │
    │  → 문제는 "동시 연결 수"입니다                                │
    └─────────────────────────────────────────────────────────────┘
    
    Redis의 장점:
    - 단일 스레드 이벤트 루프 → 연결 수 증가해도 성능 일정
    - Connection = 경량 (vs PG 프로세스 10-20MB)
    - 10,000+ 동시 연결 가능

    왜 Sync Queue인가?

    대안 1: 주기적 polling (cron)
      - 단점: sync 지연 (polling 주기만큼)
      - 단점: 불필요한 scan (변경 없어도 전체 조회)
    
    대안 2: CDC (Change Data Capture)
      - 단점: 복잡도 높음
      - 단점: Redis에는 네이티브 CDC 없음
    
    선택: Event-driven sync queue
      - 장점: 변경 즉시 이벤트 발행 → 최소 지연
      - 장점: queue 비어있으면 대기 (BRPOP) → CPU 낭비 없음
      - 장점: 배치 수집으로 효율적 bulk write
      - 장점: 실패 시 DLQ → 수동 복구 가능

    왜 batch_size=50, drain_timeout=2s인가?

    Throughput vs Latency 트레이드오프:
    
    batch_size↑, timeout↑:
      - PG write 효율↑ (bulk insert)
      - sync 지연↑
      - 장애 시 데이터 유실 범위↑
    
    batch_size↓, timeout↓:
      - sync 지연↓ (near real-time)
      - PG 연결 점유 빈번
      - 개별 insert 오버헤드
    
    현재 설정 (50개/2초):
      - 일반 부하: 이벤트 수집되는 대로 즉시 sync
      - 피크 부하: 최대 2초 내 50개 모아서 batch write
      - sync 지연: 평균 < 1초, 최대 2초

    4.4 장애 시나리오 분석

    ┌─────────────────────────────────────────────────────────────────┐
    │  Scenario 1: Redis 장애                                         │
    │                                                                 │
    │  영향:                                                          │
    │  - Write 실패 (checkpoint 유실)                                 │
    │  - Read fallback → PostgreSQL                                   │
    │                                                                 │
    │  대응:                                                          │
    │  - Redis Cluster + AOF 영속화                                   │
    │  - MemorySaver fallback (graceful degradation)                  │
    └─────────────────────────────────────────────────────────────────┘
    
    ┌─────────────────────────────────────────────────────────────────┐
    │  Scenario 2: PostgreSQL 장애                                    │
    │                                                                 │
    │  영향:                                                          │
    │  - Write: 정상 (Redis에 저장됨)                                  │
    │  - Sync: 실패 → queue에 축적                                    │
    │  - Read: Redis hit → 정상, cold start → 실패                    │
    │                                                                 │
    │  대응:                                                          │
    │  - PG 복구 후 queue에 쌓인 이벤트 일괄 sync                      │
    │  - Redis TTL을 PG 복구 예상 시간보다 길게 설정                   │
    └─────────────────────────────────────────────────────────────────┘
    
    ┌─────────────────────────────────────────────────────────────────┐
    │  Scenario 3: Sync Service 장애                                  │
    │                                                                 │
    │  영향:                                                          │
    │  - Write: 정상 (Redis + queue)                                  │
    │  - Read: 정상 (Redis 또는 PG fallback)                          │
    │  - Sync: 중단 → queue 축적                                      │
    │                                                                 │
    │  대응:                                                          │
    │  - Sync service 재시작 시 queue부터 처리                         │
    │  - Kubernetes liveness probe로 자동 재시작                       │
    └─────────────────────────────────────────────────────────────────┘
    
    ┌─────────────────────────────────────────────────────────────────┐
    │  Scenario 4: Redis TTL 만료 + PG sync 미완료                    │
    │                                                                 │
    │  상황: Redis에서 만료됐는데 아직 PG에 sync 안 됨                 │
    │                                                                 │
    │  영향:                                                          │
    │  - Checkpoint 유실 (Redis 없음 + PG 없음)                       │
    │                                                                 │
    │  대응:                                                          │
    │  - TTL > 예상 최대 sync 지연 (현재 24h >> 2s)                   │
    │  - Sync lag 모니터링 + 알림                                     │
    └─────────────────────────────────────────────────────────────────┘

    5. 비교 분석

    5.1 아키텍처 비교

    ┌─────────────────────────────────────────────────────────────────┐
    │  Option A: PgBouncer (Pure PostgreSQL)                          │
    │                                                                 │
    │  LangGraph ─→ PostgresCheckpointer ─→ PgBouncer ─→ PostgreSQL  │
    │                    │                      │                     │
    │               동기 write              연결 다중화               │
    │                 ~5ms                  pool=20                   │
    │                                                                 │
    │  - 일관성: Strong (즉시 PG에 저장)                              │
    │  - 복잡도: 낮음 (인프라 추가만)                                  │
    │  - 연결 문제: 해결 (다중화)                                      │
    │  - latency: 여전히 5ms/write (TX mode도 TX 중에는 점유)         │
    └─────────────────────────────────────────────────────────────────┘
    
    ┌─────────────────────────────────────────────────────────────────┐
    │  Option B: Redis Write-Behind (현재 구현)                       │
    │                                                                 │
    │  LangGraph ─→ ReadThroughCheckpointer                          │
    │                    │                                            │
    │                    ├─ Write: Redis (~1ms) + queue              │
    │                    └─ Read: Redis (hit) or PG (miss→promote)   │
    │                                           │                     │
    │                    CheckpointSyncService ─┴─→ PostgreSQL       │
    │                         (batch, async)         (archive)        │
    │                                                                 │
    │  - 일관성: Eventual (async sync)                                │
    │  - 복잡도: 높음 (3개 컴포넌트)                                   │
    │  - 연결 문제: 근본 해결 (PG 연결 최소화)                         │
    │  - latency: 1ms/write (PG 완전 분리)                            │
    └─────────────────────────────────────────────────────────────────┘

    5.2 정량 비교

    항목 PgBouncer Redis Write-Behind
    Write Latency ~5ms ~1ms
    Read Latency (hot) ~5ms ~1ms
    Read Latency (cold) ~5ms ~10-50ms (PG + promote)
    PG 연결 수 20 (고정) 5 (batch sync only)
    동시 처리 가능 ~200 req/s* ~1000+ req/s*
    일관성 Strong Eventual (~2s)
    구현 복잡도 낮음 높음
    운영 복잡도 낮음 중간
    장애 복구 단순 (PG 기준) 복잡 (reconciliation)
    추가 인프라 PgBouncer Redis Cluster

     

    *환경에 따라 다릅니다. Eco²의 Redis Primary, Postgres Sync 모델 기준으로 작성됐습니다.

    5.3 워크로드별 적합성

    워크로드 PgBouncer Redis Write-Behind
    짧은 TX + 높은 동시성 ✅ 적합 ✅ 최적
    긴 TX ✅ 적합 ⚠️ sync 지연
    Strong Consistency 필수 ✅ 적합 ❌ 부적합
    극한 write throughput ⚠️ PG 한계 ✅ Redis 흡수
    운영 단순성 ✅ 적합 ⚠️ 복잡
    LLM 파이프라인 (긴 idle) ⚠️ TX 중 점유 ✅ 최적
    KEDA 스케일링 ⚠️ 연결 폭발 ✅ 영향 없음

    5.4 LangGraph Checkpoint 벤치마크 참고

    langgraph-redis 0.1.0 벤치마크:

    Operation Redis PostgreSQL 비율
    Get Checkpoint 2,950 ops/s 1,038 ops/s 2.8x
    List Checkpoints 696 ops/s 695 ops/s 1.0x
    Put Checkpoint 1,647 ops/s N/A -
    Fanout-100 846ms 1,959ms 2.3x
    Fanout-500 4,578ms 9,997ms 2.2x

    Redis가 read에서 2.8x, fanout 패턴에서 2.2x 빠릅니다. 특히 Send API를 사용하는 LangGraph 패턴에서 유리합니다.


    6. 결론 및 권장사항

    6.1 현재 Eco² 구현 평가

    ✅ 장점:
    - LangGraph Send API 병렬 패턴에 최적화
    - PG 연결 수 근본적 해결 (15 → 5)
    - KEDA 스케일링에 안전
    - Write latency 5x 개선 (5ms → 1ms)
    - Read-Through로 cold start 처리
    
    ⚠️ 주의점:
    - Eventual Consistency (최대 2초 sync 지연)
    - Redis 장애 시 checkpoint 유실 가능
    - 운영 복잡도 증가 (3개 컴포넌트)

    6.2 권장 개선사항

    1. 모니터링 강화:
       - sync_queue 길이 메트릭
       - sync lag 메트릭 (queue 체류 시간)
       - promote 횟수/miss 횟수 비율
    
    2. Redis 내구성:
       - Redis Cluster (HA)
       - AOF 영속화 (fsync=everysec)
       - 또는 Redis Enterprise
    
    3. 서비스 쿼리용 PgBouncer:
       - Checkpoint은 현재 구조 유지
       - Auth/Users/Scan 등 서비스 쿼리에 PgBouncer 적용
       - HPA 스케일링 안전성 확보

    6.3 PgBouncer 단독 사용이 적합한 경우

    - Strong Consistency를 지향
    - Redis 인프라 추가 불가
    - 운영 복잡도 최소화 필요

    7. References

    PgBouncer

    PostgreSQL Connection Pooler 비교

    LangGraph Checkpoint

    Caching Patterns

    댓글

ABOUT ME

🎓 부산대학교 정보컴퓨터공학과 학사: 2017.03 - 2023.08
☁️ Rakuten Symphony Jr. Cloud Engineer: 2024.12.09 - 2025.08.31
🏆 2025 AI 새싹톤 우수상 수상: 2025.10.30 - 2025.12.02
🌏 이코에코(Eco²) 백엔드/인프라 고도화 중: 2025.12 - Present

Designed by Mango