[TeamBlog] 인프라 전환: 빅뱅 배포 → Docker Redis 클러스터링 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
인프라 전환: 빅뱅 배포 → Docker + Redis 클러스터링
목차
- 개요
- Phase 1: 빅뱅 배포에서 Docker 전환
- Phase 2: Celery와 Redis 도입 결정
- Phase 3: docker run 단독 실행 시 문제 발생
- Phase 4: Docker Compose 도입 및 Replica 문제
- Phase 5: Redis를 통한 Docker 클러스터링 (핵심 인사이트)
- Phase 6: Gunicorn 도입으로 동시성 확보
- Phase 7: Celery Fallback 전략
- Phase 8: SSE Keepalive 문제 해결
- 최종 아키텍처
- 성과 및 배운 점
1. 개요
1.1 프로젝트 배경
면접 AI 서비스를 프로덕션 환경에 배포하면서 겪은 인프라 전환 과정을 기록합니다. 빅뱅 배포 방식에서 Docker 컨테이너화, 그리고 Redis 기반 클러스터링까지 단계별로 발생한 문제와 해결 과정을 정리했습니다.
1.2 해결하고자 한 문제
[초기 문제 상황]
1. 빅뱅 배포 방식의 불안정성 (서버 직접 배포)
2. 504 Gateway Timeout 빈번 발생 (OCR/LLM 처리 중 블로킹)
3. 동시 요청 처리 불가 (단일 프로세스)
4. 배포 시 서비스 중단
1.3 최종 목표
- 컨테이너화: Docker 기반 일관된 배포 환경 구축
- 동시성 확보: 멀티 프로세스로 동시 요청 처리
- 비동기 처리: CPU-bound 작업을 별도 프로세스로 분리
- 고가용성: 장애 시 자동 복구 및 fallback 전략
2. Phase 1: 빅뱅 배포에서 Docker 전환
2.1 기존 빅뱅 배포 방식의 문제점
[빅뱅 배포 방식]
- 단일 인스턴스에 ai, be, fe 모든 서버 배포
[문제 상황 발생]
- 부하 발생 후 서버 다운되는 현상
2.2 Docker 컨테이너화 결정
# Dockerfile
FROM python:3.10-slim
WORKDIR /app
# Poetry 설치
RUN pip install poetry
# 의존성 설치
COPY pyproject.toml poetry.lock ./
RUN poetry install --no-dev
# 소스 코드 복사
COPY . .
# Uvicorn 단독 실행 (초기 버전)
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
2.3 Docker 전환의 이점
| 항목 | 빅뱅 배포 | Docker 배포 |
|---|---|---|
| 환경 일관성 | ❌ 서버마다 다름 | ✅ 이미지로 동일 환경 보장 |
| 배포 속도 | 5~10분 (수동) | 1~2분 (자동화) |
| 롤백 | 수동 복구 | 이전 이미지로 즉시 롤백 |
| 확장성 | 서버 추가 필요 | 컨테이너 복제로 확장 |
3. Phase 2: Celery와 Redis 도입 결정
3.1 도입 배경: 채용 트렌드 크롤링 배치 처리 필요
Docker 전환 후, 채용 트렌드 데이터를 주기적으로 크롤링하여 VectorDB에 적재하는 기능이 필요했습니다.
[요구사항]
1. 채용 트렌드 URL을 주기적으로 크롤링 (매주 월요일 오전 9시)
2. 크롤링한 데이터를 VectorDB에 임베딩하여 적재
3. 사용자 맞춤 추천을 위한 최신 트렌드 데이터 확보
3.2 Celery 도입 이유: 배치 작업 스케줄링
Celery는 Python 기반 분산 태스크 큐 시스템으로, 다음과 같은 기능을 제공합니다:
[Celery의 역할]
┌─────────────────────────────────────────────────────────────────┐
│ Celery 아키텍처 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Celery Beat │ → │ 메시지 큐 │ → │ Celery │ │
│ │ (스케줄러) │ │ (Redis) │ │ Worker │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ │ ▼ │
│ "매주 월요일 9시" "태스크 대기열" "실제 작업 실행" │
│ │
│ 1. Beat가 스케줄에 따라 태스크 생성 │
│ 2. 태스크가 Redis 큐에 등록 │
│ 3. Worker가 큐에서 태스크를 가져와 실행 │
│ │
└─────────────────────────────────────────────────────────────────┘
Celery를 선택한 이유:
| 대안 | 장점 | 단점 | 선택 여부 |
|---|---|---|---|
| Celery + Redis | 분산 처리, 재시도, 모니터링 | 인프라 복잡도 증가 | ✅ 선택 |
| APScheduler | 단순함 | 분산 환경 미지원, 프로세스 종료 시 스케줄 손실 | ❌ |
| cron + API 호출 | 단순함 | 실패 시 재시도 어려움, 상태 추적 불가 | ❌ |
3.3 Redis 도입 이유: Celery의 메시지 브로커
"Redis는 왜 필요한가?"
Celery는 메시지 브로커가 필수입니다. 메시지 브로커는 태스크를 저장하고 전달하는 역할을 합니다.
[Redis의 역할 — 메시지 브로커]
┌─────────────────────────────────────────────────────────────────┐
│ 메시지 브로커가 없다면? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ FastAPI Celery Worker │
│ │ │ │
│ │ "크롤링 해줘" ─────────?───→ │ ← 어디로 전달? │
│ │ │ │
│ 문제: 직접 통신 불가, 상태 저장 불가, 재시도 불가 │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Redis가 메시지 브로커 역할 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ FastAPI Redis (큐) Celery Worker │
│ │ │ │ │
│ │ LPUSH ────────→│ │ │
│ │ "태스크 등록" │ │ │
│ │ │←──────── BRPOP ─────│ │
│ │ │ "태스크 가져오기" │ │
│ │ │ │ │
│ 해결: 비동기 통신, 태스크 영속성, 재시도 가능 │
│ │
└─────────────────────────────────────────────────────────────────┘
Redis가 Celery에서 하는 일:
| 역할 | 설명 | Redis 명령어 |
|---|---|---|
| 메시지 큐 | 태스크를 대기열에 저장 | LPUSH, BRPOP |
| 결과 저장 | 태스크 실행 결과 저장 | SET, GET |
| 상태 추적 | 태스크 상태 (PENDING, SUCCESS, FAILURE) | HSET, HGET |
3.4 Redis DB 분리 설계
단일 Redis 인스턴스에서 DB 번호로 용도를 분리했습니다:
Redis (localhost:6379)
├── DB 0: 세션 저장소 (RedisSessionStore)
│ └── 사용자 세션, 채팅 히스토리
├── DB 1: Celery 브로커 (메시지 큐)
│ └── 태스크 전달 (trend_crawl_task, text_extract_task 등)
└── DB 2: Celery 결과 저장
└── 태스크 실행 결과 (SUCCESS, FAILURE 등)
# app/config/settings.py
class Settings(BaseSettings):
# Redis Configuration
redis_url: str = Field(
default="redis://localhost:6379/0",
description="Redis connection URL (세션 저장소)",
)
# Celery Configuration
celery_broker_url: str = Field(
default="redis://localhost:6379/1",
description="Celery broker URL (메시지 큐)",
)
celery_result_backend: str = Field(
default="redis://localhost:6379/2",
description="Celery result backend URL (결과 저장)",
)
3.5 Celery 태스크 구현 예시
# app/tasks/trend_tasks.py
from app.tasks.celery_app import celery_app
@celery_app.task(
bind=True,
max_retries=3,
default_retry_delay=300, # 5분 후 재시도
)
def crawl_trend_urls_task(self):
"""채용 트렌드 URL 크롤링 태스크."""
try:
urls = _get_trend_urls() # 환경변수에서 URL 파싱
result = asyncio.run(_crawl_urls_async(urls))
return {"status": "success", "crawled_count": len(result)}
except Exception as e:
# 실패 시 자동 재시도
raise self.retry(exc=e)
# app/tasks/celery_app.py
from celery import Celery
from celery.schedules import crontab
celery_app = Celery(
"ai_tasks",
broker=os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/1"),
backend=os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/2"),
)
# Celery Beat 스케줄 설정
celery_app.conf.beat_schedule = {
"crawl-trend-weekly": {
"task": "app.tasks.trend_tasks.crawl_trend_urls_task",
"schedule": crontab(hour=9, minute=0, day_of_week=1), # 매주 월요일 9시
},
}
3.6 초기 도입 시점의 인식
이 시점에서 Celery와 Redis는 채용 트렌드 크롤링 배치 처리를 위해 도입되었습니다.
[초기 인식]
Celery = 배치 작업 스케줄러 (크롤링, 데이터 적재)
Redis = Celery가 동작하기 위한 메시지 브로커
[아직 인식하지 못한 것]
- Redis가 Docker 클러스터링에서 핵심 역할을 할 수 있다는 것
- Celery가 504 타임아웃 해결에 사용될 수 있다는 것
핵심 포인트: Celery와 Redis는 처음에 크롤링 배치 처리를 위해 도입했지만, 이후 Docker 클러스터링과 504 타임아웃 해결에 결정적인 역할을 하게 됩니다. 이미 인프라가 구축되어 있었기 때문에 추가 도입 없이 문제를 해결할 수 있었습니다.
4. Phase 3: docker run 단독 실행 시 문제 발생
4.1 문제 상황: Celery Worker 미실행
클라우드팀(CL팀)이 초기 배포 시 docker run 명령으로 AI 서비스 컨테이너만 단독 실행했습니다.
# CL팀 초기 배포 방식 (문제)
docker run -d --name ai-service ai-server:latest
4.2 증상
[에러 로그]
- "Error querying VectorDB"
- "Retrieved 0 documents"
- 이력서/채용공고 분석 기능 전체 실패
4.3 원인 분석
[docker run 단독 실행 시]
┌─────────────────────────────────────────────────────────────┐
│ ai-service 컨테이너만 실행됨 │
│ │
│ ❌ celery-worker-extract (미실행) │
│ ❌ celery-worker-trend (미실행) │
│ ❌ celery-beat (미실행) │
│ │
│ 결과: Celery 태스크가 Redis 큐에만 쌓이고 처리되지 않음 │
│ → VectorDB에 데이터 적재 안 됨 │
│ → 면접 시작 시 "Retrieved 0 documents" │
└─────────────────────────────────────────────────────────────┘
4.4 해결: docker-compose 사용 필수
# 올바른 배포 방식
docker-compose up -d # ai-service + celery-worker + celery-beat 모두 실행
4.5 교훈
단일 컨테이너 실행의 한계:
docker run은 단일 컨테이너만 실행하므로, 여러 서비스가 협력해야 하는 아키텍처에서는docker-compose가 필수입니다.
5. Phase 4: Docker Compose 도입 및 Replica 문제
5.1 Docker Compose 도입 배경
504 Gateway Timeout 문제를 해결하기 위해 컨테이너 수평 확장을 시도했습니다.
5.2 deploy.replicas 설정 시도
# docker-compose.yml (초기 시도)
services:
ai-endpoint:
image: ai-server:latest
deploy:
replicas: 4 # 컨테이너 4개로 확장 시도
5.3 문제: Swarm 모드에서만 동작
# 실행 결과
$ docker-compose up -d
$ docker ps
CONTAINER ID IMAGE NAMES
abc123 ai-server ai-endpoint # 1개만 실행됨!
deploy.replicas는 Docker Swarm 모드(docker stack deploy)에서만 유효하며, docker-compose up으로 실행 시 무시됩니다.
5.4 오해: "Docker Swarm이 필요하다"
이 문제를 보고 AI팀은 "Docker Swarm이 필요하다"고 오해했습니다.
[AI팀의 오해]
"deploy.replicas가 작동하지 않는다"
↓
"Docker Swarm을 도입해야 한다"
↓
"클러스터링을 위해 오케스트레이션 도구가 필요하다"
5.5 실제 원인
[실제 원인 분석]
1. docker-compose up에서 deploy.replicas는 무시됨 (Swarm 전용)
2. 수평 확장이 필요하면 --scale 옵션 사용 필요
3. 하지만 --scale 사용 시 포트 충돌 문제 발생 (8000:8000 중복)
4. → Nginx/로드밸런서 추가 설정 필요
5. → 현재 아키텍처에 부적합
6. Phase 5: Redis를 통한 Docker 클러스터링 (핵심 인사이트)
6.1 핵심 발견: Redis가 Swarm을 대체할 수 있다
흔한 오해:
"Docker Compose에서 멀티 프로세스/멀티 컨테이너 클러스터링을 하려면 Docker Swarm이 필요하다"
실제:
Redis(ElastiCache)를 중앙 상태 저장소로 사용하면, Docker Swarm 없이 Docker Compose + Gunicorn 멀티 워커만으로도 충분한 클러스터링 효과를 얻을 수 있다.
6.2 왜 Docker Swarm이 필요하다고 생각했는가?
[Docker Swarm이 필요하다고 생각하는 이유]
1. 여러 컨테이너/프로세스 간 상태 공유가 필요
2. 세션, 태스크 상태, 캐시 등이 프로세스마다 다르면 문제
3. → "그래서 오케스트레이션 도구(Swarm/K8s)가 필요하다"
6.3 Redis가 해결하는 것
[실제로 Redis가 해결하는 것]
1. Redis가 중앙 상태 저장소 역할
2. 모든 프로세스가 같은 Redis를 바라봄
3. 세션, 태스크 상태, Celery 큐 모두 Redis에 저장
4. → 프로세스 간 상태 공유 문제 해결
5. → Docker Swarm 없이도 클러스터링 가능
6.4 아키텍처 비교
[Without Redis — Docker Swarm 필요]
┌─────────────┐ ┌─────────────┐
│ Container 1 │ │ Container 2 │
│ (메모리 상태)│ │ (메모리 상태)│ ← 상태 불일치!
└─────────────┘ └─────────────┘
↓ ↓
Docker Swarm (상태 동기화, 서비스 디스커버리)
[With Redis — Docker Compose로 충분]
┌─────────────┐ ┌─────────────┐
│ Gunicorn │ │ Celery │
│ Worker 1~4 │ │ Worker 1~2 │
└──────┬──────┘ └──────┬──────┘
│ │
└───────┬───────────┘
▼
┌─────────────┐
│ Redis │ ← 중앙 상태 저장소
│ (ElastiCache)│ (세션, 태스크, 큐)
└─────────────┘
6.5 Redis가 대체하는 Docker Swarm 기능
| Docker Swarm 기능 | Redis 대체 방식 |
|---|---|
| 서비스 간 상태 공유 | Redis 키-값 저장 |
| 태스크 큐 분산 | Celery + Redis Broker |
| 세션 공유 | Redis 세션 저장소 |
| 헬스체크 기반 재시작 | Docker Compose restart: always + 헬스체크 |
| 로드 밸런싱 | Gunicorn 내부 워커 분배 (단일 컨테이너) |
6.6 Redis DB 분리 전략
Redis (ElastiCache)
├── DB 0: 세션 저장소 (RedisSessionStore)
│ └── 사용자 세션, 채팅 히스토리
├── DB 1: Celery 브로커 (메시지 큐)
│ └── 태스크 전달 (text_extract, masking 등)
└── DB 2: Celery 결과 저장
└── 태스크 실행 결과 (SUCCESS, FAILURE 등)
6.7 Celery의 역할: 인프라 구성에서의 활용
Phase 2에서 Celery를 크롤링 배치 처리용으로 도입했지만, Docker 클러스터링 환경에서 Celery는 더 큰 역할을 합니다.
[Celery가 인프라에서 하는 일]
┌─────────────────────────────────────────────────────────────────┐
│ 1. CPU-bound 작업 분리 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ FastAPI (ai-service) Celery Worker (별도 컨테이너) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 요청 수신 │ ──────→ │ OCR 처리 │ │
│ │ 즉시 응답 반환 │ │ 마스킹 처리 │ │
│ │ (task_id) │ │ 임베딩 처리 │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ → FastAPI는 블로킹 없이 다른 요청 처리 가능 │
│ → 504 Gateway Timeout 해결 │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. 수평 확장 용이 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Worker 1 │ │ Worker 2 │ │ Worker N │ │
│ │ (extract) │ │ (trend) │ │ (...) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Redis │ │
│ │ (큐) │ │
│ └─────────────┘ │
│ │
│ → Worker 컨테이너만 추가하면 처리량 증가 │
│ → 코드 변경 없이 확장 가능 │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. 작업 유형별 큐 분리 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Queue: text_extract,masking,evaluation │
│ ├── celery-worker-extract (-c 2) # 동시 2개 처리 │
│ │ └── OCR, 마스킹, 평가 태스크 │
│ │ │
│ Queue: trend_crawl │
│ ├── celery-worker-trend (-c 1) # 동시 1개 처리 │
│ │ └── 크롤링 태스크 (I/O 위주) │
│ │ │
│ → 작업 특성에 맞게 리소스 분배 │
│ → 크롤링이 OCR 처리를 블로킹하지 않음 │
│ │
└─────────────────────────────────────────────────────────────────┘
Celery 컨테이너 구성:
# docker-compose.yml
celery-worker-extract:
image: ${ECR_IMAGE}
command: poetry run celery -A app.tasks.celery_app worker -Q text_extract,masking,evaluation -c 2
# -Q: 처리할 큐 지정
# -c 2: 동시 처리 워커 수
celery-worker-trend:
image: ${ECR_IMAGE}
command: poetry run celery -A app.tasks.celery_app worker -Q trend_crawl -c 1
# 크롤링은 I/O 위주이므로 1개로 충분
celery-beat:
image: ${ECR_IMAGE}
command: poetry run celery -A app.tasks.celery_app beat
# 스케줄러: 주기적 태스크 트리거
6.8 AWS ElastiCache 도입 결정 (
# docker-compose.yml (프로덕션)
services:
ai-endpoint:
environment:
# ElastiCache URL은 환경변수로 주입
- 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}
# depends_on에서 redis 제거 (ElastiCache 사용)
ElastiCache 도입 근거:
- ASG 확장 대비: Docker 내부 Redis는 컨테이너 간 네트워크 설정이 복잡
- 관리형 서비스: 자동 장애 복구, 백업, 모니터링 제공
- 환경변수 주입: 코드 변경 없이 환경변수만으로 연결 가능
7. Phase 6: Gunicorn 도입으로 동시성 확보
7.1 Gunicorn + Uvicorn Worker 조합
deploy.replicas 대신 단일 컨테이너 내 멀티 프로세스로 동시성을 확보했습니다.
7.2 Dockerfile CMD 변경
# Before (Uvicorn 단독)
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# After (Gunicorn 4 Uvicorn Workers)
CMD ["poetry", "run", "gunicorn", "app.main:app",
"-w", "4",
"-k", "uvicorn.workers.UvicornWorker",
"--bind", "0.0.0.0:8000",
"--timeout", "120"]
7.3 Gunicorn 설정 근거
| 옵션 | 값 | 이유 |
|---|---|---|
-w 4 |
4 workers | CPU 코어 수 기준 (2N+1 공식, 2코어 가정) |
-k uvicorn.workers.UvicornWorker |
Uvicorn | FastAPI는 ASGI → 반드시 필요 |
--timeout 120 |
120초 | Gunicorn 기본값 30s 대비 여유 마진 확보 |
7.4 docker-compose.yml 변경
# Before
ai-endpoint:
deploy:
replicas: 4 # Swarm 전용, Compose에서 무시됨
# After
ai-endpoint:
container_name: ai_endpoint
# deploy.replicas 제거 — 동시성은 Gunicorn -w 4 로 확장
7.5 성능 개선 효과
[Before: Uvicorn 단독]
- 동시 요청 처리: 1개 (이벤트 루프 블로킹)
- OCR 처리 중 다른 요청: 대기 → 504 Timeout
[After: Gunicorn 4 Workers]
- 동시 요청 처리: 4개 (프로세스 분리)
- OCR 처리 중 다른 요청: 다른 Worker에서 처리 ✅
8. Phase 7: Celery Fallback 전략
8.1 문제: Redis 연결 불가 시 서비스 중단
Celery 도입 후 Redis 연결이 불안정한 경우 전체 서비스가 중단되는 문제가 발생했습니다.
[문제 상황]
1. text_extract.py에서 process_text_extract_task.delay() 호출
2. Celery Worker 컨테이너가 실행되지 않음 → 태스크가 Redis 큐에만 쌓임
3. 태스크가 처리되지 않아 이력서/채용공고가 VectorDB에 저장 안 됨
4. 면접 시작 시 VectorDB 조회 실패 → "Retrieved 0 documents"
8.2 해결: is_celery_available() 구현
# app/utils/celery_utils.py
_last_check_time: float = 0
_last_check_result: bool = False
def is_celery_available() -> bool:
"""Celery 브로커(Redis) 연결 상태 확인. 30초 캐싱."""
global _last_check_time, _last_check_result
current_time = time.time()
if current_time - _last_check_time < 30:
return _last_check_result
try:
import redis
from app.config.settings import get_settings
settings = get_settings()
if not settings.celery_broker_url:
_last_check_result = False
return False
r = redis.from_url(
settings.celery_broker_url,
socket_connect_timeout=2,
socket_timeout=2,
)
r.ping()
_last_check_result = True
except Exception as e:
logger.warning("[CeleryUtils] Redis 연결 실패: %s → asyncio fallback", e)
_last_check_result = False
_last_check_time = current_time
return _last_check_result
8.3 Fallback 패턴 적용
# app/api/routes/v2/text_extract.py
if is_celery_available():
# Celery 사용 가능 → Redis 큐에 태스크 등록
process_text_extract_task.delay(task_id, request_data)
else:
# Celery 불가 → asyncio.create_task()로 fallback
logger.warning("Celery 불가 → asyncio fallback 실행")
asyncio.create_task(process_text_extract_async(task_id, request_data))
8.4 Fallback 전략의 이점
| 항목 | Celery 정상 | Celery 불가 (Fallback) |
|---|---|---|
| 실행 방식 | Redis 큐 → Worker | asyncio.create_task() |
| 블로킹 | ❌ 없음 | ⚠️ 이벤트 루프 공유 |
| 서비스 지속 | ✅ | ✅ (성능 저하 감수) |
| 개발 환경 | Celery 필요 | Celery 없이 테스트 가능 |
9. Phase 8: SSE Keepalive 문제 해결
9.1 문제: Gunicorn 전환 후 504 타임아웃
Gunicorn 도입 후 면접 채팅이 동작하지 않는 버그가 발생했습니다.
[증상]
- 면접 모드 진입 시 60초 후 504 Gateway Timeout
- 일반 채팅은 정상 동작
- 면접 질문 생성 중 연결 끊김
9.2 원인 분석
[아키텍처]
Client → Main Backend → [Nginx Proxy] → Gunicorn → AI Service (FastAPI)
↑
proxy_read_timeout 60s (기본값)
[문제]
면접 PHASE 1: 5개 질문 세트 생성 (RAG + LLM 호출)
↓
처리 시간: 30~90초 (이력서 분석 + 질문 생성)
↓
첫 번째 SSE 청크 전송까지 60초 이상 소요
↓
Nginx proxy_read_timeout 60초 초과
↓
504 Gateway Timeout!
9.3 해결: SSE Keepalive 주석 전송
# app/api/routes/v2/chat.py
async def _interview_phase1_stream(session, request):
"""면접 PHASE 1 — 5개 질문 세트 생성 스트리밍."""
# 즉시 keepalive 전송으로 Nginx 타임아웃 방지
yield ": keepalive\n\n"
# 질문 생성 중 주기적으로 keepalive 전송
async for chunk in generate_interview_questions():
yield f"data: {chunk}\n\n"
# 10초마다 keepalive 전송
if should_send_keepalive():
yield ": keepalive\n\n"
9.4 SSE Keepalive 원리
[SSE 프로토콜]
- ":" 로 시작하는 줄은 주석 (클라이언트 무시)
- 주석도 데이터 전송으로 간주 → Nginx 타임아웃 리셋
- 클라이언트에 영향 없이 연결 유지 가능
[타임라인]
0초: 요청 시작
0초: ": keepalive\n\n" 전송 → Nginx 타임아웃 리셋
10초: ": keepalive\n\n" 전송 → Nginx 타임아웃 리셋
20초: ": keepalive\n\n" 전송 → Nginx 타임아웃 리셋
...
45초: "data: {질문1}\n\n" 전송 → 실제 데이터
10. 최종 아키텍처
10.1 전체 구성도
┌──────────────────────────────────────────────────────────────────────┐
│ Devths AI 프로덕션 아키텍처 │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ [Client] │
│ │ │
│ ▼ │
│ [Main Backend] │
│ │ │
│ ▼ │
│ [Nginx Proxy] ─── proxy_read_timeout 120s │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
│ │ │ ai-service │ │ celery-worker │ │ │
│ │ │ (Gunicorn 4w) │ │ -extract │ │ │
│ │ │ │ │ -trend │ │ │
│ │ │ Worker 1 ─────────┼─────┤ │ │ │
│ │ │ Worker 2 ─────────┼─────┤ celery-beat │ │ │
│ │ │ Worker 3 ─────────┼─────┤ (스케줄러) │ │ │
│ │ │ Worker 4 ─────────┼─────┤ │ │ │
│ │ └──────────┬──────────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │
│ │ └───────────┬───────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ AWS ElastiCache │ │ │
│ │ │ (Redis) │ │ │
│ │ │ │ │ │
│ │ │ DB 0: 세션 │ │ │
│ │ │ DB 1: Celery 큐 │ │ │
│ │ │ DB 2: 결과 저장 │ │ │
│ │ └─────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
10.2 Docker Compose 서비스 구성
# docker-compose.yml (최종)
version: "3.8"
services:
ai-service:
image: ${ECR_IMAGE}
container_name: ai-service
ports:
- "8000:8000"
environment:
- REDIS_URL=${REDIS_URL}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: always
celery-worker-extract:
image: ${ECR_IMAGE}
container_name: celery_worker_extract
command: poetry run celery -A app.tasks.celery_app worker -Q text_extract,masking,evaluation -c 2
environment:
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
restart: always
celery-worker-trend:
image: ${ECR_IMAGE}
container_name: celery_worker_trend
command: poetry run celery -A app.tasks.celery_app worker -Q trend_crawl -c 1
environment:
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
restart: always
celery-beat:
image: ${ECR_IMAGE}
container_name: celery_beat
command: poetry run celery -A app.tasks.celery_app beat
environment:
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
restart: always
promtail:
image: grafana/promtail:3.2.1
container_name: ai-promtail
ports:
- "9080:9080"
volumes:
- ./logs:/var/log/ai-service
restart: always
11. 성과 및 배운 점
11.1 정량적 성과
| 지표 | 개선 전 | 개선 후 | 변화 |
|---|---|---|---|
| 504 타임아웃 발생률 | 30~50% | < 1% | -95%+ |
| 동시 요청 처리 | 1개 | 4개 (Gunicorn) | 4배 |
| 배포 시 중단 시간 | 5~10분 | 0분 (롤링) | -100% |
| 장애 복구 시간 | 수동 (10분+) | 자동 (30초) | -95% |
| Celery 장애 시 서비스 | 중단 | Fallback 동작 | 가용성 확보 |
11.2 기술적 교훈
핵심 인사이트: "클러스터링 = Swarm/K8s" 고정관념 탈피
[오해]
"멀티 프로세스 상태 공유가 필요하다"
↓
"Docker Swarm/Kubernetes가 필요하다"
[실제]
"멀티 프로세스 상태 공유가 필요하다"
↓
"Redis를 중앙 상태 저장소로 사용하면 된다"
↓
"Docker Compose + Gunicorn으로 충분하다"
단계별 문제 해결의 중요성
| Phase | 문제 | 해결 | 교훈 |
|---|---|---|---|
| 1 | 빅뱅 배포 불안정 | Docker 컨테이너화 | 환경 일관성 확보 |
| 2 | 배치 작업 스케줄링 | Celery + Redis 도입 | 비동기 인프라 구축 |
| 3 | docker run 단독 실행 | docker-compose 필수 | 서비스 의존성 이해 |
| 4 | replicas 무시됨 | Swarm 전용임 확인 | 문서 정확히 읽기 |
| 5 | 상태 공유 문제 | Redis 중앙 저장소 | 핵심 인사이트 |
| 6 | 동시성 부족 | Gunicorn 4 workers | 프로세스 분리 |
| 7 | Redis 장애 시 중단 | Celery Fallback | 장애 대응 설계 |
| 8 | SSE 타임아웃 | Keepalive 주석 | 프로토콜 이해 |