작지만 기억할 만한 네 가지: Alembic 빈 마이그레이션, 시뮬레이션 엔드포인트, F841 ignore, 'done' vs 'success'

시리즈 마지막 편은 작지만 기억할 만한 네 가지 수정을 묶었습니다. 빈 Alembic revision이 생기는 이유, "시뮬레이션" 주석이 남긴 엔드포인트, F841 ignore 뒤의 숨은 버그, 프론트/백엔드 상태 문자열 불일치 — 모두 "임시는 영구화된다"는 공통 교훈을 가집니다.

작지만 기억할 만한 네 가지: Alembic 빈 마이그레이션, 시뮬레이션 엔드포인트, F841 ignore, 'done' vs 'success'

1. 왜 하나로 묶었는가

앞선 여덟 편이 이 프로젝트의 큰 리팩토링을 다뤘다면, 이번 편은 그 사이사이에 나오는 자잘한 수정을 정리합니다. 각 항목은 한두 줄 diff이고, 따로 블로그 글 한 편으로 쓸 만큼 길지는 않습니다. 하지만 모으면 공통된 교훈 이 나옵니다.

  • 불변이 깨진 경계를 찾는 일의 중요성
  • "시뮬레이션으로 만들었다"는 흔적 이 코드에 남아 시간이 지나면 진짜 기능으로 오해되는 위험
  • 린터 규칙을 풀어 두면 진짜 버그가 섞여 숨는 현상
  • 이름 규약 불일치 가 프론트/백엔드 경계에서 만드는 무성 버그

네 가지를 각각 "섹션 하나"로 다루고, 마지막에 공통 교훈으로 묶습니다.

2. 사례 ①: 빈 Alembic 마이그레이션 파일

증상

새 팀원이 이 프로젝트를 받아 환경을 세팅하려다가 이런 파일을 발견했습니다.

# migrations/versions/3c7b90e3e150_initial_schema.py
"""initial schema

Revision ID: 3c7b90e3e150
Revises:
Create Date: 2026-04-07 14:53:07.154572
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa


revision: str = '3c7b90e3e150'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    pass
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    pass
    # ### end Alembic commands ###

"initial schema"라고 써 있는데 upgrade()pass 한 줄뿐입니다. 이 파일은 아무것도 생성하지 않습니다.

왜 이런 게 생기는가

Alembic의 autogenerate 기능은 "현재 DB 스키마 상태와 모델 메타데이터 상태를 비교해 차이점을 마이그레이션으로 생성"합니다. 만약 DB가 이미 모델과 동일한 상태라면? autogenerate는 차이점이 없다고 판단하고 빈 파일을 만듭니다.

이 프로젝트에서 무슨 일이 일어났었는지 순서를 재구성해 보면 이렇습니다.

  1. 2편에서 PostgreSQL 전환 때, scripts/migrate_json_to_pg.pydb.create_all()로 테이블을 먼저 만듦
  2. 이후 Alembic을 설정하고 alembic revision --autogenerate -m "initial schema"를 실행
  3. 이 시점에 DB에는 이미 테이블이 있고, 모델 메타데이터와 동일
  4. autogenerate가 "변경사항 없음"을 감지하고 pass만 있는 빈 파일을 생성

이 "빈 initial schema"는 치명적이지는 않지만, Alembic의 버전 이력 기준선이 됩니다. 앞으로 Alembic을 쓰게 되면 이 파일이 첫 revision이 되어 alembic upgrade head를 해도 아무것도 하지 않습니다. 새로운 환경에서 DB가 비어 있으면 테이블이 생성되지 않고 첫 요청에서 relation "users" does not exist 에러를 만나게 됩니다.

수정

두 가지 선택지가 있었습니다.

A. 빈 마이그레이션을 실제 스키마로 채우기: alembic revision --autogenerate를 빈 DB 기준으로 다시 돌리는 것. 이쪽이 "정석"입니다.

B. 빈 마이그레이션을 삭제하고 db.create_all()로 대체: Alembic을 "앞으로 생길 스키마 변경"용으로만 쓰고, 초기 스키마는 SQLAlchemy의 create_all()에 위임.

이 프로젝트는 팀이 1~2명이고, 현재 스키마 변경 속도가 느리며, 폐쇄망 배포라 alembic upgrade를 수동으로 실행하는 것보다 "앱 기동 시 자동 생성"이 훨씬 편했습니다. 그래서 B를 선택했습니다.

# extensions.py (After)
from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


def init_extensions(app):
    CORS(app, supports_credentials=True, origins=app.config.get("ALLOWED_ORIGINS", ["*"]))

    if app.config.get("SQLALCHEMY_DATABASE_URI"):
        db.init_app(app)
        with app.app_context():
            import models  # noqa: F401 — 모델 메타데이터 로드
            db.create_all()  # ← 테이블 자동 생성

그리고 빈 migrations/versions/3c7b90e3e150_initial_schema.py는 삭제했습니다.

import models의 의미

import models  # noqa: F401 — 모델 메타데이터 로드

이 한 줄이 중요한데, db.create_all()이 실제로 테이블을 만들려면 모든 모델 클래스가 db.metadata에 등록돼 있어야 합니다. 모델 파일은 import될 때 db.Model을 상속하는 클래스 정의를 통해 메타데이터에 자기 자신을 등록합니다. 이 import 없이 db.create_all()을 부르면 메타데이터가 비어 있어 아무것도 생성되지 않습니다.

# noqa: F401은 ruff가 "import는 했지만 사용 안 함"으로 경고하는 것을 억제하는 주석입니다. 실제로 이 import는 side-effect 목적이므로 F401 경고는 거짓 양성입니다.

트레이드오프

이 접근은 "Alembic의 버전 관리"를 포기한 것입니다. 스키마 변경 이력을 추적할 수 없고, 다운그레이드도 불가능합니다. 프로젝트가 커지면 Alembic을 제대로 쓰는 방식으로 다시 전환해야 합니다. 지금은 "간단함의 가치 > 버전 관리의 가치"로 판단한 상태입니다.

3. 사례 ②: "시뮬레이션"으로 만들어진 모델 다운로드 엔드포인트

증상

"학습 완료 후 모델 파일 다운로드" 엔드포인트가 있었습니다. 사용자가 클릭하면 이런 응답이 왔습니다.

{
    "modelId": "abc12345",
    "baseModel": "yolov8n.pt",
    "task": "detection",
    "classes": ["cat", "dog"],
    "map50": 0.72,
    "precision": 0.68,
    "recall": 0.65,
    "epochs": 50,
    "trainedAt": "2026-04-03T15:30:00Z",
    "format": "model-v1"
}

이건 메타데이터 JSON입니다. 실제 .pt weight 파일이 아닙니다. 함수 주석을 보니 이런 설명이 있었습니다.

@app.route("/api/train/<train_id>/model", methods=["GET"])
def api_download_model(train_id):
    """학습된 모델 메타데이터 다운로드 (시뮬레이션)"""

"시뮬레이션" 이라는 단어가 숨어 있었습니다. 초기 개발 시점에는 YOLO 학습이 실제로 돌아가지 않아 메타데이터 JSON으로 플레이스홀더를 만든 것이었고, 이후 실제 학습이 붙었지만 이 엔드포인트는 원래대로 남아 있었습니다.

프론트엔드는 사용자에게 "모델 다운로드" 버튼을 보여 주고, 사용자는 JSON 파일을 받아 어리둥절해하고 있었습니다.

수정

실제 weight 파일을 반환하도록 고쳤습니다.

# Before: JSON 메타데이터 반환
@app.route("/api/train/<train_id>/model", methods=["GET"])
@require_login
def api_download_model(train_id):
    """학습된 모델 메타데이터 다운로드 (시뮬레이션)"""
    uid = session["user_id"]
    history = get_train_history(uid)
    rec = next((h for h in history if h["id"] == train_id), None)
    if not rec or rec["status"] != "success":
        return jsonify({"error": "완료된 학습이 아닙니다"}), 400
    meta = {
        "modelId": train_id,
        "baseModel": rec.get("model", ""),
        # ... 메타 필드들
    }
    return Response(
        json.dumps(meta, ensure_ascii=False, indent=2),
        mimetype="application/json",
        headers={
            "Content-Disposition": f'attachment; filename="model_{train_id}.json"',
        },
    )
# After: 실제 best.pt 파일 반환
@app.route("/api/train/<train_id>/model", methods=["GET"])
@require_login
def api_download_model(train_id):
    """학습된 모델 weight 파일(best.pt) 다운로드"""
    uid = session["user_id"]
    history = get_train_history(uid)
    rec = next((h for h in history if h["id"] == train_id), None)
    if not rec or rec["status"] != "success":
        return jsonify({"error": "완료된 학습이 아닙니다"}), 400

    model_path = MODEL_DIR / uid / train_id / "weights" / "best.pt"
    if not model_path.exists():
        return jsonify({"error": "모델 파일이 없습니다"}), 404

    model_name = rec.get("model", "yolov8n").replace(".pt", "")
    filename = f"{model_name}_{train_id}.pt"
    return send_from_directory(
        str(model_path.parent), model_path.name,
        as_attachment=True, download_name=filename,
    )

변경 포인트:

  1. model_path = MODEL_DIR / uid / train_id / "weights" / "best.pt" — Ultralytics YOLO가 학습 결과를 저장하는 표준 경로
  2. model_path.exists() 체크 — 학습 기록은 있지만 파일이 사라진 경우(디스크 정리 등) 404 반환
  3. filename = f"{model_name}_{train_id}.pt" — 사용자에게 친숙한 파일명으로 내보냄
  4. send_from_directory(..., as_attachment=True) — 브라우저가 바로 다운로드 저장을 유도

교훈

"시뮬레이션", "mock", "stub", "TODO" 같은 단어가 함수 docstring이나 주석에 나타나면, 그 위치는 나중에 진짜 구현으로 교체되어야 할 빚 입니다. 하지만 개발자가 바쁘면 이 빚이 상환되지 않은 채로 프로덕션에 올라갑니다.

방법은 두 가지입니다.

  • 코드 리뷰 체크리스트에 "시뮬레이션/TODO 단어 검색" 을 넣기
  • grep/ripgrep으로 주기적 감사: rg -w "TODO|시뮬레이션|mock|stub" routes/ services/

이 프로젝트에서는 리뷰어가 코드 전수 감사를 하면서 발견했습니다.

4. 사례 ③: ruff F841 ignore를 벗겨내니 드러난 숨은 코드

증상

pyproject.toml에 이런 설정이 있었습니다.

[tool.ruff.lint]
line-length = 120
# Phase 1: 기존 코드의 스타일 이슈는 점진적으로 수정
ignore = ["E701", "E702", "E722", "F841", "E402"]

F841은 "할당된 값이 사용되지 않는 지역 변수"를 잡는 ruff 규칙입니다. 기존 코드에서 경고가 많이 떠서 "Phase 1에서는 일단 무시"하기로 했던 것입니다. 점진적 개선의 관점에서는 합리적인 선택입니다. 하지만 F841은 생각보다 중요합니다.

F841 ignore를 벗겨내니 발견된 2건

# After
[tool.ruff.lint]
line-length = 120
# E701/E702: multiple statements on one line (기존 코드 스타일, Phase 3에서 정리)
# E722: bare except (Phase 2 Step 5에서 정리)
# E402: module import not at top (dotenv 로딩 필요)
ignore = ["E701", "E702", "E722", "E402"]

F841을 ignore에서 빼고 ruff check를 돌렸더니 정확히 두 건이 걸렸습니다.

건 ①: 사용하지 않는 inp_shape

# app.py
def run_onnx_classification(onnx_path: Path, img_path: Path, ...):
    sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
    inp_name = sess.get_inputs()[0].name
    inp_shape = sess.get_inputs()[0].shape  # [1,3,H,W] or [1,H,W,3]  ← F841

    img = PILImage.open(str(img_path)).convert("RGB")
    img = img.resize((img_size, img_size), PILImage.BILINEAR)
    # ... 이후 inp_shape 사용 안 함

주석에 "[1,3,H,W] or [1,H,W,3]"이라고 써 있었습니다. 처음에는 동적으로 shape을 검사해 변환 로직을 분기할 계획 이었던 것 같습니다. 하지만 실제로는 img_size를 하드코딩으로 넘기고, inp_shape은 한 번 조회만 하고 버려지고 있었습니다.

수정은 간단합니다. inp_shape 줄을 삭제합니다.

# After
sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
inp_name = sess.get_inputs()[0].name

img = PILImage.open(str(img_path)).convert("RGB")
# ...

이것은 "의도하지 않은 기능 공백" 입니다. 원래 하려던 shape 기반 분기가 구현되지 않은 채 변수만 남았습니다. 지금 실제 동작에는 문제가 없지만, 코드를 읽는 사람에게 "왜 이 변수가 있지?"라는 혼란을 줍니다.

건 ②: subprocess 반환값 미사용

# app.py
@app.route("/api/auto-model/upload", methods=["POST"])
def api_auto_model_upload():
    # ... 모델 업로드 처리
    try:
        import subprocess
        onnx_path = user_model_dir / f"{model_id}.onnx"
        result = subprocess.run(  # ← F841
            [sys.executable, "-c",
             f"from ultralytics import YOLO; "
             f"m=YOLO('{save_path}'); "
             f"m.export(format='onnx', imgsz=640)"],
            capture_output=True, text=True, timeout=300,
        )
        # ... result 사용 안 함

이쪽이 더 심각합니다. subprocess.run()반환값을 확인하지 않으면 에러가 났는지 성공했는지 알 수 없습니다. 에러가 나도 조용히 넘어가게 되는 것입니다. subprocess가 .onnx 파일을 만들지 못하면 이후에 "파일이 없다"는 다른 에러로 나타나고, 원인 추적이 어려워집니다.

수정은 단순히 변수를 제거하는 것이 아니라, 에러 체크까지 추가하는 것이 올바른 방향입니다. 다만 이 커밋의 시점에는 이 문제를 또 다른 커밋(SQLAlchemy N+1 쿼리 최적화)에서 "subprocess 코드 인젝션 방지"와 함께 다루기로 해서, 이 커밋에서는 일단 변수만 제거하고 result =를 떼어냈습니다.

# This commit's minimal fix
subprocess.run(
    [sys.executable, "-c", ...],
    capture_output=True, text=True, timeout=300,
)

이후 PR #40 리뷰에서 더 엄밀한 수정이 추가됐습니다.

교훈: 린터 ignore에는 만료일을 적자

"Phase 1은 이 규칙을 ignore"라는 결정은 합리적입니다. 하지만 Phase 1이 언제 끝나는지 아무도 모르면, ignore가 영구화됩니다. 두 가지 방법으로 관리합니다.

  • 주석에 "어느 시점에 정리": 이 프로젝트는 ignore 목록 옆에 # Phase 2 Step 5에서 정리 같은 주석을 남깁니다
  • GitHub Issue로 트래킹: Issue #16이 ruff 강화 전담 이슈였고, 이 커밋으로 닫혔습니다

5. 사례 ④: 'done' vs 'success' 상태 문자열 불일치

증상

학습을 완료한 뒤, 프론트엔드에서 "완료" 뱃지가 안 뜨고 계속 "진행 중"으로 보이는 버그가 있었습니다. 콘솔을 열어 보면 TRAIN_HISTORY에는 status: "success"가 들어 있는데 UI에는 반영되지 않았습니다.

원인

프론트엔드 코드가 이렇게 돼 있었습니다.

// static/js/app.js (Before)
function renderTrainHist() {
  const el = document.getElementById('train-hist');
  const html = TRAIN_HISTORY.slice(0, 10).map(h => {
    // 'success' 와 'done' 두 값을 모두 허용하는 OR 조건
    const isFinished = h.status === 'success' || h.status === 'done';
    const isError = h.status === 'error';
    // ... 뱃지와 메트릭 렌더링
    return renderRow(h, isFinished, isError);
  }).join('');
  el.replaceChildren(parseSafely(html));  // 실제 삽입 방식은 sanitize 래퍼 경유
}

async function dlModel() {
  const h = TRAIN_HISTORY.find(t => t.status === 'done');  // ← 'done'만 체크
  if (!h) { toast('완료된 학습이 없습니다','error'); return; }
  window.location.href = `/api/train/${h.id}/model`;
}

문제는 여러 곳에서 다른 기준으로 "완료 상태"를 체크하고 있었다는 것입니다. 어떤 곳은 'success' || 'done', 어떤 곳은 'done'만, 어떤 곳은 'success'만 — 이 불일치가 다섯 곳에 흩어져 있었습니다.

백엔드는 언제부터인가 'success'로 통일한 상태였는데, 프론트엔드가 그 변경을 전부 따라잡지 못했습니다. 결과적으로 프론트엔드 내부에서도 일관성이 깨진 상태였습니다.

수정

프론트엔드의 모든 상태 참조를 'success'로 통일했습니다.

// static/js/app.js (After)
function renderTrainHist() {
  const rows = TRAIN_HISTORY.slice(0, 10).map(h => {
    const isFinished = h.status === 'success';  // ← 단일화
    const isError = h.status === 'error';
    return renderRow(h, isFinished, isError);
  });
  // ... 렌더링 (sanitize 래퍼 경유)
}

async function dlModel() {
  const h = TRAIN_HISTORY.find(t => t.status === 'success');  // ← 'done' 제거
  if (!h) { toast('완료된 학습이 없습니다','error'); return; }
  window.location.href = `/api/train/${h.id}/model`;
}

// SSE 완료 시점에도 d.status를 그대로 반영
TRAIN_HISTORY[targetIdx].status = d.status || 'success';
if (d.map50 !== undefined) TRAIN_HISTORY[targetIdx].map50 = d.map50;

더 중요한 수정: SSE가 실제 상태를 전달하도록

이전에는 SSE 완료 시점에 무조건 'success'로 하드코딩하고 있었습니다.

// Before
TRAIN_HISTORY[targetIdx].status = 'success';  // ← 무조건 success

이것도 잘못된 가정입니다. 학습이 에러로 끝났거나 타임아웃됐어도 이 코드는 'success'로 표시해 버립니다. 수정은 SSE 이벤트의 status 필드를 그대로 반영하는 것입니다.

// After
TRAIN_HISTORY[targetIdx].status = d.status || 'success';

d.status'error''error'로, 'timeout'이면 'timeout'으로, 없으면 fallback 'success'입니다. 백엔드 SSE가 3편7편에서 'cancelled''timeout' 상태를 추가했기 때문에, 프론트엔드도 이 상태들을 그대로 표시할 수 있게 됐습니다.

교훈: 상태 문자열은 계약이다

프론트/백엔드 경계에서 오가는 상태 문자열은 암묵적 계약입니다. 이 계약이 어느 한쪽의 마음으로 바뀌면, 다른 쪽이 잡아야 합니다. 하지만 현실에서는 바뀌는 사실 자체가 잘 전달되지 않습니다.

방어 방법:

  • 백엔드의 상태값을 상수로 관리: TrainingStatus.SUCCESS = "success" 같이 enum이나 상수 모듈을 만들어 문자열 리터럴 사용을 금지
  • 프론트엔드도 동일한 상수 공유: OpenAPI 스펙이나 codegen으로 타입 자동 생성
  • 상태 값 변경 시 전수 검색: grep -r "'done'" static/ 같은 검색을 PR 체크리스트에 추가

이 프로젝트는 아직 최소한의 "프론트/백엔드 리터럴 일치 검사"만 하고 있지만, 계약 자동화는 후속 과제로 남겨 두었습니다.

6. 네 가지를 관통하는 공통 교훈

이번 편의 네 가지 수정은 모두 "작은 증상" 이었지만, 그 뒤에는 공통된 구조적 취약점이 있었습니다.

교훈 ①: "임시"는 영구화된다

  • "initial schema"라고 이름을 붙인 빈 마이그레이션은 영구화된 빈 파일로 남음
  • "시뮬레이션"으로 만든 엔드포인트는 그대로 프로덕션까지 감
  • "Phase 1에서는 ignore"는 Phase 2, 3으로 넘어가도 유지됨
  • 'done' → 'success' 전환의 중간 잔해는 프론트엔드에 잔존

임시 상태에는 명시적 기한이 있어야 합니다. "언제 정리할 것인가"가 팀 캘린더나 이슈 트래커에 예약돼야 합니다.

교훈 ②: 자동화된 검증이 유일한 구제

사람은 이 네 가지를 모두 놓쳤습니다. 그럼 무엇이 잡을 수 있었을까요?

  • Alembic 빈 마이그레이션: alembic history를 CI에서 돌려서 빈 revision 감지
  • 시뮬레이션 엔드포인트: rg -w "시뮬레이션|mock|stub|TODO" routes/ services/ 를 pre-commit 훅으로 설정
  • F841 ignore: ruff strict 모드로 운영하되, 예외는 구체적 파일 단위로
  • 상태 문자열 불일치: 타입 공유(TypeScript + Pydantic 같은 codegen) 또는 전수 grep 리팩토링 도구

자동화 없이 "조심"만으로는 이런 버그를 막을 수 없습니다.

교훈 ③: 커밋 하나에 여러 교훈을 담지 마라

이번 편에서 다룬 네 가지 중 ③ F841 건은 별도 커밋(5257464)으로 분리됐습니다. 만약 큰 리팩토링 커밋에 섞였다면 리뷰어가 중요도를 구분하기 어려웠을 것입니다. "한 커밋 = 한 교훈"을 지키면 git log가 팀의 학습 기록 자체가 됩니다.

교훈 ④: 작은 수정도 블로그 소재가 된다

이런 작은 수정들은 단독으로는 글 하나가 되기 어렵지만, 묶어서 패턴으로 만들면 가치가 있습니다. "내 프로젝트에도 이런 패턴이 있을까?"를 독자가 체크할 수 있게 됩니다. 1~8편이 "큰 그림"이었다면, 이번 9편은 "디테일" 입니다. 디테일 없는 큰 그림은 설득력이 약합니다.

7. 핵심 개념 정리

개념 역할
db.create_all() + import models 모델 메타데이터 로드 후 자동 테이블 생성
Alembic 빈 revision 실제 DB가 메타데이터와 이미 일치할 때 autogenerate가 만드는 무용 파일
시뮬레이션 엔드포인트 초기 구현의 플레이스홀더가 남아 진짜 기능으로 오해되는 상태
F841 ruff의 "할당만 하고 사용하지 않는 변수" 경고
상태 문자열 계약 프론트/백엔드 경계의 암묵적 계약, 바뀔 때 양쪽이 함께 따라가야 함
임시 상태의 만료일 "Phase N에서 정리" 같은 주석/이슈로 명시적 관리

8. 베스트 프랙티스 체크리스트

  • [ ] Alembic initial schema 파일이 실제로 테이블을 생성하나요?
  • [ ] db.create_all()을 쓰는 경우 import models side-effect이 있나요?
  • [ ] rg "시뮬레이션|mock|stub|TODO"로 코드를 주기적으로 감사하나요?
  • [ ] ruff ignore 목록에 만료일/이슈 번호가 붙어 있나요?
  • [ ] F841을 ignore에서 뺀 적이 있나요? 벗겨 보면 숨은 버그가 나옵니다
  • [ ] 프론트/백엔드 상태 문자열이 상수로 공유되고 있나요?
  • [ ] 상태 문자열 변경 시 "전수 검색"이 PR 체크리스트에 있나요?
  • [ ] 작은 수정도 커밋을 분리해 git log 가독성을 유지하나요?

9. FAQ

Q1. Alembic을 결국 쓰지 않는다면, 초기 설정을 왜 남겨 두었나요?
A. "지금은 간단하게 가되, 미래에 언제든 Alembic으로 전환할 여지를 남기는" 선택입니다. alembic.ini, migrations/env.py, script.py.mako는 그대로 두고 빈 revision만 삭제했습니다. 필요해지면 alembic revision --autogenerate를 다시 돌리면 됩니다. Alembic의 초기 설정 자체는 작지만 재도입 비용을 낮춥니다.

Q2. F841을 enable하는 대신 # noqa: F841 주석을 각 변수에 붙이는 건 어떤가요?
A. 전역 ignore보다 지역 noqa가 낫긴 하지만, noqa 주석이 많아지면 결국 "왜 이 변수가 필요한가"에 대한 설명 책임이 생깁니다. "사용하지 않는데 있는 변수"는 대개 의도가 불분명하므로, 제거하거나 사용하도록 고치는 것이 가장 깔끔합니다. 진짜 의도적으로 남겨야 한다면(예: API 호환성), # noqa: F841 — 외부 호출자 호환용 같이 이유를 적어 주세요.

Q3. 'done' vs 'success' 같은 불일치를 완전히 막는 방법이 있나요?
A. 완벽한 방법은 스키마 기반 codegen 입니다. 백엔드에서 Pydantic 모델이나 OpenAPI 스펙을 정의하고, 프론트엔드는 그것으로부터 TypeScript 타입을 자동 생성합니다. status가 enum이면 타입 시스템이 'done' 리터럴을 거부합니다. 이 프로젝트는 아직 평가 수준이지만, 다음 리팩토링 단계의 후보입니다.

Q4. "시뮬레이션" 같은 단어를 찾는 grep에 걸리지 않도록 일부러 완곡한 표현을 쓰는 코드는 없나요?
A. 있습니다. "placeholder", "stub", "TODO", "FIXME", "hack" 같은 단어가 영어 기반 프로젝트에서 흔합니다. 한국어/영어 혼재 프로젝트에서는 두 언어 다 검색해야 합니다. 이 프로젝트의 pre-commit 훅 후보로는 rg -w "TODO|FIXME|HACK|시뮬레이션|mock|stub|XXX|나중에" 같은 패턴을 고려 중입니다.

Q5. "임시는 영구화된다"를 어떻게 조직적으로 막을 수 있나요?
A. 두 가지 관행이 도움이 됩니다. (1) "임시 해결은 이슈 번호를 본문에 포함"# HACK(#42): ... 처럼 쓰면 이슈가 열려 있는 동안에는 감사 대상이 됩니다. (2) 분기당 한 번 이상 "임시 청소의 날"을 팀 캘린더에 예약 — 이슈 트래커에서 "임시 해결책 태그"만 필터링해 집중 정리.

10. 참고 자료

  • Alembic 공식 문서 (autogenerate): 검색 키워드 alembic autogenerate revision
  • ruff 공식 문서 (F841 Rule): 검색 키워드 ruff pyflakes f841 unused variable
  • Flask send_from_directory: 검색 키워드 flask send_from_directory as_attachment
  • Pydantic + OpenAPI codegen: 검색 키워드 pydantic openapi typescript client generation

11. 시리즈를 마치며

이 한 편으로 "인수인계 받은 이미지 라벨링 플랫폼 리팩토링" 시리즈 9편이 마무리됩니다. 큰 그림은 단순합니다.

  1. 구조: 모놀리식 → Blueprint + Service + Utils
  2. 데이터: JSON → PostgreSQL + SQLAlchemy (서비스 시그니처 유지)
  3. 실행: threading → ProcessPoolExecutor + orphan 복구
  4. 성능: N+1 쿼리 3곳 + 벌크 DELETE
  5. ML: train/val 실제 분리 (조용한 mAP 버그)
  6. 기능: Detection/Classification/Segmentation 통합
  7. 보안 강화: Fail-fast CORS + Path traversal 방어
  8. 보안 단순화: 폐쇄망 맥락에서 Rate Limiting 제거
  9. 자잘한 정리: 빈 migration, 시뮬레이션, F841, 상태 불일치

한 프로젝트의 이야기지만, 각 편은 독립된 패턴으로 읽을 수 있도록 썼습니다. 여러분의 프로젝트에도 이 중 일부 패턴이 적용될 수 있기를 바랍니다. 질문이나 반론이 있으면 댓글이나 이메일로 남겨 주세요.

🐍 Flask 백엔드 실전 시리즈 (9부작)

  1. 모놀리식 app.py를 Blueprint로 분해하기
  2. JSON 파일 DB에서 PostgreSQL로 마이그레이션
  3. ML 학습 백그라운드 실행: ProcessPoolExecutor
  4. Python/SQLAlchemy N+1 쿼리 잡기
  5. train과 val이 같은 폴더일 때의 조용한 ML 버그
  6. 하나의 백엔드로 Detection/Classification/Segmentation
  7. Fail-fast 설정 검증과 Path Traversal 방어
  8. Rate Limiting을 걷어낸 날: 폐쇄망 보안
  9. 작지만 기억할 만한 네 가지 교훈 (현재 글)