기본 키·ID (Primary Key/ID)
기본 키·ID (Primary Key/ID)
지난 챕터에서 SQLModel로 데이터베이스에 행을 추가하는 법을 살펴봤어요. 이번에는 id 필드가 왜 데이터베이스에서는 NULL이 될 수 없는지, 그리고 같은 id가 왜 Python 코드에서는 None일 수 있는지를 차근차근 설명할게요.
먼저 id 필드를 다시 봐요. 이 필드는 기본 키라서 데이터베이스 레벨에서는 NULL이 될 수 없어요. 그래서 Field(primary_key=True)로 선언하죠.
그런데 사실 같은 id 필드는 Python 코드 안에서는 None일 수 있어요. 그래서 타입을 int | None으로 선언하고, 기본값을 Field(default=None)으로 설정해요:
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
secret_name: str
age: int | None = None
데이터베이스와 Python 코드 사이의 동기화가 실제로 언제 일어나는지도 좀 더 자세히 볼게요. 그 id 필드에 실제 int 값이 채워지는 순간이 언제인지, 같이 확인해 봐요.
새 Hero 인스턴스 만들기
새 Hero 인스턴스를 만들 때는 id를 설정하지 않아요:
def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson")
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador")
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48)
int | None이 왜 도움이 되는가
id를 설정하지 않았으니, 그 값은 Field(default=None)에서 선언한 Python 기본값인 None을 가져요.
우리가 id를 int | None으로 선언하고 기본값을 None으로 둔 이유는 바로 이것뿐이에요. 이 시점의 코드에서는 아직 데이터베이스에 접근하기 전이니까, Python 값이 실제로 None일 수 있거든요.
만약 id가 항상 int라고 생각하고 int | None 없이 타입 어노테이션을 붙였다면, 이런 깨진 코드를 만들 수도 있어요:
next_hero_id = hero_1.id + 1
hero를 데이터베이스에 저장하기 전에 이 코드를 실행했는데 hero_1.id가 아직 None이라면, 이런 에러가 나요:
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
하지만 int | None으로 선언해 두면, 에디터가 hero_1.id가 None일 때 코드가 유효하지 않을 수 있다는 경고를 보여줘서 깨진 코드를 피하도록 도와줘요.
기본 id 값 출력하기
데이터베이스에 넣기 전에 hero를 출력해서 그걸 확인할 수 있어요:
def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson")
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador")
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48)
print("Before interacting with the database")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
그러면 이렇게 출력돼요. 모두 id=None인 걸 볼 수 있어요.
그게 우리가 Hero 모델 클래스에 정의한 기본값이에요. 이 객체들을 세션에 추가하면 어떻게 될까요?
객체를 세션에 추가하기
Hero 인스턴스 객체를 세션에 추가한 뒤에도 id는 여전히 None이에요. with 블록으로 세션을 만들고 객체를 추가한 다음 다시 출력해서 확인해 볼게요:
def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson")
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador")
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48)
print("Before interacting with the database")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)
print("After adding to the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
이번에도 객체들의 id가 None으로 출력돼요. 앞서 봤듯이 세션은 영리해서, 변경할 내용을 준비할 때마다 매번 데이터베이스와 통신하지 않아요. 우리가 준비가 됐다고 커밋을 알리기 전까지는 기다렸다가, 그 순간에 모든 SQL을 데이터베이스로 보내 데이터를 저장해요.
변경 사항 커밋하기
이제 세션에서 변경 사항을 커밋하고 다시 출력해 볼게요:
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)
print("After adding to the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
session.commit()
print("After committing the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
그런데 이번엔 뜻밖의 일이 생겨요. 출력을 보면 Hero 인스턴스 객체에 아무 데이터도 없는 것처럼 보이거든요.
이유는 이래요. SQLModel(정확히는 SQLAlchemy)이 내부적으로 이 객체들을 "expired"(만료됨) 상태로 표시해요. 즉 객체들이 아직 최신 버전의 데이터를 갖고 있지 않다는 뜻이에요. 저장할 때 자동으로 갱신되는 updated_at: datetime 같은 필드가 있는 경우를 상상해 보면 이해가 쉬워요. 그런 필드처럼 다른 값들도 바뀌었을 수 있으니, 세션은 안전하게 하기 위해 객체들을 내부적으로 "expired"로 표시하는 선택을 해요.
그리고 나서 hero_1.name 같은 속성에 다음에 접근하면, SQLModel(정확히는 SQLAlchemy)이 데이터베이스에 다시 접속해서 가장 최신 버전의 데이터를 가져와 그 name 필드를 객체에 갱신해 주고, 나머지 Python 표현식에서 쓸 수 있게 해줘요. 위 예시에서 그 시점에 Python은 방금 갱신된 hero_1.name 값을 이용해 current_hero_name 변수에 넣고 계속 실행할 수 있어요.
이 모든 게 자동으로, 뒤에서 일어나요.
그런데 이 예시에는 재미있고 이상한 점이 있어요:
print("Hero 1:", hero_1)
우리는 hero.name처럼 객체의 속성에 접근하지 않았어요. 그냥 객체 전체에 접근해서 출력만 했죠. 그래서 SQLAlchemy는 이 객체의 데이터에 접근하고 싶다는 걸 알 방법이 없어요.
단일 필드 출력하기
속성에 접근할 때 자동으로 만료되고 갱신되는 이 동작을 이해하고 확인하기 위해, 개별 필드(인스턴스 속성)를 출력해 볼게요:
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)
print("After adding to the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
session.commit()
print("After committing the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
print("After committing the session, show IDs")
print("Hero 1 ID:", hero_1.id)
print("Hero 2 ID:", hero_2.id)
print("Hero 3 ID:", hero_3.id)
print("After committing the session, show names")
print("Hero 1 name:", hero_1.name)
print("Hero 2 name:", hero_2.name)
print("Hero 3 name:", hero_3.name)
이제 우리는 속성에 실제로 접근하고 있어요. hero_1처럼 객체 전체를 출력하는 대신:
print("Hero 1:", hero_1)
이제 hero.id의 id 속성을 출력하고 있죠:
print("Hero 1 ID:", hero_1.id)
속성에 접근하면 SQLModel(정확히는 SQLAlchemy)이 뒤에서 데이터베이스에서 데이터를 갱신하고, 객체의 id 속성에 설정하고, Python 표현식(이 경우 그냥 출력)에서 쓸 수 있게 만드는 많은 작업을 촉발해요. 어떻게 동작하는지 볼게요.
객체를 명시적으로 갱신하기
지금까지 속성에 접근할 때 세션이 부수 효과로 데이터를 자동으로 갱신하는 법을 배웠어요.
그런데 데이터를 명시적으로 갱신하고 싶다면 어떻게 할까요? 그것도 session.refresh(object)로 할 수 있어요:
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)
print("After adding to the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
session.commit()
print("After committing the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
print("After committing the session, show IDs")
print("Hero 1 ID:", hero_1.id)
print("Hero 2 ID:", hero_2.id)
print("Hero 3 ID:", hero_3.id)
print("After committing the session, show names")
print("Hero 1 name:", hero_1.name)
print("Hero 2 name:", hero_2.name)
print("Hero 3 name:", hero_3.name)
session.refresh(hero_1)
session.refresh(hero_2)
session.refresh(hero_3)
print("After refreshing the heroes")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
Python이 이 코드를 실행하면:
session.refresh(hero_1)
세션이 engine이 데이터베이스와 통신하게 만들어 이 hero_1 객체의 최신 데이터를 가져오고, 세션이 그 데이터를 hero_1 객체에 넣어서 "fresh"(신선함) 또는 "not expired"(만료되지 않음) 상태로 표시해요.
이런 출력이 나와요.
이건 예를 들어 hero를 만드는 웹 API를 구축할 때 유용할 수 있어요. hero가 어떤 데이터로 생성되면 그것을 클라이언트에 반환하게 되죠. 데이터를 갱신하는 그 자동 기능이 촉발되지 않아서 텅 빈 것처럼 보이는 객체를 반환하고 싶지는 않을 거예요.
이 경우 세션으로 객체를 데이터베이스에 커밋한 뒤 refresh하고 클라이언트에 반환하면, 객체가 신선한 데이터를 갖고 있음을 보장할 수 있어요.
세션을 닫은 뒤 데이터 출력하기
마지막 실험으로, 세션을 닫은 뒤에도 데이터를 출력해 볼게요. 여기서 놀랄 일은 없어요. 여전히 잘 동작해요:
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)
print("After adding to the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
session.commit()
print("After committing the session")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
print("After committing the session, show IDs")
print("Hero 1 ID:", hero_1.id)
print("Hero 2 ID:", hero_2.id)
print("Hero 3 ID:", hero_3.id)
print("After committing the session, show names")
print("Hero 1 name:", hero_1.name)
print("Hero 2 name:", hero_2.name)
print("Hero 3 name:", hero_3.name)
session.refresh(hero_1)
session.refresh(hero_2)
session.refresh(hero_3)
print("After refreshing the heroes")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
print("After the session closes")
print("Hero 1:", hero_1)
print("Hero 2:", hero_2)
print("Hero 3:", hero_3)
출력에서 다시 같은 데이터를 보여줘요.
전체 코드 정리
이제 이 전체 코드를 다시 한번 정리해 볼게요.
Tip: 번호가 매겨진 말풍선 하나하나는 각 줄이 출력에 무엇을 찍는지 보여줘요. 그리고 우리가 echo=True로 engine을 만들었으니, 각 단계에서 실행되는 SQL 문장들도 볼 수 있어요.
from sqlmodel import Field, Session, SQLModel, create_engine
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
secret_name: str
age: int | None = None
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():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson") # id는 None
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador") # id는 None
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48) # id는 None
print("Before interacting with the database") #
print("Hero 1:", hero_1) # id는 None
print("Hero 2:", hero_2) # id는 None
print("Hero 3:", hero_3) # id는 None
with Session(engine) as session: #
session.add(hero_1) #
session.add(hero_2) #
session.add(hero_3) #
print("After adding to the session") #
print("Hero 1:", hero_1) # id는 None
print("Hero 2:", hero_2) # id는 None
print("Hero 3:", hero_3) # id는 None
session.commit() #
print("After committing the session") # 객체들은 expired 상태
print("Hero 1:", hero_1) # 객체 전체라 데이터 없음
print("Hero 2:", hero_2) # 객체 전체라 데이터 없음
print("Hero 3:", hero_3) # 객체 전체라 데이터 없음
print("After committing the session, show IDs") #
print("Hero 1 ID:", hero_1.id) # 새로 할당된 id
print("Hero 2 ID:", hero_2.id) # 새로 할당된 id
print("Hero 3 ID:", hero_3.id) # 새로 할당된 id
print("After committing the session, show names") #
print("Hero 1 name:", hero_1.name) # 자동 갱신된 name
print("Hero 2 name:", hero_2.name) # 자동 갱신된 name
print("Hero 3 name:", hero_3.name) # 자동 갱신된 name
session.refresh(hero_1) #
session.refresh(hero_2) #
session.refresh(hero_3) #
print("After refreshing the heroes") #
print("Hero 1:", hero_1) # 이제 데이터가 꽉 참
print("Hero 2:", hero_2) # 이제 데이터가 꽉 참
print("Hero 3:", hero_3) # 이제 데이터가 꽉 참
print("After the session closes") #
print("Hero 1:", hero_1) # 여전히 데이터가 꽉 참
print("Hero 2:", hero_2) # 여전히 데이터가 꽉 참
print("Hero 3:", hero_3) # 여전히 데이터가 꽉 참
def main():
create_db_and_tables()
create_heroes()
if __name__ == "__main__":
main()
그리고 이 프로그램을 실행해서 나오는 모든 출력을 한데 모아 보면 이렇게 돼요.
Recap
여기까지 다 읽으셨네요! 정말 많았죠. 케이크 하나 드세요. 🍰
우리는 세션이 engine을 이용해 SQL을 데이터베이스로 보내 데이터를 만들고 또 가져오는 법을 다뤘어요. 세션이 "expired"와 "fresh" 데이터를 어떻게 추적하는지, 인스턴스 속성에 접근할 때 언제 자동으로 데이터를 가져오는지, 그리고 인메모리의 객체와 데이터베이스 사이에 어떻게 그 데이터가 세션을 통해 동기화되는지를요.
여기까지 이해했다면, 이제 SQLModel과 SQLAlchemy, 그리고 Python에서 데이터베이스와 상호작용하는 방식에 대해 아주 많은 걸 알게 된 거예요. 잘 모르겠더라도 괜찮아요. 언제든 나중에 돌아와 개념을 다시 익힐 수 있으니까요.
이건 아마 문제를 일으키고 머리를 긁적이게 만드는 주요 버그 유형 중 하나일 거예요. 그래도 잘 공부했어요! 💪