추가 필드가 있는 링크 모델
추가 필드가 있는 링크 모델 (Link Model with Extra Fields)
이전 예제에서는 HeroTeamLink 모델을 직접 다룬 적이 없었어요. 모든 게 자동 다대다 관계를 통해 이뤄졌죠.
하지만 두 모델 사이의 링크를 설명하는 추가 데이터가 필요하다면 어떨까요?
예를 들어 히어로가 그 팀에서 아직 훈련 중인지, 아니면 이미 임무를 나가고 있는지 등을 알려주는 여분의 필드/열이 필요하다고 해 봅시다.
그걸 어떻게 하는지 볼게요.
출처: 공식문서
두 개의 일대다로 이뤄진 링크 모델
이를 다루는 방법은 링크 모델을 명시적으로 사용하는 거예요. (Hero와 Team 두 모델을 가리키는 외래 키 외에) 그 데이터를 얻고 수정할 수 있도록요.
결국 동작 방식은 일대다 관계 두 개를 합친 것과 같아요.
heroteamlink 테이블의 한 행은 하나의 특정 히어로를 가리켜요. 하지만 한 히어로가 많은 히어로-팀 링크에 연결될 수 있으니, 일대다예요.
또한 heroteamlink 테이블의 같은 행은 하나의 팀을 가리켜요. 하지만 한 팀이 많은 히어로-팀 링크에 연결될 수 있으니, 이것도 일대다예요.
!!! tip "팁"
이전 다대다 관계도 그냥 일대다 두 개를 합친 거였어요. 하지만 지금은 훨씬 더 명시적으로 될 거예요.
링크 모델 갱신
HeroTeamLink 모델을 갱신해 볼게요.
새 필드 is_training을 추가해요.
그리고 연결된 team과 hero를 위한 관계 속성 두 개도 추가해요:
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)
is_training: bool = False
team: "Team" = Relationship(back_populates="hero_links")
hero: "Hero" = Relationship(back_populates="team_links")
새 관계 속성들은 각자의 back_populates를 갖는데, 그건 Hero와 Team 모델에 만들 새 관계 속성을 가리켜요:
team:back_populates="hero_links"를 가져요.Team모델의 속성에 팀의 히어로들에 대한 링크가 담길 테니까요.hero:back_populates="team_links"를 가져요.Hero모델의 속성에 히어로의 팀들에 대한 링크가 담길 테니까요.
!!! note "참고"
SQLAlchemy에서는 이걸 Association Object(연관 객체) 또는 Association Model(연관 모델)이라고 불러요.
저는 오타를 피하기 쉽게 **링크 모델(Link Model)** 이라고 부르는 거예요. 여러분도 원하는 대로 부르셔도 돼요. 😉
Team 모델 갱신
이제 Team 모델을 갱신해 볼게요.
더는 heroes 관계 속성이 없고, 대신 새 hero_links 속성이 있어요:
class Team(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
headquarters: str
hero_links: list[HeroTeamLink] = Relationship(back_populates="team")
Hero 모델 갱신
Hero 모델도 마찬가지예요.
teams 관계 속성을 team_links로 바꿔요:
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_links: list[HeroTeamLink] = Relationship(back_populates="hero")
관계 만들기
이제 관계를 만드는 과정은 아주 비슷해요.
하지만 이제 명시적인 링크 모델을 직접 만들어서 각자의 히어로·팀 인스턴스를 가리키게 하고, 추가 링크 데이터(is_training)도 지정해요:
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",
)
hero_rusty_man = Hero(
name="Rusty-Man",
secret_name="Tommy Sharp",
age=48,
)
hero_spider_boy = Hero(
name="Spider-Boy",
secret_name="Pedro Parqueador",
)
deadpond_team_z_link = HeroTeamLink(team=team_z_force, hero=hero_deadpond)
deadpond_preventers_link = HeroTeamLink(
team=team_preventers, hero=hero_deadpond, is_training=True
)
spider_boy_preventers_link = HeroTeamLink(
team=team_preventers, hero=hero_spider_boy, is_training=True
)
rusty_man_preventers_link = HeroTeamLink(
team=team_preventers, hero=hero_rusty_man
)
session.add(deadpond_team_z_link)
session.add(deadpond_preventers_link)
session.add(spider_boy_preventers_link)
session.add(rusty_man_preventers_link)
session.commit()
링크 모델 인스턴스들만 세션에 추가하면 돼요. 링크 모델 인스턴스가 히어로와 팀에 연결되어 있으니, 커밋할 때 세션에 자동으로 포함되거든요.
프로그램 실행하기
이제 프로그램을 실행하면 이전과 거의 같은 출력이 나와요. 거의 같은 SQL을 만들기 때문이죠. 다만 이번에는 새 is_training 열이 포함돼요:
$ uv run python app.py
// 이전 출력은 생략했어요 🙈
// 자동으로 새 트랜잭션 시작
INFO Engine BEGIN (implicit)
// 히어로 삽입
INFO Engine INSERT INTO hero (name, secret_name, age) VALUES (?, ?, ?)
INFO Engine [generated in 0.00025s] ('Deadpond', 'Dive Wilson', None)
INFO Engine INSERT INTO hero (name, secret_name, age) VALUES (?, ?, ?)
INFO Engine [cached since 0.00136s ago] ('Spider-Boy', 'Pedro Parqueador', None)
INFO Engine INSERT INTO hero (name, secret_name, age) VALUES (?, ?, ?)
INFO Engine [cached since 0.001858s ago] ('Rusty-Man', 'Tommy Sharp', 48)
// 팀 삽입
INFO Engine INSERT INTO team (name, headquarters) VALUES (?, ?)
INFO Engine [generated in 0.00019s] ('Z-Force', 'Sister Margaret's Bar')
INFO Engine INSERT INTO team (name, headquarters) VALUES (?, ?)
INFO Engine [cached since 0.0007985s ago] ('Preventers', 'Sharp Tower')
// 히어로-팀 링크 삽입
INFO Engine INSERT INTO heroteamlink (team_id, hero_id, is_training) VALUES (?, ?, ?)
INFO Engine [generated in 0.00023s] ((1, 1, 0), (2, 1, 1), (2, 2, 1), (2, 3, 0))
// 트랜잭션의 변경 사항을 데이터베이스에 저장
INFO Engine COMMIT
// 자동으로 새 트랜잭션 시작
INFO Engine BEGIN (implicit)
// 속성 접근 시 데이터 자동 조회
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.00028s] (1,)
INFO Engine SELECT heroteamlink.team_id AS heroteamlink_team_id, heroteamlink.hero_id AS heroteamlink_hero_id, heroteamlink.is_training AS heroteamlink_is_training
FROM heroteamlink
WHERE ? = heroteamlink.team_id
INFO Engine [generated in 0.00026s] (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
WHERE hero.id = ?
INFO Engine [generated in 0.00024s] (1,)
// 링크 데이터를 포함한 Z-Force 히어로 데이터 프린트
Z-Force hero: name='Deadpond' age=None id=1 secret_name='Dive Wilson' is training: False
// 속성 접근 시 데이터 자동 조회
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 [cached since 0.008822s ago] (2,)
INFO Engine SELECT heroteamlink.team_id AS heroteamlink_team_id, heroteamlink.hero_id AS heroteamlink_hero_id, heroteamlink.is_training AS heroteamlink_is_training
FROM heroteamlink
WHERE ? = heroteamlink.team_id
INFO Engine [cached since 0.005778s ago] (2,)
// 링크 데이터를 포함한 Preventers 히어로 데이터 프린트
Preventers hero: name='Deadpond' age=None id=1 secret_name='Dive Wilson' is training: True
// 속성 접근 시 데이터 자동 조회
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.004196s ago] (2,)
// 링크 데이터를 포함한 Preventers 히어로 데이터 프린트
Preventers hero: name='Spider-Boy' age=None id=2 secret_name='Pedro Parqueador' is training: True
// 속성 접근 시 데이터 자동 조회
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.006005s ago] (3,)
// 링크 데이터를 포함한 Preventers 히어로 데이터 프린트
Preventers hero: name='Rusty-Man' age=48 id=3 secret_name='Tommy Sharp' is training: False
관계 추가하기
이제 새 관계를 추가하려면, 히어로와 팀을 가리키는 새 HeroTeamLink 인스턴스를 만들고, 세션에 추가하고, 커밋하면 돼요.
여기서는 update_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()
spider_boy_z_force_link = HeroTeamLink(
team=team_z_force, hero=hero_spider_boy, is_training=True
)
team_z_force.hero_links.append(spider_boy_z_force_link)
session.add(team_z_force)
session.commit()
새 관계로 프로그램 실행하기
그 프로그램을 실행하면 이런 출력이 보일 거예요:
$ uv run python app.py
// 이전 출력은 생략했어요 🙈
// 자동으로 새 트랜잭션 시작
INFO Engine BEGIN (implicit)
// 히어로 조회
INFO Engine SELECT hero.id, hero.name, hero.secret_name, hero.age
FROM hero
WHERE hero.name = ?
INFO Engine [no key 0.00014s] ('Spider-Boy',)
// 팀 조회
INFO Engine SELECT team.id, team.name, team.headquarters
FROM team
WHERE team.name = ?
INFO Engine [no key 0.00012s] ('Z-Force',)
// 링크 만들기
INFO Engine INSERT INTO heroteamlink (team_id, hero_id, is_training) VALUES (?, ?, ?)
INFO Engine [generated in 0.00023s] (1, 2, 1)
// 속성 접근 시 데이터 자동 새로고침
INFO Engine SELECT heroteamlink.team_id AS heroteamlink_team_id, heroteamlink.hero_id AS heroteamlink_hero_id, heroteamlink.is_training AS heroteamlink_is_training
FROM heroteamlink
WHERE ? = heroteamlink.team_id
INFO Engine [cached since 0.01514s ago] (1,)
INFO Engine COMMIT
INFO Engine BEGIN (implicit)
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.08953s ago] (2,)
INFO Engine SELECT heroteamlink.team_id AS heroteamlink_team_id, heroteamlink.hero_id AS heroteamlink_hero_id, heroteamlink.is_training AS heroteamlink_is_training
FROM heroteamlink
WHERE ? = heroteamlink.hero_id
INFO Engine [generated in 0.00018s] (2,)
// 갱신된 히어로 링크 프린트
Updated Spider-Boy's Teams: [
HeroTeamLink(team_id=2, is_training=True, hero_id=2),
HeroTeamLink(team_id=1, is_training=True, hero_id=2)
]
// 속성 접근 시 팀 데이터 자동 새로고침
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 [cached since 0.1084s ago] (1,)
INFO Engine SELECT heroteamlink.team_id AS heroteamlink_team_id, heroteamlink.hero_id AS heroteamlink_hero_id, heroteamlink.is_training AS heroteamlink_is_training
FROM heroteamlink
WHERE ? = heroteamlink.team_id
INFO Engine [cached since 0.1054s ago] (1,)
// 팀 히어로 링크 프린트
Z-Force heroes: [
HeroTeamLink(team_id=1, is_training=False, hero_id=1),
HeroTeamLink(team_id=1, is_training=True, hero_id=2)
]
링크로 관계 갱신하기
이제 Spider-Boy가 Preventers에서 충분히 훈련했다고 해 봅시다. 팀에서 풀타임으로 합류해도 된다고 했어요.
그러니 이제 is_training 상태를 False로 갱신하고 싶어요.
링크들을 순회하면서 그렇게 할 수 있어요:
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()
spider_boy_z_force_link = HeroTeamLink(
team=team_z_force, hero=hero_spider_boy, is_training=True
)
team_z_force.hero_links.append(spider_boy_z_force_link)
session.add(team_z_force)
session.commit()
print("Updated Spider-Boy's Teams:", hero_spider_boy.team_links)
print("Z-Force heroes:", team_z_force.hero_links)
for link in hero_spider_boy.team_links:
if link.team.name == "Preventers":
link.is_training = False
session.add(hero_spider_boy)
session.commit()
for link in hero_spider_boy.team_links:
print("Spider-Boy team:", link.team, "is training:", link.is_training)
갱신된 관계로 프로그램 실행하기
지금 프로그램을 실행하면 이렇게 출력돼요:
$ uv run python app.py
// 이전 출력은 생략했어요 🙈
// 속성 접근 시 팀 데이터 자동 조회
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.00015s] (2,)
// 링크 행 갱신
INFO Engine UPDATE heroteamlink SET is_training=? WHERE heroteamlink.team_id = ? AND heroteamlink.hero_id = ?
INFO Engine [generated in 0.00020s] (0, 2, 2)
// 현재 트랜잭션을 데이터베이스에 저장
INFO Engine COMMIT
// 자동으로 새 트랜잭션 시작
INFO Engine BEGIN (implicit)
// 속성 접근 시 데이터 자동 조회
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.2004s ago] (2,)
INFO Engine SELECT heroteamlink.team_id AS heroteamlink_team_id, heroteamlink.hero_id AS heroteamlink_hero_id, heroteamlink.is_training AS heroteamlink_is_training
FROM heroteamlink
WHERE ? = heroteamlink.hero_id
INFO Engine [cached since 0.1005s ago] (2,)
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 [cached since 0.09707s ago] (2,)
// 링크 데이터를 포함한 Spider-Boy 팀 프린트, 훈련 중인지
Spider-Boy team: headquarters='Sharp Tower' id=2 name='Preventers' is training: False
// 속성 접근 시 데이터 자동 조회
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 [cached since 0.2097s ago] (1,)
// 링크 데이터를 포함한 Spider-Boy 팀 프린트, 훈련 중인지
Spider-Boy team: headquarters='Sister Margaret's Bar' id=1 name='Z-Force' is training: True
INFO Engine ROLLBACK
요약
다대다 관계에 대해 더 많은 정보를 저장해야 한다면, 추가 데이터가 담긴 명시적 링크 모델을 쓰면 돼요. 🤓