회사에서 FastAPI + SQLModel 기반 프로젝트를 하고 있어 SQLModel을 공부했습니다. 공식문서 번역본에 가깝지만 나름대로 이해한 내용을 정리해봤습니다.
이 글은 sqlalchemy 2.0.4 버전을 기준으로 작성했습니다.
SQLModel은 SQLAlchemy를 기반으로 동작하므로, 데이터베이스 연결은 create_engine에서 생성된 Engine 객체가 관리합니다.
Engin은 커넥션 풀과 Dialect를 통해 애플리케이션에 전달되는 데이터베이스의 시작점입니다.
DBAPI란?
Python Database API Specification의 약자로, 모든 데이터베이스 커넥션 패키지에 대한 일반적인 사용 패턴을 정의하기 위해 파이썬의 PEP 249에 정의된 스펙입니다.
DBAPI는 파이썬 애플리케이션에서 데이터베이스와 통신하는 데 사용되는 가장 낮은 단계의 API입니다.
SQLAlchemy의 dialect 시스템은 특정 데이터베이스 엔진 위에 특정 DBAPI를 서비스하는 구현체를 통해 구성됩니다.
예를 들어, create_engine(postgresql+psycopg2://@localhost/test)는 psycopg2라는 DBAPI 구현체를사용하고, create_engine(mysql+mysqldb://@localhost/test)는 MySQL for Python이라는 DBAPI 구현체를 사용합니다.
JDBC(Java Database Connectivity)와 가장 큰 차이점은 DBAPI는 표준 스펙일뿐이며 Python 기본 패키지에 없지만(sqlite3 구현체만 기본 제공), JDBC는 Java 표준 API로 패키지에 포함되어있다는 점입니다.
Dialect란?
sqlalchemy.engine.Dialect 클래스로, 데이터베이스마다 다른 메타데이터 정의, SQL 쿼리 생성, 실행, 결과 집합 처리 등은 Dialect의 일반 범주에 정의됩니다. Dialect는 데이터베이스별 객체 구현을 위한 팩토리 역할을 합니다. 쉽게 말하면, 데이터베이스 종류마다 각자만의 '방언'을 지원하는 클래스입니다.
이제 Engine의 동작 방식을 알아봅시다.
위 그림에서 Engine 객체는 connection pool을 추상화한 Pool 객체와 Dialect 객체 둘다 생성하고 참조합니다.
Pool 객체는 데이터베이스 커넥션 풀을 생성하고 관리합니다.
Dialect는 데이터베이스의 동작과 DBAPI의 모듈 기능을 해석합니다.
Engine 객체를 생성할때 사용되는 커넥션 풀의 기본 구현은 QueuePool입니다.
Engine 객체가 생성될때 적절한 기본값으로 세팅된 QueuePool이 커넥션 풀로 설정되며 Engine 객체에 들어있습니다.QueuePool은 열려있는 커넥션 수에 제한을 두며, Pool을 상속받습니다.
create_engine(..., poolclass=[Pool])을 통해 다른 풀 구현체(sqlalchemy.pool.NullPool, sqlalchemy.pool.StaticPool 등)를 사용할 수 있습니다.
from sqlalchemy import create_engine
engine = create_engine("postgresql+psycopg2://scott:tiger@localhost:4321/mydatabase")
위 코드와 그림을 함께 살펴보면 Engine은 다음과 같이 작동합니다.
create_engine()으로 Engine 객체 생성 및 데이터베이스 연결 요청
localhost:5432로 DBAPI 연결을 설정하는 Pool 객체 생성
PostgreSQL에 맞게 조정된 Dialect 객체 생성
Engine.connect() 호출 시 데이터베이스 커넥션 생성
그러면 Engine.connect()는 언제 호출되는 걸까요?
다음 코드를 보며 알아봅시다.
from sqlmodel import create_engine, Session
engine = create_engine("postgresql+psycopg2://scott:tiger@localhost:5432/mydatabase")
with Session(engine) as session:
session.add(some_object)
session.add(some_other_object)
session.commit()
위 코드에서 Engine은 연결 요청이 처음 수신될 때PostgreSQL에 맞게 조정된 Dialect 객체를 만들고, localhost:5432로 DBAPI 연결을 설정하는 Pool 객체를 생성한다고 했습니다.
Engine이 Pool을 만들기 때문에 create_engine()에 커넥션 풀과 관련된 여러 설정을 인자로 넘겨주면애플리케이션이 시작될때 Engine이이에 맞게 커넥션 풀을생성하고 관리합니다.
Engine과 Pool은 Engine.connect()(또는 Engine.start())가 호출될 때까지 첫 번째 실제 DBAPI 연결을 설정하지 않습니다. 즉, 데이터베이스 커넥션은 실제로 사용되기 전까지는 생성되지 않으며, 처음 Engine이 생성됐을때 커넥션 풀은 비어있는 상태입니다.
Engine.connect() 같은 커넥션 생성 메서드는 Session을 통해 쿼리를 실행하거나 트랜잭션을 시작하는 등의 데이터베이스 작업이 이뤄질때 내부적으로 호출되고, 이로써 커넥션이 실제로 사용됩니다.
그림으로 보면 다음과 같습니다.
이런 식으로 Engine과 Pool은 초기화 지연 동작을 가집니다. 이렇게 하면 커넥션 낭비를 줄이고 컴퓨터 자원도 덜 소모할 수 있어 효율적입니다.
만약 5개의 커넥션을 생성하고 실제로 데이터베이스와 연결해 두었다가 오랜시간 동안 데이터베이스와 통신이 없다면, 데이터베이스에서 timeout이 발생해 연결을 끊어버릴 수 있습니다. 그러면 실제로 커넥션을 사용할때 다시 연결해야하는데 이는 비용이 많이 드는 작업입니다. "진짜 쓸때까지 연결 안한다"는 정책은 이런 비효율적인 작업을 막기 위한 것입니다.
일반적으로 요청 1개에 커넥션 1개가 생성됩니다. 애플리케이션이 동시에 5개의 요청을 받으면 5개의 커넥션을 사용하고, 커넥션 풀 크기가 5로 늘어납니다.
Session.commit() 후에는 해당 트랜잭션과 연결된 Connection 객체가 닫히고(Connection.close()), 이 Session이 연결된 Engine의 DBAPI 커넥션이 커넥션 풀로 반환됩니다. 하지만 커넥션이 반환된다는게 Session.close()를 의미하지는 않습니다.
그러면 위 코드에서 Session은 언제 닫히는 걸까요?
with 블록이 끝날때 __exit__() 내부에서 Session.close()가 호출되며 세션이 닫힙니다.
Session.commit()을 호출하면 트랜잭션이 끝나고 커넥션이 닫기지만, Session은 살아있는 상태입니다.
Session을 닫지 않고 새로운 작업을 시작하면 다시 커넥션풀에서 커넥션을 가져옵니다.
커넥션이 반환되면, 애플리케이션에서는 더이상 이 커넥션을 직접 사용할 수 없습니다. 하지만 데이터베이스와의 실제 연결은 끊기지 않고 유지된 상태로 풀에 남아있으며, 유휴상태(idle)로 간주되어 이후 다른 요청에서 Session이 사용될때 재사용됩니다.
간단히 정리하면,
SQLModel의 Engine은 자신이 생성한 Pool로 데이터베이스 커넥션 풀을 관리하고, 여기서 커넥션을 꺼내 쓰는 주체는 Session.exec() 같은 Session의 데이터베이스 작업 메서드들입니다.
Session의 입장에서 정리하면,
Session 객체는 Engine에서 제공하는 데이터베이스 연결 메서드를 내부적으로 호출하여 커넥션 풀에서 커넥션을 가져와 애플리케이션을 데이터베이스에 연결합니다.