[AI] 25. ADR 126‐130 ‐ 고가용성 및 마이그레이션 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

ADR 126-130: 인프라 고가용성 및 운영 안정성

작성일: 2026-03-11 상태: 승인됨 (Accepted)


📚 목차


ADR-126: Redis 고가용성(HA) 전략 — Sentinel vs. Cluster vs. ElastiCache Replication Group

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-03-11
결정자 AI팀
관련 기능 OCR 비동기 처리 (Celery), 면접 세션 저장, SSE 챗봇
관련 ADR ADR-096 (Celery + Redis 도입), ADR-102 (Gunicorn 4 workers), ADR-123 (FileLock — Redis 장애 시 파일 폴백)
수정 파일 app/config/settings.py, docker-compose.yml, AWS ElastiCache 설정

🎯 컨텍스트 (Context)

현재 아키텍처

[로컬 개발]                          [프로덕션]
docker-compose                      AWS EC2
└── redis:7-alpine (standalone)     └── AWS ElastiCache (standalone, 단일 노드)
    ├── DB 0: 면접 세션 저장              ├── DB 0: 면접 세션
    ├── DB 1: Celery 브로커               ├── DB 1: Celery 브로커 (OCR, 크롤링 등)
    └── DB 2: Celery 결과 백엔드          └── DB 2: Celery 결과 백엔드

Redis를 사용하는 구성 요소:

사용처 Redis DB 비고
면접 세션 저장 (RedisSessionStore) DB 0 장애 시 메모리+파일 폴백 있음 (ADR-123)
Celery 브로커 DB 1 장애 시 폴백 없음 — 새 태스크 제출 불가
Celery 결과 백엔드 DB 2 장애 시 폴백 없음 — 태스크 결과 조회 불가

문제점

ADR-123에서 면접 세션은 2단계 파일 폴백으로 보호됐지만, Celery 브로커/백엔드는 여전히 단일 장애점(SPOF)이다.

ElastiCache 단일 노드 장애
  → Celery 브로커(DB 1) 접속 불가
  → OCR Celery 태스크 제출 불가 (process_text_extract_task)
  → 이력서 텍스트 추출 전체 중단
  → 504 / 500 에러 사용자 노출

OCR 작업(text_extract_tasks.py)은 이력서 업로드 → 면접 준비 파이프라인의 시작점으로, 이 단계가 막히면 전체 서비스가 사용 불가 상태가 됨.

요구사항

  1. Primary 장애 시 자동 페일오버 — 운영자 개입 없이 수 초 내 복구
  2. Celery 브로커/백엔드 가용성 향상 — OCR 비동기 처리 연속성 보장
  3. 코드 변경 최소화 — 기존 redis:// URL 기반 연결 가능한 수준
  4. 스케일링(샤딩) 필요 없음 — 현재 데이터량이 수평 확장이 필요한 수준이 아님

🔍 선택지 분석 (Options)

Redis 고가용성 기술 개요

기술 목적 샤딩 자동 페일오버 복잡도
Standalone 단순 사용 낮음
Replication (Primary-Replica) 읽기 분산, 백업 ❌ (수동 승격) 낮음
Sentinel HA (자동 페일오버) 중간
Cluster HA + 수평 확장 높음
ElastiCache Replication Group Sentinel과 동일, AWS 완전관리 낮음

Option 1: Redis Sentinel (자체 구성)

구성도:

┌─────────────────────────────────────────────────────┐
│                    Redis Sentinel 구성               │
│                                                     │
│  Primary (6379) ──replication──► Replica-1 (6380)  │
│       │                         Replica-2 (6381)   │
│       │                                             │
│  Sentinel-1 (26379)  ─┐                            │
│  Sentinel-2 (26380)  ─┼─ 상태 감시 + 투표 → 페일오버 │
│  Sentinel-3 (26381)  ─┘                            │
└─────────────────────────────────────────────────────┘

동작 방식:

  1. 3개 Sentinel이 Primary를 주기적으로 PING
  2. Primary 응답 없음 → Sentinel quorum(2/3) 동의 → Replica 중 하나를 Primary로 승격
  3. 클라이언트는 Sentinel에 현재 Primary 주소를 질의

코드 변경:

# settings.py
redis_url: str = "redis+sentinel://sentinel-1:26379,sentinel-2:26380,sentinel-3:26381/mymaster/0"

# redis.asyncio는 Sentinel URL 지원
# Celery도 sentinel URL 지원
CELERY_BROKER_URL = "redis+sentinel://sentinel-1:26379,...sentinel.../mymaster/1"
장점 단점
자동 페일오버 (보통 30초 내) EC2에 Sentinel 3개 + Replica 2개 추가 운영 필요
샤딩 없어 코드 변경 최소 서버 비용 증가
Celery Sentinel URL 네이티브 지원 직접 운영 부담 (패치, 모니터링)
검증된 패턴 Docker 네트워크 설정 복잡

Option 2: Redis Cluster (자체 구성)

구성도:

┌──────────────────────────────────────────────────────┐
│                    Redis Cluster 구성                 │
│                                                      │
│  노드-1 (Primary, slot 0-5460)    ←→  노드-4 (Replica) │
│  노드-2 (Primary, slot 5461-10922) ←→  노드-5 (Replica) │
│  노드-3 (Primary, slot 10923-16383)←→  노드-6 (Replica) │
│                                                      │
│  클라이언트가 직접 slot 계산 후 해당 노드에 연결        │
└──────────────────────────────────────────────────────┘

Redis Cluster의 제약:

  • MGET, MSET 등 다중 키 명령어가 같은 슬롯에 있는 키에만 적용 가능
  • Celery의 일부 명령어(BRPOPLPUSH 등)가 Cluster에서 제한됨
  • 서버 6개 이상 필요 (최소 구성)
# redis-py ClusterRedis 사용 필요
from redis.asyncio.cluster import RedisCluster
client = RedisCluster.from_url("redis://cluster-endpoint:6379")

# Celery는 Cluster 모드에서 별도 설정 필요
# kombu의 Redis transport가 Cluster 일부 기능 미지원 (Celery 이슈 #6067)
장점 단점
수평 확장 가능 최소 6노드 → 비용 부담
HA 내장 Celery와 호환성 이슈
고성능 대용량 처리 코드 변경 범위 큼
현 데이터량에 과도한 엔지니어링

Option 3: AWS ElastiCache Replication Group (채택) ✅

구성도:

┌──────────────────────────────────────────────────────────────────┐
│              AWS ElastiCache Replication Group                    │
│   (Cluster mode DISABLED — Sentinel-equivalent 자동 페일오버)     │
│                                                                  │
│  ┌─────────────────────┐    ┌─────────────────────┐             │
│  │  Primary Node       │ ── │  Replica Node        │             │
│  │  (AZ: ap-ne-2a)     │    │  (AZ: ap-ne-2c)      │             │
│  │  redis.001.xxxx.cache│    │  redis.002.xxxx.cache│             │
│  └─────────────────────┘    └─────────────────────┘             │
│           ↑                                                       │
│  Primary Endpoint (단일 주소, 페일오버 시 자동 교체됨)            │
│  redis.xxxx.cfg.ap-northeast-2.cache.amazonaws.com:6379          │
└──────────────────────────────────────────────────────────────────┘

동작 방식:

  • AWS가 Sentinel 역할을 완전 관리 (EC2에 별도 Sentinel 불필요)
  • Primary 장애 → 약 60~120초 내 Replica 자동 승격
  • 클라이언트는 Primary Endpoint 한 개만 알면 됨 (페일오버 후 DNS가 새 Primary를 가리킴)

코드 변경:

# settings.py — URL 형식 변경 없음 (redis:// 그대로 사용)
redis_url: str = Field(
    default="redis://your-elasticache-primary-endpoint.cache.amazonaws.com:6379/0",
)
celery_broker_url: str = Field(
    default="redis://your-elasticache-primary-endpoint.cache.amazonaws.com:6379/1",
)
celery_result_backend: str = Field(
    default="redis://your-elasticache-primary-endpoint.cache.amazonaws.com:6379/2",
)

AWS Console 설정:

ElastiCache → Redis clusters → Create cluster
  ├── Cluster mode: Disabled (Sentinel-equivalent HA)
  ├── Node type: cache.t3.micro (개발) / cache.t3.small (운영)
  ├── Number of replicas: 1
  ├── Multi-AZ: Enabled
  └── Automatic failover: Enabled
장점 단점
코드 변경 없음redis:// URL 그대로 사용 비용: 현재 single보다 노드 2배
AWS 완전 관리 (패치, 모니터링, 백업) 페일오버 시간 60~120초 (Sentinel 30초보다 느림)
기존 ElastiCache 환경 확장 (인프라 일관성)
Multi-AZ → AZ 장애에도 자동 복구
CloudWatch 지표 기본 제공

✅ 결정 (Decision)

Option 3 채택: AWS ElastiCache Replication Group (Cluster mode disabled)

근거:

  1. 코드 변경 없음redis:// URL 형식이 동일하므로 settings.py 환경변수 값만 교체
  2. 운영 부담 최소 — Sentinel 노드를 직접 EC2에서 운영하지 않아도 됨 (AWS 완전 관리)
  3. 현 스케일에 적합 — 면접 앱 특성상 수평 확장(샤딩)이 필요한 데이터량이 아님, Sentinel이면 충분
  4. 기존 ElastiCache 인프라 일관성 — 이미 ElastiCache를 사용 중이므로 Replication Group 활성화는 설정 변경 수준
  5. 페일오버 60~120초 — 세션 저장은 ADR-123 파일 폴백으로 보호됨; Celery 태스크는 retry 로직(최대 2회, 60초 간격)이 있어 이 시간 내 자연 복구 가능

OCR 비동기 처리 개선 흐름 (페일오버 시나리오):

ElastiCache Primary 장애
  ↓ (즉시)
Celery 브로커(DB 1) 접속 실패 → 새 OCR 태스크 제출 불가
  ↓ (60~120초 내)
AWS 자동 페일오버 → Replica → Primary 승격 → DNS 교체
  ↓
Celery 재연결 → 대기 중이던 태스크 자동 처리 재개
  ↓
Celery retry 로직(60초 간격, 최대 2회)이 페일오버 시간을 커버

SSE 챗봇(면접 채팅) 영향:

면접 세션은 ADR-123에서 이미 메모리+파일 폴백이 구현돼 있어, Redis 일시 장애 시에도 세션 데이터는 보존됨. ElastiCache HA 도입으로 세션 저장도 이중 보호됨 (Redis HA + 파일 폴백).


📊 결과 (Consequences)

긍정적 효과:

  • OCR Celery 브로커/백엔드 단일 장애점 제거 — AZ 장애에도 약 2분 내 자동 복구
  • 세션 저장 이중 보호 (ElastiCache HA + ADR-123 파일 폴백)
  • 코드 변경 없이 환경변수(REDIS_URL) 교체만으로 적용
  • AWS CloudWatch 자동 지표 수집 (CacheMisses, Evictions, ReplicationLag 등)

부정적 영향 / 트레이드오프:

항목 현재 변경 후
노드 수 1 2 (Primary + Replica)
비용 ~$15/월 (t3.micro) ~$30/월 (t3.micro × 2)
페일오버 시간 N/A (수동) 60~120초 (자동)
코드 변경 없음

페일오버 시간(60~120초) 동안의 영향:

  • OCR 태스크: Celery retry(max_retries=2, countdown=60)가 페일오버 시간 커버
  • 면접 세션: ADR-123 파일 폴백으로 세션 데이터 보존
  • SSE 챗봇: 연결 유지 중인 스트림은 영향 없음; 새 세션 시작은 파일 폴백으로 처리

후속 작업:

  • AWS Console에서 ElastiCache → Replication Group 활성화 (Cluster mode DISABLED)
  • Multi-AZ: Enabled, Automatic failover: Enabled 설정
  • .env 환경변수 REDIS_URL, CELERY_BROKER_URL, CELERY_RESULT_BACKEND 갱신
  • CloudWatch 알람 설정: ReplicationLag > 5s, EngineCPUUtilization > 80%
  • 페일오버 시뮬레이션 테스트 (ElastiCache → Failover 버튼으로 강제 테스트)
  • redis_socket_timeout: 2.0s5.0s 조정 검토 (페일오버 중 일시적 느린 응답 허용)

🔬 참고: Redis Sentinel vs. Cluster 핵심 차이

Redis Sentinel (= HA only)
  목적: Primary 장애 감지 → 자동 Replica 승격
  샤딩: ❌ (모든 데이터가 각 노드에 동일하게 복제)
  언제 선택: 데이터 양이 단일 노드로 충분, 가용성만 필요할 때
  ElastiCache 대응: Replication Group (Cluster mode DISABLED)

Redis Cluster (= HA + Sharding)
  목적: 데이터를 16384 슬롯으로 분산 + HA
  샤딩: ✅ (각 노드가 전체 데이터의 일부만 보유)
  언제 선택: 단일 노드 메모리/처리량 한계 → 수평 확장 필요 시
  ElastiCache 대응: Replication Group (Cluster mode ENABLED)

현재 프로젝트: Sentinel 수준이면 충분 → ElastiCache Replication Group (Cluster mode DISABLED)

이력

날짜 변경 내용
2026-03-11 초기 작성

ADR-127: SSE 클라이언트 연결 해제 감지 — is_disconnected() + CancelledError 패턴

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-03-11
결정자 AI팀
관련 기능 SSE 채팅 스트리밍(/ai/chat), 면접 평가 스트리밍(/ai/evaluation/analyze)
관련 ADR ADR-096 (Celery + Redis), ADR-102 (비동기 OCR Celery 이관)
수정 파일 app/api/routes/v2/chat.py, app/api/routes/v2/evaluation.py

🎯 컨텍스트 (Context)

현재 문제

채팅·평가 SSE 스트리밍 도중 클라이언트(브라우저)가 연결을 끊어도 서버 generator가 계속 실행된다.

사용자가 브라우저 탭을 닫음
  → 브라우저: TCP 연결 종료
  → 서버: generate_chat_stream() 계속 실행  ← ❌
     ├─ Gemini / vLLM API 스트리밍 계속 소비 (비용 낭비)
     ├─ 면접 세션 상태 불필요하게 변경
     └─ Redis 세션 저장·삭제 쿼리 발생

기존 코드:

# chat.py — http_request 파라미터 없음
async def generate_chat_stream(request: ChatRequest, session_store):
    async for chunk in rag.llm.generate_response(...):
        yield f"data: {json.dumps({'chunk': chunk})}\n\n"  # 클라이언트 없어도 계속 yield

영향 범위

파일 문제
chat.py NORMAL 5개 루프 + INTERVIEW 4개 stream_text_chars + 2개 keepalive
evaluation.py _generate_analyze_stream vLLM/Gemini 루프, _generate_debate_stream Gemini·GPT-4o 단계

요구사항

  1. 클라이언트 연결 해제 시 즉시 스트리밍 중단 — LLM API 잔여 호출 취소
  2. 면접 세션 상태를 불필요하게 변경하지 않음
  3. ASGI 태스크 취소(asyncio.CancelledError)에 대한 안전한 처리
  4. 기존 스트림 구조 최소 변경 (generator 패턴 유지)

🔍 선택지 분석 (Options)

Option 1: asyncio.CancelledError만 처리 (수동 취소 대응)

async def generate_chat_stream(...):
    try:
        async for chunk in rag.llm.generate_response(...):
            yield f"data: ..."
    except asyncio.CancelledError:
        logger.info("스트림 취소됨")
        return  # generator 종료

동작 원리:

  • FastAPI/Starlette이 클라이언트 연결 해제를 감지하면 ASGI 태스크를 취소
  • 취소 시 현재 await 지점에서 CancelledError가 발생
장점 단점
코드 변경 최소 Starlette이 항상 태스크를 취소하지는 않음 (버전·설정에 따라 다름)
기존 구조 변경 없음 LLM 응답이 끝날 때까지 취소가 늦어질 수 있음

Option 2: request.is_disconnected() 주기적 폴링 ⭐

async def generate_chat_stream(request: ChatRequest, session_store, http_request: Request):
    async for chunk in rag.llm.generate_response(...):
        if await http_request.is_disconnected():          # 매 청크마다 확인
            logger.info("[Chat] 클라이언트 연결 해제 — 스트림 종료")
            return
        yield f"data: {json.dumps({'chunk': chunk})}\n\n"

동작 원리:

  • FastAPI Request.is_disconnected()는 ASGI 연결 상태를 비동기로 확인
  • 실제로는 asyncio.Event를 읽는 수준으로 비용이 거의 없음
  • 반환 즉시 generator를 종료 → 업스트림 LLM 스트림도 aclose() 처리됨
장점 단점
능동적 감지 — CancelledError와 무관하게 작동 매 청크마다 await 1회 추가 (사실상 오버헤드 없음)
토큰 단위로 즉시 중단 가능 루프가 많은 경우 체크 포인트를 수동으로 추가해야 함
FastAPI/Starlette 표준 패턴

Option 3: Option 2 + asyncio.CancelledError 병행 처리 (채택) ✅

async def generate_chat_stream(request: ChatRequest, session_store, http_request: Request):
    try:
        async for chunk in rag.llm.generate_response(...):
            if await http_request.is_disconnected():
                logger.info("[Chat] 클라이언트 연결 해제 — 스트림 종료")
                return
            yield f"data: {json.dumps({'chunk': chunk})}\n\n"
    except asyncio.CancelledError:
        logger.info("[Chat] 스트림 취소됨 (클라이언트 연결 해제)")
        return  # CancelledError를 삼키고 generator 정상 종료

두 메커니즘의 역할:

  • is_disconnected(): 능동적 감지 (청크 단위 체크)
  • CancelledError: 수동적 안전망 (Starlette/ASGI가 태스크를 취소한 경우)
장점 단점
두 경로 모두 커버 — 실패 케이스 없음 코드 변경 범위 큼 (루프마다 1줄 추가)
LLM API 비용 즉시 절감
세션 상태 보호

✅ 결정 (Decision)

Option 3 채택: is_disconnected() 체크 + asyncio.CancelledError 핸들러 병행

적용 위치 (chat.py):

위치 추가 내용
NORMAL 모드 vLLM 분석 루프 is_disconnected() 체크
NORMAL 모드 Gemini 분석 루프 is_disconnected() 체크
NORMAL 모드 피드백/followup/RAG 루프 (3개) is_disconnected() 체크
NORMAL 모드 외부 except asyncio.CancelledError 핸들러
INTERVIEW 모드 첫 keepalive 전 is_disconnected() 체크
INTERVIEW 모드 꼬리질문 keepalive 전 is_disconnected() 체크
INTERVIEW 모드 vLLM/Gemini 꼬리질문 루프 (2개) is_disconnected() 체크
INTERVIEW 모드 stream_text_chars 루프 (4개) is_disconnected() 체크
INTERVIEW 모드 외부 except asyncio.CancelledError 핸들러

적용 위치 (evaluation.py):

위치 추가 내용
_generate_analyze_stream vLLM/Gemini 루프 (2개) is_disconnected() 체크
_generate_analyze_stream 외부 except asyncio.CancelledError 핸들러
_generate_debate_stream Gemini 분석 단계 전 is_disconnected() 체크
_generate_debate_stream GPT-4o 토론 단계 전 is_disconnected() 체크
_generate_debate_streamexcept 블록 asyncio.CancelledError 핸들러 (각 1개)

엔드포인트 변경:

# chat.py
async def chat(request: ChatRequest, http_request: Request, session_store=Depends(...)):
    return StreamingResponse(generate_chat_stream(request, session_store, http_request), ...)

# evaluation.py
async def analyze_interview(request: AnalyzeInterviewRequest, http_request: Request, ...):
    return StreamingResponse(_generate_analyze_stream(request, http_request), ...)

asyncio.CancelledErrorexcept Exception으로 잡지 않는 이유: Python 3.8+에서 asyncio.CancelledErrorBaseException이지 Exception이 아니다. 기존 except Exception 블록에 자연히 잡히지 않으므로, 명시적 핸들러가 없으면 generator 외부로 예외가 전파된다.


📊 결과 (Consequences)

긍정적 효과:

  • 클라이언트 탭 닫기 → 즉시 LLM API 스트리밍 중단 → Gemini/vLLM 토큰 비용 절감
  • 면접 세션 상태가 완료 전에 Redis에 저장되는 부작용 방지
  • SSE keepalive 코루틴 조기 종료 → 서버 리소스 절약

부정적 영향:

  • 매 LLM 토큰마다 await is_disconnected() 1회 추가 — 사실상 오버헤드 없음
  • 코드 줄이 증가 (루프당 2줄 추가)

후속 작업:

  • chat.py — NORMAL + INTERVIEW 모드 disconnect 체크 적용
  • evaluation.py — analyze + debate 스트림 disconnect 체크 적용
  • 통합 테스트: 브라우저 탭 닫기 → 서버 로그에서 "클라이언트 연결 해제" 확인
  • 부하 테스트: is_disconnected() 호출이 처리량에 영향 없음 확인

이력

날짜 변경 내용
2026-03-11 초기 작성

ADR-128: 폴링 힌트 추가 — 202 응답에 interval_ms / max_attempts 포함

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-03-11
결정자 AI팀
관련 기능 OCR 비동기 처리(/ai/text/extract), PII 마스킹(/ai/masking/draft)
관련 ADR ADR-102 (asyncio → Celery 이관), ADR-126 (ElastiCache HA)
수정 파일 app/schemas/common.py, app/api/routes/v2/text_extract.py, app/api/routes/v2/masking.py

🎯 컨텍스트 (Context)

현재 문제

OCR/마스킹 작업 제출 시 202 Accepted를 반환하지만, "얼마나 자주 폴링해야 하는지" 에 대한 정보가 없다.

기존 202 응답:

{
  "task_id": "task_masking_a1b2c3d4e5f6",
  "status": "processing",
  "message": "마스킹 작업을 시작했습니다."
}

클라이언트의 현실:

클라이언트 A: 1초마다 폴링 → 서버 부하 ↑, Redis I/O 낭비
클라이언트 B: 10초마다 폴링 → OCR 완료 후에도 사용자 10초 대기
클라이언트 C: 몇 번 폴링해야 포기할지 모름 → 무한 폴링

현재 비동기 작업 처리 시간

작업 평균 소요 시간 최대 허용 시간
OCR (이력서 + 채용공고) 20~60초 5분 (Celery time_limit=3600s)
PII 마스킹 (Gemini/Chandra) 5~30초 5분

→ 2초 간격 폴링 × 150회 = 최대 5분이면 두 작업 모두 커버 가능

요구사항

  1. 클라이언트가 별도 문서 참조 없이 응답 본문만으로 폴링 전략 수립 가능
  2. 서버-클라이언트 간 폴링 간격 일관성 확보
  3. 스키마 하위 호환성 유지 (polling 필드 없어도 동작하도록 Optional)
  4. 기존 AsyncTaskResponse 모델 확장 (새 모델 불필요)

🔍 선택지 분석 (Options)

Option 1: 응답 헤더로 힌트 전달

return Response(
    content=jsonable_encoder(response),
    headers={
        "Retry-After": "2",           # 폴링 간격 (초)
        "X-Max-Poll-Attempts": "150",  # 최대 횟수
    },
    status_code=202,
)

HTTP 표준 활용:

  • Retry-After 헤더는 RFC 7231에서 202 응답에 사용 가능
  • X- 커스텀 헤더는 비표준이지만 널리 사용됨
장점 단점
HTTP 표준 준수 클라이언트가 헤더를 파싱해야 함
응답 바디 변경 없음 X-Max-Poll-Attempts는 비표준
메인 백엔드 프록시가 헤더 제거할 가능성
FastAPI response_model로 문서화 불가

Option 2: 응답 바디에 polling 객체 포함 (채택) ✅

# schemas/common.py
class PollingHint(BaseModel):
    interval_ms: int = Field(..., description="권장 폴링 간격 (밀리초)")
    max_attempts: int = Field(..., description="최대 폴링 횟수 (초과 시 타임아웃 처리 권장)")

class AsyncTaskResponse(BaseModel):
    task_id: str | int
    status: TaskStatus
    message: str | None = None
    polling: PollingHint | None = None  # 추가

변경 후 202 응답:

{
  "task_id": "task_masking_a1b2c3d4e5f6",
  "status": "processing",
  "message": "마스킹 작업을 시작했습니다.",
  "polling": {
    "interval_ms": 2000,
    "max_attempts": 150
  }
}
장점 단점
Pydantic 모델로 자동 문서화 (Swagger UI에 표시) 응답 바디 크기 소폭 증가 (약 50 bytes)
JSON 직렬화 → 클라이언트가 별도 파싱 불필요
Optional 필드 → 하위 호환성 유지
중앙화된 스키마 (common.py) → 일관성

Option 3: Long Polling (?wait=N 쿼리 파라미터)

@router.get("/ai/task/{task_id}?wait=30")  # 최대 30초 대기 후 응답

클라이언트가 ?wait=30을 붙이면 서버가 최대 30초 동안 작업 완료를 기다렸다가 응답. 결과가 나오면 즉시 반환.

장점 단점
클라이언트 폴링 횟수 대폭 감소 FastAPI async defasyncio.sleep 루프 필요
결과 지연 최소화 HTTP keep-alive 연결 유지 비용
현 규모에서 실익 불분명
기존 /ai/task/{task_id} 엔드포인트 대폭 변경 필요

✅ 결정 (Decision)

Option 2 채택: 응답 바디에 polling 객체 포함

구현 세부:

# text_extract.py
return AsyncTaskResponse(
    task_id=task_id,
    status=TaskStatus.PROCESSING,
    polling=PollingHint(interval_ms=2000, max_attempts=150),  # 최대 5분
)

# masking.py
return AsyncTaskResponse(
    task_id=task_id,
    status=TaskStatus.PROCESSING,
    message="마스킹 작업을 시작했습니다. /ai/task/{task_id}로 진행 상태를 확인하세요.",
    polling=PollingHint(interval_ms=2000, max_attempts=150),  # 최대 5분
)

폴링 파라미터 근거:

파라미터 근거
interval_ms 2,000 (2초) OCR 평균 20~60초 → 30번 정도 폴링으로 충분; 1초면 Redis 부하 ↑
max_attempts 150 150 × 2초 = 300초(5분) = Celery task_time_limit=3600s의 안전 상한

Long Polling을 선택하지 않은 이유: 현재 OCR 작업 수(사용자 1명 업로드 시 1~2건)와 트래픽 규모에서 long polling의 구현 복잡성 대비 실익이 없다. 단순 polling + hint가 클라이언트 구현과 서버 디버깅 모두 더 명확하다.


📊 결과 (Consequences)

긍정적 효과:

  • 클라이언트가 응답 JSON만으로 폴링 전략 수립 가능 (문서 의존도 감소)
  • 과도한 폴링(1초 이하) 방지 → Redis 부하 감소
  • Swagger UI에 polling 필드가 자동 문서화됨

부정적 영향:

  • 없음 (polling 필드가 None이면 기존 동작과 동일 — 완전 하위 호환)

클라이언트 권장 구현 패턴:

const { task_id, polling } = await fetch('/ai/text/extract', { method: 'POST', body }).then(r => r.json());
const interval = polling?.interval_ms ?? 2000;
const maxAttempts = polling?.max_attempts ?? 150;

let attempts = 0;
while (attempts < maxAttempts) {
  await sleep(interval);
  const status = await fetch(`/ai/task/${task_id}`).then(r => r.json());
  if (status.status === 'completed') return status.result;
  if (status.status === 'failed') throw new Error(status.error);
  attempts++;
}
throw new Error('Polling timeout: task did not complete in time');

후속 작업:

  • schemas/common.pyPollingHint 모델 추가 + AsyncTaskResponse.polling 필드 추가
  • text_extract.py — 202 응답에 polling 포함
  • masking.py — 202 응답에 polling 포함
  • 메인 백엔드(Spring) 클라이언트에 폴링 힌트 적용 안내
  • 향후 작업 시간이 크게 늘어날 경우 max_attempts 값 재검토

이력

날짜 변경 내용
2026-03-11 초기 작성

ADR-129: 인터뷰 라우트 코드 품질 고도화 — 헬퍼 추출 / Few-Shot 캐시 / 프롬프트 외부화

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-03-11
결정자 AI팀
관련 기능 SSE 채팅 스트리밍, 면접 세션, 면접 평가 리포트, Few-Shot 예제 선택
관련 ADR ADR-066 (기술면접 질문 중복 방지), ADR-077 (MultiQueryRetriever), ADR-123 (FileLock 세션 폴백)
수정 파일 app/api/routes/v2/_helpers.py, app/services/example_selector.py, app/prompts/templates/evaluation/report.md

🎯 컨텍스트 (Context)

refactor/interview-code-quality 브랜치(PR#245)에서 면접 시스템 전반의 코드 품질을 높이기 위해 3가지 리팩토링을 동시에 적용했다.

문제점

① 라우트 코드 중복:

# chat.py, evaluation.py 각각에 동일한 초기화 코드 중복
rag = RAGChain(...)
llm_gateway = LangChainGateway(...)
session_key = f"interview:{user_id}:{interview_id or 'default'}"  # 여러 곳에 산재

② Few-Shot 임베딩 매 요청마다 재계산:

# example_selector.py — 기존
def get_few_shot_for_general(query: str) -> list[dict]:
    example_embeddings = embedding_model.embed_documents(GENERAL_CHAT_EXAMPLES)  # 매번 호출 ❌
    query_embedding = embedding_model.embed_query(query)
    # 코사인 유사도 계산...

③ 평가 리포트 프롬프트 하드코딩:

# evaluation.py — 기존
REPORT_PROMPT = """
다음은 면접 Q&A 기록입니다:
{qa_text}
위 면접 내용을 바탕으로 상세한 평가 리포트를 작성해주세요. ...
"""  # Python 파일 내 하드코딩 → 수정 시 재배포 필요

요구사항:

  • 라우트 파일 복잡도 감소 (관심사 분리)
  • LLM API 비용 절감 (임베딩 캐시)
  • 프롬프트 수정 주기를 배포 주기에서 분리

🔍 선택지 분석 (Options)

결정 1: 라우트 헬퍼 함수 추출

Option A: 각 라우트 파일에 로직 유지

장점 단점
변경 없음 코드 중복 (n개 라우트 파일에 n번 수정 필요)
서비스 초기화 순서 불일치 가능

Option B: _helpers.py로 공용 헬퍼 추출 (채택) ✅

# app/api/routes/v2/_helpers.py
_services: dict | None = None

def get_services() -> dict:
    """AI 서비스 싱글톤 초기화 (최초 1회)"""
    global _services
    if _services is None:
        _services = {
            "rag": RAGChain(settings.chroma_host, ...),
            "llm_gateway": LangChainGateway(...),
            "vllm": VLLMService(...),
        }
    return _services

def get_session_key(user_id: str, interview_id: str | None) -> str:
    return f"interview:{user_id}:{interview_id or 'default'}"

async def stream_text_chars(text: str) -> AsyncGenerator[str, None]:
    """한 글자씩 비동기 SSE 스트리밍 (SSE_CHAR_DELAY=0.015초)"""
    for char in text:
        yield char
        await asyncio.sleep(SSE_CHAR_DELAY)

def extract_json_from_llm_response(text: str) -> dict:
    """마크다운 코드펜스 제거 후 JSON 파싱"""
    cleaned = re.sub(r"```(?:json)?", "", text).strip()
    match = re.search(r"\{.*\}", cleaned, re.DOTALL)
    return json.loads(match.group()) if match else {}
장점 단점
단일 수정 지점 (모든 라우트가 동일 헬퍼 공유) 파일 1개 추가
서비스 초기화 일관성 보장
세션 키 패턴 표준화
stream_text_chars SSE 딜레이 상수 중앙화

Option C: 별도 서비스 레이어 (app/services/route_helpers.py)

현 규모에서 과도한 추상화. _helpers.py는 라우트 전용 내부 유틸리티이므로 라우트 패키지 내에 위치하는 것이 적절.


결정 2: Few-Shot 임베딩 캐시 + 단계적 거리 완화

Option A: 매 요청마다 전체 임베딩 계산

장점 단점
구현 단순 정적 예제 5개를 매번 임베딩 → API 비용 낭비
응답 지연 증가

Option B: 정적 예제 캐시 + 단계적 거리 완화 (채택) ✅

_GENERAL_CHAT_EMBEDDINGS: list[list[float]] | None = None  # 모듈 레벨 캐시

def get_few_shot_for_general(query: str, k: int = 2) -> list[dict]:
    global _GENERAL_CHAT_EMBEDDINGS
    if _GENERAL_CHAT_EMBEDDINGS is None:
        _GENERAL_CHAT_EMBEDDINGS = embed_docs(GENERAL_CHAT_EXAMPLES)  # 최초 1회만
    query_emb = embed_query(query)
    # 코사인 유사도 계산 → min_similarity=0.3 이상만 선택

async def get_few_shot_for_personality(query: str, ...) -> list[dict]:
    # 1차: distance=1.5, filter={"interview_type": "personality"}
    results = await vectordb.query(query, distance=1.5, filter=filter_)
    if not results:
        # 2차: distance=2.25 (1.5²), 동일 필터
        results = await vectordb.query(query, distance=2.25, filter=filter_)
    if not results:
        # 3차: 필터 제거 (최후 수단)
        results = await vectordb.query(query, distance=2.25)
    return results
장점 단점
정적 예제 임베딩 API 호출 횟수: 요청마다 → 프로세스 최초 1회 모듈 레벨 전역 변수 사용
단계적 완화로 결과 없음 방지 캐시가 프로세스 메모리에 종속 (재시작 시 재계산 — 1회성)
유사도 임계값으로 무관한 예제 필터

Option C: VectorDB에 정적 예제도 저장

정적 예제(5개)를 ChromaDB 컬렉션에 저장하는 방안. 컬렉션 관리·마이그레이션 비용 대비 이점이 없음.


결정 3: 평가 리포트 프롬프트 외부화

Option A: Python 코드 내 하드코딩

장점 단점
파일 1개로 관리 프롬프트 수정 = 코드 수정 = 재배포 필요
프롬프트 검토자가 Python 코드 직접 접근 필요

Option B: 마크다운 파일 외부화 (채택) ✅

app/
└── prompts/
    └── templates/
        └── evaluation/
            └── report.md   ← 프롬프트 템플릿 (Git 관리)
<!-- report.md -->
다음은 면접 Q&A 기록입니다:

{qa_text}

위 면접 내용을 바탕으로 상세한 평가 리포트를 작성해주세요.
1. 각 답변에 대한 개별 평가 (잘한 점, 개선점)
2. 전체적인 강점 패턴
3. 전체적인 약점 패턴
4. 향후 학습 가이드
장점 단점
배포 없이 프롬프트 수정 가능 (파일만 교체) 파일 경로 관리 필요
기존 app/prompts/templates/ 패턴과 일관성
마크다운으로 가독성 높게 관리, Git 이력 추적

Option C: DB/Redis에 프롬프트 저장

동적 A/B 테스트가 필요할 때 유효하나, 현 단계에서 과도한 인프라. Git 기반 버전 관리보다 추적이 어려움.


✅ 결정 (Decision)

세 가지 Option B를 모두 채택한다:

  1. _helpers.py 추출 — 라우트 공용 헬퍼 중앙화
  2. 임베딩 캐시 + 단계적 거리 완화 — Few-Shot 품질 유지 + LLM 비용 절감
  3. report.md 외부화 — 프롬프트 수정 주기를 배포에서 분리

설계 원칙:

  • 단일 책임: 라우트 파일은 요청/응답 처리, 헬퍼는 공용 유틸리티
  • 최소 변경: 기존 라우트 시그니처를 크게 바꾸지 않고 헬퍼를 import해서 사용
  • 점진적 품질 보장: Few-Shot에서 거리 임계값을 단계적으로 완화해 결과 없음 방지

📊 결과 (Consequences)

긍정적 효과:

  • chat.py, evaluation.py 코드 줄 수 감소 (서비스 초기화 로직 제거)
  • 정적 Few-Shot 예제 임베딩 API 호출: 매 요청 → 프로세스 최초 1회로 절감
  • 프롬프트 담당자가 Python 코드 접근 없이 report.md만 수정 가능
  • 세션 키 형식 표준화 → 키 불일치로 인한 세션 손실 버그 방지

부정적 영향 / 트레이드오프:

  • _GENERAL_CHAT_EMBEDDINGS 캐시는 프로세스 메모리에 종속 (Gunicorn 워커 재시작 시 재계산 — 1회성 비용)
  • get_few_shot_for_personality()의 최대 3회 VectorDB 쿼리 — 최악 케이스에서 지연 소폭 증가

후속 작업:

  • app/api/routes/v2/_helpers.py — 헬퍼 함수 추출 완료
  • app/services/example_selector.py — 임베딩 캐시 + 단계적 거리 완화 적용
  • app/prompts/templates/evaluation/report.md — 프롬프트 외부화 완료
  • 통합 테스트: 일반 채팅 Few-Shot 예제가 유사도 임계값(0.3) 이상인 예제만 반환하는지 확인
  • 부하 테스트: get_services() 싱글톤이 Gunicorn 멀티워커 환경에서 올바르게 동작 확인
  • 프롬프트 A/B 테스트 필요 시 report.md를 복수 버전으로 관리하는 방안 검토

이력

날짜 변경 내용
2026-03-11 초기 작성

ADR-130: FileTaskStore → RedisTaskStore 마이그레이션 — k8s 멀티 파드 환경 대응

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-03-16
결정자 AI팀 / 인프라팀
관련 기능 OCR 비동기 처리(/ai/text/extract), PII 마스킹(/ai/masking/draft), 태스크 상태 조회(/ai/task/{task_id})
관련 ADR ADR-096 (Celery + Redis 도입), ADR-102 (asyncio → Celery 이관), ADR-126 (ElastiCache HA), ADR-128 (폴링 힌트)
수정 파일 app/utils/task_store.py, app/config/dependencies.py

🎯 컨텍스트 (Context)

현재 아키텍처

Celery Worker가 태스크 결과를 파일(/tmp/ai_tasks/*.json)에 기록하고, FastAPI가 같은 파일을 읽어 상태를 조회하는 FileTaskStore 방식이 사용되고 있다.

[현재 흐름]
POST /ai/text/extract
  └─ FastAPI  ─write─► /tmp/ai_tasks/{task_id}.json
                                ▲
Celery Worker ─update──────────┘
                                │
GET /ai/task/{task_id}          │
  └─ FastAPI  ─read─────────────┘

코드 현황:

# app/config/dependencies.py
def get_legacy_task_storage(settings: Settings = Depends(get_settings)):
    """
    TODO: Redis 기반 task storage로 교체 필요 (프로덕션)
    """
    from app.utils.task_store import FileTaskStore

    global _task_storage_instance
    if _task_storage_instance is None:
        _task_storage_instance = FileTaskStore(storage_dir=settings.task_storage_dir)
    return _task_storage_instance

환경별 task 저장소 사용 현황 (마이그레이션 전):

구성 요소 dev nonprod prod
get_session_store() InMemory InMemory Redis DB0
get_task_queue() FileTaskQueue FileTaskQueue CeleryTaskQueue (Redis DB1/2)
get_legacy_task_storage() FileTaskStore FileTaskStore FileTaskStore ← ⚠️ 모든 환경 파일 기반

get_session_store()get_task_queue()는 이미 프로덕션에서 Redis를 사용하도록 분기가 구현되어 있으나, get_legacy_task_storage()만 환경 분기 없이 항상 파일 기반으로 동작하고 있다.

문제점 — k8s CI/CD 파이프라인 도입 시 발생

k8s 환경에서 FastAPI와 Celery Worker를 별도 파드로 분리 배포하면 /tmp/ai_tasks/ 디렉터리를 공유할 수 없어 태스크 상태 조회가 불가능해진다.

[k8s 분리 배포 시 문제]

┌─────────────────────────┐     ┌─────────────────────────┐
│  fastapi-pod            │     │  celery-worker-pod       │
│  /tmp/ai_tasks/         │  ✗  │  /tmp/ai_tasks/          │
│  (파드 로컬 스토리지)    │     │  (별개의 파드 로컬 스토리지)│
└─────────────────────────┘     └─────────────────────────┘

GET /ai/task/{task_id}
  └─ FastAPI가 자신의 /tmp/ai_tasks/에서 조회 → 파일 없음 → 404 ❌

요구사항:

  • FastAPI·Celery Worker를 독립적으로 스케일링·배포 가능해야 함
  • 별도 인프라(EFS 등 RWX 스토리지) 추가 없이 해결
  • 코드 수정 범위 최소화
  • dev/nonprod/prod 환경에서 동일한 코드 경로 사용

🔍 선택지 분석 (Options)

Option 1: 단일 파드 멀티 컨테이너 (FastAPI + Celery Worker)

# k8s Deployment 예시
spec:
  containers:
    - name: fastapi
      image: devths-ai:latest
      volumeMounts:
        - name: task-storage
          mountPath: /tmp/ai_tasks
    - name: celery-worker
      image: devths-ai:latest
      command: ["celery", "-A", "app.tasks.celery_app", "worker"]
      volumeMounts:
        - name: task-storage
          mountPath: /tmp/ai_tasks
  volumes:
    - name: task-storage
      emptyDir: {}
장점 단점
코드 변경 없음 FastAPI·Celery 독립 스케일링 불가
emptyDir 볼륨으로 파일 공유 자연스럽게 해결 한 컨테이너 OOM → 파드 전체 재시작
Celery Worker 수 조정 시 FastAPI도 함께 재배포
HPA(수평 파드 오토스케일러) 독립 설정 불가

Option 2: 분리 Deployment + PV/PVC (ReadWriteMany)

# PersistentVolumeClaim (EFS 기반 RWX)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: task-storage-pvc
spec:
  accessModes: [ReadWriteMany]
  storageClassName: efs-sc   # AWS EFS StorageClass
  resources:
    requests:
      storage: 1Gi
# FastAPI·Celery Worker Deployment 모두 동일 PVC 마운트
volumeMounts:
  - name: task-storage
    mountPath: /tmp/ai_tasks
volumes:
  - name: task-storage
    persistentVolumeClaim:
      claimName: task-storage-pvc
장점 단점
독립 스케일링 가능 AWS EFS 등 RWX 스토리지 추가 비용 (~$0.30/GB/월)
장애 격리 EFS 마운트 레이턴시 (NFS 특성, 수 ms 추가)
StorageClass·PV·PVC 3개 k8s 리소스 추가 관리
코드 수정 + 인프라 수정 모두 필요

Option 3: 전체 Redis 통일 (채택) ✅

get_legacy_task_storage()도 Redis를 사용하도록 변경한다. Redis는 이미 4개 용도(세션·Celery 브로커·결과 백엔드·CeleryTaskQueue 메타데이터)로 운영 중이며, dev 환경에도 Redis가 실행 중이다.

RedisTaskStore 구현 (app/utils/task_store.py 추가):

class RedisTaskStore:
    """Redis 기반 Task 저장소 — FileTaskStore 드롭인 교체.

    FileTaskStore와 동일한 save() / get() / delete() 인터페이스를 제공하여
    호출 측 코드(text_extract.py, masking.py, task.py) 변경 불필요.
    """

    def __init__(self, redis_url: str, ttl: int = 86400, prefix: str = "legacy_task:"):
        import redis as _redis
        self._redis = _redis.from_url(redis_url, decode_responses=True)
        self._ttl = ttl
        self._prefix = prefix

    def _key(self, task_id: str) -> str:
        return f"{self._prefix}{task_id}"

    def save(self, task_id: str, data: dict[str, Any]) -> None:
        serializable = data.copy()
        if "created_at" in serializable and isinstance(serializable["created_at"], datetime):
            serializable["created_at"] = serializable["created_at"].isoformat()
        self._redis.setex(
            self._key(task_id), self._ttl, json.dumps(serializable, ensure_ascii=False)
        )

    def get(self, task_id: str) -> dict[str, Any] | None:
        raw = self._redis.get(self._key(task_id))
        if raw is None:
            return None
        data = json.loads(raw)
        if "created_at" in data and isinstance(data["created_at"], str):
            data["created_at"] = datetime.fromisoformat(data["created_at"])
        return data

    def delete(self, task_id: str) -> None:
        self._redis.delete(self._key(task_id))

get_legacy_task_storage() 분기 추가 (app/config/dependencies.py):

_redis_task_storage_instance = None  # 전역 싱글톤 추가

def get_legacy_task_storage(settings: Settings = Depends(get_settings)):
    """Task 저장소 — Redis 우선, Redis 미설정 시 파일 폴백.

    Redis URL이 설정된 환경(dev 포함)에서는 RedisTaskStore를 반환한다.
    Redis가 없는 순수 로컬 환경에서만 FileTaskStore로 폴백한다.
    """
    if settings.redis_url:
        from app.utils.task_store import RedisTaskStore
        global _redis_task_storage_instance
        if _redis_task_storage_instance is None:
            _redis_task_storage_instance = RedisTaskStore(
                redis_url=settings.redis_url,  # DB 0, prefix "legacy_task:"으로 세션과 네임스페이스 분리
                ttl=settings.redis_task_ttl,   # 기본 86400초 (24시간)
            )
        return _redis_task_storage_instance

    # Redis 없는 순수 로컬 환경 폴백
    from app.utils.task_store import FileTaskStore
    global _task_storage_instance
    if _task_storage_instance is None:
        _task_storage_instance = FileTaskStore(storage_dir=settings.task_storage_dir)
    return _task_storage_instance

Redis DB 키 네임스페이스 (DB 0 내 충돌 없음):

Redis DB 0
  ├── "session:{key}"        ← RedisSessionStore (ADR-123)
  └── "legacy_task:{task_id}" ← RedisTaskStore (신규, 이번 ADR)

Redis DB 1  ← Celery 브로커
Redis DB 2  ← Celery 결과 백엔드 + CeleryTaskQueue 메타데이터 ("ai_task:{task_id}")
장점 단점
인프라 추가 없음 — 기존 Redis 재사용 Redis 의존도 소폭 증가
독립 스케일링 · 장애 격리 (k8s 분리 Deployment) — (dev 이미 Redis 운영 중, 실질 영향 없음)
코드 수정 2개 파일, 클래스 1개 추가
dev/nonprod/prod 동일 코드 경로
TTL 24시간 자동 만료 (파일 누적 없음)
ADR-126 ElastiCache HA로 Redis 가용성 보장

✅ 결정 (Decision)

Option 3 채택: get_legacy_task_storage()를 Redis 기반으로 통일한다.

근거:

  1. 인프라 추가 없음 — Redis는 이미 4개 용도로 운영 중. EFS 등 RWX 스토리지를 신규 도입하지 않아도 된다.
  2. 코드 수정 최소화 — 파일 2개, 클래스 1개 추가. FileTaskStore와 동일한 인터페이스를 유지하므로 호출 측(text_extract.py, masking.py, task.py) 변경 불필요.
  3. 모든 환경 일관성 — dev/nonprod/prod가 동일 코드 경로(Redis)를 타므로 환경별 버그 가능성 제거. dev도 Redis가 이미 실행 중이라 동작 차이 없음.
  4. k8s 분리 Deployment 지원 — FastAPI·Celery Worker를 독립 파드로 자유롭게 스케일링·배포 가능. PV/PVC 불필요.
  5. ADR-126 ElastiCache HA — Redis 자체가 Replication Group으로 고가용성 보장되어 단일 장애점 우려 없음.
  6. 자동 TTL 만료redis.setex(86400) 적용으로 완료 태스크 파일이 /tmp에 누적되는 문제도 동시 해결.

단일 파드 멀티 컨테이너를 선택하지 않은 이유: 스케일링 유연성과 장애 격리가 k8s의 핵심 장점인데, 멀티 컨테이너 단일 파드는 이 장점을 포기한다. 코드 수정 없이 파일 공유 문제를 해결하는 것처럼 보이지만, 장기적으로 운영 부채가 더 크다.

환경별 task 저장소 사용 현황 (마이그레이션 후):

구성 요소 dev nonprod prod
get_session_store() InMemory InMemory Redis DB0
get_task_queue() FileTaskQueue FileTaskQueue CeleryTaskQueue (Redis DB1/2)
get_legacy_task_storage() Redis DB0 Redis DB0 Redis DB0 ← ✅ 통일

📊 결과 (Consequences)

긍정적 효과:

  • k8s 분리 Deployment 환경에서 FastAPI·Celery Worker 간 태스크 상태 공유 정상 동작
  • FastAPI HPA와 Celery Worker 레플리카를 독립적으로 조정 가능
  • 완료 태스크가 /tmp에 영구 누적되는 문제 해결 (TTL 24시간 자동 만료)
  • dev/nonprod/prod 동일 코드 경로 → QA 신뢰도 향상

부정적 영향 / 트레이드오프:

항목 기존 변경 후
Redis 미연결 시 동작 파일 기반으로 정상 동작 FileTaskStore 폴백 (redis_url 미설정 시)
태스크 데이터 영구성 서버 재시작 시 /tmp 소실 TTL 24h 내 Redis에 유지
추가 인프라 없음 없음 (기존 Redis 재사용)
코드 수정 범위 파일 2개, 클래스 1개 추가

후속 작업:

  • app/utils/task_store.pyRedisTaskStore 클래스 추가
  • app/config/dependencies.pyget_legacy_task_storage() Redis 분기 추가 + _redis_task_storage_instance 전역 변수 선언
  • k8s FastAPI Deployment manifest — REDIS_URL 환경변수 주입 확인
  • k8s Celery Worker Deployment manifest — REDIS_URL 환경변수 주입 확인 (Celery 브로커와 동일 값)
  • 통합 테스트: 분리 파드 환경에서 OCR 태스크 제출 → 상태 폴링 정상 동작 확인
  • FileTaskStore는 Redis 없는 순수 로컬 환경 fallback으로 유지 (삭제 불필요)

이력

날짜 변경 내용
2026-03-16 초기 작성 — k8s CI/CD 파이프라인 도입 시 FileTaskStore 공유 문제 해결 방안