SQLAlchemy 2.0 #4 세션: 변경 추적, flush와 commit, 객체의 네 가지 상태
Core에서는 conn.execute()로 SQL을 직접 밀어 넣었습니다. ORM은 다르게 일합니다. 객체를 만들고, 고치고, 지우면 세션(Session)이 그 변경들을 기억해 뒀다가 적절한 시점에 SQL로 변환해서 내보냅니다. 이 “기억해 뒀다가 한 번에"가 단위 작업(Unit of Work) 패턴이고, 세션을 이해한다는 것은 곧 이 타이밍을 이해한다는 뜻입니다.
세션 만들기: sessionmaker #
세션은 엔진에서 커넥션을 빌려 쓰는 짧은 수명의 작업 공간입니다. 매번 Session(engine)으로 만들 수도 있지만, 설정을 고정한 공장을 하나 만들어 두는 것이 관례입니다.
from sqlalchemy.orm import Session, sessionmaker
SessionLocal = sessionmaker(engine)
# 기본 사용 패턴: 블록 전체가 한 트랜잭션
with SessionLocal.begin() as session:
session.add(User(name="김세션", email="kim@example.com"))
# 정상 종료 → commit, 예외 → rollback
# 세밀하게 제어할 때
with SessionLocal() as session:
user = User(name="이플러시", email="lee@example.com")
session.add(user)
session.commit()sessionmaker.begin()은 2편에서 본 engine.begin()의 ORM판입니다. 짧은 스크립트라면 이 형태가 기본값이고, 커밋 시점을 직접 정해야 하면 두 번째 형태를 씁니다.
변경은 즉시 SQL이 되지 않습니다: flush와 commit #
세션의 핵심 동작을 코드로 확인합니다.
with SessionLocal() as session:
user = User(name="김지연", email="delay@example.com")
session.add(user) # 아직 SQL 없음. 세션이 "새 객체"로 기억만 함
print(user.id) # None. INSERT 전이라 기본 키가 없음
session.flush() # 여기서 INSERT 실행 (트랜잭션은 열린 상태)
print(user.id) # 1. DB가 발급한 기본 키가 채워짐
session.commit() # 트랜잭션 확정- flush: 세션에 쌓인 변경(INSERT, UPDATE, DELETE)을 SQL로 변환해 DB로 보냅니다. 트랜잭션 안에서 실행될 뿐 확정은 아닙니다.
- commit: flush를 한 번 더 수행한 뒤 트랜잭션을 확정합니다. 그래서 대부분의 코드에서는 flush를 직접 부를 일이 없습니다.
flush를 직접 부르는 대표적인 경우는 위 코드처럼 DB가 발급하는 기본 키가 커밋 전에 필요할 때입니다. 또 세션은 쿼리를 실행하기 직전에 자동으로 flush합니다(autoflush). 방금 add한 객체가 바로 다음 select에 잡히는 것은 이 덕분입니다.
수정과 삭제도 같은 방식입니다. 조회해 온 객체의 속성을 바꾸면 그것만으로 UPDATE 대상이 됩니다. 별도의 save 호출이 없습니다.
with SessionLocal.begin() as session:
user = session.get(User, 1) # 기본 키 조회
user.name = "김수정" # 세션이 변경을 감지 (dirty)
session.delete(session.get(User, 2)) # DELETE 예약
# 블록 종료 시 UPDATE와 DELETE가 함께 flush + commit객체의 네 가지 상태 #
세션과 객체의 관계는 네 상태로 정리됩니다. 에러 메시지에 그대로 등장하는 용어라서 알아 두면 디버깅이 빨라집니다.
| 상태 | 의미 | 언제 |
|---|---|---|
| transient | 세션과 무관한 새 객체 | User(...) 직후 |
| pending | 세션에 등록됐지만 아직 INSERT 전 | session.add() 후 |
| persistent | DB 행과 연결됨 (기본 키 있음) | flush 후, 또는 조회해 온 객체 |
| detached | DB 행과 연결됐었지만 세션이 닫힘 | 세션 종료 후 |
실무에서 문제가 되는 것은 주로 detached입니다. 세션이 닫힌 뒤에 객체의 로드되지 않은 속성(특히 다음 편에서 다룰 관계 속성)에 접근하면 DetachedInstanceError가 납니다. “세션 밖으로 나간 객체는 더 이상 DB와 대화할 수 없다"는 원칙만 기억하면 원인이 바로 보입니다.
identity map: 같은 행은 같은 객체입니다 #
한 세션 안에서 같은 기본 키를 두 번 조회하면, 세션은 두 번째 조회에서 이미 들고 있는 그 객체를 그대로 돌려줍니다.
with SessionLocal() as session:
a = session.get(User, 1)
b = session.get(User, 1)
print(a is b) # True. 같은 파이썬 객체이것이 identity map입니다. 세션이 “기본 키 → 객체” 사전을 유지하기 때문에, 한 트랜잭션 안에서 같은 행을 가리키는 객체가 두 개 생겨 서로 다른 값을 갖는 사고가 원천 차단됩니다. 반대로 말하면, 세션이 다르면 같은 행이라도 다른 객체입니다. 세션 A에서 수정한 내용이 세션 B의 객체에 자동 반영되지는 않습니다.
commit 후의 첫 접근은 왜 쿼리를 날리는가: expire #
기본 설정(expire_on_commit=True)에서 커밋은 세션 안 모든 객체의 속성을 만료 처리합니다. 커밋 후 처음 속성에 접근하면 세션이 SELECT를 다시 실행해 최신 값을 가져옵니다. 커밋 사이에 다른 트랜잭션이 값을 바꿨을 수 있으니, 오래된 값을 계속 들고 있지 않겠다는 안전장치입니다.
이 동작은 두 가지 실무 포인트를 만듭니다.
- 커밋 후 루프에서 객체 속성을 하나씩 읽으면 SELECT가 여러 번 나갈 수 있습니다.
echo=True로 확인해 보면 바로 보입니다. - 커밋 후 세션을 닫고 나서 속성에 접근하면, 만료된 속성을 다시 로드할 세션이 없으므로 위에서 말한
DetachedInstanceError가 납니다. API 응답으로 객체를 반환하기 전에 필요한 값을 먼저 읽어 두거나, 응답용 데이터로 변환(Pydantic 모델 등)을 세션 안에서 끝내는 것이 정석입니다.
세션 스코프: 요청 하나에 세션 하나 #
세션을 어디서 만들고 어디서 닫을지는 아키텍처 문제입니다. 원칙은 하나입니다. 작업 단위 하나에 세션 하나. 웹 애플리케이션이라면 HTTP 요청 하나가 작업 단위입니다.
- 전역 세션 하나를 계속 쓰는 것은 금물입니다. 세션은 스레드 안전하지 않고, 에러로 롤백이 필요한 상태가 되면 이후 모든 요청이 오염됩니다.
- FastAPI라면 의존성 주입으로 요청마다 세션을 만들어 넘기고 응답 후 닫습니다. 구체적 패턴은 모던 파이썬 실전 #3에서 다뤘습니다.
- 배치 작업이라면 논리적 처리 단위(파일 하나, 청크 하나)마다 세션을 열고 닫습니다. 수십만 건을 세션 하나로 처리하면 변경 추적 대상이 쌓여 메모리와 flush 시간이 함께 늘어납니다.
정리 #
- 세션은 변경을 모아 두었다가 flush 시점에 SQL로 내보내는 단위 작업 관리자입니다.
add와 속성 수정은 즉시 SQL이 되지 않습니다. - flush는 SQL 전송, commit은 flush + 확정입니다. DB 발급 기본 키가 미리 필요할 때만 flush를 직접 부릅니다.
- 객체는 transient, pending, persistent, detached 네 상태를 오갑니다. 세션 밖에서 미로드 속성에 접근하면
DetachedInstanceError입니다. - 한 세션 안에서 같은 행은 항상 같은 객체입니다(identity map). 커밋은 속성을 만료시켜 다음 접근에서 재조회하게 합니다.
- 스코프 원칙은 작업 단위당 세션 하나입니다. 전역 세션은 쓰지 않습니다.
- 다음 편은 relationship입니다. 1:N, N:M 관계 선언과 ORM 최대의 함정인 N+1 문제를 다룹니다.