[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
- ADR-127: SSE 클라이언트 연결 해제 감지 —
is_disconnected()+CancelledError패턴 - ADR-128: 폴링 힌트 추가 — 202 응답에
interval_ms/max_attempts포함 - ADR-129: 인터뷰 라우트 코드 품질 고도화 — 헬퍼 추출 / Few-Shot 캐시 / 프롬프트 외부화
- ADR-130: FileTaskStore → RedisTaskStore 마이그레이션 — k8s 멀티 파드 환경 대응
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)은 이력서 업로드 → 면접 준비 파이프라인의 시작점으로, 이 단계가 막히면 전체 서비스가 사용 불가 상태가 됨.
요구사항
- Primary 장애 시 자동 페일오버 — 운영자 개입 없이 수 초 내 복구
- Celery 브로커/백엔드 가용성 향상 — OCR 비동기 처리 연속성 보장
- 코드 변경 최소화 — 기존
redis://URL 기반 연결 가능한 수준 - 스케일링(샤딩) 필요 없음 — 현재 데이터량이 수평 확장이 필요한 수준이 아님
🔍 선택지 분석 (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) ─┘ │
└─────────────────────────────────────────────────────┘
동작 방식:
- 3개 Sentinel이 Primary를 주기적으로 PING
- Primary 응답 없음 → Sentinel quorum(2/3) 동의 → Replica 중 하나를 Primary로 승격
- 클라이언트는 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)
근거:
- 코드 변경 없음 —
redis://URL 형식이 동일하므로settings.py환경변수 값만 교체 - 운영 부담 최소 — Sentinel 노드를 직접 EC2에서 운영하지 않아도 됨 (AWS 완전 관리)
- 현 스케일에 적합 — 면접 앱 특성상 수평 확장(샤딩)이 필요한 데이터량이 아님, Sentinel이면 충분
- 기존 ElastiCache 인프라 일관성 — 이미 ElastiCache를 사용 중이므로 Replication Group 활성화는 설정 변경 수준
- 페일오버 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.0s→5.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 단계 |
요구사항
- 클라이언트 연결 해제 시 즉시 스트리밍 중단 — LLM API 잔여 호출 취소
- 면접 세션 상태를 불필요하게 변경하지 않음
- ASGI 태스크 취소(
asyncio.CancelledError)에 대한 안전한 처리 - 기존 스트림 구조 최소 변경 (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_stream 두 except 블록 |
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.CancelledError를 except Exception으로 잡지 않는 이유:
Python 3.8+에서 asyncio.CancelledError는 BaseException이지 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분이면 두 작업 모두 커버 가능
요구사항
- 클라이언트가 별도 문서 참조 없이 응답 본문만으로 폴링 전략 수립 가능
- 서버-클라이언트 간 폴링 간격 일관성 확보
- 스키마 하위 호환성 유지 (
polling필드 없어도 동작하도록 Optional) - 기존
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 def 내 asyncio.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.py—PollingHint모델 추가 +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를 모두 채택한다:
_helpers.py추출 — 라우트 공용 헬퍼 중앙화- 임베딩 캐시 + 단계적 거리 완화 — Few-Shot 품질 유지 + LLM 비용 절감
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 기반으로 통일한다.
근거:
- 인프라 추가 없음 — Redis는 이미 4개 용도로 운영 중. EFS 등 RWX 스토리지를 신규 도입하지 않아도 된다.
- 코드 수정 최소화 — 파일 2개, 클래스 1개 추가.
FileTaskStore와 동일한 인터페이스를 유지하므로 호출 측(text_extract.py,masking.py,task.py) 변경 불필요. - 모든 환경 일관성 — dev/nonprod/prod가 동일 코드 경로(Redis)를 타므로 환경별 버그 가능성 제거. dev도 Redis가 이미 실행 중이라 동작 차이 없음.
- k8s 분리 Deployment 지원 — FastAPI·Celery Worker를 독립 파드로 자유롭게 스케일링·배포 가능. PV/PVC 불필요.
- ADR-126 ElastiCache HA — Redis 자체가 Replication Group으로 고가용성 보장되어 단일 장애점 우려 없음.
- 자동 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.py—RedisTaskStore클래스 추가 -
app/config/dependencies.py—get_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 공유 문제 해결 방안 |