1,932줄 app.py를 54줄로: 모놀리식 Flask를 Blueprint로 분해하기
인수인계 받은 Flask 백엔드의 `app.py`가 1,932줄 한 파일에 42개 API가 섞여 있었습니다. Application Factory 패턴과 Blueprint로 도메인을 분리하고, 42개 경로를 그대로 유지하며 프론트엔드 변경 없이 리팩토링한 실전 기록입니다.
1. 문제 상황: 1,932줄 단일 파일 앱
어느 날 인수인계 받은 이미지 라벨링 플랫폼의 저장소를 열어보니, app.py 파일 하나에 전부 들어 있었습니다. 프론트엔드가 아니라 백엔드 전체가 말이죠.
$ wc -l app.py
1932 app.py
1,932줄짜리 Python 파일 하나에 다음이 모두 섞여 있었습니다.
- Flask 앱 초기화
- 경로 상수 정의
- CSRF 검증 로직
- 비밀번호 해싱(PBKDF2)과 사용자 CRUD
- 이미지 업로드, 라벨 저장, 학습 큐, 자동 라벨링, 추론, 내보내기, 피드백, 관리자 API
- 백그라운드 학습 스레드
- SSE(Server-Sent Events) 진행 스트리밍
- 헬퍼 함수 수십 개
- SPA 폴백 라우트
API 엔드포인트를 세어 보니 총 42개. 모두 @app.route(...) 데코레이터가 붙은 글로벌 함수였습니다.
# 대충 이런 구조가 1,932줄 내내 반복됐습니다
app = Flask(__name__, ...)
app.secret_key = os.environ.get("SECRET_KEY")
@app.route("/api/auth/login", methods=["POST"])
def api_login():
d = request.get_json()
# ... 로직
return jsonify({...})
@app.route("/api/images/upload", methods=["POST"])
def api_upload_image():
# ... 전혀 다른 도메인 로직
return jsonify({...})
# ... 40개 더
증상
이 구조는 아래와 같은 고통을 동시에 주고 있었습니다.
| 증상 | 설명 |
|---|---|
| Import 지옥 | 파일 상단에 전역 import 17개. 선택적 의존성(YOLO, ONNXRuntime, PIL)까지 try/except로 섞여 있어 가독성 바닥 |
| Merge conflict 폭발 | 두 명이 동시에 서로 다른 API를 작업하면 100% 같은 파일 충돌 |
| 테스트 불가 | 전역 app 객체를 import 시점에 생성하므로 pytest 픽스처로 환경을 교체하기 어려움 |
| 도메인 경계 부재 | "사용자" 로직과 "이미지" 로직이 200줄 차이로 섞여 있어 코드 리뷰 시 맥락 전환 비용이 큼 |
| 순환 import 리스크 | 파일을 쪼개려 해도 전역 app을 어디서나 참조하기 때문에 분리가 어려움 |
인수인계 로드맵 중 가장 먼저 정리해야 할 항목이었습니다. GitHub Issue #13번이 바로 이 리팩토링이었습니다.
2. 왜 Blueprint인가: 두 가지 후보 비교
Flask에서 대형 앱을 쪼개는 방법은 크게 두 가지입니다.
- Flask Blueprint — Flask가 기본 제공하는 라우트 그룹화 메커니즘
- Flask-RESTful / Flask-Smorest 같은 확장 프레임워크 — 클래스 기반 리소스
이 프로젝트는 이미 프론트엔드가 /api/* 경로를 42개나 사용 중이었습니다. 프론트엔드 변경 없이 백엔드만 리팩토링하는 것이 첫 번째 제약이었기 때문에, 추가 추상화가 없는 순수 Blueprint를 선택했습니다.
# Blueprint의 핵심 장점: 라우트 경로를 건드리지 않고 파일을 쪼갤 수 있다
from flask import Blueprint
bp = Blueprint("auth", __name__, url_prefix="/api")
@bp.route("/auth/login", methods=["POST"]) # 최종 경로: /api/auth/login
def api_login():
...
url_prefix를 /api로 두고 개별 라우트에서 나머지 경로를 이어 붙이면, 기존 프론트엔드가 호출하던 /api/auth/login이 그대로 유지됩니다. 프론트엔드 코드는 단 한 줄도 수정하지 않았습니다.
3. 분할 경계 선정: 도메인이냐, 기술 스택이냐
Blueprint로 쪼갠다는 결정 후, 두 번째 질문이 남습니다. 무엇을 기준으로 쪼갤 것인가?
대표적으로 두 가지 방식이 있습니다.
- 기술 스택 중심:
routes/,models/,helpers/,validators/같이 "하는 일의 종류"로 분리 - 도메인 중심:
auth/,images/,labels/,training/같이 "다루는 대상"으로 분리
저는 도메인 중심을 선택했습니다. 이유는 세 가지입니다.
- 변경 단위가 도메인 단위: "라벨 저장 로직 수정"이 오면, 한 폴더 안에서 route → service → (앞으로 생길) model까지 모두 만질 수 있어야 합니다.
- 코드 리뷰 집중도: 도메인 경계가 파일 경계와 일치하면 리뷰어의 context switching 비용이 줄어듭니다.
- 데이터 레이어 교체 준비: 이 리팩토링의 진짜 목적은 다음 단계인 PostgreSQL 전환이었습니다. 서비스 레이어를 도메인별로 미리 뽑아 두면, 라우트를 건드리지 않고 서비스 내부만 JSON → ORM으로 교체할 수 있습니다.
최종 도메인 경계는 다음과 같았습니다.
routes/
├── __init__.py # register_blueprints(app) — 9개 Blueprint를 앱에 등록
├── auth.py # 로그인, 로그아웃, 회원가입, CSRF 토큰
├── images.py # 이미지 업로드/목록/삭제
├── labels.py # 라벨 CRUD, 클래스 관리
├── training.py # 학습 큐, 진행 모니터링(SSE)
├── inference.py # 학습된 모델로 추론
├── auto_label.py # 자동 라벨링(업로드 모델 기반)
├── export.py # 데이터셋 내보내기(ZIP/CSV)
├── feedback.py # 사용자 피드백 수집
└── admin.py # 관리자 전용 API
각 Blueprint 옆에 1:1로 대응하는 서비스 모듈이 services/ 폴더에 위치합니다.
services/
├── user_service.py # 비밀번호 해싱, 사용자 CRUD
├── image_service.py # 이미지 파일/메타 관리
├── label_service.py # 라벨, 클래스 관리
├── training_service.py # 백그라운드 학습 제출/취소
├── inference_service.py # 추론 실행
├── auto_model_service.py # 업로드 모델 등록/목록
├── activity_service.py # 활동 로그
└── export_service.py # (리팩토링 직후에는 비어 있었음)
4. 단계별 분해: create_app 팩토리부터 시작
저는 이런 종류의 리팩토링을 할 때 가장 바깥쪽부터 깎아 나가는 방식을 선호합니다. 파일 내부를 쪼개기 전에 앱 초기화 부분을 먼저 create_app() 팩토리로 감싸는 것입니다.
왜냐하면 전역 app 객체를 그대로 둔 채 내부만 쪼개면, 결국 모든 Blueprint가 순환 import에 걸리거나 전역 상태에 의존하게 되기 때문입니다.
Before: 전역 app
# app.py (1,932줄 중 일부)
from flask import Flask
app = Flask(__name__, static_folder=..., template_folder=...)
app.secret_key = os.environ.get("SECRET_KEY")
# ... 여러 설정들
CORS(app, supports_credentials=True, origins=_allowed_origins)
@app.before_request
def csrf_protect():
...
@app.route("/api/auth/login", methods=["POST"])
def api_login():
...
# ... 42개의 @app.route, 백그라운드 스레드, 헬퍼 수십 개
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
파일을 열자마자 app이 만들어지고, 그 다음에 42개의 라우트가 이 전역 객체에 붙는 구조입니다.
After: Application Factory
# app.py (54줄)
#!/usr/bin/env python3
"""Image Labeling Platform - Application Factory"""
import os
import argparse
from flask import Flask, send_from_directory
from config import Config, ALLOWED_ORIGINS, STATIC_DIR, TMPL_DIR
from extensions import init_extensions
from routes import register_blueprints
from utils.auth_decorators import csrf_protect
from utils.helpers import logger
def create_app():
app = Flask(__name__, static_folder=str(STATIC_DIR), template_folder=str(TMPL_DIR))
app.config.from_object(Config)
app.config["ALLOWED_ORIGINS"] = ALLOWED_ORIGINS
init_extensions(app)
app.before_request(csrf_protect)
register_blueprints(app)
# SPA fallback
@app.route("/")
def index():
return send_from_directory(str(TMPL_DIR), "index.html")
@app.route("/<path:path>")
def static_files(path):
full = STATIC_DIR / path
if full.exists():
return send_from_directory(str(STATIC_DIR), path)
return send_from_directory(str(TMPL_DIR), "index.html")
return app
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Image Labeling Platform Server")
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8080)))
parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
app = create_app()
app.run(host=args.host, port=args.port, debug=args.debug)
여기서 주목할 점은 app이 더 이상 모듈 레벨에 없다는 것입니다. create_app()을 호출해야만 생성됩니다. 이 한 가지 변화가 앞으로의 모든 분리를 가능하게 해 줍니다.
- 테스트에서
test_app = create_app()으로 독립된 인스턴스를 만들 수 있습니다. - 환경별로
Config를 바꿔 주입할 수 있습니다. - Blueprint는
create_app내부에서 등록되므로, import 순서가 꼬이지 않습니다.
5. Config와 Extensions 분리
create_app()이 바깥을 감싸는 뼈대였다면, 그 다음은 설정과 확장 초기화를 빼내는 것입니다.
config.py — 경로와 환경 설정
# config.py
import os
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
BASE_DIR = Path(__file__).parent
DATA_DIR = BASE_DIR / "data"
UPLOAD_DIR = BASE_DIR / "uploads"
MODEL_DIR = BASE_DIR / "models"
STATIC_DIR = BASE_DIR / "static"
TMPL_DIR = BASE_DIR / "templates"
# 데이터 파일 경로 상수
USERS_FILE = DATA_DIR / "users.json"
ACTIVITY_DIR = DATA_DIR / "activities"
LABELS_DIR = DATA_DIR / "labels"
# ... 나머지
# 디렉토리 자동 생성
for d in [DATA_DIR, UPLOAD_DIR, MODEL_DIR, ACTIVITY_DIR, LABELS_DIR]:
d.mkdir(parents=True, exist_ok=True)
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError(
"SECRET_KEY environment variable is required."
)
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = os.environ.get("FLASK_ENV") != "development"
PERMANENT_SESSION_LIFETIME = 86400 * 30 # 30일
ALLOWED_ORIGINS = [
o.strip()
for o in os.environ.get("ALLOWED_ORIGINS", "").split(",")
if o.strip()
]
if not ALLOWED_ORIGINS:
raise RuntimeError(
"ALLOWED_ORIGINS environment variable is required."
)
경로 상수와 Flask Config 클래스, 그리고 Fail-fast 검증(필수 환경변수 미설정 시 서버 기동 거부)까지 모두 여기로 모였습니다. 이 파일만 보면 "이 앱이 어떤 환경을 요구하는지"를 한눈에 파악할 수 있습니다.
extensions.py — Flask 확장 초기화
# extensions.py
from flask_cors import CORS
def init_extensions(app):
CORS(
app,
supports_credentials=True,
origins=app.config.get("ALLOWED_ORIGINS", ["*"]),
)
현재는 CORS 하나뿐이지만, 앞으로 Flask-SQLAlchemy, Flask-Migrate 같은 확장이 추가되면 여기에 모일 자리를 만들어 둔 것입니다. (실제로 다음 단계인 PostgreSQL 전환에서 이 파일이 db 객체의 정착지가 되었습니다.)
6. utils/ — 공통 헬퍼와 데코레이터
app.py에 섞여 있던 다음 함수들은 모두 도메인 경계가 없는 공통 유틸리티였습니다.
_now()— ISO 8601 UTC 시간_load_json()/_save_json()— JSON 파일 I/Ologger— 로거 인스턴스require_login/require_admin— 데코레이터_check_csrf/csrf_protect— CSRF 검증
도메인 경계가 없다는 것이 중요합니다. 어느 Blueprint에서도 쓰이니까요. 그래서 utils/라는 공용 패키지로 분리했습니다.
utils/helpers.py
# utils/helpers.py
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("labelplatform")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _load_json(path: Path, default=None):
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
pass
return default if default is not None else {}
def _save_json(path: Path, data):
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
utils/auth_decorators.py
# utils/auth_decorators.py
from functools import wraps
from flask import request, session, jsonify, abort
def require_login(f):
@wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return jsonify({"error": "로그인이 필요합니다"}), 401
return f(*args, **kwargs)
return decorated
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return jsonify({"error": "로그인이 필요합니다"}), 401
# 지연 import로 순환 의존성 방지
from services.user_service import _get_user_by_id
user = _get_user_by_id(session["user_id"])
if not user or user.get("role") != "admin":
return jsonify({"error": "관리자 권한이 필요합니다"}), 403
return f(*args, **kwargs)
return decorated
def _check_csrf():
if request.method in ("GET", "HEAD", "OPTIONS"):
return
token = request.headers.get("X-CSRF-Token") or (request.json or {}).get("_csrf_token")
if not token or token != session.get("_csrf_token"):
abort(403, description="CSRF token missing or invalid")
_CSRF_EXEMPT_PATHS = frozenset((
"/api/csrf-token",
"/api/auth/login",
"/api/auth/register",
))
def csrf_protect():
if request.path.startswith("/api/") and request.path not in _CSRF_EXEMPT_PATHS:
_check_csrf()
여기서 한 가지 주목할 점은 require_admin 안의 지연 import입니다.
from services.user_service import _get_user_by_id # 함수 내부
utils.auth_decorators는 services.user_service를 필요로 하고, 서비스는 다시 유틸리티를 참조할 가능성이 있습니다. 모듈 로드 시점에 import 하면 순환이 생기지만, 함수 호출 시점에 import 하면 Python이 이미 로드된 모듈을 재사용하므로 안전합니다. 대규모 Flask 앱을 Blueprint로 쪼개다 보면 이 패턴을 자주 쓰게 됩니다.
7. Blueprint 실전: routes/auth.py
가장 작은 Blueprint 하나만 예시로 보여드리겠습니다.
# routes/auth.py
"""Auth routes — CSRF token, login, logout, me, register."""
import secrets
import uuid
from flask import Blueprint, request, jsonify, session
from services.user_service import (
_get_users, _save_users, _verify_password, _hash_password, _hash,
_get_user_by_id,
)
from services.activity_service import add_activity
from utils.helpers import _now
bp = Blueprint("auth", __name__, url_prefix="/api") # ← 핵심
@bp.route("/csrf-token", methods=["GET"])
def get_csrf_token():
token = secrets.token_hex(32)
session["_csrf_token"] = token
return jsonify(csrf_token=token)
@bp.route("/auth/login", methods=["POST"])
def api_login():
d = request.get_json()
username = (d.get("username") or "").strip()
password = d.get("password") or ""
if not username or not password:
return jsonify({"error": "아이디와 비밀번호를 입력하세요"}), 400
users = _get_users()
user = next((u for u in users.values() if u["username"] == username), None)
if not user or not _verify_password(password, user["password"]):
return jsonify({"error": "아이디 또는 비밀번호가 올바르지 않습니다"}), 401
# 구형 SHA-256 해시면 로그인 성공 시 PBKDF2로 자동 업그레이드
if not user["password"].startswith("pbkdf2$"):
user["password"] = _hash_password(password)
user["lastLogin"] = _now()
_save_users(users)
session["user_id"] = user["id"]
session.permanent = True
add_activity(user["id"], "로그인")
return jsonify({"ok": True, "user": {
"id": user["id"], "username": user["username"],
"displayName": user["displayName"], "role": user["role"],
}})
@bp.route("/auth/logout", methods=["POST"])
def api_logout():
session.clear()
return jsonify({"ok": True})
핵심은 세 줄입니다.
bp = Blueprint("auth", __name__, url_prefix="/api")— 이름과 prefix 정의@bp.route(...)—@app.route대신 Blueprint에 등록from services.user_service import ...— 비즈니스 로직은 서비스 모듈로 위임
라우트 함수 내부는 이제 HTTP 입력 파싱 → 서비스 호출 → HTTP 출력 직렬화 의 얇은 레이어가 됐습니다. 비밀번호 해싱, 사용자 조회 같은 실제 로직은 services/user_service.py가 담당합니다.
8. 서비스 레이어: 분리의 진짜 가치
routes/와 services/를 왜 굳이 분리했을까요? 단순히 "파일이 작아 보이게 하려고"가 아니었습니다. 다음 리팩토링을 가능하게 만들기 위해서였습니다.
services/user_service.py (일부)
# services/user_service.py
"""User management service — password hashing, user CRUD."""
import os
import hashlib
import hmac
from config import USERS_FILE
from utils.helpers import _load_json, _save_json, _now, logger
_PBKDF2_ITER = 260_000 # NIST 권장 반복 횟수
def _hash_password(pw: str) -> str:
"""새 비밀번호를 PBKDF2-HMAC-SHA256으로 해시 (salt 포함)"""
salt = os.urandom(32)
dk = hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt, _PBKDF2_ITER)
return f"pbkdf2${_PBKDF2_ITER}${salt.hex()}${dk.hex()}"
def _verify_password(pw: str, stored: str) -> bool:
"""저장된 해시와 비교 (구형 SHA-256 해시도 자동 호환)"""
if stored.startswith('pbkdf2$'):
try:
_, iters, salt_hex, dk_hex = stored.split('$')
salt = bytes.fromhex(salt_hex)
dk = hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt, int(iters))
return hmac.compare_digest(dk.hex(), dk_hex) # timing-safe
except Exception:
return False
else:
old_hash = hashlib.sha256(pw.encode()).hexdigest()
return hmac.compare_digest(old_hash, stored)
def _get_users() -> dict:
users = _load_json(USERS_FILE, {})
if not users:
# 최초 실행 시 기본 admin 계정 생성
admin_pw = os.environ.get("ADMIN_PASSWORD", "admin1234")
users["admin"] = {
"id": "admin", "username": "admin", "displayName": "관리자",
"password": _hash_password(admin_pw),
"role": "admin", "createdAt": _now(), "lastLogin": "",
}
_save_json(USERS_FILE, users)
return users
def _save_users(users: dict):
_save_json(USERS_FILE, users)
def _get_user_by_id(uid: str):
return _get_users().get(uid)
주의 깊게 본 사람은 눈치채셨을 겁니다. 이 서비스 레이어는 여전히 JSON 파일 기반입니다. _load_json(USERS_FILE, {}) 같은 함수가 남아 있죠.
그런데 핵심은 routes/auth.py는 이 사실을 모른다는 것입니다. 라우트는 _get_users()를 호출할 뿐, 그것이 JSON이든 PostgreSQL이든 관심이 없습니다.
이것이 리팩토링의 진짜 보상입니다. 한 달 뒤 이 서비스 레이어가 다음과 같이 바뀌었을 때:
# 앞으로 PostgreSQL 전환 후 (다음 편에서 다룹니다)
from models.user import User
from extensions import db
def _get_users() -> dict:
users = db.session.query(User).all()
return {u.id: u.to_dict() for u in users}
def _save_users(users: dict):
# ORM 기반 저장
...
라우트 파일 9개 중 단 한 줄도 수정할 필요가 없었습니다. 함수 시그니처와 반환 포맷만 유지하면, 내부 구현은 자유롭게 교체할 수 있죠.
9. Blueprint 등록 진입점
아홉 개 Blueprint를 create_app()에 하나하나 import 하면 코드가 지저분해집니다. 그래서 routes/__init__.py에 집중 등록 함수를 만들었습니다.
# routes/__init__.py
def register_blueprints(app):
from routes.auth import bp as auth_bp
from routes.images import bp as images_bp
from routes.labels import bp as labels_bp
from routes.training import bp as training_bp
from routes.inference import bp as inference_bp
from routes.auto_label import bp as auto_label_bp
from routes.export import bp as export_bp
from routes.feedback import bp as feedback_bp
from routes.admin import bp as admin_bp
for blueprint in [
auth_bp, images_bp, labels_bp, training_bp, inference_bp,
auto_label_bp, export_bp, feedback_bp, admin_bp,
]:
app.register_blueprint(blueprint)
여기서도 함수 내부 import를 사용한 점을 보세요. routes/__init__.py가 로드되는 시점에 9개 Blueprint 파일이 전부 import 되면, 그 안에서 다시 services/를 import 하고, services가 다시 utils/를 import 하는 연쇄 반응이 일어나 순환 의존 위험이 커집니다. register_blueprints(app)이 create_app() 안에서 호출될 때까지 import를 지연시키는 것이 안전합니다.
10. 42개 경로 유지 검증
리팩토링 전후로 API 경로가 하나라도 달라지면 프론트엔드가 깨집니다. 그래서 커밋 직전에 Flask의 url_map을 덤프해서 before/after를 비교했습니다.
# scripts/dump_routes.py
from app import create_app
app = create_app()
rules = sorted(
(str(r), sorted(r.methods - {"HEAD", "OPTIONS"}))
for r in app.url_map.iter_rules()
)
for path, methods in rules:
print(f"{','.join(methods):10s} {path}")
# 리팩토링 전
$ git checkout HEAD~1
$ python scripts/dump_routes.py > routes_before.txt
# 리팩토링 후
$ git checkout HEAD
$ python scripts/dump_routes.py > routes_after.txt
# diff
$ diff routes_before.txt routes_after.txt
# (아무 출력 없음 = 완벽히 동일)
42개 경로가 전부 동일하게 유지되는 것을 확인한 뒤 PR을 올렸습니다. 이 검증 단계가 없었다면 리뷰어가 경로 하나하나 눈으로 찾아야 했을 것입니다.
11. 커밋 결과
최종 diff 요약입니다.
app.py | 1932 +---------------------------------------
config.py | 57 ++
extensions.py | 5 +
routes/__init__.py | 14 +
routes/admin.py | 183 ++++
routes/auth.py | 93 ++
routes/auto_label.py | 499 +++++++++++
routes/export.py | 180 ++++
routes/feedback.py | 72 ++
routes/images.py | 89 ++
routes/inference.py | 85 ++
routes/labels.py | 54 ++
routes/training.py | 171 ++++
services/activity_service.py | 19 +
services/auto_model_service.py | 24 +
services/export_service.py | 1 +
services/image_service.py | 33 +
services/inference_service.py | 163 ++++
services/label_service.py | 27 +
services/training_service.py | 174 ++++
services/user_service.py | 69 ++
utils/auth_decorators.py | 46 +
utils/helpers.py | 29 +
25 files changed, 2115 insertions(+), 1906 deletions(-)
app.py: 1,932줄 → 54줄 (−97%)- 25개 파일로 분산, 순수 추가량은 200줄 수준(도메인 로직 자체가 커진 게 아니라 경계선이 생긴 것)
- 프론트엔드 변경: 0건
- 42개 API 경로: 전부 동일
12. 핵심 개념 정리
| 개념 | 역할 | 파일 예 |
|---|---|---|
| Application Factory | create_app() 함수로 앱 인스턴스 지연 생성 |
app.py |
| Config 클래스 | 환경 설정을 객체로 추상화 + Fail-fast 검증 | config.py |
| Extensions 초기화 | Flask 확장(CORS, DB 등)을 한곳에 집중 | extensions.py |
| Blueprint | URL 그룹과 라우트 함수의 네임스페이스 | routes/auth.py |
| Service 모듈 | 비즈니스 로직을 라우트에서 분리 | services/user_service.py |
| Utils 패키지 | 도메인에 속하지 않는 공통 유틸 | utils/helpers.py |
| 지연 import | 순환 의존성을 함수 호출 시점으로 회피 | require_admin 내부 |
13. 베스트 프랙티스 체크리스트
이런 규모의 Flask 리팩토링을 하실 때 점검해 볼 항목입니다.
- [ ]
app을 모듈 레벨이 아닌create_app()함수 안에서 생성하나요? - [ ] Blueprint 이름과
url_prefix가 기존 경로 구조와 일치하나요? - [ ] 라우트 함수는 HTTP 입출력만 담당하고, 로직은 서비스로 위임하나요?
- [ ] 서비스 모듈은 Flask 객체(
request,session)를 직접 참조하지 않고 인자로 받나요? (단위 테스트 용이) - [ ] 설정 객체(
Config)가 필수 환경변수 누락 시 Fail-fast 하나요? - [ ]
url_map덤프로 리팩토링 전후 경로 동일성을 검증했나요? - [ ] 순환 import 위험이 있는 데코레이터/유틸은 지연 import를 썼나요?
- [ ] 다음 리팩토링(DB 교체, 테스트 추가)이 가능해진 구조인가요?
14. FAQ
Q1. 왜 Flask-RESTful이나 Flask-Smorest 같은 프레임워크를 쓰지 않았나요?
A. 프론트엔드가 이미 /api/* 경로 42개에 의존하고 있었고, OpenAPI 스키마 자동 생성 같은 추가 기능이 당장 필요하지 않았습니다. 추가 추상화의 학습 곡선보다 "순수 Blueprint로 단순 분리"의 이득이 컸습니다. 프로젝트가 더 성장해 문서화/검증 자동화가 필요해지면 그때 마이그레이션하면 됩니다.
Q2. 기술 스택(routes/, models/, serializers/) 중심 분리가 Django 스타일인데 왜 쓰지 않았나요?
A. 변경의 결합도가 기준이었습니다. "이미지 업로드 API에 필드 추가" 같은 요구가 오면, Django 스타일은 routes/, models/, serializers/, forms/ 네 폴더를 동시에 열어야 합니다. 도메인 중심이면 routes/images.py + services/image_service.py 두 파일만 만지면 됩니다. 이 프로젝트는 도메인이 9개로 상대적으로 좁아서 도메인 중심 분할이 더 깔끔했습니다.
Q3. 지연 import를 자주 쓰면 안티패턴 아닌가요?
A. 남용하면 그렇습니다. 하지만 데코레이터처럼 "import가 더 넓은 그래프의 출발점"인 곳에서는 정당한 기법입니다. 일반 비즈니스 로직 함수 안에서 지연 import를 쓴다면 의심해 봐야 합니다. 기준은 "이 import가 모듈 로드 시점에 정말 필요한가?"입니다.
Q4. create_app() 안에서 @app.route를 정의한 SPA fallback 부분은 Blueprint로 안 빼도 되나요?
A. SPA fallback은 단 두 개의 경로(/, /<path:path>)이고, STATIC_DIR과 TMPL_DIR에 강하게 결합된 "앱 전체의 정적 서빙" 이라 별도 Blueprint로 뺄 실익이 작았습니다. 의도적으로 팩토리에 남겼습니다. 필요하면 나중에 routes/static.py로 뺄 수 있습니다.
Q5. 리팩토링 중에 테스트가 없어서 불안하지 않았나요?
A. 매우 불안했습니다. 그래서 url_map 덤프 비교와 수동 Smoke Test(로그인 → 이미지 업로드 → 라벨 저장 → 학습 시작) 시나리오를 반복했습니다. 테스트 코드 작성은 다음 단계 과제였고, Blueprint 분리가 오히려 테스트를 쓰기 쉬운 구조를 만들어 준 셈입니다. create_app()만 있으면 pytest 픽스처로 독립 인스턴스를 띄울 수 있으니까요.
15. 참고 자료
- Flask 공식 문서 (Application Factories): 검색 키워드
flask application factory pattern - Flask 공식 문서 (Blueprints): 검색 키워드
flask blueprint url_prefix - Miguel Grinberg, Flask Mega-Tutorial — 대규모 Flask 구조화 권위 참고 자료
routes/__init__.py의 집중 등록 패턴은 커뮤니티에서register_blueprints로 널리 공유되는 레시피입니다
16. 다음 단계
라우트와 서비스를 분리한 진짜 목적은 데이터 레이어 교체였습니다. 지금 서비스 모듈은 JSON 파일을 직접 읽고 씁니다. 다음 편에서는 이 서비스 레이어의 함수 시그니처와 반환 포맷을 그대로 유지한 채, 내부 구현만 SQLAlchemy ORM + PostgreSQL로 교체하는 과정을 다룹니다. 프론트엔드는 물론이고 라우트 파일도 단 한 줄 수정하지 않습니다. 그게 가능해진 이유가 바로 이번 편의 리팩토링이었다는 것을, 다음 글에서 증명해 보일게요.
🐍 Flask 백엔드 실전 시리즈 (9부작)
- 모놀리식 app.py를 Blueprint로 분해하기 (현재 글)
- JSON 파일 DB에서 PostgreSQL로 마이그레이션
- ML 학습 백그라운드 실행: ProcessPoolExecutor
- Python/SQLAlchemy N+1 쿼리 잡기
- train과 val이 같은 폴더일 때의 조용한 ML 버그
- 하나의 백엔드로 Detection/Classification/Segmentation
- Fail-fast 설정 검증과 Path Traversal 방어
- Rate Limiting을 걷어낸 날: 폐쇄망 보안
- 작지만 기억할 만한 네 가지 교훈