다대다 관계 갱신·제거

다대다 관계 갱신·제거 (Update and Remove Many-to-Many Relationships)

이제 이런 다대다 관계를 갱신하고 제거하는 법을 볼게요.

이전 코드에서 멈춘 지점부터 이어갈게요.

👀 전체 파일 미리 보기

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

class HeroTeamLink(SQLModel, table=True):
    team_id: int | None = Field(default=None, foreign_key="team.id", primary_key=True)
    hero_id: int | None = Field(default=None, foreign_key="hero.id", primary_key=True)

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="teams", link_model=HeroTeamLink)

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)

    teams: list[Team] = Relationship(back_populates="heroes", link_model=HeroTeamLink)

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",
            teams=[team_z_force, team_preventers],
        )
        hero_rusty_man = Hero(
            name="Rusty-Man",
            secret_name="Tommy Sharp",
            age=48,
            teams=[team_preventers],
        )
        hero_spider_boy = Hero(
            name="Spider-Boy", secret_name="Pedro Parqueador", teams=[team_preventers]
        )
        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("Deadpond:", hero_deadpond)
        print("Deadpond teams:", hero_deadpond.teams)
        print("Rusty-Man:", hero_rusty_man)
        print("Rusty-Man Teams:", hero_rusty_man.teams)
        print("Spider-Boy:", hero_spider_boy)
        print("Spider-Boy Teams:", hero_spider_boy.teams)

def main():
    create_db_and_tables()
    create_heroes()

if __name__ == "__main__":
    main()

출처: 공식문서

갱신할 데이터 가져오기

update_heroes() 함수를 만들어 볼게요.

Spider-BoyZ-Force 팀을 가져올 거예요.

이런 과정은 이미 익숙하니 짧은 버전을 써서 데이터를 한 개의 파이썬 문장으로 가져올게요.

그리고 이제 select()를 쓰니 그것도 import 해야 해요.

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

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

그리고 물론, update_heroes()를 우리의 main() 함수에 추가해야 해요:

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

다대다 관계 추가하기

이제 Spider-BoyZ-Force 팀이 정말 멋지다고 생각해서, 그곳에 가서 합류하기로 했다고 해 봅시다.

같은 관계 속성을 써서 hero_spider_boyteam_z_force.heroes에 넣을 수 있어요:

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

        team_z_force.heroes.append(hero_spider_boy)
        session.add(team_z_force)
        session.commit()

        print("Updated Spider-Boy's Teams:", hero_spider_boy.teams)
        print("Z-Force heroes:", team_z_force.heroes)

!!! tip "팁"

커밋 직후 모델의 속성에 `hero_spider_boy.teams`와 `team_z_force.heroes`로 접근하고 있으니, 데이터가 자동으로 새로고침돼요.

그래서 `session.refresh()`를 호출할 필요가 없어요.

그런 다음 변경을 커밋하고, 갱신된 Spider-Boy의 팀을 프린트해서 확인해요.

세션에는 Z-Forceadd하고 커밋한다는 점을 눈여겨보세요.

Spider-Boy는 세션에 추가하지도 않고, 심지어 새로고침도 하지 않아요. 그래도 그의 팀을 프린트하죠.

이게 여전히 올바르게 동작하는 이유는 모델의 Relationship()에서 back_populates를 쓰고 있기 때문이에요. 덕분에 SQLModel(실제로는 SQLAlchemy)이 변경 사항과 갱신을 추적해서, 다른 관련 모델의 관계에도 그 변경이 일어나도록 보장할 수 있어요. 🎉

프로그램 실행하기

커맨드 라인에서 프로그램을 실행하면 전부 잘 동작하는지 확인할 수 있어요:

$ uv run python app.py

// 이전 출력은 생략했어요 🙈

// 새 다대다 관계 만들기
INFO Engine INSERT INTO heroteamlink (team_id, hero_id) VALUES (?, ?)
INFO Engine [generated in 0.00020s] (1, 3)
INFO Engine COMMIT

// 새 자동 트랜잭션 시작
INFO Engine BEGIN (implicit)

// .teams 속성에 접근하는 동안 데이터 자동 새로고침
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
FROM hero
WHERE hero.id = ?
INFO Engine [generated in 0.00044s] (3,)
INFO Engine SELECT team.id AS team_id, team.name AS team_name, team.headquarters AS team_headquarters
FROM team, heroteamlink
WHERE ? = heroteamlink.hero_id AND team.id = heroteamlink.team_id
INFO Engine [cached since 0.1648s ago] (3,)

// Z-Force를 포함한 Spider-Boy 팀 프린트 🎉
Updated Spider-Boy's Teams: [
    Team(id=2, name='Preventers', headquarters='Sharp Tower'),
    Team(id=1, name='Z-Force', headquarters='Sister Margaret's Bar')
]

// .heroes 속성에 접근하는 동안 데이터 자동 새로고침
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
FROM hero, heroteamlink
WHERE ? = heroteamlink.team_id AND hero.id = heroteamlink.hero_id
INFO Engine [cached since 0.1499s ago] (1,)

// Spider-Boy를 포함한 Z-Force 히어로 프린트 🎉
Z-Force heroes: [
    Hero(name='Deadpond', age=None, id=1, secret_name='Dive Wilson'),
    Hero(name='Spider-Boy', age=None, id=3, secret_name='Pedro Parqueador', teams=[
        Team(id=2, name='Preventers', headquarters='Sharp Tower'),
        Team(id=1, name='Z-Force', headquarters='Sister Margaret's Bar', heroes=[...])
    ])
]

다대다 관계 제거하기

이제 Spider-Boy가 팀에 들어간 직후, 그들의 "생명 보존 정책"이 자기한테 익숙한 것보다 훨씬 느슨하다는 걸 깨달았다고 해 봅시다. 💀

게다가 산업 안전·보건도 그다지 좋지 않아요... 💥

그래서 Spider-BoyZ-Force를 떠나기로 결심해요.

관계를 갱신해서 hero_spider_boy.teams에서 team_z_force를 제거해 볼게요.

hero_spider_boy.teams는 그냥 리스트(SQLAlchemy가 관리하는 특별한 리스트지만 어쨌든 리스트)니까, 표준 리스트 메서드를 쓸 수 있어요.

이 경우 항목을 받아 리스트에서 제거하는 .remove() 메서드를 써요:

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

        team_z_force.heroes.append(hero_spider_boy)
        session.add(team_z_force)
        session.commit()

        print("Updated Spider-Boy's Teams:", hero_spider_boy.teams)
        print("Z-Force heroes:", team_z_force.heroes)

        hero_spider_boy.teams.remove(team_z_force)
        session.add(team_z_force)
        session.commit()

        print("Reverted Z-Force's heroes:", team_z_force.heroes)
        print("Reverted Spider-Boy's teams:", hero_spider_boy.teams)

그리고 이번에는, 다시 한번 보여주기 위해, back_populates 덕분에 SQLModel(실제로는 SQLAlchemy)이 관계로 모델들을 연결해 준다는 점을 이용해요. 우리는 hero_spider_boy 객체에서 작업을 수행했는데도(hero_spider_boy.teams를 수정), 세션에는 team_z_force를 추가해요. 그리고 hero_spider_boy는 아예 추가하지도 않고 커밋해요.

이게 여전히 동작하는 이유는 hero_spider_boy의 팀을 갱신하면 back_populates로 동기화되어 team_z_force에도 그 변경이 반영되기 때문이에요. 그래서 그것도 DB에 저장할 변경 사항(Spider-Boy가 제거됐다는)을 갖게 되는 거죠.

그리고 팀을 추가하고 변경을 커밋하면 team_z_force 객체가 갱신되고, hero_spider_boy와 연결돼 있던 테이블도 바뀌었으니 그것도 내부적으로 갱신됨으로 표시돼서 전부 잘 동작해요.

그런 다음 전부 제대로 됐는지 확인하려고 다시 프린트해요.

프로그램 다시 실행하기

마지막 부분이 잘 됐는지 확인하려면 프로그램을 다시 실행하면 돼요. 이런 출력이 나올 거예요:

$ uv run python app.py

// 이전 출력은 생략했어요 🙈

// 링크 테이블의 행 삭제
INFO Engine DELETE FROM heroteamlink WHERE heroteamlink.team_id = ? AND heroteamlink.hero_id = ?
INFO Engine [generated in 0.00043s] (1, 3)
// 변경 사항 저장
INFO Engine COMMIT

// 자동으로 새 트랜잭션 시작
INFO Engine BEGIN (implicit)

// .heroes 속성에 접근하는 동안 데이터 자동 새로고침
INFO Engine SELECT team.id AS team_id, team.name AS team_name, team.headquarters AS team_headquarters
FROM team
WHERE team.id = ?
INFO Engine [generated in 0.00029s] (1,)
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
FROM hero, heroteamlink
WHERE ? = heroteamlink.team_id AND hero.id = heroteamlink.hero_id
INFO Engine [cached since 0.5625s ago] (1,)

// 변경을 되돌린 후의 Z-Force 히어로 프린트
Reverted Z-Force's heroes: [
    Hero(name='Deadpond', age=None, id=1, secret_name='Dive Wilson')
]

// .teams 속성에 접근하는 동안 데이터 자동 새로고침
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
FROM hero
WHERE hero.id = ?
INFO Engine [cached since 0.4209s ago] (3,)
INFO Engine SELECT team.id AS team_id, team.name AS team_name, team.headquarters AS team_headquarters
FROM team, heroteamlink
WHERE ? = heroteamlink.hero_id AND team.id = heroteamlink.team_id
INFO Engine [cached since 0.5842s ago] (3,)

// 변경을 되돌린 후의 Spider-Boy 팀 프린트
Reverted Spider-Boy's teams: [
    Team(id=2, name='Preventers', headquarters='Sharp Tower')
]

// 저장되지 않았을 수 있는 이전 트랜잭션을 자동으로 롤백
INFO Engine ROLLBACK

요약

링크 모델과 관계 속성을 설정한 뒤에는 다대다 관계를 갱신하고 제거하는 게 꽤 간단해요.

흔한 리스트 연산을 쓰면 돼요. 🚀

더 알아보기 (Learn more)