모델 정의 (Define Models)
모델 정의 (Define Models)
이제 코드로 들어가 볼게요. 👩💻
프로젝트 디렉터리 안에 있는지 먼저 확인해주세요.
우리가 할 일은 이렇습니다:
- SQLModel 로 테이블을 정의하기
- SQLModel 로 같은 SQLite 데이터베이스와 테이블을 만들기
- DB Browser for SQLite 로 그 작업을 확인하기
원하는 테이블 구조를 다시 떠올려볼게요:
| id | name | secret_name | age |
|---|---|---|---|
| 1 | Deadpond | Dive Wilson | null |
| 2 | Spider-Boy | Pedro Parqueador | null |
| 3 | Rusty-Man | Tommy Sharp | 48 |
테이블 모델 클래스 만들기 (Create the Table Model Class)
가장 먼저 해야 할 일은 테이블 안의 데이터를 표현하는 클래스를 만드는 거예요.
이렇게 어떤 데이터를 표현하는 클래스를 흔히 모델(model) 이라고 불러요.
/// tip
그래서 이 패키지 이름이 SQLModel 인 거예요. 주로 SQL 모델을 만들 때 쓰이니까요.
///
그러려면 SQLModel 을 (그리고 앞으로 쓸 다른 것들도) 임포트하고, SQLModel 을 상속받아 우리 히어로들의 테이블 모델을 나타내는 클래스 Hero 를 만들면 됩니다:
from sqlmodel import Field, 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
이 클래스 Hero 는 우리 히어로들의 테이블을 나타내요. 그리고 앞으로 우리가 만드는 각 인스턴스는 테이블의 한 행을 나타내죠.
table=True 설정을 써서 SQLModel 에게 이건 테이블 모델이며 테이블을 나타낸다는 걸 알려줘요.
/// note
table=True 없이 만든 모델도 가능해요. 그런 모델은 데이터 모델일 뿐이고 데이터베이스에 테이블이 없으니 테이블 모델이 아니에요.
그런 데이터 모델들은 나중에 아주 유용해지는데, 지금은 일단 table=True 설정을 계속 붙여줄게요.
///
필드, 컬럼 정의하기 (Define the Fields, Columns)
다음 단계는 표준 파이썬 타입 어노테이션을 이용해 클래스의 필드(= 컬럼)를 정의하는 거예요.
이 변수들의 이름이 각각 테이블의 컬럼 이름이 되고,
각각의 타입이 테이블 컬럼의 타입이 됩니다:
from sqlmodel import Field, 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
이 필드/컬럼 선언들을 좀 더 자세히 살펴볼게요.
None 필드, Nullable 컬럼
age 부터 볼게요. 타입이 int | None 인 게 보이죠.
바로 이게 파이썬에서 어떤 값이 "int 이거나 None 일 수 있다"고 선언하는 표준 방식이에요.
그리고 age 의 기본값도 None 으로 설정해줬어요.
from sqlmodel import Field, 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
/// tip
id 도 int | None 으로 정의하긴 했는데, id 에 대한 이야기는 아래에서 할게요.
///
타입이 int | None 이기 때문에:
- 데이터를 검증할 때
None은age의 허용 값이 돼요. - 데이터베이스에서
age컬럼은NULL(파이썬의None과 같은 SQL 값)을 허용하게 돼요.
그리고 = None 이라는 기본값이 있기 때문에:
- 데이터를 검증할 때 이
age필드는 필수(required)가 아니며, 기본적으로None이 되요. - 데이터베이스에 저장할 때
age컬럼은 기본적으로NULL값을 가지게 돼요.
/// tip
기본값은 다른 걸로 할 수도 있어요. 예를 들어 = 42 처럼요.
///
기본 키 id
이제 id 필드를 살펴볼게요. 이건 테이블의 기본 키(primary key)예요.
그래서 id 를 기본 키로 표시해야 해요.
그렇게 하려면 sqlmodel 의 특별한 함수 Field 를 쓰고 인자 primary_key=True 를 설정하면 돼요:
from sqlmodel import Field, 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
이렇게 해서 SQLModel 에게 이 id 필드/컬럼이 테이블의 기본 키라는 걸 알려줍니다.
그런데 SQL 데이터베이스 안에서는 id 가 항상 필수이고 NULL 이 될 수 없어요. 그런데 왜 int | None 으로 선언해야 할까요?
id 는 데이터베이스에서 필수이긴 하지만, 우리 코드가 아니라 데이터베이스가 생성하는 값이에요.
그래서 (다음 장들에서) 이 클래스의 인스턴스를 만들 때마다 우리는 id 를 설정하지 않을 거예요. 그리고 id 의 값은 데이터베이스에 저장하기 전까지 None 이다가, 저장된 뒤에야 비로소 값을 가지게 되는 거죠.
my_hero = Hero(name="Spider-Boy", secret_name="Pedro Parqueador")
do_something(my_hero.id) # Oh no! my_hero.id is None! 😱🚨
# Imagine this saves it to the database
somehow_save_in_db(my_hero)
do_something(my_hero.id) # Now my_hero.id has a value generated in DB 🎉
즉, (데이터베이스가 아니라) 우리 코드 에서 id 값이 None 일 수 있기 때문에 int | None 을 쓰는 거예요. 이렇게 하면 편집기가 우리를 도와줄 수 있어요. 예를 들어 아직 데이터베이스에 저장하지 않아 여전히 None 인 객체의 id 에 접근하려 할 때 말이죠.
이제 우리가 Field() 함수로 기본값 자리를 대신했으니, Field() 의 인자 default=None 으로 id 의 실제 기본값을 None 으로 설정해줍니다:
Field(default=None)
만약 default 값을 설정하지 않았다면, 나중에 이 모델로 데이터 검증을 할 때(Pydantic 으로 동작하죠) int 말고도 None 값을 허용하긴 하지만, 그 None 값을 꼭 전달하도록 요구하게 돼요. 그러면 이 모델을 나중에 쓰는 사람(아마 우리 자신이겠죠)이 혼란스러워져요. 그래서 여기서 기본값을 설정해 두는 게 낫습니다.
엔진 만들기 (Create the Engine)
이제 SQLAlchemy Engine 을 만들어야 해요.
엔진은 데이터베이스와의 통신을 처리하는 객체예요.
서버형 데이터베이스(예: PostgreSQL 이나 MySQL)를 쓴다면, 엔진이 그 데이터베이스로의 네트워크 연결을 들고 있게 돼요.
엔진을 만드는 건 아주 간단해요. 그냥 사용할 데이터베이스 URL을 넣어 create_engine() 을 호출하면 됩니다:
from sqlmodel import Field, 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)
보통 애플리케이션 전체에 엔진 객체는 하나만 두고 모든 곳에서 그걸 재사용하는 게 좋아요.
/// tip
Session 이라는 관련된 다른 것도 있는데, 이건 보통 애플리케이션마다 하나만 두면 안 되는 객체예요.
그건 나중에 다룰게요.
///
엔진 데이터베이스 URL
지원되는 데이터베이스마다 자기만의 URL 형태가 있어요. 예를 들어 SQLite 는 파일 경로 뒤에 sqlite:/// 이 붙습니다. 예를 들면:
sqlite:///database.dbsqlite:///databases/local/application.dbsqlite:///db.sqlite
SQLite 는 전부 메모리 안에만 존재하는 특별한 데이터베이스를 지원해요. 그래서 아주 빠르지만, 프로그램이 끝나면 데이터베이스가 삭제되니 조심해야 해요. 슬래시 두 개(//)만 쓰고 파일 이름 없이 쓰면 이 인메모리 데이터베이스를 지정할 수 있어요:
sqlite://
from sqlmodel import Field, 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)
SQLAlchemy (그리고 그 덕분에 SQLModel) 가 지원하는 모든 데이터베이스에 대해 훨씬 더 자세히 알고 싶다면 SQLAlchemy 문서를 읽어보세요.
엔진 에코 (Engine Echo)
이 예제에서는 인자 echo=True 도 함께 쓰고 있어요.
이러면 엔진이 실행하는 모든 SQL 문을 출력해줘서, 무슨 일이 벌어지는지 이해하는 데 도움을 줘요.
학습과 디버깅에 특히 유용합니다:
from sqlmodel import Field, 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)
하지만 운영(production)에서는 아마 echo=True 를 빼고 싶을 거예요:
engine = create_engine(sqlite_url)
엔진 기술 세부사항
/// tip
SQLAlchemy 를 몰랐고 그냥 SQLModel 을 배우는 중이라면 이 섹션은 건너뛰고 아래로 내려가도 돼요.
///
엔진에 대해 더 자세히 알고 싶다면 SQLAlchemy 문서를 읽어보세요.
SQLModel 은 자체 create_engine() 함수를 정의해요. SQLAlchemy 의 create_engine() 과 같지만, 기본적으로 future=True 를 쓰도록 되어 있다는 차이가 있어요 (즉, 최신 SQLAlchemy인 1.4 스타일과 미래의 2.0 스타일을 쓴다는 뜻이죠).
그리고 SQLModel 의 create_engine() 은 내부적으로 타입 어노테이션이 되어 있어서, 편집기가 자동완성과 인라인 에러로 도와줄 수 있어요.
데이터베이스와 테이블 만들기
이제 드디어 데이터베이스와 테이블을 만들 준비가 모두 끝났습니다:
SQLModel.metadata.create_all(engine)
/// tip
엔진을 만드는 것만으로는 database.db 파일이 생기지 않아요.
하지만 SQLModel.metadata.create_all(engine) 을 실행하면 database.db 파일을 만들고 동시에 그 데이터베이스 안에 hero 테이블을 만들어줘요.
두 가지 모두 이 한 단계에서 처리되죠.
///
이걸 풀어서 볼게요:
SQLModel.metadata.create_all(engine)
SQLModel MetaData
SQLModel 클래스에는 metadata 속성이 있어요. 이건 MetaData 클래스의 인스턴스예요.
SQLModel 을 상속받고 table = True 로 설정된 클래스를 만들 때마다, 그 클래스는 이 metadata 속성에 등록됩니다.
그래서 마지막 줄까지 오면 SQLModel.metadata 에는 이미 Hero 가 등록되어 있어요.
create_all() 호출하기
SQLModel.metadata 에 있는 이 MetaData 객체는 create_all() 메서드를 가져요.
이 메서드는 엔진을 받아서, 그 엔진을 이용해 데이터베이스와 이 MetaData 객체에 등록된 모든 테이블을 만들어줍니다.
SQLModel MetaData 의 순서가 중요해요
이 말은 SQLModel.metadata.create_all() 을 SQLModel 을 상속받는 새 모델 클래스를 만드는 코드 다음에 호출해야 한다는 뜻이기도 해요.
예를 들어 이런 상황을 상상해보죠:
- 한 파이썬 파일
models.py에서 모델들을 만든다. db.py파일에서 엔진 객체를 만든다.app.py에서 메인 앱을 만들고SQLModel.metadata.create_all()을 호출한다.
만약 app.py 에서 SQLModel 만 임포트한 채 SQLModel.metadata.create_all() 을 호출하려 하면, 테이블이 만들어지지 않아요:
# This wouldn't work! 🚨
from sqlmodel import SQLModel
from .db import engine
SQLModel.metadata.create_all(engine)
이건 SQLModel 을 혼자 임포트하면 파이썬이 (우리 예제의 Hero 같은) 그것을 상속받는 클래스를 만드는 모든 코드를 실행하지 않기 때문이에요. 그래서 SQLModel.metadata 는 여전히 비어 있죠.
하지만 SQLModel.metadata.create_all() 을 호출하기 전에 모델들을 임포트하면, 이건 동작해요:
from sqlmodel import SQLModel
from . import models
from .db import engine
SQLModel.metadata.create_all(engine)
모델들을 임포트하면 파이썬이 SQLModel 을 상속받는 클래스를 만드는 모든 코드를 실행하고, 그 클래스들을 SQLModel.metadata 에 등록하기 때문에 동작하죠.
대안으로, db.py 안에서 SQLModel 과 우리 모델들을 임포트할 수도 있어요:
# db.py
from sqlmodel import SQLModel, create_engine
from . import models
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
engine = create_engine(sqlite_url)
그리고 app.py 에서 db.py 로부터 SQLModel 을 임포트한 다음, 그곳에서 SQLModel.metadata.create_all() 을 호출하게 됩니다:
# app.py
from .db import engine, SQLModel
SQLModel.metadata.create_all(engine)
SQLModel 을 db.py 에서 임포트하면 동작하는 이유는, db.py 안에서도 SQLModel 이 임포트되기 때문이에요.
그리고 이 요령은 제대로 동작해서 데이터베이스에 테이블을 만들어줘요. 왜냐하면 db.py 에서 SQLModel 을 임포트하면 파이썬이 그 db.py 파일 안에서 SQLModel 을 상속받는 클래스(예: Hero)를 만드는 모든 코드를 실행하니까요.
마이그레이션
이 간단한 예제에서는, 그리고 Tutorial - User Guide 의 대부분에서는 SQLModel.metadata.create_all() 을 쓰는 걸로 충분해요.
하지만 운영 시스템이라면 아마 데이터베이스를 마이그레이션하는 시스템을 쓰는 게 좋을 거예요.
예를 들어 컬럼을 추가하거나 제거할 때, 새 테이블을 추가할 때, 타입을 바꿀 때 등에 그게 유용하고 중요해요.
마이그레이션에 대해서는 나중에 Advanced User Guide 에서 배우게 될 거예요.
프로그램 실행하기
프로그램을 실행해서 전부 제대로 동작하는지 확인해볼게요.
아직 안 했다면 코드를 app.py 파일에 넣어주세요.
from sqlmodel import Field, 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)
SQLModel.metadata.create_all(engine)
/// tip
프로젝트 환경 안에서 uv run 으로 실행해주세요.
///
이제 파이썬으로 프로그램을 실행해봅시다:
// We set echo=True, so this will show the SQL code
$ uv run python app.py
// First, some boilerplate SQL that we are not that interested in
INFO Engine BEGIN (implicit)
INFO Engine PRAGMA main.table_info("hero")
INFO Engine [raw sql] ()
INFO Engine PRAGMA temp.table_info("hero")
INFO Engine [raw sql] ()
INFO Engine
// Finally, the glorious SQL to create the table ✨
CREATE TABLE hero (
id INTEGER,
name VARCHAR NOT NULL,
secret_name VARCHAR NOT NULL,
age INTEGER,
PRIMARY KEY (id)
)
// More SQL boilerplate
INFO Engine [no key 0.00020s] ()
INFO Engine COMMIT
/// note
위 출력을 읽기 쉽게 조금 단순화했어요.
실제로는 이렇게 보이는 대신:
INFO Engine BEGIN (implicit)
이런 느낌으로 보일 거예요:
2021-07-25 21:37:39,175 INFO sqlalchemy.engine.Engine BEGIN (implicit)
///
TEXT vs VARCHAR
이전 장의 예제에서는 일부 컬럼을 TEXT 로 테이블을 만들었어요.
그런데 이번 출력에서 SQLAlchemy 는 VARCHAR 를 쓰고 있죠. 무슨 일인지 알아볼게요.
각 SQL 데이터베이스는 지원하는 것에 차이가 있다는 걸 기억하시죠?
이게 그 차이 중 하나예요. 각 데이터베이스는 INTEGER 나 TEXT 같은 특정 데이터 타입을 지원해요.
어떤 데이터베이스는 특정 용도에 특별한 타입을 갖고 있기도 해요. 예를 들어 PostgreSQL 과 MySQL 은 True 와 False 값을 위한 BOOLEAN 을 지원해요. SQLite 는 테이블 컬럼을 정의할 때조차 boolean 을 포함한 SQL을 받아들이지만, 내부적으로 실제로 쓰는 건 INTEGER 예요. True 를 1 로, False 를 0 으로 나타내죠.
마찬가지로 문자열을 저장하는 타입도 여러 가지 가능해요. SQLite 는 TEXT 타입을 써요. 그런데 PostgreSQL 이나 MySQL 같은 다른 데이터베이스는 기본적으로 VARCHAR 타입을 쓰고, VARCHAR 는 가장 흔한 데이터 타입 중 하나죠.
VARCHAR 는 variable(가변) length character(길이 문자)에서 온 거예요.
SQLAlchemy 는 VARCHAR 를 사용해 테이블을 만드는 SQL 문을 생성하고, SQLite 가 그걸 받아서 내부적으로 TEXT 로 변환해줍니다.
이 두 데이터 타입의 차이 말고도, MySQL 같은 일부 데이터베이스는 VARCHAR 타입에 최대 길이를 설정하도록 요구해요. 예를 들어 VARCHAR(255) 는 최대 문자 수를 255로 설정하죠.
SQLModel 을 어떤 데이터베이스를 쓰든 (MySQL 까지도) 추가 설정 없이 바로 시작하기 쉽게 하려고, 기본적으로 str 필드는 대부분의 데이터베이스에서 VARCHAR 로, MySQL 에서는 VARCHAR(255) 로 해석됩니다. 이렇게 하면 같은 클래스가 별다른 노력 없이 가장 흔한 데이터베이스들과 호환된다는 걸 알 수 있어요.
/// tip
문자열 컬럼의 최대 길이를 바꾸는 방법은 나중에 Advanced Tutorial - User Guide 에서 배우게 돼요.
///
데이터베이스 확인하기
이제 DB Browser for SQLite 로 데이터베이스를 열어보면, 프로그램이 이전과 똑같이 hero 테이블을 만들어낸 걸 볼 수 있어요. 🎉
데이터 생성 코드 리팩터링하기
이제 코드를 조금 재구성해서 나중에 재사용하고, 공유하고, 테스트하기 쉽게 만들어볼게요.
주요 **부수 효과(side effect)**가 있는 코드, 즉 데이터를 바꾸는(데이터베이스와 테이블이 있는 파일을 만드는) 코드를 함수로 옮겨봅시다.
이 예제에서는 그게 바로 SQLModel.metadata.create_all(engine) 예요.
그걸 create_db_and_tables() 라는 함수에 넣어볼게요:
from sqlmodel import Field, 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)
만약 SQLModel.metadata.create_all(engine) 이 함수 안에 있지 않고, 다른 파일에서 이 모듈(이 파일)에서 무언가를 임포트하면, 그 다른 파일을 실행할 때마다 데이터베이스와 테이블을 만들려고 시도하게 돼요.
우리는 그렇게 되는 걸 원하지 않아요. 우리가 의도한 때에만 동작하길 원하죠. 그래서 함수 안에 넣는 거예요. 테이블이 만들어지는 건 다른 곳에서 이 모듈을 임포트할 때가 아니라, 그 함수를 호출할 때뿐임을 보장할 수 있으니까요.
이제 예를 들어 다른 파일에서 Hero 클래스를 임포트해도 그런 부수 효과 없이 할 수 있게 됐어요.
/// tip
😅 스포일러 경고: 함수 이름이 create_db_and_tables() 인 이유는, 앞으로 Hero 말고도 다른 클래스들로 이루어진 테이블이 더 생길 예정이기 때문이에요. 🚀
///
스크립트로 데이터 만들기
우리는 app.py 파일에서 뭔가를 임포트할 때 부수 효과가 생기지 않게 막았어요.
하지만 위에서 했던 것처럼 터미널에서 독립 스크립트로 파이썬으로 직접 호출할 때는 데이터베이스와 테이블을 만들도록 여전히 원해요.
/// tip
스크립트와 프로그램이라는 단어는 서로 바꿔 써도 된다고 생각하세요.
스크립트라는 단어는 보통 코드를 독립적이고 쉽게 실행할 수 있음을 암시해요. 또는 비교적 간단한 프로그램을 가리키기도 하죠.
///
그러려면 if 블록 안에서 특별한 변수 __name__ 을 쓰면 됩니다:
if __name__ == "__main__":
create_db_and_tables()
__name__ == "__main__" 에 대해서
__name__ == "__main__" 의 주요 목적은, 이렇게 파일을 호출했을 때 실행되는 코드를 두는 거예요:
$ uv run python app.py
// Something happens here ✨
...그런데 다른 파일이 이걸 임포트했을 때는 실행되지 않도록 하는 거죠. 예를 들면:
from app import Hero
/// tip
if __name__ == "__main__": 을 쓰는 그 if 블록은 가끔 "main block" 이라고 불려요.
공식 이름(Python 문서)은 "Top-level script environment" 이에요.
///
더 자세히
파일 이름이 myapp.py 라고 해볼게요.
이렇게 실행하면:
$ uv run python myapp.py
// This will call create_db_and_tables()
...파이썬이 자동으로 만든, 파일 안의 내부 변수 __name__ 값이 문자열 "__main__" 이 돼요.
그래서 이 안의 함수는:
if __name__ == "__main__":
create_db_and_tables()
...실행됩니다.
그런데 그 모듈(파일)을 임포트하면 이런 일이 벌어지지 않아요.
즉, importer.py 라는 다른 파일이 있다고 해보죠:
from myapp import Hero
# Some more code
...그 경우 myapp.py 안의 자동 변수 __name__ 은 "__main__" 이라는 값을 갖지 않아요.
그래서 이 줄은:
if __name__ == "__main__":
create_db_and_tables()
...실행되지 않을 거예요.
/// note
더 자세한 내용은 공식 Python 문서를 확인해주세요.
///
마지막 점검 (Last Review)
이런 변경들 후에 다시 실행하면, 이전과 같은 출력이 만들어질 거예요.
하지만 이제 다른 파일에서 이 모듈의 것들을 임포트할 수 있게 됐죠.
이제 코드를 마지막으로 한 번 살펴볼게요:
from sqlmodel import Field, SQLModel, create_engine # (2)!
class Hero(SQLModel, table=True): # (3)!
id: int | None = Field(default=None, primary_key=True) # (4)!
name: str # (5)!
secret_name: str # (6)!
age: int | None = None # (7)!
sqlite_file_name = "database.db" # (8)!
sqlite_url = f"sqlite:///{sqlite_file_name}" # (9)!
engine = create_engine(sqlite_url, echo=True) # (10)!
def create_db_and_tables(): # (11)!
SQLModel.metadata.create_all(engine) # (12)!
if __name__ == "__main__": # (13)!
create_db_and_tables() # (14)!
/// tip
코드의 각 번호 표시를 클릭하면 그 줄이 하는 일을 확인할 수 있어요. 👆
///
정리 (Recap)
SQLModel 을 이용해 데이터베이스의 테이블이 어떤 모습이어야 하는지를 정의하는 법을 배웠고, SQLModel 로 데이터베이스와 테이블을 만들어봤어요.
나중에 재사용하고, 공유하고, 테스트하기 쉽도록 코드를 리팩터링하는 것도 했죠.
다음 장들에서는 SQLModel 이 코드에서 SQL 데이터베이스와 상호작용하는 걸 어떻게 도와주는지 볼 거예요. 🤓