[AI] test_redis - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

ADR 101-105: 검색 고도화 및 서비스 확장

작성일: 2026-02-26 상태: 승인됨 (Accepted)


📚 목차


ADR-101: Tavily Search API 도입 — 채용 트렌드 Phase 2 크롤링 고도화

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-26
결정자 AI팀
관련 기능 채용 트렌드 크롤링, VectorDB 적재, Celery 스케줄링
관련 ADR ADR-094 (WebBaseLoader 크롤링 Phase 1), ADR-095 (크롤링 로드맵), ADR-096 (Celery/Redis)

🎯 컨텍스트 (Context)

ADR-095에서 채용 트렌드 크롤링을 2단계 로드맵으로 수립했다.

Phase 1 (ADR-094, 완료) 한계

항목 문제점 영향
URL 수동 관리 TREND_CRAWL_URLS 환경변수에 직접 URL 나열 필요 새 채용 트렌드 URL 발견 불가
JS 렌더링 불가 WebBaseLoader는 정적 HTML만 파싱 사람인, 잡코리아 등 SPA 사이트 수집 불가
메타데이터 사후 추출 LLM으로 텍스트에서 메타데이터 추출 추출 실패율 + Gemini API 비용 발생
레이트 제한 없음 서버 직접 요청 → IP 차단 위험 안정성 낮음

Phase 2 목표 (ADR-101, 이번 결정)

  • 검색 쿼리 기반 자동 발견: TREND_CRAWL_QUERIES에 검색어만 설정하면 관련 URL 자동 발견
  • 메타데이터 자동 제공: Tavily가 title, URL, relevance score 자동 반환 → LLM 추출 불필요
  • JS 렌더링 지원: search_depth=advanced로 SPA 사이트 본문 추출
  • 기존 Phase 1 병행: URL 기반 WebBaseLoader 크롤링은 그대로 유지 (하위 호환)

🔍 선택지 분석 (Options)

Option 1: LangChain Tavily 통합 (langchain-tavily) ⭐ 권장

from langchain_tavily import TavilySearch

tool = TavilySearch(
    api_key=settings.tavily_api_key,
    max_results=5,
    search_depth="advanced",  # 전체 본문 추출
    include_raw_content=True,
)
results = await tool.ainvoke({"query": "2026 백엔드 채용 트렌드"})
장점 단점
✅ LangChain 생태계 일관성 ⚠️ 월 $49-149 비용 (무료 티어 포함)
✅ 검색 + 본문 추출 일체형 ⚠️ TAVILY_API_KEY 환경변수 필수
✅ 메타데이터 자동 제공 -
✅ JS 렌더링 지원 (advanced) -
✅ 중복 필터링 내장 -

Option 2: Tavily Python SDK (tavily-python) 직접 사용

from tavily import AsyncTavilyClient

client = AsyncTavilyClient(api_key="...")
results = await client.search("쿼리", search_depth="advanced")

기각: LangChain Tavily가 동일 기능 제공, 생태계 일관성 저하.

Option 3: SerpAPI / Bing Search API 사용

기각: 채용 트렌드 특화 검색 품질 및 본문 추출 기능 미흡.


✅ 결정 (Decision)

Option 1 채택langchain-tavily 패키지를 통한 Tavily Search API 통합.

구현 전략:

기존 Phase 1 유지                  신규 Phase 2 추가
────────────────────────────────   ──────────────────────────────────
TREND_CRAWL_URLS (URL 목록)    →   TREND_CRAWL_QUERIES (검색 쿼리 목록)
WebBaseLoader 크롤링            →   Tavily 검색 + 본문 추출
LLM 메타데이터 추출             →   Tavily 메타데이터 자동 제공
/crawl/trend (POST)             →   /crawl/trend/search (POST) 추가
crawl-trend-weekly (Beat)       →   search-trend-weekly (Beat) 추가 (30분 후)
metadata.source = "webbaseloader" → metadata.source = "tavily"

병행 운영: TREND_CRAWL_URLS 미설정 → Phase 1 스킵, TREND_CRAWL_QUERIES 미설정 → Phase 2 스킵. 두 모드 독립적으로 동작.


📁 구현 파일

파일 변경 내용
pyproject.toml 수정 langchain-tavily = "^0.1.0" 추가
app/config/settings.py 수정 Tavily 설정 4개 필드 추가 (ADR-101 섹션)
app/services/tavily_search_service.py 신규 TavilySearchService 클래스
app/services/trend_crawler_service.py 수정 Phase 2 메서드 추가 (get_configured_queries, search_by_queries, trigger_tavily_task)
app/tasks/trend_tasks.py 수정 search_trend_queries_task Celery 태스크 추가
app/tasks/celery_app.py 수정 search-trend-weekly Beat 스케줄 추가
app/api/routes/v2/crawl.py 수정 3개 엔드포인트 추가 (/crawl/trend/search, /crawl/trigger/search, /crawl/queries)

🔌 신규 API 엔드포인트

메서드 경로 설명
POST /ai/crawl/trend/search Tavily 쿼리 검색 + 적재 (수동)
POST /ai/crawl/trigger/search Tavily Celery 태스크 수동 트리거
GET /ai/crawl/queries TREND_CRAWL_QUERIES 환경변수 조회

POST /ai/crawl/trend/search 요청/응답:

// 요청
{
  "queries": ["2026 백엔드 채용 트렌드", "AI 엔지니어 채용 공고"],
  "ingest_multi_vector": false
}

// 응답
{
  "total_urls": 10,
  "success_count": 9,
  "documents": [
    {"id": "uuid", "text_length": 3420, "metadata": {"source": "tavily", "title": "...", "score": 0.87}}
  ],
  "multi_vector_ingested": 0
}

에러 응답:

상황 HTTP code
TAVILY_API_KEY 미설정 503 TAVILY_UNAVAILABLE
검색 중 Tavily API 오류 500 -

📊 Phase 1 vs Phase 2 비교

항목 Phase 1 (ADR-094) Phase 2 (ADR-101)
입력 URL 목록 (TREND_CRAWL_URLS) 검색 쿼리 (TREND_CRAWL_QUERIES)
URL 발견 수동 설정 자동 발견
JS 렌더링 ❌ 불가 ✅ 가능 (advanced)
메타데이터 LLM 사후 추출 Tavily 자동 제공
비용 $0 월 $0~$149 (사용량에 따라)
안정성 서버 차단 위험 보장된 레이트 제한
source 태그 webbaseloader tavily
Beat 스케줄 crawl-trend-weekly (월 09:00) search-trend-weekly (월 09:30)

⚙️ 환경변수 설정 가이드

# Tavily Search API (필수)
TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 검색 설정 (선택, 기본값 있음)
TAVILY_SEARCH_DEPTH=advanced        # basic(빠름, 요약) / advanced(전체 본문)
TAVILY_MAX_RESULTS=5                # 쿼리당 최대 결과 수

# 검색 쿼리 목록 (쉼표 구분)
TREND_CRAWL_QUERIES=2026 백엔드 채용 트렌드,AI 엔지니어 채용 공고,주요 IT 기업 기술 면접 트렌드,프론트엔드 개발자 채용 동향

# Phase 1 URL 크롤링도 병행 가능 (선택)
TREND_CRAWL_URLS=https://example.com/tech-trends,...

Tavily API 키 발급: https://tavily.com

  • 무료 플랜: 월 1,000 크레딧 (advanced 검색 1회 ≈ 1~5 크레딧)
  • 기본 플랜: 월 $49 (무제한)

📊 결과 (Consequences)

긍정적 영향:

  • 운영 편의성 향상: URL 수동 관리 → 검색 쿼리만 설정하면 자동 발견
  • 수집 품질 향상: JS 렌더링 지원으로 SPA 사이트 본문 수집 가능
  • LLM 비용 절감: 메타데이터 추출용 Gemini 호출 제거 (Tavily 자동 제공)
  • 기존 호환 유지: Phase 1 그대로 병행 운영, 기존 API 미변경

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

  • 추가 비용: Tavily API 월 $0~$149 (쿼리 수 및 검색 깊이에 따라)
  • 외부 의존성 추가: Tavily 서비스 장애 시 Phase 2 크롤링 불가 (Phase 1은 영향 없음)
  • 환경변수 추가: TAVILY_API_KEY 미설정 시 503 에러 → AWS Parameter Store 등록 필요

후속 작업:

  • langchain-tavily 패키지 추가 (pyproject.toml)
  • TavilySearchService 구현 (app/services/tavily_search_service.py)
  • TrendCrawlerService Phase 2 메서드 추가
  • Celery 태스크 및 Beat 스케줄 추가
  • API 엔드포인트 추가 (/crawl/trend/search)
  • AWS Parameter Store /Dev/AI/TAVILY_API_KEY 등록
  • TREND_CRAWL_QUERIES 환경변수 설정 (채용 트렌드 쿼리 목록)
  • 첫 실행 후 ChromaDB trend_data 컬렉션 source=tavily 문서 확인
  • poetry.lock 업데이트 (poetry lock --no-update)

이력

날짜 변경 내용
2026-02-26 초기 작성 — ADR-095 Phase 2 구체화, Tavily Search API 도입 결정. langchain-tavily 통합, 신규 API 3개 추가, Celery Beat 스케줄 추가.

ADR-102: CPU-bound 비동기 태스크 Celery 이관 — text_extract/masking 504 타임아웃 해결

📋 메타데이터

항목 내용
상태 🟡 제안됨 (Proposed)
작성일 2026-02-26
결정자 AI팀
관련 기능 이력서/채용공고 텍스트 추출, 개인정보 마스킹, 면접 QA 적재
관련 ADR ADR-026 (Gunicorn+Uvicorn), ADR-094 (Celery 도입), ADR-096 (Redis/ElastiCache)

🎯 컨텍스트 (Context)

프로덕션 환경에서 504 Gateway Timeout 오류가 빈번하게 발생하고 있다.

현재 상태

# app/api/routes/v2/text_extract.py (478번 줄)
asyncio.create_task(process_text_extract(task_storage))

# app/api/routes/v2/masking.py (206번 줄)
task = asyncio.create_task(process_masking(task_storage))

# app/api/routes/v2/evaluation.py (208번 줄)
asyncio.create_task(_ingest_interview_qa_best_effort(request))
┌─────────────────────────────────────────────────────────────────┐
│  현재 아키텍처 (문제 상황)                                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [Client]                                                       │
│      │                                                          │
│      ▼                                                          │
│  [Uvicorn 단일 Worker] ← 이벤트 루프 1개                         │
│      │                                                          │
│      ├─ POST /text-extract → asyncio.create_task(OCR 처리)      │
│      │      ↓                                                   │
│      │   [OCR + LLM 호출] ← CPU-bound, 30초~2분 소요             │
│      │      ↓                                                   │
│      │   이벤트 루프 블로킹! ❌                                   │
│      │                                                          │
│      ├─ GET /task/{id} → 폴링 요청 (상태 확인)                   │
│      │      ↓                                                   │
│      │   대기... 대기... 504 Timeout! ❌                         │
│      │                                                          │
│      └─ 다른 요청들도 모두 블로킹                                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Dockerfile 현재 설정

# Uvicorn 단독, 단일 프로세스
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

문제점:

  • asyncio.create_task()동일 이벤트 루프에서 실행됨
  • OCR/LLM 호출은 CPU-bound 또는 긴 외부 API 호출 (30초~2분)
  • 단일 Uvicorn Worker에서 해당 작업 실행 중 다른 요청 처리 불가
  • 폴링 요청(GET /ai/task/{id})도 같은 Worker에 도착 시 대기 → 504 Timeout

요구사항:

  • CPU-bound 작업을 별도 프로세스에서 실행하여 이벤트 루프 블로킹 방지
  • 기존 폴링 API (GET /ai/task/{id}) 호환 유지
  • 이미 구축된 Celery/Redis 인프라 활용 (ADR-094, ADR-096)

🔍 선택지 분석 (Options)

Option 1: Docker Replicas 증가 (수평 스케일링)

# docker-compose.yml
ai-endpoint:
  ...
  deploy:
    replicas: 4  # 컨테이너 4개로 증가
┌─────────────────────────────────────────────────────────────────┐
│  Option 1: Docker Replicas                                      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [Load Balancer]                                                │
│      ├─ Container 1 (Uvicorn) ← OCR 처리 중 (블로킹)             │
│      ├─ Container 2 (Uvicorn) ← 폴링 요청 처리 가능 ✅           │
│      ├─ Container 3 (Uvicorn) ← 다른 요청 처리 가능 ✅           │
│      └─ Container 4 (Uvicorn) ← 다른 요청 처리 가능 ✅           │
│                                                                 │
│  장점: 설정 간단, 즉시 적용 가능                                  │
│  단점: 메모리 4배 증가, 각 컨테이너 내부는 여전히 블로킹           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
장점 단점
설정 간단 (1줄 추가) 메모리 사용량 N배 증가
즉시 적용 가능 각 컨테이너 내부는 여전히 블로킹
기존 코드 변경 없음 동시 요청 수 제한 (컨테이너 수만큼)

Option 2: Gunicorn + Uvicorn Workers (수직 스케일링) — 비컨테이너 환경 전용

⚠️ Docker/Kubernetes 환경에서는 불필요: Gunicorn은 단일 서버에서 멀티 프로세스를 관리하는 도구입니다. Docker Compose, Kubernetes 등 컨테이너 오케스트레이션 환경에서는 replicas로 수평 확장하므로 Gunicorn이 필요 없습니다. Docker가 프로세스 관리/재시작을 대신 담당합니다.

# 비컨테이너 환경 (단일 서버 직접 배포 시)
gunicorn app.main:app \
  --workers 4 \
  --worker-class uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000
┌─────────────────────────────────────────────────────────────────┐
│  Option 2: Gunicorn + Uvicorn Workers (비컨테이너 환경)          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [Gunicorn Master]                                              │
│      ├─ Worker 1 (Uvicorn) ← OCR 처리 중 (블로킹)                │
│      ├─ Worker 2 (Uvicorn) ← 폴링 요청 처리 가능 ✅              │
│      ├─ Worker 3 (Uvicorn) ← 다른 요청 처리 가능 ✅              │
│      └─ Worker 4 (Uvicorn) ← 다른 요청 처리 가능 ✅              │
│                                                                 │
│  적용 환경: Docker/Kubernetes 없이 단일 서버에 직접 배포 시       │
│  현재 프로젝트: Docker Compose 사용 → Option 1 (replicas) 적용   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
장점 단점
메모리 효율적 (프로세스 간 공유) gunicorn 의존성 추가 필요
단일 서버에서 멀티 프로세스 관리 각 Worker 내부는 여전히 블로킹
Worker 재시작 (장애 복구) Docker 환경에서는 불필요

현재 프로젝트 적용 여부: ❌ 미적용 (Docker Compose 사용 중이므로 replicas로 대체)


Option 3: Celery 태스크 이관 (근본 해결) ⭐ 권장

# 현재 (문제)
asyncio.create_task(process_text_extract(task_storage))

# 개선 (Celery)
from app.tasks.text_extract_tasks import process_text_extract_task
process_text_extract_task.delay(task_id, request_data)
┌─────────────────────────────────────────────────────────────────┐
│  Option 3: Celery 태스크 이관 (권장)                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [Client]                                                       │
│      │                                                          │
│      ▼                                                          │
│  [FastAPI / Uvicorn]                                            │
│      │                                                          │
│      ├─ POST /text-extract                                      │
│      │      ↓                                                   │
│      │   task.delay(task_id, data) → Redis Queue 전송           │
│      │      ↓                                                   │
│      │   즉시 202 Accepted 반환 ✅ (블로킹 없음)                 │
│      │                                                          │
│      ├─ GET /task/{id} → Redis에서 상태 조회                    │
│      │      ↓                                                   │
│      │   즉시 응답 ✅ (블로킹 없음)                              │
│      │                                                          │
│  [Celery Worker] (별도 프로세스/컨테이너)                        │
│      │                                                          │
│      └─ process_text_extract_task 실행                          │
│             ↓                                                   │
│          OCR + LLM 호출 (CPU-bound, 30초~2분)                   │
│             ↓                                                   │
│          Redis에 결과 저장                                       │
│                                                                 │
│  장점: 이벤트 루프 완전 분리, 기존 인프라 활용                    │
│  단점: 코드 리팩토링 필요, 태스크 직렬화 고려                     │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
장점 단점
이벤트 루프 완전 분리 (근본 해결) 코드 리팩토링 필요
기존 Celery/Redis 인프라 활용 (ADR-094, 096) 태스크 인자 직렬화 고려 필요
독립 스케일링 (Worker 수 조절) 디버깅 복잡도 증가
재시도/실패 처리 내장 -
트렌드 크롤링과 동일 패턴 -

✅ 결정 (Decision)

Option 3 (Celery 태스크 이관)을 선택합니다.

단, 단계적 적용을 권장합니다:

단계 내용 효과
Phase 0 (즉시) Docker replicas 2~4개 증가 임시 완화
Phase 1 (단기) text_extract, masking Celery 이관 504 근본 해결
Phase 2 (중기) evaluation QA 적재 Celery 이관 완전 분리

ℹ️ Gunicorn 미적용: 현재 Docker Compose 환경에서는 replicas로 수평 확장하므로 Gunicorn + Uvicorn Workers 구성이 불필요합니다. Gunicorn은 Docker/Kubernetes 없이 단일 서버에 직접 배포할 때 사용합니다.

근거:

  1. 근본 원인 해결: asyncio.create_task()의 이벤트 루프 블로킹 문제를 완전히 해결
  2. 기존 인프라 활용: ADR-094, ADR-096에서 이미 Celery/Redis 구축 완료
  3. 일관된 패턴: 트렌드 크롤링(trend_tasks.py)과 동일한 패턴 적용
  4. 독립 스케일링: CPU-bound 작업량에 따라 Celery Worker만 증설 가능

🎯 최종 구현 요약 (한눈에 보기)

핵심 변경 사항

┌─────────────────────────────────────────────────────────────────┐
│  🔴 현재 (504 타임아웃 발생)                                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  POST /text-extract 요청                                        │
│      ↓                                                          │
│  FastAPI (Uvicorn 단일 Worker)                                  │
│      ↓                                                          │
│  asyncio.create_task(process_text_extract)  ← 같은 이벤트 루프   │
│      ↓                                                          │
│  OCR + LLM 처리 (30초~2분) ← 이벤트 루프 블로킹!                 │
│      ↓                                                          │
│  GET /task/{id} 폴링 요청 → 대기... → 504 Timeout ❌             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

                            ↓ 변경 후 ↓

┌─────────────────────────────────────────────────────────────────┐
│  🟢 개선 (504 해결)                                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  POST /text-extract 요청                                        │
│      ↓                                                          │
│  FastAPI (Uvicorn)                                              │
│      ↓                                                          │
│  task.delay(task_id, data) → Redis Queue 전송 (즉시 반환) ✅     │
│      ↓                                                          │
│  202 Accepted 응답 (블로킹 없음)                                 │
│                                                                 │
│  [별도 프로세스] Celery Worker                                   │
│      ↓                                                          │
│  OCR + LLM 처리 (30초~2분) ← FastAPI와 완전 분리                 │
│      ↓                                                          │
│  Redis에 결과 저장                                               │
│                                                                 │
│  GET /task/{id} 폴링 요청 → Redis 조회 → 즉시 응답 ✅            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

구체적 코드 변경 예시

Before (text_extract.py):

# 478번 줄 — 문제의 코드
asyncio.create_task(process_text_extract(task_storage))
return {"task_id": task_id, "status": "processing"}

After (text_extract.py):

# Celery 태스크로 변경
from app.tasks.text_extract_tasks import process_text_extract_task

# Redis에 태스크 등록 (즉시 반환, 블로킹 없음)
process_text_extract_task.delay(
    task_id=task_id,
    file_urls=request.file_urls,
    ocr_provider=request.ocr_provider,
    # ... 직렬화 가능한 데이터만 전달
)
return {"task_id": task_id, "status": "processing"}

신규 파일 (text_extract_tasks.py):

from app.tasks.celery_app import celery_app

@celery_app.task(bind=True, name="app.tasks.text_extract_tasks.process_text_extract_task")
def process_text_extract_task(self, task_id: str, file_urls: list, ocr_provider: str, ...):
    """텍스트 추출 Celery 태스크 — 별도 Worker 프로세스에서 실행."""
    # 기존 process_text_extract 로직을 여기로 이동
    # Redis에 진행 상태 업데이트
    # 완료 시 Redis에 결과 저장

docker-compose.yml 변경

# 기존 celery-worker (트렌드 크롤링 전용)
celery-worker:
  command: celery -A app.tasks.celery_app worker --loglevel=info -Q trend_crawl

# 추가: text_extract, masking 전용 Worker
celery-worker-extract:
  build:
    context: .
    dockerfile: Dockerfile
  image: ai-endpoint:v2
  container_name: celery_worker_extract
  command: celery -A app.tasks.celery_app worker --loglevel=info -Q text_extract,masking,evaluation
  environment:
    # ... (ai-endpoint와 동일한 환경변수)
  depends_on:
    - vectordb
  networks:
    - ai_network

요약: 무엇이 바뀌나?

항목 현재 변경 후
OCR/LLM 실행 위치 FastAPI 프로세스 내부 Celery Worker (별도 프로세스)
POST /text-extract 응답 작업 시작 후 반환 즉시 반환 (Redis에 등록만)
GET /task/{id} 응답 블로킹 위험 즉시 응답 (Redis 조회만)
504 타임아웃 발생 해결
docker-compose.yml celery-worker 1개 celery-worker-extract 추가

📁 구현 파일

파일 변경 내용
app/tasks/text_extract_tasks.py 신규 process_text_extract_task Celery 태스크
app/tasks/masking_tasks.py 신규 process_masking_task Celery 태스크
app/tasks/evaluation_tasks.py 신규 ingest_interview_qa_task Celery 태스크
app/tasks/celery_app.py 수정 새 태스크 등록
app/api/routes/v2/text_extract.py 수정 asyncio.create_tasktask.delay
app/api/routes/v2/masking.py 수정 asyncio.create_tasktask.delay
app/api/routes/v2/evaluation.py 수정 asyncio.create_tasktask.delay
docker-compose.yml 수정 Celery Worker 큐 추가 (-Q text_extract,masking,evaluation)

📊 현재 vs 개선 비교

항목 현재 (asyncio.create_task) 개선 (Celery)
실행 위치 FastAPI 이벤트 루프 (동일 프로세스) Celery Worker (별도 프로세스)
블로킹 ❌ 이벤트 루프 블로킹 ✅ 블로킹 없음
폴링 응답 504 Timeout 위험 즉시 응답
스케일링 Uvicorn Worker 수 제한 Celery Worker 독립 스케일링
재시도 수동 구현 필요 Celery 내장
모니터링 제한적 Flower, Prometheus 연동

📊 Celery 적용 현황

기능 현재 방식 개선 후
트렌드 크롤링 ✅ Celery 태스크 ✅ 유지
text_extract asyncio.create_task() ✅ Celery 태스크
masking asyncio.create_task() ✅ Celery 태스크
evaluation (QA 적재) asyncio.create_task() ✅ Celery 태스크

📊 결과 (Consequences)

긍정적 영향:

  • 504 Timeout 해결: CPU-bound 작업이 별도 프로세스에서 실행되어 이벤트 루프 블로킹 제거
  • 폴링 응답 즉시화: GET /ai/task/{id} 요청이 Redis 조회만으로 즉시 응답
  • 독립 스케일링: 작업량 증가 시 Celery Worker만 증설 (FastAPI 서버 영향 없음)
  • 일관된 아키텍처: 모든 백그라운드 작업이 Celery로 통일

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

  • 코드 리팩토링 필요: 기존 asyncio.create_task() 호출부 수정
  • 태스크 직렬화: 복잡한 객체(예: TaskStorage)는 JSON 직렬화 가능한 형태로 변환 필요
  • 디버깅 복잡도: 분산 시스템 특성상 로그 추적이 복잡해질 수 있음

후속 작업:

  • Phase 0: docker-compose.ymlreplicas: 2 추가 (즉시 완화)
  • Phase 1: text_extract_tasks.py 구현 및 text_extract.py 수정
  • Phase 1: masking_tasks.py 구현 및 masking.py 수정
  • Phase 2: evaluation_tasks.py 구현 및 evaluation.py 수정
  • Celery Worker 큐 분리 (-Q text_extract,masking,evaluation)
  • celery_app.py에 태스크 라우팅 설정 추가
  • docker-compose.ymlcelery-worker-extract 서비스 추가
  • Flower 대시보드 연동 (태스크 모니터링)
  • 프로덕션 배포 후 504 타임아웃 해결 확인

ℹ️ Gunicorn + Uvicorn Workers 전환은 Docker 환경에서 불필요하므로 제외.


이력

날짜 변경 내용
2026-02-26 초기 작성 — 504 타임아웃 원인 분석, asyncio.create_task → Celery 이관 결정. 단계적 적용 계획 수립.
2026-02-26 Phase 0~2 구현 완료 — text_extract, masking, evaluation 태스크 Celery 이관. docker-compose.yml 수정.

ADR-103: Tavily Search API 전체 구현 요약

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-26
결정자 AI팀
관련 기능 채용 트렌드 크롤링 Phase 2, VectorDB 적재, Celery 스케줄링, FastAPI 엔드포인트
관련 ADR ADR-101 (Tavily 도입 결정), ADR-094 (Phase 1 WebBaseLoader), ADR-096 (Celery/Redis)

🎯 목적 (Purpose)

ADR-101에서 Tavily 도입을 결정했다면, ADR-103은 실제로 무엇을 어떻게 구현했는지를 기록한다.

  • ADR-101: 왜 Tavily를 선택했는가 (Decision Record — 선택지 분석, 트레이드오프)
  • ADR-103: 무엇을 어떻게 만들었는가 (Implementation Summary — 클래스 설계, API, 설정)

📁 구현 파일 전체 목록

파일 변경 주요 내용
pyproject.toml 수정 langchain-tavily = "^0.1.0" 의존성 추가
app/config/settings.py 수정 Tavily 설정 4개 필드 추가 (ADR-101 섹션)
app/services/tavily_search_service.py 신규 TavilySearchResult, TavilySearchStats, TavilySearchService
app/services/trend_crawler_service.py 수정 get_configured_queries, search_by_queries, trigger_tavily_task 추가
app/tasks/trend_tasks.py 수정 _get_trend_queries, search_trend_queries_task, _search_queries_async 추가
app/tasks/celery_app.py 수정 search-trend-weekly Beat 스케줄 추가
app/api/routes/v2/crawl.py 수정 3개 엔드포인트 추가 (/crawl/trend/search, /crawl/trigger/search, /crawl/queries)

🏗️ 핵심 설계 결정

1. TavilySearchService — 독립 서비스 클래스

# app/services/tavily_search_service.py

@dataclass
class TavilySearchResult:
    title: str
    url: str
    content: str          # raw_content 우선, content 폴백
    score: float          # relevance score (0.0 ~ 1.0)
    published_date: str | None = None
    query: str = ""       # 어느 쿼리에서 발견됐는지 추적

@dataclass
class TavilySearchStats:
    total_queries: int
    total_results: int          # 중복 제거 전 총 결과 수
    deduplicated_results: int   # 중복 제거 후 결과 수
    failed_queries: list[str]   # 실패한 쿼리 목록

class TavilySearchService:
    def __init__(self, settings=None):
        # TAVILY_API_KEY 미설정 시 ValueError → API에서 503으로 처리
        if not self.settings.tavily_api_key:
            raise ValueError("TAVILY_API_KEY 환경변수가 설정되지 않았습니다.")

    async def search(self, query: str) -> list[TavilySearchResult]:
        """단일 쿼리 검색. langchain_tavily.TavilySearch 사용."""

    async def search_multiple(self, queries: list[str]) -> tuple[list[TavilySearchResult], TavilySearchStats]:
        """asyncio.gather 병렬 검색 + seen_urls set으로 중복 URL 제거."""

설계 포인트:

  • search_multiple(): asyncio.gather(*tasks, return_exceptions=True) — 쿼리 1개 실패해도 나머지 계속 진행
  • include_raw_content=True, search_depth=advanced — 전체 본문 추출 (요약 아닌 원문)
  • SAST: 쿼리/URL 로그에 직접 포함하지 않음

2. 에러 처리 계층

TavilySearchService.__init__()
    └── TAVILY_API_KEY 없음 → ValueError 발생

/crawl/trend/search (FastAPI)
    └── ValueError catch → HTTP 503 + code: "TAVILY_UNAVAILABLE"
    └── 그 외 Exception → HTTP 500

search_trend_queries_task (Celery)
    └── Exception → retry 3회 (countdown=300초)
    └── TREND_CRAWL_QUERIES 비설정 → {"status": "skipped"} 정상 반환

3. ChromaDB 호환성

# trend_crawler_service.py — None 값 제거로 ChromaDB 저장 오류 방지
metadata = {k: v for k, v in metadata.items() if v is not None}

4. Phase 1 / Phase 2 독립 병행

환경변수 미설정 시 동작:
  TREND_CRAWL_URLS 비설정    → Phase 1 스킵 (에러 없음, "no_urls_configured" 반환)
  TREND_CRAWL_QUERIES 비설정 → Phase 2 스킵 (에러 없음, "no_queries_configured" 반환)
  TAVILY_API_KEY 비설정      → Phase 2 API 호출 시 503 반환

Beat 스케줄 타이밍:
  crawl-trend-weekly   → 월요일 09:00 (Phase 1 URL 크롤링)
  search-trend-weekly  → 월요일 09:30 (Phase 2 Tavily 검색, 30분 후 실행)

🔌 신규 API 엔드포인트

메서드 경로 설명
POST /ai/crawl/trend/search Tavily 쿼리 검색 + trend_data 적재 (수동)
POST /ai/crawl/trigger/search Tavily Celery 태스크 수동 트리거
GET /ai/crawl/queries TREND_CRAWL_QUERIES 환경변수 조회

POST /ai/crawl/trend/search 요청/응답:

// 요청
{
  "queries": ["2026 백엔드 채용 트렌드", "AI 엔지니어 채용 공고"],
  "ingest_multi_vector": false
}

// 응답
{
  "total_urls": 10,
  "success_count": 9,
  "documents": [
    {
      "id": "uuid",
      "text_length": 3420,
      "metadata": {"source": "tavily", "title": "...", "score": 0.87}
    }
  ],
  "multi_vector_ingested": 0
}

에러 응답:

상황 HTTP code
TAVILY_API_KEY 미설정 503 TAVILY_UNAVAILABLE
검색 중 Tavily API 오류 500 -

⚙️ 설정 필드 (settings.py)

# ADR-101 섹션 — 4개 필드 추가
tavily_api_key: str = Field(default="", description="...")      # 필수
tavily_search_depth: str = Field(default="advanced", ...)       # basic/advanced
tavily_max_results: int = Field(default=5, ...)                 # 쿼리당 최대 결과 수
trend_crawl_queries: str = Field(default="", ...)               # 쉼표 구분 쿼리 목록

환경변수 설정 예시:

TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TAVILY_SEARCH_DEPTH=advanced
TAVILY_MAX_RESULTS=5
TREND_CRAWL_QUERIES=2026 백엔드 채용 트렌드,AI 엔지니어 채용 공고,주요 IT 기업 기술 면접 트렌드

🔍 코드 리뷰 결과 (2026-02-26)

항목 상태 비고
부분 실패 허용 (asyncio.gather + return_exceptions) ✅ 구현 완료
SAST 준수 (쿼리/URL 로그 미포함) ✅ 구현 완료 # SAST: 주석 명시
ChromaDB None 값 방어 ✅ 구현 완료
503 / TAVILY_UNAVAILABLE 에러 코드 ✅ 구현 완료
TavilySearchService.__init__ 타입 힌트 누락 ⚠️ 개선 권장 settings=Nonesettings: Settings | None = None
settings.py tavily_available property 미구현 ⚠️ 개선 권장 langfuse_available 등 기존 패턴과 불일치

📊 결과 (Consequences)

긍정적 영향:

  • 운영 편의성: URL 수동 관리 불필요 — 검색 쿼리만 설정하면 관련 URL 자동 발견
  • 수집 품질: JS 렌더링 지원(SPA 사이트), raw_content 전체 본문 추출
  • LLM 비용 절감: Tavily 메타데이터 자동 제공으로 Gemini 메타데이터 추출 호출 제거
  • 기존 호환: Phase 1 코드 전혀 수정 없음, 기존 API 미변경

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

  • 추가 비용: Tavily API 월 $0~$149 (쿼리 수 및 advanced 검색 빈도에 따라)
  • 외부 의존성: Tavily 장애 시 Phase 2 크롤링 불가 (Phase 1은 영향 없음)

후속 작업:

  • AWS Parameter Store /Dev/AI/TAVILY_API_KEY 등록
  • TREND_CRAWL_QUERIES 환경변수 설정 (운영 환경)
  • poetry lock --no-update 실행 후 poetry.lock 커밋
  • 첫 실행 후 ChromaDB trend_data 컬렉션 source=tavily 문서 적재 확인
  • TavilySearchService.__init__ 타입 힌트 보완 (코드 리뷰 권장 사항)

이력

날짜 변경 내용
2026-02-26 초기 작성 — ADR-101 결정 기반 전체 구현 완료 요약. TavilySearchService 설계, 에러 처리 계층, API 3개, Celery Beat 추가, 코드 리뷰 결과 포함.

ADR-104: RAG 리랭커 선정 — FlashRank 도입

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-26
결정자 AI팀
관련 기능 RAG 파이프라인, 검색 결과 재정렬, 응답 품질 개선
관련 ADR ADR-069 (앙상블 리트리버 BM25+MMR+RRF), ADR-076 (ParentDocumentRetriever), ADR-091 (LLM-as-Judge)

🎯 컨텍스트 (Context)

ADR-069에서 BM25+MMR 앙상블 리트리버 + RRF 머지를 도입했다. 현재 RAG 파이프라인의 재정렬(rerank) 단계는 단순 top_k 자르기만 수행하고 있어, 의미적 재정렬이 이루어지지 않는다.

현재 상태:

# app/services/rag_service.py:189-193
def _rerank(pairs: list[tuple[str, Document]], top_k: int) -> list[tuple[str, Document]]:
    """ADR-069: 재정렬 단계. 현재는 top_k 자르기만 적용 (cross-encoder 등은 후속)."""
    if top_k <= 0:
        return pairs
    return pairs[:top_k]

현재 RAG 파이프라인 흐름:

User Query
    ↓
Dense (MMR) + Sparse (BM25) 검색
    ↓
RRF Merge (dense=0.7, sparse=0.3)
    ↓
_rerank() ← 🔴 단순 top_k 자르기 (의미적 재정렬 없음)
    ↓
Context → LLM → Response

문제점:

  • RRF 머지 후 순위가 검색 순서 기반이지, 쿼리-문서 의미 유사도 기반이 아님
  • 면접 준비 도메인에서 질문과 무관한 문서가 상위에 위치할 가능성
  • LLM-as-Judge(ADR-091) 평가에서 relevance 점수가 낮은 응답이 발생하는 원인 중 하나

요구사항:

  • RRF 머지 후 쿼리-문서 의미 유사도 기반 재정렬
  • 현재 인프라(CPU 서버, Docker Compose)에서 추가 비용 없이 동작
  • 레이턴시 증가 최소화 (실시간 채팅 응답에 영향 없어야 함)
  • rag_service.py_rerank() 함수만 수정하는 최소 변경

🔍 선택지 분석 (Options)

Option 1: Cross-Encoder 리랭커 (sentence-transformers)

from sentence_transformers import CrossEncoder

model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
scores = model.predict([(query, doc.page_content) for _, doc in pairs])
# 점수순 정렬
장점 단점
가장 높은 정확도 (MS MARCO 벤치마크 1위권) GPU 없으면 매우 느림 (CPU에서 문서 10개 기준 2~5초)
다양한 사전학습 모델 선택 가능 sentence-transformers 패키지 무거움 (~500MB+)
오프라인 동작 (API 불필요) PyTorch 의존성 → Docker 이미지 크기 증가 (~2GB+)
영어 기반 모델은 풍부 한국어 특화 모델 부족 (ms-marco 계열은 영어 중심)

벤치마크 (CPU, 문서 10개 기준):

모델 레이턴시 크기
ms-marco-MiniLM-L-6-v2 ~2.5초 80MB
ms-marco-MiniLM-L-12-v2 ~4.0초 120MB
bge-reranker-base (중국어/영어) ~3.0초 278MB

⚠️ 현재 서버는 GPU 없는 CPU 환경 (AWS EC2 t3/t4). Cross-Encoder 레이턴시가 실시간 채팅에 부적합.


Option 2: Cohere Rerank API

from langchain_cohere import CohereRerank

compressor = CohereRerank(model="rerank-v3.5", top_n=5)
results = compressor.compress_documents(docs, query)
장점 단점
매우 높은 정확도 (rerank-v3.5) 유료 API (월 $0 ~ $83+, 요청당 과금)
다국어 지원 (한국어 포함) COHERE_API_KEY 추가 환경변수 필요
레이턴시 빠름 (API 서버 GPU 처리) 외부 API 의존 → 네트워크 장애 시 리랭킹 불가
LangChain 공식 통합 이미 Gemini + OpenAI + Tavily 3개 외부 API 사용 중 → 의존성 과다

비용 구조:

플랜 비용 호출 제한
Trial 무료 월 100회
Production $0.001/검색 무제한
Enterprise 별도 협의 무제한

⚠️ 채팅 1회당 리랭킹 1회 × 일 평균 100회 = 월 ~3,000회, 약 $3/월. 비용은 적지만 추가 API 키 관리 부담.


Option 3: Jina Reranker API

import requests

url = "https://api.jina.ai/v1/rerank"
headers = {"Authorization": "Bearer jina_xxx"}
payload = {
    "model": "jina-reranker-v2-base-multilingual",
    "query": query,
    "documents": [doc.page_content for _, doc in pairs],
    "top_n": 5,
}
result = requests.post(url, json=payload, headers=headers)
장점 단점
다국어 특화 (jina-reranker-v2-base-multilingual) 유료 API ($0.018/1M 토큰)
한국어 성능 상위권 JINA_API_KEY 추가 환경변수 필요
무료 티어 있음 (월 1M 토큰) LangChain 공식 통합 미비 (직접 HTTP 호출 필요)
코드 검색 리랭킹 지원 (jina-reranker-v2-base-code) 외부 API 의존

⚠️ 무료 티어(1M 토큰)로 커버 가능하나, LangChain 통합이 없어 직접 HTTP 클라이언트 구현 필요.


Option 4: FlashRank 리랭커 ⭐ 권장

from flashrank import Ranker, RerankRequest

ranker = Ranker(model_name="ms-marco-MiniLM-L-12-v2", cache_dir="./flashrank_cache")
rerank_request = RerankRequest(query=query, passages=[{"text": doc.page_content} for _, doc in pairs])
results = ranker.rerank(rerank_request)
# results: [{"text": ..., "score": 0.95, "meta": {...}}, ...]
장점 단점
완전 무료 (Apache 2.0 라이센스) Cross-Encoder 대비 정확도 약간 낮음
CPU에서 매우 빠름 (~50ms, 문서 10개 기준) 한국어 특화 모델은 없음 (영어 중심, 다만 다국어 추론 가능)
경량 (~60MB, ONNX Runtime 기반) 모델 다운로드 필요 (최초 1회, 캐시됨)
설치 간단 (pip install flashrank) -
오프라인 동작 (API 키 불필요) -
LangChain 통합 (FlashrankRerank) -
PyTorch 의존성 없음 (ONNX) -
Docker 이미지 크기 거의 미증가 -

벤치마크 (CPU, 문서 10개 기준):

모델 레이턴시 크기 NDCG@10 (MS MARCO)
ms-marco-MiniLM-L-12-v2 (기본) ~50ms 60MB 0.39
ms-marco-MultiBERT-L-12 (다국어) ~70ms 100MB 0.36
rank-T5-flan (T5 기반) ~120ms 110MB 0.40

FlashRank의 핵심: Cross-Encoder의 정확도를 ONNX 최적화로 CPU에서도 빠르게 달성. PyTorch 없이 onnxruntime만 사용.


🔢 4가지 리랭커 종합 비교

항목 Cross-Encoder Cohere Jina FlashRank
비용 🟢 무료 (로컬) 🔴 유료 API 🟡 무료 티어 (제한) 🟢 무료 (로컬)
CPU 레이턴시 🔴 2~5초 🟢 ~200ms (API) 🟢 ~200ms (API) 🟢 ~50ms
GPU 필요 ⚠️ 권장
정확도 (NDCG@10) 🟢 0.42 🟢 0.41 🟢 0.40 🟡 0.39
한국어 지원 🟡 제한적 🟢 다국어 🟢 다국어 특화 🟡 영어 중심 (다국어 추론)
설치 크기 🔴 ~2GB (PyTorch) 🟢 SDK만 🟡 HTTP 직접 🟢 ~60MB (ONNX)
API 키 필요 ⚠️ COHERE_API_KEY ⚠️ JINA_API_KEY
오프라인
LangChain 통합 ✅ 공식 ❌ 미비 FlashrankRerank
Docker 이미지 🔴 크기 대폭 증가 🟢 변화 없음 🟢 변화 없음 🟢 미미한 증가

✅ 결정 (Decision)

Option 4 (FlashRank)를 선택합니다.

근거:

  1. 비용 제로: 이미 Gemini + OpenAI + Tavily 3개 유료 API를 사용 중. 추가 API 키/비용 부담 없음
  2. CPU 환경 최적: 현재 서버(AWS EC2 t3/t4)에 GPU 없음. Cross-Encoder는 CPU에서 2~5초로 실시간 채팅에 부적합. FlashRank는 ONNX 최적화로 ~50ms
  3. 최소 변경: _rerank() 함수 하나만 수정, pip install flashrank 추가면 완료
  4. Docker 친화: PyTorch 의존성 없음 → Docker 이미지 크기 거의 미증가
  5. 정확도 충분: NDCG@10 0.39로 Cross-Encoder(0.42) 대비 7% 낮지만, RRF 머지 후의 재정렬 목적으로는 충분. 단순 top_k 대비 큰 품질 향상
  6. Langfuse 추적 가능: LLM-as-Judge(ADR-091) 점수로 리랭커 도입 전후 품질 변화를 정량 측정 가능

한국어 성능 보완 전략:

  • FlashRank는 영어 중심 모델이지만, 현재 검색 대상이 이력서/채용공고 (한영 혼합 기술 문서)이므로 영어 모델로도 충분한 리랭킹 효과 기대
  • 향후 한국어 성능이 부족하면 ms-marco-MultiBERT-L-12 (다국어 모델)로 교체 가능 (FlashRank 내에서 모델만 변경)
  • 최종적으로 한국어 전용 리랭킹이 필요하면 Jina Reranker API로 전환 검토 (무료 티어 활용)

📁 구현 파일

파일 변경 내용
pyproject.toml 수정 flashrank 의존성 추가
app/services/rag_service.py 수정 _rerank() 함수에 FlashRank 리랭킹 로직 추가
app/config/settings.py 수정 rag_reranker_model 설정 필드 추가 (선택적)

🔧 구현 예시

변경 전 (_rerank):

def _rerank(pairs: list[tuple[str, Document]], top_k: int) -> list[tuple[str, Document]]:
    """ADR-069: 재정렬 단계. 현재는 top_k 자르기만 적용."""
    if top_k <= 0:
        return pairs
    return pairs[:top_k]

변경 후 (_rerank):

import asyncio
from flashrank import Ranker, RerankRequest

# 모듈 레벨 싱글톤 (모델 로딩 1회만)
_flashrank_ranker: Ranker | None = None

def _get_flashrank_ranker(model_name: str = "ms-marco-MiniLM-L-12-v2") -> Ranker:
    global _flashrank_ranker
    if _flashrank_ranker is None:
        _flashrank_ranker = Ranker(model_name=model_name, cache_dir="./flashrank_cache")
    return _flashrank_ranker

async def _rerank(
    pairs: list[tuple[str, Document]],
    top_k: int,
    query: str = "",
) -> list[tuple[str, Document]]:
    """ADR-104: FlashRank 리랭커로 의미적 재정렬 후 top_k 반환."""
    if top_k <= 0 or not pairs:
        return pairs

    if not query:
        # 쿼리 없으면 기존 방식 (단순 자르기)
        return pairs[:top_k]

    try:
        ranker = _get_flashrank_ranker()
        passages = [
            {"id": idx, "text": doc.page_content[:1000]}
            for idx, (_, doc) in enumerate(pairs)
        ]
        request = RerankRequest(query=query, passages=passages)
        loop = asyncio.get_running_loop()
        results = await loop.run_in_executor(None, ranker.rerank, request)

        # FlashRank 점수순으로 pairs 재정렬
        id_to_score = {r["id"]: r["score"] for r in results}
        scored_pairs = [(id_to_score.get(i, 0.0), pair) for i, pair in enumerate(pairs)]
        scored_pairs.sort(key=lambda x: x[0], reverse=True)
        reranked = [pair for _, pair in scored_pairs[:top_k]]

        logger.info(
            "ADR-104: FlashRank reranked %d → %d docs (top score=%.3f)",
            len(pairs), len(reranked),
            scored_pairs[0][0] if scored_pairs else 0,
        )
        return reranked
    except Exception as e:
        logger.warning("ADR-104: FlashRank failed, fallback to top_k: %s", e)
        return pairs[:top_k]

호출부 변경 (retrieve_context):

# 기존
merged = _rerank(merged, rerank_k)

# 변경
merged = await _rerank(merged, rerank_k, query=query)

📊 기대 효과 (Langfuse 측정 예정)

지표 현재 (top_k 자르기) 도입 후 (FlashRank) 측정 방법
Relevance 점수 ~3.5/5.0 (추정) ~4.0+/5.0 (목표) LLM-as-Judge (ADR-091)
Rerank 레이턴시 0ms (자르기만) ~50ms (FlashRank) Langfuse span
전체 RAG 레이턴시 ~1.5초 ~1.55초 (+50ms) Langfuse trace
Context 품질 RRF 순서 의존 쿼리 의미 기반 정렬 수동 비교

Langfuse 대시보드에서 pipeline_stage=ragjudge_relevance 점수 변화를 A/B 비교 예정.


📊 결과 (Consequences)

긍정적 영향:

  • 검색 품질 향상: 단순 순서 기반 → 쿼리-문서 의미 유사도 기반 재정렬
  • 비용 제로: 로컬 실행, API 키 불필요, Docker 이미지 미미한 증가
  • 레이턴시 미미: CPU에서 ~50ms 추가 — 실시간 채팅에 영향 없음
  • 정량 측정 가능: Langfuse + LLM-as-Judge로 도입 전후 품질 비교
  • 점진적 업그레이드 경로: FlashRank → 다국어 모델 → Jina API 순서로 필요 시 교체 가능

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

  • 한국어 특화 아님: 영어 중심 모델 → 순수 한국어 문서 리랭킹 정확도 제한적
  • 모델 다운로드: 최초 실행 시 ~60MB 모델 다운로드 필요 (이후 캐시)
  • ONNX Runtime 의존: onnxruntime 패키지 추가 (~30MB)

후속 작업:

  • pip install flashrank / pyproject.toml 의존성 추가
  • _rerank() 함수 FlashRank 로직 구현
  • retrieve_context() 호출부 query 인자 전달 수정
  • Langfuse에서 리랭커 도입 전후 judge_relevance 점수 비교
  • 한국어 성능 부족 시 ms-marco-MultiBERT-L-12 다국어 모델로 교체 검토
  • .gitignoreflashrank_cache/ 추가

이력

날짜 변경 내용
2026-02-26 초기 작성 — Cross-Encoder / Cohere / Jina / FlashRank 4가지 리랭커 비교 분석, FlashRank 선정 (비용 제로 + CPU 최적 + 최소 변경).

ADR-105: AI 서비스 Redis 사용 현황 및 AWS ElastiCache 연동 가이드

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-26
결정자 AI팀
관련 팀 AI팀, 클라우드팀 (CL)
관련 기능 세션 저장소, Celery 비동기 큐, 작업 결과 저장, AWS ElastiCache
관련 ADR ADR-096 (Celery/Redis 도입), ADR-102 (Celery 이관)

🎯 컨텍스트 (Context)

CL팀이 AWS ElastiCache 구성 중 클러스터 모드(샤딩) vs 레플리케이션 모드 선택을 논의하면서 AI 서비스의 Redis 사용처를 정리할 필요가 생겼다.

[Slack 논의 요약 — 2026-02-26]
henry.myeong(CL): 레디스 클러스터링이 어디에 쓰이는지 알 수 있을까요?
                  현재 ElastiCache는 클러스터링 비활성화 상태입니다.
estar.yoon(AI):   Redis 쓰는 곳들이 늘어나고 있어서 정리해드릴게요.
henry.myeong(CL): 샤딩(클러스터 모드) vs 다중 노드 레플리카 구분해서 정리 부탁드립니다.
                  (다중 노드 레플리카는 이미 적용, 클러스터 모드는 미적용 상태)

📊 AI 서비스 Redis 사용처 전체 정리

DB 구분

AI 서비스는 Redis DB를 3개로 분리하여 사용한다.

DB 환경변수 용도 TTL 클래스
DB 0 REDIS_URL 채팅·면접 세션 저장소 1시간 RedisSessionStore
DB 1 CELERY_BROKER_URL Celery 메시지 브로커 (비동기 작업 큐) Celery 자동 관리 Celery 내부
DB 2 CELERY_RESULT_BACKEND Celery 작업 결과 저장 24시간 CeleryTaskQueue

🗂️ DB 0 — 세션 저장소

클래스: app/infrastructure/session/redis.pyRedisSessionStore

저장 데이터: 채팅/면접 세션 (JSON 직렬화)

  • 대화 메시지 히스토리, RAG 컨텍스트 참조, 면접 Q&A 상태

키 패턴:

session:chat_{user_id}_{session_id}
session:interview_{user_id}_{session_id}

데이터 크기: 1~50KB (대화 히스토리 포함)

연결 방식:

self._redis = redis.from_url(settings.redis_url, decode_responses=True)
# TTL 갱신: 요청마다 setex(key, 3600, json.dumps(data))

📬 DB 1 — Celery 메시지 브로커 (작업 큐)

설정: app/tasks/celery_app.py

운영 중인 큐 4종:

큐 이름 Worker 컨테이너 용도 실행 빈도
trend_crawl celery-worker-trend 채용 트렌드 크롤링 (ADR-094/101) 주 1회 (Beat)
text_extract celery-worker-extract 이력서/채용공고 OCR + 텍스트 추출 (ADR-102) API 요청마다
masking celery-worker-extract 개인정보 마스킹 (ADR-102) API 요청마다
evaluation celery-worker-extract 면접 Q&A 임베딩 적재 (ADR-102) 면접 완료마다

키 패턴: Celery 내부 자동 관리 (celery-task-{id}, celery.queue.{name})

데이터 크기: 1~10KB (태스크 메시지 메타데이터만 저장)


📦 DB 2 — Celery 작업 결과 저장

클래스: app/infrastructure/queue/celery_queue.pyCeleryTaskQueue

저장 데이터: 비동기 작업 상태 + 결과

# 키 패턴
ai_task:{task_id}

# 저장 구조
{
    "task_id": "extract_abc123",
    "task_type": "text_extract",
    "status": "COMPLETED",    # PENDING / PROCESSING / COMPLETED / FAILED
    "progress": 100,
    "result": { /* OCR 결과 */ },
    "error": null
}

데이터 크기: 5KB~1MB (분석 결과 포함 시)

TTL: 24시간 (setex(key, 86400, ...))

사용 API: GET /ai/v2/task/{task_id} — 폴링으로 상태 조회


🔌 동시 연결 수 예측

┌─────────────────────────────────────────────────────────┐
│  컨테이너별 Redis 연결 수 (최대 기준)                      │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ai-endpoint (replicas: 4)                              │
│  └─ DB 0 (세션): 10~20개                                │
│                                                         │
│  celery-worker-extract (concurrency: 2)                 │
│  ├─ DB 1 (브로커): 2~3개                                │
│  └─ DB 2 (결과):  3~5개                                 │
│                                                         │
│  celery-worker-trend + celery-beat                      │
│  ├─ DB 1: 1~2개                                         │
│  └─ DB 2: 1개                                           │
│                                                         │
│  합계: 18~35개 (ElastiCache default maxclients 65,536)  │
│  → 연결 수 부족 위험 없음                                 │
│                                                         │
└─────────────────────────────────────────────────────────┘

⚠️ 클러스터 모드 전환 시 영향 분석

핵심: Redis Cluster Mode(샤딩)는 DB 0만 지원SELECT 명령 불가

구분 레플리케이션 모드 (현재) 클러스터 모드 (샤딩)
멀티 DB 지원 ✅ DB 0/1/2 모두 사용 가능 ❌ DB 0만 지원
현재 AI 코드 ✅ 변경 없이 동작 ⚠️ 코드 수정 필요
Celery 연동 ✅ 정상 ⚠️ 설정 변경 필요

클러스터 모드 전환 시 필요한 AI 코드 변경:

# 현재: DB별로 분리
REDIS_URL             = redis://host:6379/0
CELERY_BROKER_URL     = redis://host:6379/1
CELERY_RESULT_BACKEND = redis://host:6379/2

# 클러스터 모드 전환 후: DB 0으로 통합 + 키 prefix로 분리
REDIS_URL             = redis://host:6379/0  # 그대로
CELERY_BROKER_URL     = redis://host:6379/0  # DB 1 → DB 0 변경
CELERY_RESULT_BACKEND = redis://host:6379/0  # DB 2 → DB 0 변경

# Celery 키 prefix 설정 추가
celery_app.conf.update(
    result_key_prefix="celery-result:",
    task_default_queue="celery",
)

결론: 현재 레플리케이션 모드(다중 노드 레플리카)는 멀티 DB를 지원하므로 AI 코드 변경 없이 연동 가능. 클러스터 모드(샤딩) 전환 계획 시 반드시 AI팀에 사전 통보 필요.


⚙️ AWS ElastiCache 연동 설정

Parameter Store 등록 값 (Dev 환경 기준)

/Dev/AI/REDIS_URL             = redis://:${AUTH_TOKEN}@${ELASTICACHE_ENDPOINT}:6379/0
/Dev/AI/CELERY_BROKER_URL     = redis://:${AUTH_TOKEN}@${ELASTICACHE_ENDPOINT}:6379/1
/Dev/AI/CELERY_RESULT_BACKEND = redis://:${AUTH_TOKEN}@${ELASTICACHE_ENDPOINT}:6379/2

docker-compose.yml 환경변수 주입 (현재 구조)

# ai-endpoint 서비스
environment:
  - REDIS_URL=${REDIS_URL:-redis://localhost:6379/0}
  - CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://localhost:6379/1}
  - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://localhost:6379/2}

# celery-worker-extract 서비스 (동일하게 주입 필요)
environment:
  - CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://localhost:6379/1}
  - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://localhost:6379/2}

주의: celery-worker-extractai-endpoint 모두에 동일한 Redis URL이 주입되어야 함. Worker가 다른 Redis를 바라보면 태스크 결과를 찾을 수 없음.


📊 결과 (Consequences)

긍정적 영향:

  • 고가용성: ElastiCache 레플리케이션(다중 노드)으로 Redis 장애 시 자동 Failover
  • 운영 부담 감소: AWS 관리형 서비스로 Redis 패치/백업 자동화
  • 성능: ElastiCache는 로컬 Redis 대비 낮은 레이턴시 보장

주의사항:

  • 멀티 DB 의존: AI 서비스는 DB 0/1/2를 분리 사용 → 클러스터 모드 비활성 유지 권장
  • AUTH 토큰 필수: ElastiCache TLS + AUTH 활성화 시 모든 Redis URL에 :auth_token@ 포함 필요
  • VPC 내부 통신: ElastiCache는 VPC 내부에서만 접근 가능 — AI EC2와 동일 VPC/Security Group 설정 확인

후속 작업:

  • AI 서비스 Redis 사용처 현황 파악 및 CL팀 공유
  • AWS Parameter Store /Dev/AI/REDIS_URL, /Dev/AI/CELERY_BROKER_URL, /Dev/AI/CELERY_RESULT_BACKEND 등록
  • ElastiCache 엔드포인트로 배포 후 Celery Worker 태스크 정상 처리 확인
  • 클러스터 모드 전환 계획 시 AI팀 사전 협의 진행

이력

날짜 변경 내용
2026-02-26 초기 작성 — CL팀 Redis 클러스터링 논의 기반, AI 서비스 Redis 사용처(DB 0/1/2) 전체 정리. ElastiCache 레플리케이션 vs 클러스터 모드 영향 분석 포함.