Skip to main content
Summary

routes.py 하나도 언젠가는 길어진다

지난 장에서는 app.py 하나에 모여 있던 코드를 board/ 패키지로 옮겼습니다. create_app() 함수에서 Flask 앱을 만들고, db.py에는 데이터베이스 코드를, routes.py에는 화면과 URL 코드를 두었습니다.

이것만으로도 구조가 훨씬 좋아졌습니다. 하지만 게시판 앱이 조금 더 커진다고 생각해 봅시다.

  • 게시글 목록, 상세, 작성, 수정, 삭제

  • 회원가입, 로그인, 로그아웃

  • 관리자용 게시글 관리

  • 댓글 작성과 삭제

  • JSON API

이 모든 라우트가 다시 routes.py 하나에 들어가면 파일 이름만 바뀌었을 뿐, 처음의 app.py 문제로 돌아갑니다. 그래서 Flask에는 Blueprint라는 도구가 있습니다.

Blueprint는 여러 라우트를 기능 단위로 묶어 주는 상자입니다. 게시글 기능은 posts 상자에, 인증 기능은 auth 상자에, 관리자 기능은 admin 상자에 담는 식입니다. Flask 공식 문서는 Blueprint를 애플리케이션에 나중에 등록할 작업을 기록하는 객체로 설명합니다.1 쉽게 말해, Blueprint는 "이 URL들과 함수들을 한 묶음으로 앱에 붙이겠습니다"라고 적어 둔 설계도입니다.

블루프린트로 기능별 모듈 나누기AI 생성 이미지

Blueprint의 기본 모양

Blueprint를 쓰기 전에는 라우트를 보통 이렇게 작성했습니다.

@app.route('/posts')
def post_list():
    return '게시글 목록'

이 코드는 app 객체에 바로 라우트를 붙입니다. Blueprint를 사용하면 먼저 bp라는 묶음을 만들고, 그 묶음에 라우트를 붙입니다.

from flask import Blueprint

bp = Blueprint('posts', __name__)

@bp.route('/posts')
def list_posts():
    return '게시글 목록'

차이는 작아 보이지만 중요합니다. 이제 이 라우트는 Flask 앱에 바로 붙은 것이 아니라 posts라는 Blueprint에 들어 있습니다. 실제 앱에 연결하려면 create_app() 안에서 등록해야 합니다.

from . import posts
app.register_blueprint(posts.bp)

Blueprint를 쓰면 URL이 사라지는 것이 아닙니다. URL과 함수의 연결은 그대로 있습니다. 다만 연결을 기능별 파일에 나누어 담을 수 있습니다.

게시글 라우트를 posts.py로 옮기기

먼저 프로젝트 구조를 다음처럼 바꾸겠습니다.

flask-study/
├─ board/
│  ├─ __init__.py
│  ├─ db.py
│  ├─ posts.py
│  ├─ auth.py
│  ├─ templates/
│  │  ├─ base.html
│  │  ├─ index.html
│  │  ├─ posts/
│  │  │  ├─ list.html
│  │  │  ├─ detail.html
│  │  │  └─ create.html
│  │  └─ auth/
│  │     ├─ login.html
│  │     └─ register.html
│  └─ static/
│     └─ style.css
└─ instance/
   └─ board.sqlite3

기존 templates/posts.html은 templates/posts/list.html로, post_detail.html은 templates/posts/detail.html로, post_create.html은 templates/posts/create.html로 옮긴다고 생각하면 됩니다. 폴더를 하나 더 만든 이유는 이름 충돌을 줄이기 위해서입니다. 나중에 인증에도 list.html이 생기고 관리자에도 list.html이 생길 수 있습니다. posts/list.html, auth/login.html처럼 기능 이름을 앞에 붙이면 찾기 쉽습니다.

이제 board/posts.py를 만듭니다. 지난 장에서 데이터베이스 테이블의 작성자 컬럼 이름을 author_email로 만들었으므로, 여기서도 같은 이름을 사용하겠습니다.

from flask import Blueprint, abort, flash, redirect, render_template, request, url_for

from .db import get_db

bp = Blueprint('posts', __name__, url_prefix='/posts')


def validate_post(form):
    errors = {}

    if not form['title']:
        errors['title'] = '제목을 입력하세요.'
    if not form['author_email']:
        errors['author_email'] = '이메일을 입력하세요.'
    if not form['content']:
        errors['content'] = '내용을 입력하세요.'

    return errors


@bp.route('/')
def list_posts():
    db = get_db()
    posts = db.execute('''
        SELECT id, title, author_email, created_at
        FROM posts
        ORDER BY id DESC
    ''').fetchall()

    return render_template('posts/list.html', posts=posts)


@bp.route('/<int:post_id>')
def detail(post_id):
    db = get_db()
    post = db.execute('''
        SELECT id, title, author_email, content, created_at
        FROM posts
        WHERE id = ?
    ''', (post_id,)).fetchone()

    if post is None:
        abort(404)

    return render_template('posts/detail.html', post=post)


@bp.route('/create', methods=('GET', 'POST'))
def create():
    if request.method == 'POST':
        form = {
            'title': request.form.get('title', '').strip(),
            'author_email': request.form.get('author_email', '').strip(),
            'content': request.form.get('content', '').strip(),
        }
        errors = validate_post(form)

        if errors:
            flash('입력 내용을 다시 확인해 주세요.', 'error')
            return render_template('posts/create.html', form=form, errors=errors)

        db = get_db()
        db.execute('''
            INSERT INTO posts (title, author_email, content)
            VALUES (?, ?, ?)
        ''', (form['title'], form['author_email'], form['content']))
        db.commit()

        flash('게시글이 저장되었습니다.', 'success')
        return redirect(url_for('posts.list_posts'))

    form = {
        'title': '',
        'author_email': '',
        'content': '',
    }
    return render_template('posts/create.html', form=form, errors={})

핵심은 첫 부분의 이 코드입니다.

bp = Blueprint('posts', __name__, url_prefix='/posts')

'posts'는 Blueprint의 이름입니다. 이 이름은 url_for()에서 사용됩니다. __name__은 Flask가 이 Blueprint가 어느 파이썬 모듈에 있는지 알 수 있게 도와줍니다. url_prefix='/posts'는 이 Blueprint 안의 모든 URL 앞에 /posts를 붙입니다.

그래서 위 코드는 다음 URL을 만듭니다.

함수라우트실제 URL
list_posts()//posts/
detail(post_id)/<int:post_id>/posts/1
create()/create/posts/create

지난 장에서는 게시글 목록 주소를 /posts로 보았습니다. 이번 코드에서는 url_prefix='/posts'와 @bp.route('/')를 함께 사용하므로 실제 주소가 /posts/처럼 끝에 슬래시가 붙은 형태가 됩니다. 브라우저에서 /posts로 접속해도 Flask가 보통 /posts/로 안내해 주지만, 템플릿 링크는 url_for('posts.list_posts')로 만드는 편이 가장 안전합니다.

url_prefix를 쓰면 같은 단어를 반복하지 않아도 됩니다. 매번 @bp.route('/posts/create'), @bp.route('/posts/<int:post_id>')처럼 쓰지 않아도 됩니다. 게시글 기능은 전부 /posts 아래에 모인다는 규칙이 코드에 드러납니다.

인증 라우트를 auth.py로 분리하기

아직 회원가입과 로그인 기능은 완성하지 않았습니다. 뒤에서 실제 기능을 채울 예정입니다. 하지만 지금은 구조를 먼저 잡아 보겠습니다. board/auth.py를 만듭니다.

from flask import Blueprint, render_template

bp = Blueprint('auth', __name__, url_prefix='/auth')


@bp.route('/register', methods=('GET', 'POST'))
def register():
    return render_template('auth/register.html')


@bp.route('/login', methods=('GET', 'POST'))
def login():
    return render_template('auth/login.html')

Flask 공식 튜토리얼도 인증 기능을 별도 모듈의 Blueprint로 만들고, url_prefix='/auth'를 사용해 /auth/register 같은 URL을 구성합니다.2 우리 예제도 같은 흐름을 따릅니다.

이제 URL 규칙이 깔끔해집니다.

기능BlueprintURL 예시
게시글posts/posts/, /posts/create, /posts/3
인증auth/auth/register, /auth/login
관리자admin/admin/posts, /admin/users

이런 규칙은 실무에서도 중요합니다. URL만 보아도 어느 기능에 속하는지 짐작할 수 있기 때문입니다.

create_app()에서 Blueprint 등록하기

Blueprint 파일을 만들었다고 자동으로 적용되지는 않습니다. 반드시 Flask 앱에 등록해야 합니다.

board/__init__.py의 create_app() 안에서 기존 routes.init_app(app) 부분을 posts와 auth Blueprint 등록으로 바꿉니다. 다만 지난 장의 routes.py 안에는 / 주소를 처리하는 index() 함수도 있었습니다. routes.init_app(app)을 완전히 제거하면 첫 화면 라우트까지 함께 사라질 수 있으므로, 여기서는 간단한 첫 화면 라우트를 create_app() 안에 남겨 두겠습니다.

import os
from flask import Flask, render_template


def create_app(test_config=None):
    app = Flask(__name__, instance_relative_config=True)

    app.config.from_mapping(
        SECRET_KEY='dev',
        DATABASE=os.path.join(app.instance_path, 'board.sqlite3'),
    )

    if test_config is None:
        app.config.from_pyfile('config.py', silent=True)
    else:
        app.config.from_mapping(test_config)

    os.makedirs(app.instance_path, exist_ok=True)

    from . import db
    db.init_app(app)

    @app.route('/')
    def index():
        return render_template('index.html')

    from . import posts
    app.register_blueprint(posts.bp)

    from . import auth
    app.register_blueprint(auth.bp)

    return app

중요한 순서는 이렇습니다.

  1. app = Flask(...)로 앱을 만든다.

  2. 설정을 읽는다.

  3. 데이터베이스 초기화 함수를 등록한다.

  4. 첫 화면처럼 아주 공통적인 라우트가 있으면 등록한다.

  5. posts, auth Blueprint를 가져온다.

  6. app.register_blueprint(...)로 앱에 붙인다.

posts.py와 auth.py 안에서 app을 직접 import하지 않는다는 점을 눈여겨보세요. 각 파일은 자기 Blueprint만 만들고, 최종 조립은 create_app()이 담당합니다. 이 방식이 프로젝트가 커질 때 훨씬 안전합니다.

물론 첫 화면도 별도 Blueprint로 뺄 수 있습니다. 예를 들어 나중에 main.py를 만들고 main Blueprint를 등록하는 식입니다. 하지만 지금 단계에서는 게시글과 인증을 나누는 흐름에 집중하기 위해 / 라우트만 create_app() 안에 간단히 두겠습니다.

url_for() 이름도 바뀐다

Blueprint를 사용하면 엔드포인트 이름이 바뀝니다. 엔드포인트는 url_for()가 URL을 만들 때 사용하는 내부 이름입니다.

예전에는 이렇게 썼을 수 있습니다.

<a href="{{ url_for('post_detail', post_id=post.id) }}">자세히 보기</a>

Blueprint 안의 라우트는 보통 블루프린트이름.함수이름 형식으로 부릅니다. 따라서 다음처럼 바꿔야 합니다.

<a href="{{ url_for('posts.detail', post_id=post.id) }}">자세히 보기</a>
<a href="{{ url_for('posts.create') }}">글쓰기</a>
<a href="{{ url_for('auth.login') }}">로그인</a>

posts.detail에서 posts는 Blueprint 이름이고, detail은 함수 이름입니다. 점 하나가 기능 영역과 실제 함수를 나누는 표시라고 생각하면 됩니다.

게시글 목록 링크도 마찬가지입니다. 위 코드에서는 목록 함수 이름이 list_posts()이므로 다음처럼 씁니다.

<a href="{{ url_for('posts.list_posts') }}">게시글 목록</a>

만약 기존 이름을 그대로 두면 다음과 비슷한 오류를 만날 수 있습니다.

werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'post_detail'

이 오류는 Flask가 post_detail이라는 엔드포인트를 찾지 못했다는 뜻입니다. Blueprint로 옮긴 뒤에는 posts.detail처럼 새 이름을 사용해야 합니다.

템플릿을 옮길 때는 파일 이름만 바꾸는 데서 끝나지 않습니다. 안에 들어 있는 링크도 함께 확인해야 합니다.

<!-- 예전 방식 -->
<a href="{{ url_for('post_create') }}">글쓰기</a>

<!-- Blueprint 적용 후 -->
<a href="{{ url_for('posts.create') }}">글쓰기</a>

이 작은 차이를 놓치면 화면은 잘 뜨는데 링크를 누르는 순간 오류가 날 수 있습니다.

템플릿과 정적 파일은 어디에 둘까

Blueprint는 템플릿 폴더와 정적 파일 폴더도 가질 수 있습니다. 예를 들어 게시글 기능을 완전히 독립적으로 만들고 싶다면 이런 구조도 가능합니다.

board/
├─ posts.py
├─ posts_templates/
│  └─ posts/
│     └─ list.html
└─ posts_static/
   └─ posts.css

그리고 Blueprint를 만들 때 이렇게 지정할 수 있습니다.

bp = Blueprint(
    'posts',
    __name__,
    url_prefix='/posts',
    template_folder='posts_templates',
    static_folder='posts_static',
)

장점은 기능별 파일을 한곳에 모을 수 있다는 것입니다. 게시글 기능을 옮기거나 재사용할 때 편합니다.

하지만 초보 단계에서는 먼저 하나의 templates/ 폴더와 하나의 static/ 폴더를 추천합니다. 이유는 단순합니다.

  • 공통 레이아웃인 base.html을 찾기 쉽습니다.

  • CSS 파일을 한곳에서 관리하기 쉽습니다.

  • 템플릿 검색 순서와 이름 충돌을 덜 신경 써도 됩니다.

대신 템플릿 이름만 기능별 폴더로 나누면 됩니다.

board/templates/
├─ base.html
├─ index.html
├─ posts/
│  ├─ list.html
│  ├─ detail.html
│  └─ create.html
└─ auth/
   ├─ login.html
   └─ register.html

이 정도만 해도 충분히 깔끔합니다. Blueprint별 템플릿 폴더와 정적 파일 폴더는 프로젝트가 더 커진 뒤에 선택해도 늦지 않습니다.

순환 import 오류를 줄이는 구조

프로젝트를 나누다 보면 자주 만나는 오류가 있습니다.

ImportError: cannot import name 'app' from partially initialized module

초보자에게는 무섭게 보이지만 원리는 단순합니다. A 파일이 B 파일을 import하고, B 파일이 다시 A 파일을 import하면 파이썬이 어느 쪽을 먼저 완성해야 할지 헷갈립니다. 이것을 순환 import라고 합니다. 두 사람이 서로 먼저 지나가라고 양보하다가 문 앞에서 멈춰 선 상황과 비슷합니다.

Blueprint 구조에서는 다음 원칙을 지키면 순환 import를 줄일 수 있습니다.

첫째, posts.py나 auth.py에서 app을 import하지 않습니다.

# 피하세요
from board import app

대신 각 파일은 자기 Blueprint만 만듭니다.

bp = Blueprint('posts', __name__, url_prefix='/posts')

둘째, Blueprint 등록은 create_app() 안에서만 합니다.

from . import posts
app.register_blueprint(posts.bp)

셋째, 공통으로 쓰는 코드는 별도 파일로 뺍니다. 데이터베이스는 db.py, 입력 검증은 validators.py, 로그인 확인 도구는 나중에 auth.py 안의 데코레이터나 별도 유틸 파일로 정리할 수 있습니다.

넷째, 다른 기능의 URL이 필요할 때 파일을 직접 import하지 말고 url_for()를 사용합니다.

return redirect(url_for('auth.login'))

이 코드는 auth.py를 직접 가져오지 않습니다. Flask에 등록된 엔드포인트 이름으로 URL을 만들 뿐입니다. 그래서 파일 사이의 의존성이 줄어듭니다.

이번 장의 정리

Blueprint는 Flask 앱을 기능별로 나누는 핵심 도구입니다. 작은 앱에서는 없어도 괜찮지만, 게시글·인증·관리자·API처럼 기능이 늘어나면 거의 반드시 필요해집니다.

이번 장에서 기억할 내용은 여섯 가지입니다.

  • Blueprint는 여러 라우트를 하나의 기능 단위로 묶습니다.

  • posts.py, auth.py처럼 파일을 나누면 코드를 찾기 쉬워집니다.

  • url_prefix를 사용하면 /posts, /auth, /admin 같은 URL 규칙을 일관되게 만들 수 있습니다.

  • Blueprint 안의 엔드포인트는 posts.detail, posts.create, auth.login처럼 블루프린트이름.함수이름 형식으로 바뀝니다.

  • 템플릿과 정적 파일은 처음에는 공통 폴더에 두고, 템플릿 경로만 posts/list.html처럼 나누는 방식이 단순합니다.

  • app을 각 모듈에서 직접 import하지 않고, create_app()에서 Blueprint를 등록하면 순환 import 오류를 줄일 수 있습니다.

이제 프로젝트는 중간 규모로 성장할 준비가 되었습니다. 다음에는 auth Blueprint 안에 실제 회원가입과 로그인 기능을 채워 넣을 수 있습니다.

참고

1Modular Applications with Blueprints — Flask Documentation (3.1.x)

2Blueprints and Views — Flask Documentation (3.1.x)

책 목록

초보자를 위한 파이썬 Flask 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기