SQLAlchemy 2.0 – Core 연결/트랜잭션의 변화

2021.06.03

지난 포스팅에 이어, SQLAlchemy 1.3 이하에서 2.0 으로 넘어가려는 분들에게 도움이 되길 바라며 마련한 글입니다. 호출 패턴이 일관되게 변화할 Core 연결/트랜잭션을 요리조리 살펴보겠습니다.

목차

  1. 커밋은 셀프
    • analytical query를 자주 쓰는 우리는 어떻게 써야하나?
    • 그 외 어떤 영향이 있나?
    • TLDR;
      • 번거로움에도 불구하고 왜 이런 결정을 했을까?
      • 왜 그 때는 그럴 수 밖에 없었나?
  2. MetaData는 데이터베이스에 국한되지 않는다.
    • Bound MetaData 여서 할 수 있는 일 vs 할 수 없는 일
    • 데이터베이스에 매이지 않는 MetaData
  3. Engine은 구문을 실행할 수 없다.
  4. Connection과 Session의 execute() 호출패턴이 비슷해졌다.
    • 전에는 어땠길래?
  5. 나가며

1. 커밋은 셀프

Engine 레벨에서 데이터 입력 또는 변경하는 명령은 자동으로 commit이 실행되었는데, 2.0 부터는 그렇지 않을 것입니다. context manager로 with engine.connect() 을 사용하고, with절이 끝날때 commit이 일어납니다. 만약 중간에 예외가 생기면, 트랜잭션은 롤백됩니다.

analytical query를 자주 쓰는 우리는 어떻게 써야하나?

분석/집계용 쿼리는 주로 읽기만 하므로 명시적인 commit의 영향력이 크지 않습니다. 그러나 with engine.connect() 을 추가로 써야만 하는건 확실히 번거로워질 것 같습니다.

# 1.3
result = engine.execute(t.select())
# 2.0
with engine.connect() as connection: 
    result = connection.execute(t.select())

그 외 어떤 영향이 있나?

분석용도에 맞는 컬럼들로 구성된 테이블을 생성하거나 편집할 때, 머신러닝에 필요한 피처를 테이블로 만들고 입력할 때입니다. 이런 쓰기 상황에서 with engine.connect() 을 꼭 챙겨서 써야 할 것 입니다.

TLDR;

번거로움에도 불구하고 왜 이런 결정을 했을까?

SQLAlchemy의 첫번째 릴리즈는 암묵적인 begin과 commit을 썼다네요. 그 릴리즈로부터 15년이 지난 지금에서 이 선택이 틀린게 보인다고 합니다. 현재 코드블럭에 트랜잭션의 존재를 감춘게 트랜잭션 라이프사이클 관리 없이 프로그래밍을 도와줬다기보다 오히려 api를 복잡하게 만들었기 때문입니다. 

특히 관계형 데이터베이스와 ACID 트랜잭션을 처음 접하는 사용자들을 특히 헷갈리게 만들었습니다.  사용자에게 트랜잭션 존재를 인지하지 않고도 데이터베이스를 쓰게하는게 “편리함”을 생각한 조치일거라 기대했지만, 오히려 데이터베이스가 항상 트랜잭션을 사용한다는 점을 사용자가 이해하지 못함으로써 잘못 사용하기 쉽게 만들었습니다.

결국 SQLAlchemy 2.0부터 암시적으로 트랜잭션을 커밋하려는 모든 시도를 없애고 사용 패턴은 사용자가 파일을 읽거나 쓰는 것과 같은 방식으로 항상 트랜잭션의 “시작”과 “끝”을 구분하게 바뀌었습니다.

왜 그 때는 그럴 수 밖에 없었나?

이 모든 레거시 패턴은 SQLAlchemy가 처음 생성되었을 때 Python에 context manager 또는 데코레이터가 없을 때 만들어졌고, 그래서 리소스 사용 시 바운더리를 정하기 편한 관용적 패턴이 없었다네요.

2. MetaData는 데이터베이스에 국한되지 않는다.

SQLAlchemy 0.1에는 MetaData가 없었습니다. Engine이 각 테이블에 직접 바인딩되었었지요. 

그런데 제가 작성했던 코드들을 떠올려보면 데이터베이스에 연결하기 전에 테이블 객체를 정의한 적이 훨씬 많습니다. 이런 사용패턴이 증가하면서 선언된 테이블 정보를 담아놓고 특정 데이터베이스와 연결지을 수 있는 “Bound MetaData” 개념이 생겨났습니다.

Bound MetaData 여서 할 수 있는 일 vs 할 수 없는 일

스키마, 테이블, 컬럼의 정보는 MetaData에 담겨집니다. 다음은 MetaData로 할 수 있는 일들입니다. Connection을 만들지 않고도, Engine만으로 실행할 수 있습니다.

  • SQLAlchemy로 선언한 테이블과 컬럼 정보 보유
  • 데이터베이스로부터 테이블 정의를 불러와서 보유
  • 새로운 데이터베이스에 테이블을 생성 및 삭제
  • 기존 데이터베이스의 스키마 변경

그러나 특정 Engine에 생성 때부터 매여있는 MetaData로는 다음과 같은 상황에 효율적으로 대응할 수 없습니다.

복제된 데이터베이스들로 스케일아웃한 경우, 여러 개의 데이터베이스가 같은 테이블을 갖고 있을 것입니다. MetaData(bind=engine1), MetaData(bind=engine2), MetaData(bind=engine3) 처럼 Engine의 개수만큼 MetaData가 불필요하게 생겨납니다.

또 다른 예로는, 주 데이터베이스를 복사해서 기록용 데이터베이스에 입력하는 경우입니다. 두 데이터베이스의 스키마는 동일합니다. 그러나 MetaData를 두 개 만들어야 합니다.

데이터베이스에 매이지 않는 MetaData

2.0 부터는 내가 지정한 데이터베이스와 메타데이터를 자유롭게 연결지을 수 있게 되었습니다.

# 1.3

from sqlalchemy import MetaData
metadata = MetaData(bind=engine) # no longer supported
metadata.create_all() # requires Engine or Connection
metadata.reflect() # requires Engine or Connection
# 2.0

from sqlalchemy import MetaData
metadata = MetaData()
# 엔진 레벨의 테이블 생성
metadata.create_all(engine)
# 엔진 레벨의 테이블 정보 불러오기
metadata.reflect(engine)

3. Engine은 구문을 실행할 수 없다.

구문을 실행할 방법이 여러가지 있었습니다. 그러나 2.0부터는 Engine으로 구문을 실행할 수 없게 되었습니다. 오직 Connection 으로 구문을 실행해야 합니다. Engine만으로 데이터베이스를 변경하는 작업은 metadata.create_all()/drop_all()/reflect() 로 제한됩니다.


t = Table("t", metadata, autoload=True) 
# Bound metadata로 테이블 정보를 불러올 수 있음
result = engine.execute(t.select())
# engine으로 구문 실행 할 수 있음
result = t.select().execute()
# 구문에서 실행 함수를 호출할 수 있음

# engine으로 개별 테이블 정보를 불러오기
t = Table("t", metadata, autoload_with=engine)
with engine.connect() as connection:
    # connection으로 개별 테이블 정보를 불러오기
    t = Table("t", metadata, autoload_with=connection)
    # SQL 구문 실행하기
    result = connection.execute(t.select())

4. Connection과 Session의 execute() 호출패턴이 비슷해졌다.

2.0 부터 가장 두드러진 변화는 호출 패턴이 일관성있게 변화한 것인데요. Connection.execute()Session.execute() 은 결국 구문을 실행하는 역할을 하는데, 호출 패턴이 달랐었습니다. 

비슷한 역할을 하는 두 함수의 호출 패턴이 동일해져서 쓰기 편해진것 같습니다.

# 1.3

result = connection.execute("select * from table")
# 문자열 SQL을 직접 사용할 수 있음
# 2.0

from sqlalchemy import text
result = connection.execute(text("select * from table"))
# text()를 사용해야 함
# 1.3

result = connection.execute(
    table.insert(), 
    ('x', 'y', 'z'))
# 위치 파라미터로 사용이 지원됨
# 2.0

result = connection.execute(
    table.insert(),
    {"x": 10, "y": 5})
# 단일행 입력이면 단일 딕셔너리를 넘겨줌
# 1.3

result = connection.execute(table.insert(), x=10, y=5)
# 단일행 입력 시 **kwargs 로 값을 넘겨줌

result = connection.execute(
    table.insert(),
    {"x": 10, "y": 5},
    {"x": 15, "y": 12},
    {"x": 9, "y": 8})
# 여러개의 *args로 복수행 입력값을 넘겨줌
# 2.0

result = connection.execute(
    table.insert(), 
    [{"x": 10, "y": 5},
     {"x": 15, "y": 12},
     {"x": 9, "y": 8}])
# 배열로 복수행 입력값을 넘겨줌

전에는 어땠길래?

*args, **kwargs를 사용하다보니 어떤 종류의 인자가 전달되는지 추측하느라 복잡도가 올라갔습니다. 이것을 대신해서 Connection.execute.execution_options 딕셔너리를 사용해 구문 실행 옵션을 주도록 바뀌었습니다. 이로 인해 Connection.execute(), Session.execute() 함수의 사용 패턴이 같아졌습니다. 

5. 나가며

2.0 부터 달라지는 Core 연결/트랜잭션을 알아보았습니다. 본 포스팅이 새로운 SQLAlchemy를 기다리는 분들에게 유용한 정보가 되길 바랍니다.

라인웍스는 확장성 있는 의료데이터 처리 시스템을 개발하고 있습니다. 저희와 함께 의료 데이터를 처리할 데이터 엔지니어를 모시고 있습니다. 자세한 내용은 채용 공고를 확인해주세요.

Cinyoung Hur

Lead Data Engineer, Software Developer

Cinyoung Hur