[TeamBlog] 인프라 전환: 빅뱅 배포 → Docker Redis 클러스터링 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

인프라 전환: 빅뱅 배포 → Docker + Redis 클러스터링


목차

  1. 개요
  2. Phase 1: 빅뱅 배포에서 Docker 전환
  3. Phase 2: Celery와 Redis 도입 결정
  4. Phase 3: docker run 단독 실행 시 문제 발생
  5. Phase 4: Docker Compose 도입 및 Replica 문제
  6. Phase 5: Redis를 통한 Docker 클러스터링 (핵심 인사이트)
  7. Phase 6: Gunicorn 도입으로 동시성 확보
  8. Phase 7: Celery Fallback 전략
  9. Phase 8: SSE Keepalive 문제 해결
  10. 최종 아키텍처
  11. 성과 및 배운 점

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 도입 근거:

  1. ASG 확장 대비: Docker 내부 Redis는 컨테이너 간 네트워크 설정이 복잡
  2. 관리형 서비스: 자동 장애 복구, 백업, 모니터링 제공
  3. 환경변수 주입: 코드 변경 없이 환경변수만으로 연결 가능

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 주석 프로토콜 이해