"로컬에서는 되는데요..." — 이 말을 반복하지 않기 위한 환경 정비 기록.
배경
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.txt에 fastmcp>=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.py에 model="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)이 아닌 엉뚱한 시간에 발송되는 문제.
원인을 파보니 세 가지 문제가 겹쳐있었다:
- startup이 즉시 발송 — "오늘 다이제스트 있냐"만 체크하고, "지금이 적절한 시간인가"는 안 봄
- scheduled job에 중복 방지 없음 — startup이 06:00에 보내고, 07:30에 또 보냄
- 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이 실패한다.
교훈
- 전부 고치지 않는다. ROI로 거른다. 1인 개발에서 "해야 할 것"과 "하면 좋은 것"의 차이는 크다.
- 같은 실수 2번은 문서화, 3번은 자동화. Lesson #70(마이그레이션 누락)은 문서화로 안 됐고, Lesson #80에서 deploy.sh 자동화로 해결됐다. 테스트 수 동기화도 같은 패턴 — 규칙을 적어놔도 안 지켜지면 스크립트로 옮긴다.
- Lock 파일은 Day 1부터. "나중에 문제 생기면 하지" → 문제가 생기면 이미 의존성 그래프가 엉켜있어서 풀기가 더 어렵다.
- 환경 차이는 줄일수록 좋다. Python 3.11 vs 3.12, 한 버전 차이가 조용히 숨기는 버그는 찾기가 가장 어렵다.
- "사람이 기억해야 하는 것"은 스크립트로 옮겨라. deploy.sh에 린트와 테스트 수 검증을 넣으니, 누락이 구조적으로 불가능해졌다. 체크리스트는 사람에게 의존하고, 스크립트는 기계에게 의존한다.
- 인프라 라이프사이클과 애플리케이션 설계는 함께 고려해야 한다. "VM이 항상 07:00에 켜진다"는 가정 위에 세운 스케줄러는, 재배포 한 번에 깨진다. 설계 시 "이 서버가 임의의 시간에 시작되면?"을 질문해야 한다.
이 글은 Contents Hub 프로젝트의 개발/운영 환경 정비 과정을 기록한 것입니다.