기본 키·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을 가져요.

우리가 idint | 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.idNone일 때 코드가 유효하지 않을 수 있다는 경고를 보여줘서 깨진 코드를 피하도록 도와줘요.

기본 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)

이번에도 객체들의 idNone으로 출력돼요. 앞서 봤듯이 세션은 영리해서, 변경할 내용을 준비할 때마다 매번 데이터베이스와 통신하지 않아요. 우리가 준비가 됐다고 커밋을 알리기 전까지는 기다렸다가, 그 순간에 모든 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.idid 속성을 출력하고 있죠:

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에서 데이터베이스와 상호작용하는 방식에 대해 아주 많은 걸 알게 된 거예요. 잘 모르겠더라도 괜찮아요. 언제든 나중에 돌아와 개념을 다시 익힐 수 있으니까요.

이건 아마 문제를 일으키고 머리를 긁적이게 만드는 주요 버그 유형 중 하나일 거예요. 그래도 잘 공부했어요! 💪