Relationship 뒤채우기

Relationship 뒤채우기 (Relationship back_populates)

이제 관계 속성을 사용해서 데이터베이스에서 서로 연결된 데이터를 다루는 방법을 알게 됐어요! 🎉 이번 장에서는 잠깐 한 걸음 물러나서, 우리가 그 Relationship() 속성들을 어떻게 정의했는지 다시 점검하면서 그 back_populates 인자가 뭘 하는 건지 명확하게 다뤄볼게요. 🤓

출처: 공식문서

back_populates가 있는 관계

Relationship() 안의 그 back_populates 인자는 대체 뭘까요?

그 값은 다른 모델 클래스에 있는 속성의 이름을 담은 문자열이에요.

이 값을 주면 SQLModel은 "이 모델에서 뭔가가 바뀌면, 다른 모델의 그 속성도 함께 바꿔야 한다"고 알게 되고, 심지어 세션으로 커밋하기 전에도 그렇게 동작해요(커밋은 데이터의 새로고침을 강제하거든요).

예시를 통해 더 잘 이해해 볼게요.

불완전한 관계

먼저 back_populates 없이 불완전한 버전을 작성해서 어떻게 동작하는지 봅시다:

from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select


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()


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()

# Code below omitted 👇

👀 전체 파일 미리보기 (Full file preview)

from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select


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()


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()


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

engine = create_engine(sqlite_url, echo=True)


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


def create_heroes():
    with Session(engine) as session:
        team_preventers = Team(name="Preventers", headquarters="Sharp Tower")
        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
        )
        hero_rusty_man = Hero(
            name="Rusty-Man", secret_name="Tommy Sharp", age=48, team=team_preventers
        )
        hero_spider_boy = Hero(name="Spider-Boy", secret_name="Pedro Parqueador")
        session.add(hero_deadpond)
        session.add(hero_rusty_man)
        session.add(hero_spider_boy)
        session.commit()

        session.refresh(hero_deadpond)
        session.refresh(hero_rusty_man)
        session.refresh(hero_spider_boy)

        print("Created hero:", hero_deadpond)
        print("Created hero:", hero_rusty_man)
        print("Created hero:", hero_spider_boy)

        hero_spider_boy.team = team_preventers
        session.add(hero_spider_boy)
        session.commit()
        session.refresh(hero_spider_boy)
        print("Updated hero:", hero_spider_boy)

        hero_black_lion = Hero(name="Black Lion", secret_name="Trevor Challa", age=35)
        hero_sure_e = Hero(name="Princess Sure-E", secret_name="Sure-E")
        team_wakaland = Team(
            name="Wakaland",
            headquarters="Wakaland Capital City",
            heroes=[hero_black_lion, hero_sure_e],
        )
        session.add(team_wakaland)
        session.commit()
        session.refresh(team_wakaland)
        print("Team Wakaland:", team_wakaland)

        hero_tarantula = Hero(name="Tarantula", secret_name="Natalia Roman-on", age=32)
        hero_dr_weird = Hero(name="Dr. Weird", secret_name="Steve Weird", age=36)
        hero_cap = Hero(
            name="Captain North America", secret_name="Esteban Rogelios", age=93
        )

        team_preventers.heroes.append(hero_tarantula)
        team_preventers.heroes.append(hero_dr_weird)
        team_preventers.heroes.append(hero_cap)
        session.add(team_preventers)
        session.commit()
        session.refresh(hero_tarantula)
        session.refresh(hero_dr_weird)
        session.refresh(hero_cap)
        print("Preventers new hero:", hero_tarantula)
        print("Preventers new hero:", hero_dr_weird)
        print("Preventers new hero:", hero_cap)


def select_heroes():
    with Session(engine) as session:
        statement = select(Team).where(Team.name == "Preventers")
        result = session.exec(statement)
        team_preventers = result.one()

        print("Preventers heroes:", team_preventers.heroes)


def update_heroes():
    with Session(engine) as session:
        hero_spider_boy = session.exec(
            select(Hero).where(Hero.name == "Spider-Boy")
        ).one()

        preventers_team = session.exec(
            select(Team).where(Team.name == "Preventers")
        ).one()

        print("Hero Spider-Boy:", hero_spider_boy)
        print("Preventers Team:", preventers_team)
        print("Preventers Team Heroes:", preventers_team.heroes)

        hero_spider_boy.team = None

        print("Spider-Boy without team:", hero_spider_boy)

        print("Preventers Team Heroes again:", preventers_team.heroes)

        session.add(hero_spider_boy)
        session.commit()
        print("After committing")

        session.refresh(hero_spider_boy)
        print("Spider-Boy after commit:", hero_spider_boy)

        print("Preventers Team Heroes after commit:", preventers_team.heroes)


def main():
    create_db_and_tables()
    create_heroes()
    select_heroes()
    update_heroes()


if __name__ == "__main__":
    main()

데이터 객체 읽기

이제 Spider-Boy 히어로와, 별도로 Preventers 팀을 두 개의 select로 각각 가져와 볼게요.

이 부분이 어떻게 동작하는지는 이미 알기 때문에, select 문 · result 등으로 쪼개지 않고 한 번의 호출로 간결하게 쓸게요:

# Code above omitted 👆

def update_heroes():
    with Session(engine) as session:
        hero_spider_boy = session.exec(
            select(Hero).where(Hero.name == "Spider-Boy")
        ).one()

        preventers_team = session.exec(
            select(Team).where(Team.name == "Preventers")
        ).one()

# Code below omitted 👇

💡 팁

직접 코드를 작성할 때는 아마 이 스타일을 가장 자주 쓰게 될 거예요. 더 짧고 편리하면서도 자동 완성과 인라인 오류 검사는 그대로 누릴 수 있으니까요.

데이터 출력하기

이제 현재의 Spider-Boy, 현재의 Preventers 팀, 특히 현재 Preventers 팀의 hero 목록을 출력해 볼게요:

# Code above omitted 👆

def update_heroes():
    with Session(engine) as session:
        hero_spider_boy = session.exec(
            select(Hero).where(Hero.name == "Spider-Boy")
        ).one()

        preventers_team = session.exec(
            select(Team).where(Team.name == "Preventers")
        ).one()

        print("Hero Spider-Boy:", hero_spider_boy)
        print("Preventers Team:", preventers_team)
        print("Preventers Team Heroes:", preventers_team.heroes)

# Code below omitted 👇

여기까지는 전부 좋아요. 😊 특히 preventers_team.heroes를 출력한 결과는 이랬어요:

Preventers Team Heroes: [
        Hero(name='Rusty-Man', age=48, id=2, secret_name='Tommy Sharp', team_id=2),
        Hero(name='Spider-Boy', age=None, id=3, secret_name='Pedro Parqueador', team_id=2),
        Hero(name='Tarantula', age=32, id=6, secret_name='Natalia Roman-on', team_id=2),
        Hero(name='Dr. Weird', age=36, id=7, secret_name='Steve Weird', team_id=2),
        Hero(name='Captain North America', age=93, id=8, secret_name='Esteban Rogelios', team_id=2)
]

목록에 Spider-Boy가 포함되어 있는 걸 볼 수 있어요.

커밋 전에 객체 업데이트하기

이제 Spider-Boy를 업데이트해서, hero_spider_boy.team = None으로 설정해 팀에서 제외한 다음 이 객체를 다시 출력해 볼게요:

# Code above omitted 👆

def update_heroes():
    with Session(engine) as session:

# Code here omitted 👈

        hero_spider_boy.team = None

        print("Spider-Boy without team:", hero_spider_boy)

        print("Preventers Team Heroes again:", preventers_team.heroes)

# Code below omitted 👇

여기서 중요한 첫 번째 사실은, 아직 히어로를 커밋하지 않았기 때문에 hero 목록에 접근해도 자동 새로고침이 일어나지 않는다는 점이에요.

하지만 우리 코드에서는, 이 시점에 이미 "Spider-Boy는 더 이상 Preventers 소속이 아니다"라고 선언했죠. 🔥

💡 팁

나중에 세션을 커밋하지 않음으로써 되돌릴 수도 있지만, 지금 우리가 관심 있는 건 그게 아니에요.

코드의 이 지점에서, 메모리 안에서는 Preventers에 Spider-Boy가 포함되지 않을 것을 기대하고 있는 거예요.

hero_spider_boy를 팀 없이 출력한 결과는 이랬어요:

Spider-Boy without team: name='Spider-Boy' age=None id=3 secret_name='Pedro Parqueador' team_id=2 team=None

좋아요, teamNone으로 설정됐고, team_id 속성은 저장하기 전까지 팀 ID를 여전히 갖고 있어요. 하지만 지금 우리는 주로 관계 속성과 객체를 다루고 있으니 괜찮아요. ✅

그럼 이제 preventers_team.heroes를 출력하면 어떻게 될까요?

Preventers Team Heroes again: [
        Hero(name='Rusty-Man', age=48, id=2, secret_name='Tommy Sharp', team_id=2),
        Hero(name='Spider-Boy', age=None, id=3, secret_name='Pedro Parqueador', team_id=2, team=None),
        Hero(name='Tarantula', age=32, id=6, secret_name='Natalia Roman-on', team_id=2),
        Hero(name='Dr. Weird', age=36, id=7, secret_name='Steve Weird', team_id=2),
        Hero(name='Captain North America', age=93, id=8, secret_name='Esteban Rogelios', team_id=2)
]

어머나! 😱 Spider-Boy가 아직 목록에 남아 있네요!

커밋하고 출력하기

이제 커밋하고 다시 출력해 보면:

# Code above omitted 👆

def update_heroes():
    with Session(engine) as session:

# Code here omitted 👈

        session.add(hero_spider_boy)
        session.commit()
        print("After committing")

        session.refresh(hero_spider_boy)
        print("Spider-Boy after commit:", hero_spider_boy)

        print("Preventers Team Heroes after commit:", preventers_team.heroes)

# Code below omitted 👇

커밋 후에 preventers_team.heroes에 접근하면 새로고침이 일어나서, Spider-Boy가 빠진 최신 목록을 얻을 수 있어요. 괜찮아졌죠:

INFO Engine SELECT hero.id AS hero_id, hero.name AS hero_name, hero.secret_name AS hero_secret_name, hero.age AS hero_age, hero.team_id AS hero_team_id
FROM hero
WHERE ? = hero.team_id
2021-08-13 11:15:24,658 INFO sqlalchemy.engine.Engine [cached since 0.1924s ago] (2,)

Preventers Team Heroes after commit: [
        Hero(name='Rusty-Man', age=48, id=2, secret_name='Tommy Sharp', team_id=2),
        Hero(name='Tarantula', age=32, id=6, secret_name='Natalia Roman-on', team_id=2),
        Hero(name='Dr. Weird', age=36, id=7, secret_name='Steve Weird', team_id=2),
        Hero(name='Captain North America', age=93, id=8, secret_name='Esteban Rogelios', team_id=2)
]

커밋 후에는 Spider-Boy가 없으니 좋아요. 😊

하지만 아까 그 시점에는 여전히 불일치가 있었죠.

커밋하기 전에 객체를 사용한다면 오류가 생길 수 있어요. 😔

그걸 고쳐 볼게요. 🤓

back_populates로 고치기

그것이 바로 back_populates가 존재하는 이유예요. ✨

다시 추가해 볼게요:

from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select


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")

# Code below omitted 👇

그리고 나머지 코드는 그대로 두면 돼요:

# Code above omitted 👆

def update_heroes():
    with Session(engine) as session:

# Code here omitted 👈

        hero_spider_boy.team = None

        print("Spider-Boy without team:", hero_spider_boy)

        print("Preventers Team Heroes again:", preventers_team.heroes)

# Code below omitted 👇

💡 팁

이건 아까 hero_spider_boy.teamNone으로 업데이트했지만 아직 커밋하지 않은 바로 그 부분이에요.

아까 문제가 생겼던 바로 그 부분이죠.

결과 확인하기

이번에는 SQLModel(정확히는 SQLAlchemy)이 그 변화를 알아채고, 커밋하기 전에도 팀의 hero 목록을 자동으로 업데이트해 줄 거예요.

두 번째 출력은 이렇게 나올 거예요:

Preventers Team Heroes again: [
        Hero(name='Rusty-Man', age=48, id=2, secret_name='Tommy Sharp', team_id=2),
        Hero(name='Tarantula', age=32, id=6, secret_name='Natalia Roman-on', team_id=2),
        Hero(name='Dr. Weird', age=36, id=7, secret_name='Steve Weird', team_id=2),
        Hero(name='Captain North America', age=93, id=8, secret_name='Esteban Rogelios', team_id=2)
]

이제 Spider-Boy가 목록에 없는 걸 볼 수 있어요. back_populates 덕분에 고쳤어요! 🎉

back_populates의 값

이제 왜 back_populates가 필요한지 알았으니, 그 정확한 값을 다시 살펴볼게요.

아주 단순한 코드, 그냥 문자열 하나지만, 정확히 어떤 문자열을 넣어야 할지 생각해 보면 헷갈릴 수 있어요:

from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select


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")

# Code below omitted 👇

back_populates의 문자열은 현재 모델을 가리키는, 다른 모델의 속성 이름이에요.

그러니까 Team 클래스에는 heroes 속성이 있고, 그걸 Relationship(back_populates="team")으로 선언해요.

# Code above omitted 👆

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")

# Code below omitted 👇

여기서 back_populates="team"의 문자열 "team"은 다른 클래스(여기선 Hero)의 team 속성을 가리켜요.

그리고 Hero 클래스에는 team 속성을 Relationship(back_populates="heroes")으로 선언해요.

그러니 문자열 "heroes"Team 클래스의 heroes 속성을 가리키는 거예요.

# Code above omitted 👆

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")

# Code below omitted 👇

💡 팁

각 관계 속성은 back_populates를 통해 다른 모델의 상대 속성을 가리켜요.

단순한 코드지만 생각해 보면 헷갈릴 수 있어요 😵, 왜냐하면 같은 줄에 두 모델 모두와 관련된 개념이 여러 곳에 섞여 있기 때문이에요:

  • 현재 모델에 있다는 것만으로 이미 현재 모델과 관련이 있어요.
  • 속성의 이름은 다른 모델에 대한 거예요.
  • 타입 어노테이션도 다른 모델에 대한 거예요.
  • 그리고 back_populates는 다른 모델의 속성 중, 현재 모델을 가리키는 속성을 가리켜요.

back_populates를 기억하는 머릿속 요령

기억할 수 있는 머릿속 요령 하나를 소개할게요. back_populates의 문자열은 항상 지금 편집 중인 현재 모델 클래스에 관한 것이에요. 🤓

그러니까 Hero 클래스 안에 있다면, 다른 어떤 테이블(다른 어떤 모델 — Team이 될 수도, Weapon, Powers 등이 될 수도 있어요)과 연결하는 어떤 관계 속성의 back_populates 값이든, 그 값은 여전히 항상 이 현재 클래스(Hero) 를 가리켜요.

그래서 back_populates는 아마 거의 "hero""heroes" 같은 값일 거예요.

# Code above omitted 👆

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")

    weapon_id: int | None = Field(default=None, foreign_key="weapon.id")
    weapon: Weapon | None = Relationship(back_populates="hero")

    powers: list[Power] = Relationship(back_populates="hero")

# Code below omitted 👇

더 알아보기 (Learn more)