코드 구조와 여러 파일

코드 구조와 여러 파일 (Code Structure and Multiple Files)

이번 장에서는 특히 여러 파일로 구성된 큰 프로젝트에서, 코드를 어떻게 구조화할지를 잠깐 생각해 볼게요. SQLModel 프로젝트를 깔끔하게 나누는 방법이에요.

출처: 공식문서

순환 import (Circular Imports)

Hero 클래스는 내부적으로 Team 클래스를 참조해요.

하지만 Team 클래스도 Hero 클래스를 참조하죠.

그래서 이 두 클래스가 각각 별도 파일에 있고, 서로의 파일에서 그 클래스들을 직접 import 하려 하면 순환 import(circular import) 가 생겨요. 🔄

그리고 파이썬은 이를 처리하지 못하고 오류를 던질 거예요. 🚨

하지만 우리는 실제로 그 순환 참조의미하고 싶어요. 우리 코드에서는 이런 멋진 일을 할 수 있으니까요.

hero.team.heroes[0].team.heroes[1].team.heroes[2].name

그리고 그 순환 참조가 바로 우리가 이 관계 속성(relationship attributes) 으로 표현하는 것이에요.

  • 히어로는 팀을 가질 수 있고
    • 그 팀은 히어로 목록을 가질 수 있고
      • 그 각 히어로는 팀을 가질 수 있고
        • ...계속 이어지죠.

이것을 고려해 코드를 구조화하는 여러 전략을 볼게요.

모델을 한 파일에 (Single Module for Models)

이게 가장 단순한 방법이에요. ✨

이 해결책에서도 우리는 여전히 models, database, app을 위해 여러 파일을 사용해요.

그리고 필요한 다른 파일들도 있을 수 있죠.

하지만 이 첫 번째 경우에는, 모든 모델이 단일 파일에 들어갈 거예요.

프로젝트의 파일 구조는 이렇게 될 수 있어요.

.
├── project
    ├── __init__.py
    ├── app.py
    ├── database.py
    └── models.py

우리는 3개의 파이썬 모듈(또는 파일)이 있어요.

  • app
  • database
  • models

그리고 이 프로젝트를 "Python package"(파이썬 모듈의 모음)로 만들기 위해 빈 __init__.py 파일도 있어요. 이렇게 하면 app.py 파일/모듈에서 상대 import(relative imports) 를 쓸 수 있어요.

from .models import Hero, Team
from .database import engine

이 상대 import를 쓸 수 있는 이유는, 예를 들어 app.py(즉 app 모듈)에서 파이썬이 이 파일이 __init__.py와 같은 디렉터리에 있기 때문에 우리 Python package의 일부임을 알기 때문이에요. 그리고 같은 디렉터리의 모든 파이썬 파일도 같은 Python package의 일부예요.

모델 파일 (Models File)

모든 데이터베이스 모델을 단일 파이썬 모듈(단일 파이썬 파일), 예를 들어 models.py에 넣으면 돼요.

from sqlmodel import Field, Relationship, SQLModel


class Team(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    headquarters: str

    heroes: list["Hero"] = Relationship(back_populates="team")


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: int | None = Field(default=None, index=True)

    team_id: int | None = Field(default=None, foreign_key="team.id")
    team: Team | None = Relationship(back_populates="heroes")

이렇게 하면 다른 모델들 때문에 순환 import를 다룰 필요가 없어져요.

그리고 나서 애플리케이션의 어떤 다른 파일/모듈에서도 이 파일에서 모델들을 import 하면 됩니다.

데이터베이스 파일 (Database File)

그리고 나서 engine을 만드는 코드와 모든 테이블을 만드는 함수(마이그레이션을 쓰지 않는다면)를 다른 파일 database.py에 넣을 수 있어요.

from sqlmodel import SQLModel, create_engine

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

이 파일은 공유 engine을 사용하고 create_db_and_tables() 함수를 얻고 호출하기 위해 애플리케이션 코드에서도 import 될 거예요.

애플리케이션 파일 (Application File)

마지막으로, app을 만드는 코드를 다른 파일 app.py에 넣을 수 있어요.

from sqlmodel import Session

from .database import create_db_and_tables, engine
from .models import Hero, Team


def create_heroes():
    with Session(engine) as session:
        team_z_force = Team(name="Z-Force", headquarters="Sister Margaret's Bar")

        hero_deadpond = Hero(
            name="Deadpond", secret_name="Dive Wilson", team=team_z_force
        )
        session.add(hero_deadpond)
        session.commit()

        session.refresh(hero_deadpond)

        print("Created hero:", hero_deadpond)
        print("Hero's team:", hero_deadpond.team)


def main():
    create_db_and_tables()
    create_heroes()


if __name__ == "__main__":
    main()

여기서 우리는 모델, engine, 그리고 모든 테이블을 만드는 함수를 import 해서 그들 모두를 내부에서 사용할 수 있어요.

순서가 중요하다 (Order Matters)

SQLModel.metadata.create_all()을 호출할 때 순서가 중요하다는 걸 기억하나요?

그 문서 섹션의 핵심은 모델이 있는 모듈을 먼저 import 한 다음 SQLModel.metadata.create_all()을 호출해야 한다는 거예요.

우리는 여기서 그렇게 하고 있어요. app.py에서 모델을 import 하고, 그 다음에 데이터베이스와 테이블을 만드니까, 문제없이 모든 것이 올바르게 동작해요. 👌

명령줄에서 실행하기

이제 이건 더 큰 프로젝트이고 Python package이지 단일 파이썬 파일이 아니기 때문에, 이전처럼 단일 파일 이름만 넘겨서는 호출할 수 없어요.

$ uv run python app.py

이제 파이썬에게 package의 일부인 모듈 을 실행하고 싶다고 알려줘야 해요.

$ uv run python -m project.app

-m은 파이썬에게 모듈 을 호출하라고 알려주는 거예요. 그리고 그다음 우리는 project.app이라는 문자열을 넘겨요. 이는 import에서 쓰는 것과 같은 형식이에요.

import project.app

그러면 파이썬은 그 package 안에서 그 모듈을 실행할 거고, 파이썬이 그것을 직접 실행하기 때문에 app.py에 있는 main block의 비법도 여전히 동작해요.

if __name__ == '__main__':
    main()

그래서 출력은 이렇게 돼요.

$ uv run python -m project.app

Created hero: id=1 secret_name='Dive Wilson' team_id=1 name='Deadpond' age=None
Hero's team: name='Z-Force' headquarters='Sister Margaret's Bar' id=1

순환 import 동작시키기 (Make Circular Imports Work)

어떤 이유로든 모든 데이터베이스 모델을 한 파일에 모아두는 아이디어가 싫고, 정말로 별도 파일: hero_model.py 파일과 team_model.py 파일을 갖고 싶다고 해볼게요.

그렇게 할 수도 있어요. 😎 몇 가지 기억할 점이 있긴 한데요. 🤓

경고

이건 조금 더 고급 내용이에요.

위의 해결책이 이미 잘 동작했다면, 그것으로 충분할 수도 있고, 다음 장으로 넘어가도 돼요. 🤓

이제 파일 구조가 이렇게 됐다고 가정해 볼게요.

.
├── project
    ├── __init__.py
    ├── app.py
    ├── database.py
    ├── hero_model.py
    └── team_model.py

순환 import와 타입 어노테이션 (Circular Imports and Type Annotations)

순환 import의 문제는 파이썬이 그것을 런타임(runtime)에서 해결할 수 없다는 거예요.

하지만 파이썬 타입 어노테이션을 쓸 때는 다른 파일에서 import 한 클래스로 어떤 변수의 타입을 선언해야 하는 경우가 아주 흔해요.

그리고 그 클래스가 있는 파일들도 첫 번째 파일에서 더 많은 것들을 import 해야 할 수도 있어요.

그리고 이는 파이썬이 런타임 에서 지원하지 않는 같은 순환 import요구 하게 됩니다.

타입 어노테이션과 런타임 (Type Annotations and Runtime)

하지만 우리가 선언하고 싶은 이 타입 어노테이션런타임 에는 필요하지 않아요.

사실, 우리가 list["Hero"]를 썼고, "Hero"를 문자열로 넣었다는 걸 기억하나요?

파이썬에게 런타임에서는, 그것은 그냥 문자열이에요.

그래서 필요한 타입 어노테이션을 문자열 버전으로 추가할 수 있다면 파이썬은 문제가 없을 거예요.

하지만 아무것도 import 하지 않고 타입 어노테이션에 문자열만 넣으면, 에디터가 우리가 무슨 뜻인지 몰라서 자동완성인라인 오류로 도와줄 수 없어요.

그래서 코드를 편집하는 동안에는 "import 된" 것처럼 동작하지만 런타임 에는 그렇지 않은 것들을 "import" 할 방법이 있다면 해결될 거예요... 그리고 그게 존재해요! 바로 그거예요. 🎉

TYPE_CHECKING으로 편집 중에만 import 하기

이를 해결하려면, typing 모듈의 특별한 변수 TYPE_CHECKING과 함께 쓰는 특별한 비법이 있어요.

타입 어노테이션으로 코드를 분석하는 에디터와 도구에서는 이 값이 True예요.

하지만 파이썬이 실행할 때는 그 값이 False입니다.

그래서 if 블록에서 그것을 사용하고 그 if 블록 안에서 import 하면, 그것들은 에디터에게만 "import 된" 것이 되고 런타임에는 그렇지 않아요.

Hero 모델 파일 (Hero Model File)

TYPE_CHECKING 비법을 사용해 hero_model.py에서 Team을 "import" 할 수 있어요.

from typing import TYPE_CHECKING, Optional

from sqlmodel import Field, Relationship, SQLModel

if TYPE_CHECKING:
    from .team_model import Team


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: int | None = Field(default=None, index=True)

    team_id: int | None = Field(default=None, foreign_key="team.id")
    team: Optional["Team"] = Relationship(back_populates="heroes")

이제 우리는 Team의 어노테이션을 문자열로, "Team"으로 넣어야 _한다_는 점을 명심하세요. 그래야 런타임에서 오류가 나지 않아요.

Team 모델 파일 (Team Model File)

team_model.py 파일에서도 같은 비법을 사용해요.

from typing import TYPE_CHECKING

from sqlmodel import Field, Relationship, SQLModel

if TYPE_CHECKING:
    from .hero_model import Hero


class Team(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    headquarters: str

    heroes: list["Hero"] = Relationship(back_populates="team")

이제 우리는 에디터 지원, 자동완성, 인라인 오류를 받고, SQLModel도 계속 동작해요. 🎉

앱 파일 (App File)

이제 완성도를 위해, app.py 파일은 두 모듈에서 모델들을 import 하게 돼요.

from sqlmodel import Session

from .database import create_db_and_tables, engine
from .hero_model import Hero
from .team_model import Team


def create_heroes():
    with Session(engine) as session:
        team_z_force = Team(name="Z-Force", headquarters="Sister Margaret's Bar")

        hero_deadpond = Hero(
            name="Deadpond", secret_name="Dive Wilson", team=team_z_force
        )
        session.add(hero_deadpond)
        session.commit()

        session.refresh(hero_deadpond)

        print("Created hero:", hero_deadpond)
        print("Hero's team:", hero_deadpond.team)


def main():
    create_db_and_tables()
    create_heroes()


if __name__ == "__main__":
    main()

그리고 당연히 TYPE_CHECKING과 문자열 타입 어노테이션의 모든 비법은 순환 import가 있는 파일에서만 필요해요.

app.py에는 순환 import가 없으니, 여기서는 일반 import를 쓰고 클래스들을 평범하게 사용하면 돼요.

그리고 실행하면 이전과 같은 결과를 얻어요.

$ uv run python -m project.app

Created hero: id=1 age=None name='Deadpond' secret_name='Dive Wilson' team_id=1
Hero's team: id=1 name='Z-Force' headquarters='Sister Margaret's Bar'

요약 (Recap)

가장 단순한 경우(대부분의 경우)에는 모든 모델을 단일 파일에 두고, 나머지 애플리케이션(engine 설정 포함)을 원하는 만큼의 파일로 구조화하면 돼요.

그리고 정말로 모든 모델을 별도 파일로 나눠야 하는 복잡한 경우에는 TYPE_CHECKING을 사용해 모든 것이 동작하게 하면서, 최고의 에디터 지원으로 최상의 개발자 경험을 유지할 수 있어요. ✨

더 알아보기 (Learn more)