연결된 데이터 읽기
연결된 데이터 읽기 (Read Connected Data)
이번 장에서는 서로 연결된 두 테이블의 데이터를 함께 조회(select) 하는 방법을 배워요. 히어로와 그 히어로가 속한 팀을 한 번에 가져오는 거죠. SQL 데이터베이스가 진가를 발휘하는 부분이에요.
출처: 공식문서
현재 데이터 상태
두 테이블 모두 데이터가 있으니, 이제 연결된 데이터를 선택해 볼게요.
team 테이블:
| id | name | headquarters |
|---|---|---|
| 1 | Preventers | Sharp Tower |
| 2 | Z-Force | Sister Margaret's Bar |
hero 테이블:
| id | name | secret_name | age | team_id |
|---|---|---|---|---|
| 1 | Deadpond | Dive Wilson | null | 2 |
| 2 | Rusty-Man | Tommy Sharp | 48 | 1 |
| 3 | Spider-Boy | Pedro Parqueador | null | null |
이전 예제의 코드를 이어서, 더 많은 것들을 추가할 거예요.
from sqlmodel import Field, Session, SQLModel, create_engine
class Team(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
headquarters: str
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")
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")
session.add(team_preventers)
session.add(team_z_force)
session.commit()
hero_deadpond = Hero(
name="Deadpond", secret_name="Dive Wilson", team_id=team_z_force.id
)
hero_rusty_man = Hero(
name="Rusty-Man",
secret_name="Tommy Sharp",
age=48,
team_id=team_preventers.id,
)
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)
def main():
create_db_and_tables()
create_heroes()
if __name__ == "__main__":
main()
SQL로 연결된 데이터 SELECT 하기
연결된 데이터를 선택할 때 SQL이 어떻게 동작하는지 먼저 볼게요. 이때가 SQL 데이터베이스가 정말 빛나는 순간이에요.
만약 database.db 파일이 없다면, 앞서 만든 프로그램을 실행(또는 위 미리보기에서 복사)해서 만들어 두세요.
이제 DB Browser for SQLite를 열고 database.db 파일을 엽니다.
연결된 데이터를 SELECT 하려면 이전에 썼던 것과 같은 키워드를 쓰는데, 이제는 두 테이블을 함께 결합해요.
각 히어로의 id, name, 그리고 팀의 name을 가져와 볼게요.
SELECT hero.id, hero.name, team.name
FROM hero, team
WHERE hero.team_id = team.id
참고
name이라는 컬럼이 두 개 있어서(hero용,team용), 테이블 이름과 점을 앞에 붙여 우리가 어느 것을 말하는지 명확히 해주면 돼요.
이제 WHERE 부분에서 한 컬럼을 리터럴 값(예: hero.name = "Deadpond")과 비교하는 게 아니라, 두 컬럼을 서로 비교한다는 점을 눈여겨보세요.
이건 대략 이렇게 말하는 것과 같아요.
안녕 SQL 데이터베이스 👋, 나를 위해 데이터를 좀
SELECT해줘.내가 원하는 컬럼을 먼저 말할게:
hero테이블의idhero테이블의nameteam테이블의name그 데이터를
hero와team테이블FROM에서 가져와 줘.그리고 각 히어로를 모든 가능한 팀과 결합하지는 말아줘. 대신 각 히어로에 대해 가능한 팀을 하나씩 확인하되,
WHEREhero.team_id가team.id와 같은 것만 줘.
그 SQL을 실행하면 다음 테이블을 반환해요.
| id | name | name |
|---|---|---|
| 1 | Deadpond | Z-Force |
| 2 | Rusty-Man | Preventers |
DB Browser for SQLite에서 직접 시도해 볼 수 있어요.
참고
잠깐만요, Spider-Boy는 어디 있죠? 😱
그는 팀이 없어서 데이터베이스에서
team_id가NULL이에요. 그리고 이 SQL은 그team_id의NULL을team테이블 행들의 모든id필드와 비교하고 있어요.ID가
NULL인 팀은 없으니, 일치하는 것을 찾지 못해요.하지만 나중에
LEFT JOIN으로 그걸 고치는 방법을 볼 거예요.
SQLModel로 관련 데이터 선택하기
이제 SQLModel로 같은 select를 해볼게요.
이전처럼 select_heroes() 함수를 만들 건데, 이번에는 두 테이블을 다룹니다.
SQLModel의 select() 함수를 기억하나요? 이 함수는 인자를 두 개 이상 받을 수 있어요.
그래서 Hero와 Team 모델 클래스를 전달할 수 있고, .where() 부분에서도 두 모델의 컬럼을 모두 쓸 수 있어요.
# Code above omitted 👆
def select_heroes():
with Session(engine) as session:
statement = select(Hero, Team).where(Hero.team_id == Team.id)
results = session.exec(statement)
for hero, team in results:
print("Hero:", hero, "Team:", team)
# Code below omitted 👇
== 비교에서 Hero.team_id와 Team.id 둘 다 클래스 속성을 사용하고 있다는 점을 눈여겨보세요.
그러면 위에서 본 SQL 예제와 동등한 올바른 SQL로 변환될 표현식(expression) 객체가 만들어져요.
이제 그것을 실행해 results 객체를 얻을 수 있어요.
그리고 두 모델로 select를 썼으니, 두 모델의 인스턴스로 된 튜플을 받게 돼요. 그래서 for 루프에서 자연스럽게 그들을 반복할 수 있죠.
for 루프의 각 반복에서 Hero 클래스의 인스턴스와 Team 클래스의 인스턴스로 된 튜플을 받아요.
그리고 이 for 루프에서 그것들을 hero 변수와 team 변수에 할당합니다.
참고
SQLModel 뒤에는 최고의 개발자 경험을 제공하려는 많은 연구, 설계, 작업이 있었어요.
그리고 에디터에서
hero와team둘 다에 대해 자동완성과 인라인 오류를 받게 될 거예요. 🎉
main()에 추가하기
항상 그렇듯, 이 새 select_heroes() 함수를 main() 함수에 추가해서 명령줄에서 프로그램을 호출할 때 실행되게 해야 해요.
# Code above omitted 👆
def main():
create_db_and_tables()
create_heroes()
select_heroes()
# Code below omitted 👇
프로그램 실행하기
이제 프로그램을 실행해서 각 히어로가 대응하는 팀과 함께 보이는지 확인해 봐요.
$ uv run python app.py
// Previous output omitted 😉
// Get the heroes with their teams
2021-08-09 08:55:50,682 INFO sqlalchemy.engine.Engine SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.team_id, team.id AS id_1, team.name AS name_1, team.headquarters
FROM hero, team
WHERE hero.team_id = team.id
2021-08-09 08:55:50,682 INFO sqlalchemy.engine.Engine [no key 0.00015s] ()
// Print the first hero and team
Hero: id=1 secret_name='Dive Wilson' team_id=2 name='Deadpond' age=None Team: headquarters='Sister Margaret's Bar' id=2 name='Z-Force'
// Print the second hero and team
Hero: id=2 secret_name='Tommy Sharp' team_id=1 name='Rusty-Man' age=48 Team: headquarters='Sharp Tower' id=1 name='Preventers'
2021-08-09 08:55:50,682 INFO sqlalchemy.engine.Engine ROLLBACK
SQL로 테이블 JOIN 하기
위의 SQL 쿼리에는 WHERE 대신 JOIN 키워드를 쓰는 대체 문법도 있어요.
WHERE를 쓰는 같은 버전:
SELECT hero.id, hero.name, team.name
FROM hero, team
WHERE hero.team_id = team.id
JOIN을 쓰는 대체 버전:
SELECT hero.id, hero.name, team.name
FROM hero
JOIN team
ON hero.team_id = team.id
둘은 동등해요. SQL 코드의 차이는, FROM 부분(또는 FROM 절)에 team을 넘기는 대신 JOIN을 추가하고 team 테이블을 거기에 두는 거예요.
그리고 WHERE에 조건을 두는 대신 ON 키워드에 조건을 두죠. ON은 JOIN과 함께 오는 키워드니까요. 🤷
그래서 이 두 번째 버전은 대략 이렇게 말하는 것과 같아요.
안녕 SQL 데이터베이스 👋, 나를 위해 데이터를 좀
SELECT해줘.내가 원하는 컬럼을 먼저 말할게:
hero테이블의idhero테이블의nameteam테이블의name...여기까지는 이전과 같아, 하하.
이제 그 데이터를
hero테이블FROM에서 가져와 줘.그리고 나머지 데이터를 얻으려면
team테이블과JOIN해줘.
hero.team_id가team.id와 같은 값을 갖는 행의 조합ON에서 두 테이블을 결합해 줘.이거 아까도 말했던 것 같지 않아? 나 스스로 반복하는 것 같네. 🤔
그러면 이전과 같은 테이블을 반환해요.
| id | name | name |
|---|---|---|
| 1 | Deadpond | Z-Force |
| 2 | Rusty-Man | Preventers |
DB Browser for SQLite에서도요.
팁
결과가 같은데 왜 이걸 다 신경 쓰나요?
이
JOIN은 조금 있다가 팀이 없는 Spider-Boy조차도 얻을 수 있게 하기 위해 유용해요.
SQLModel에서 테이블 JOIN 하기
select()를 쓸 때 .where()가 있었던 것처럼, .join()도 있어요.
그리고 SQLModel(실제로는 SQLAlchemy)에서는 .join()을 쓸 때 ON 부분을 넘길 필요가 없어요. 모델 정의에서 이미 foreign_key를 선언했기 때문에 자동으로 추론되거든요.
# Code above omitted 👆
def select_heroes():
with Session(engine) as session:
statement = select(Hero, Team).join(Team)
results = session.exec(statement)
for hero, team in results:
print("Hero:", hero, "Team:", team)
# Code below omitted 👇
여전히 select(Hero, Team)에 Team을 포함하고 있다는 점도 눈여겨보세요. 그 데이터에 여전히 접근하고 싶으니까요.
이것은 이전 예제와 동등해요.
명령줄에서 실행하면 이렇게 출력돼요.
$ uv run python app.py
// Previous output omitted 😉
// Select using a JOIN with automatic ON
INFO Engine SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.team_id, team.id AS id_1, team.name AS name_1, team.headquarters
FROM hero JOIN team ON team.id = hero.team_id
INFO Engine [no key 0.00032s] ()
// Print the first hero and team
Hero: id=1 secret_name='Dive Wilson' team_id=2 name='Deadpond' age=None Team: headquarters='Sister Margaret's Bar' id=2 name='Z-Force'
// Print the second hero and team
Hero: id=2 secret_name='Tommy Sharp' team_id=1 name='Rusty-Man' age=48 Team: headquarters='Sharp Tower' id=1 name='Preventers'
SQL로 LEFT OUTER로 테이블 JOIN 하기 (아마도 JOIN)
JOIN을 쓸 때, FROM 부분의 테이블로 시작해서 그 테이블을 상상 속 공간의 왼쪽에 둔다고 생각해 볼 수 있어요.
그리고 결과에 JOIN 할 다른 테이블을 원하죠.
그 두 번째 테이블을 그 상상 속 공간의 오른쪽에 둡니다.
그리고 조건 ON에 대해 두 테이블을 어떻게 결합하고 결과를 돌려줄지 데이터베이스에 알려줘요.
하지만 기본적으로는, 조건에 맞는 왼쪽과 오른쪽의 행만 반환됩니다.
위의 예제 테이블에서는 모든 히어로가 반환될 거예요. 모든 히어로가 team_id를 갖고 있어서, 모든 히어로가 team 테이블과 결합될 수 있으니까요.
| id | name | name |
|---|---|---|
| 1 | Deadpond | Z-Force |
| 2 | Rusty-Man | Preventers |
| 3 | Spider-Boy | Preventers |
NULL인 외래 키
하지만 위 코드에서 다루는 데이터베이스에서는 Spider-Boy가 팀이 없어서, 데이터베이스에서 team_id 값이 NULL이에요.
그래서 Spider-Boy 행을 team 테이블의 어떤 행과 결합할 방법이 없어요.
위에서 쓴 같은 SQL을 실행하면 결과 테이블에 Spider-Boy가 포함되지 않아요 😱.
| id | name | name |
|---|---|---|
| 1 | Deadpond | Z-Force |
| 2 | Rusty-Man | Preventers |
LEFT OUTER에 모든 것을 포함하기
이런 경우, 팀이 없어도 모든 히어로를 결과에 포함하고 싶다면, 위의 JOIN SQL에 JOIN 바로 앞에 LEFT OUTER를 추가하면 돼요.
SELECT hero.id, hero.name, team.name
FROM hero
LEFT OUTER JOIN team
ON hero.team_id = team.id
이 LEFT OUTER 부분은 데이터베이스에, 첫 번째 테이블(상상 속 공간에서 LEFT에 있는 것)의 모든 것을 유지하고 싶다고 말해주는 거예요. 행들이 밖(out) 으로 빠져나가더라도요. 그래서 OUTER 행도 포함하라는 뜻이에요. 이 경우에는 팀이 있든 없든 모든 히어로를요.
그리고 그러면 Spider-Boy까지 포함한 다음 결과를 반환해요 🎉.
| id | name | name |
|---|---|---|
| 1 | Deadpond | Z-Force |
| 2 | Rusty-Man | Preventers |
| 3 | Spider-Boy | null |
팁
이 쿼리와 이전 쿼리의 유일한 차이는 저
LEFT OUTER하나뿐이에요.
그리고 여기 SQL 변형이 또 하나 있어요. LEFT OUTER JOIN이라고 쓰든 그냥 LEFT JOIN이라고 쓰든, 같은 뜻이에요.
SQLModel에서 LEFT OUTER로 테이블 JOIN 하기
이제 같은 쿼리를 SQLModel로 만들어 볼게요.
.join()에는 JOIN이 LEFT OUTER JOIN이 되도록 만드는 isouter=True라는 매개변수가 있어요.
# Code above omitted 👆
def select_heroes():
with Session(engine) as session:
statement = select(Hero, Team).join(Team, isouter=True)
results = session.exec(statement)
for hero, team in results:
print("Hero:", hero, "Team:", team)
# Code below omitted 👇
실행하면 이렇게 출력돼요.
$ uv run python app.py
// Previous output omitted 😉
// SELECT using LEFT OUTER JOIN
INFO Engine SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.team_id, team.id AS id_1, team.name AS name_1, team.headquarters
FROM hero LEFT OUTER JOIN team ON team.id = hero.team_id
INFO Engine [no key 0.00051s] ()
// Print the first hero and team
Hero: id=1 secret_name='Dive Wilson' team_id=2 name='Deadpond' age=None Team: headquarters='Sister Margaret's Bar' id=2 name='Z-Force'
// Print the second hero and team
Hero: id=2 secret_name='Tommy Sharp' team_id=1 name='Rusty-Man' age=48 Team: headquarters='Sharp Tower' id=1 name='Preventers'
// Print the third hero and team, we included Spider-Boy 🎉
Hero: id=3 secret_name='Pedro Parqueador' team_id=None name='Spider-Boy' age=None Team: None
select()에 무엇을 넣나
Team을 .join()에 넣지 않고 왜 select()에 넣는지 궁금할 거예요.
그리고 왜 .join()에 Hero를 포함하지 않았는지도요. 🤔
SQLModel(실제로는 SQLAlchemy)에서 이 모든 함수와 도구는 SQL 언어로 작업하는 것과 똑같이 동작하도록 하려고 해요.
SELECT는 가져올 컬럼을 정의하고 WHERE는 어떻게 필터링할지 정의한다는 걸 기억하나요?
여기서도 동일하게 적용되지만, JOIN과 ON과 함께요.
히어로만 선택하되 팀과 조인하기
Team을 .join()에만 넣고 select() 함수에는 넣지 않으면, team 데이터는 받지 못해요.
하지만 그것으로 행을 필터링할 수는 여전히 있어요. 🤓
.join() 뒤에 추가적인 .where()를 붙여 데이터를 더 필터링할 수도 있어요. 예를 들어 한 팀의 히어로만 반환하려는 경우요.
# Code above omitted 👆
def select_heroes():
with Session(engine) as session:
statement = select(Hero).join(Team).where(Team.name == "Preventers")
results = session.exec(statement)
for hero in results:
print("Preventer Hero:", hero)
# Code below omitted 👇
여기서 .where()로 Preventers 팀에 속한 히어로만 가져오도록 필터링하고 있어요.
하지만 여전히 요청하는 데이터는 히어로뿐이고, 그들의 팀 데이터는 아니에요.
실행하면 이렇게 출력돼요.
$ uv run python app.py
// Select only the hero data
INFO Engine SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.team_id
// But still join with the team table
FROM hero JOIN team ON team.id = hero.team_id
// And filter with WHERE to get only the Preventers
WHERE team.name = ?
INFO Engine [no key 0.00066s] ('Preventers',)
// We filter with the team, but only get the hero
Preventer Hero: id=2 secret_name='Tommy Sharp' team_id=1 name='Rusty-Man' age=48
Team 포함하기
select()에 Team을 넣으면 SQLModel과 데이터베이스에 팀 데이터도 원한다고 알려줘요.
# Code above omitted 👆
def select_heroes():
with Session(engine) as session:
statement = select(Hero, Team).join(Team).where(Team.name == "Preventers")
results = session.exec(statement)
for hero, team in results:
print("Preventer Hero:", hero, "Team:", team)
# Code below omitted 👇
실행하면 이렇게 출력돼요.
$ uv run python app.py
// Select the hero and the team data
INFO Engine SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.team_id, team.id AS id_1, team.name AS name_1, team.headquarters
// Join the hero with the team table
FROM hero JOIN team ON team.id = hero.team_id
// Filter with WHERE to get only Preventers
WHERE team.name = ?
INFO Engine [no key 0.00018s] ('Preventers',)
// Print the hero and the team
Preventer Hero: id=2 secret_name='Tommy Sharp' team_id=1 name='Rusty-Man' age=48 Team: headquarters='Sharp Tower' id=1 name='Preventers'
.join()은 여전히 해야 해요. 그렇지 않으면 히어로와 팀의 가능한 모든 조합을 계산하기 때문이에요. 예를 들어 Rusty-Man과 Preventers, 그리고 Rusty-Man과 Z-Force까지 포함하는 건 실수가 되겠죠.
관계 속성(Relationship Attributes)
여기서는 순수 클래스 모델을 직접 사용했어요. 하지만 다음 장에서는 파이썬 객체로 코드에 훨씬 더 가깝게 데이터베이스와 상호작용하게 해주는 Relationship Attributes를 사용하는 방법도 볼 거예요.
그리고 그들의 데이터를 더 단순하고 다른 방식으로 로드해서, 여기서 이룬 것과 같은 결과를 얻는 방법도 볼 거예요. ✨