"로컬에서는 되는데요..." — 이 말을 반복하지 않기 위한 환경 정비 기록.

배경

1인 개발 프로젝트 Contents Hub를 운영하면서, 개발 환경에서 잘 되던 것이 운영에서 터지는 일이 반복됐다. LESSONS.md에 쌓인 89개의 교훈 중 상당수가 dev/prod 환경 차이에서 비롯됐다.

더 이상 운영에서 터진 후 고치는 게 아니라, 로컬에서 모든 형상 변경을 검증하고 배포하는 프로세스를 세우기로 했다.

분석: 어디서 갭이 생기나

개발과 운영 환경을 꼼꼼히 비교해보니 갭이 꽤 많았다. 전부 나열하면 끝이 없으니, 실제로 사고가 났거나 날 수 있는 것만 추렸다.

1. Python 버전 불일치 (3.11 vs 3.12)

Dockerfile은 python:3.11-slim, 로컬 venv는 3.12. datetime.utcnow() deprecation 같은 런타임 차이가 조용히 숨겨지고 있었다. 3.12 전용 문법을 썼다면 운영에서 바로 터졌을 것이다.

2. 의존성이 빌드할 때마다 달라짐

requirements.txtfastmcp>=2.0, google-genai>=1.0.0 같은 범위 지정이 있어서, 빌드 시점에 따라 다른 버전이 설치됐다. 이미 두 번 당했다:

  • Lesson #72: fastmcp>=2.0 설치 시 starlette이 0.35→0.52로 올라가면서 FastAPI와 충돌
  • Lesson #79: httpx 0.28로 올라가면서 anthropic SDK가 proxies 파라미터 없다고 크래시

3. create_all이 마이그레이션 누락을 숨김

SQLAlchemy의 Base.metadata.create_all()은 새 테이블을 만들지만, 기존 테이블에 컬럼을 추가하지 않는다. 개발 환경에서는 DB를 자주 날리니까 문제가 안 보이는데, 운영 DB는 기존 테이블이 그대로 있으니 새 컬럼이 없어서 크래시. Lesson #70, #80에서 같은 실수를 두 번 했다.

4. 모델명 하드코딩

digest.pymodel="gemini-2.5-flash"가 박혀있었다. Lesson #81에서 gemini-2.0-flash의 deprecation 예고를 발견했을 때, 모델 교체에 코드 배포가 필요하다는 걸 깨달았다.

결정: 전부 하지 않는다

9개 갭을 찾았지만, 전부 고치는 건 오버엔지니어링이다. 1인 개발에서 ROI 기준으로 걸렀다.

지금 하는 것 (3+2)

항목 이유 비용
Dockerfile 3.12 가장 근본적인 차이 제거 한 줄
requirements.lock 두 번 당한 시한폭탄 pip-compile 한 번
Docker smoke test 빌드 후 import 검증 deploy.sh에 한 줄
Gemini 모델 환경변수화 5분이면 끝 config.py + digest.py
.env.example 완성 누락 변수 3개 3줄 추가

안 하는 것

  • create_all 제거: deploy.sh에 이미 마이그레이션 자동 감지가 있고, 제거하면 개발 마찰만 늘어남
  • 통합 테스트를 배포 게이트에 넣기: 단위 187개가 충분한 게이트 역할. 통합은 의심될 때 수동으로
  • 컨테이너 리소스 제한: VM이 죽은 적 없으면 아직 불필요
  • 배포 롤백 자동화: 배포 빈도 낮고 수동 롤백 명령이 문서화되어 있음

구현

Dockerfile: 3.11 → 3.12

# Before
FROM python:3.11-slim

# After
FROM python:3.12-slim

Lock 파일 도입

# requirements.txt (사람이 편집하는 범위 지정)
fastapi>=0.115.0
google-genai>=1.0.0

# pip-compile로 고정
pip-compile --output-file=requirements.lock requirements.txt

# Dockerfile (lock 파일로 설치)
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock

이제 requirements.txt는 "무엇을 원하는지", requirements.lock은 "정확히 무엇을 설치하는지"가 분리됐다. 패키지 추가 워크플로우:

requirements.txt에 추가 → pip-compile → requirements.lock 갱신 → 커밋

deploy.sh: Smoke Test

기존에는 docker buildx build --push로 빌드와 푸시가 한 번에 됐다. 이걸 분리해서 중간에 smoke test를 넣었다:

# Build (로컬에 로드)
docker buildx build --platform linux/amd64 \
    -t "$IMAGE:$VERSION" --load .

# Smoke test
docker run --rm "$IMAGE:$VERSION" \
    python -c "from app.main import app; print('OK')"

# Push (검증 후)
docker push "$IMAGE:$VERSION"
docker push "$IMAGE:latest"

이러면 Lesson #68(의존성 누락을 운영에서 발견) 같은 사고를 빌드 단계에서 잡는다.

최종 배포 플로우

[로컬 Mac]                    [GHCR]                     [운영 VM]
    │                           │                           │
    │ 1. ruff lint              │                           │
    │ 2. pytest + count check   │                           │
    │ 3. docker buildx (load)   │                           │
    │ 4. smoke test (import)    │                           │
    │ 5. docker push ──────────▶│                           │
    │                           │◀───── 6. docker pull ─────│
    │                           │                           │
    │                           │       7. alembic migrate  │
    │                           │       8. docker-compose   │
    │                           │          up -d            │

후속: deploy.sh에 린트와 테스트 수 검증 추가

환경 정비 후 실제로 배포를 돌리다가 또 빈 칸을 발견했다. deploy.sh는 pytest만 실행하고 ruff lint는 포함하지 않았다. 테스트가 통과해도 lint가 깨진 코드가 운영에 올라갈 수 있었다.

더 미묘한 문제도 있었다. 테스트를 8개 추가했는데(199→207), CLAUDE.md/README.md/TODO.md의 테스트 수를 업데이트하는 걸 깜빡했다. "문서 동기화 규칙"이 CLAUDE.md에 적혀있지만, 적어놨다고 지켜지는 건 아니다. Lesson #80(같은 실수 반복 → 자동화)의 데자뷰였다.

deploy.sh에 두 가지 게이트 추가

# 1. Ruff lint (실패 시 배포 중단)
.venv/bin/ruff check app/

# 2. 테스트 수 검증 (CLAUDE.md 기록값 vs 실제 실행 결과)
ACTUAL_COUNT=$(... | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+')
DOC_COUNT=$(grep -oE '단위 [0-9]+' ../CLAUDE.md | head -1 | grep -oE '[0-9]+')
if [ "$ACTUAL_COUNT" != "$DOC_COUNT" ]; then
    echo "⚠️  테스트 수 불일치! 실행: $ACTUAL_COUNT, CLAUDE.md: $DOC_COUNT"
    read -p "계속 배포할까요? (y/N) "
fi

테스트 수 검증은 경고 + 확인으로 했다. 실패로 막으면 테스트 추가 → 문서 업데이트 → 배포를 항상 순서대로 해야 하는데, 급할 때 병목이 된다. 경고만 해도 "아, 문서 업데이트 안 했네"를 배포 시점에 잡아준다.

후속: 스케줄러도 "로컬에서 다 잡는" 범위에 포함

환경 정비가 "빌드/배포"에만 해당하는 줄 알았는데, 스케줄러 동작도 dev/prod 갭이 있었다. VM이 재시작되거나 재배포하면 다이제스트가 예정 시간(07:30/17:30)이 아닌 엉뚱한 시간에 발송되는 문제.

원인을 파보니 세 가지 문제가 겹쳐있었다:

  1. startup이 즉시 발송 — "오늘 다이제스트 있냐"만 체크하고, "지금이 적절한 시간인가"는 안 봄
  2. scheduled job에 중복 방지 없음 — startup이 06:00에 보내고, 07:30에 또 보냄
  3. watchdog이 알림만 — 실패를 감지하지만 보상 발송은 안 함

이건 코드 버그가 아니라 설계 문제다. VM이 항상 같은 시간에 켜진다고 가정한 설계가, 실제 인프라 라이프사이클(재시작, 재배포, 비용 절감 자동 종료)과 맞지 않았다.

해결: 발송 경로 단일화

이전: startup → 즉시 발송 (시간 무관)
      scheduled → 무조건 발송 (중복 가능)
      watchdog → 알림만

이후: startup → 크롤만 + 보상 판단
                (다음 스케줄 2h 이내? → 대기)
                (아니면 → 보상 발송)
      scheduled → 최근 3h 내 발송 있으면 스킵
      watchdog → 알림 + 보상 발송

핵심 원칙은 "다이제스트는 스케줄 시간에만 발송". startup은 크롤(데이터 준비)만 하고, 발송은 scheduled job이나 watchdog에 맡긴다. 이렇게 하면 어떤 시나리오(조기 시작, 재배포, 장애 복구)에서든 발송 시간이 예측 가능하다.

보너스: 통합 테스트가 깨져있었다

배포 플로우 개선 후 통합 테스트를 돌려보니... 12개 전부 실패. embed_contents 컬럼이 테스트 DB에 없었다.

원인은 앞서 분석한 create_all 문제의 거울상이었다. 모델에 컬럼을 추가했지만, 테스트 DB는 이미 존재하는 테이블이라 create_all이 새 컬럼을 추가하지 않은 것이다.

# Before: 기존 테이블에 새 컬럼을 추가하지 않음
async with engine.begin() as conn:
    await conn.run_sync(Base.metadata.create_all)

# After: 매 테스트마다 깨끗하게 재생성
async with engine.begin() as conn:
    await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
    await conn.run_sync(Base.metadata.drop_all)
    await conn.run_sync(Base.metadata.create_all)

pgvector extension을 drop_all 전에 보장해야 한다는 것도 포인트. 테이블을 지우면 vector 타입이 사라져서 create_all이 실패한다.

교훈

  1. 전부 고치지 않는다. ROI로 거른다. 1인 개발에서 "해야 할 것"과 "하면 좋은 것"의 차이는 크다.
  2. 같은 실수 2번은 문서화, 3번은 자동화. Lesson #70(마이그레이션 누락)은 문서화로 안 됐고, Lesson #80에서 deploy.sh 자동화로 해결됐다. 테스트 수 동기화도 같은 패턴 — 규칙을 적어놔도 안 지켜지면 스크립트로 옮긴다.
  3. Lock 파일은 Day 1부터. "나중에 문제 생기면 하지" → 문제가 생기면 이미 의존성 그래프가 엉켜있어서 풀기가 더 어렵다.
  4. 환경 차이는 줄일수록 좋다. Python 3.11 vs 3.12, 한 버전 차이가 조용히 숨기는 버그는 찾기가 가장 어렵다.
  5. "사람이 기억해야 하는 것"은 스크립트로 옮겨라. deploy.sh에 린트와 테스트 수 검증을 넣으니, 누락이 구조적으로 불가능해졌다. 체크리스트는 사람에게 의존하고, 스크립트는 기계에게 의존한다.
  6. 인프라 라이프사이클과 애플리케이션 설계는 함께 고려해야 한다. "VM이 항상 07:00에 켜진다"는 가정 위에 세운 스케줄러는, 재배포 한 번에 깨진다. 설계 시 "이 서버가 임의의 시간에 시작되면?"을 질문해야 한다.

이 글은 Contents Hub 프로젝트의 개발/운영 환경 정비 과정을 기록한 것입니다.