SQLAlchemy 2.0 #3 ORM 모델 정의: DeclarativeBase, Mapped, mapped_column
2편에서 Table 객체로 테이블을 정의했습니다. ORM은 같은 정보를 파이썬 클래스로 선언합니다. 행 하나가 객체 하나가 되고, 컬럼이 속성이 됩니다. 2.0의 모델 선언은 타입 힌트가 중심이라 1.x와 생김새가 꽤 다릅니다. 이번 편에서 그 문법을 확실히 잡습니다.
기본형: DeclarativeBase와 Mapped #
from datetime import datetime
from typing import Optional
from sqlalchemy import String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user_account"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(30))
email: Mapped[str] = mapped_column(String(100), unique=True)
nickname: Mapped[Optional[str]] = mapped_column(String(30))
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
def __repr__(self) -> str:
return f"User(id={self.id!r}, name={self.name!r})"구조를 뜯어 보면 이렇습니다.
Base: 프로젝트의 모든 모델이 상속하는 공통 부모입니다.DeclarativeBase를 상속한 클래스를 하나 만들면, 그 하위 클래스들이 등록되는MetaData가 자동으로 만들어집니다. 2편의metadata.create_all(engine)은 ORM에서Base.metadata.create_all(engine)이 됩니다.Mapped[타입]: 이 속성이 DB 컬럼에 매핑된다는 선언이자 타입 힌트입니다. IDE와 mypy가 이 정보를 그대로 이해합니다.mapped_column(): 컬럼의 세부 설정(기본 키, 길이, 유니크, 기본값 등)을 담당합니다. 설정할 것이 없으면 생략해도 됩니다.name: Mapped[str]만 써도 NOT NULL 문자열 컬럼이 됩니다.
역할 분담이 핵심입니다. 타입과 널 허용 여부는 Mapped[...]가, 나머지 세부 사항은 mapped_column()이 결정합니다.
타입 매핑 규칙: 파이썬 타입이 DB 타입이 됩니다 #
Mapped[...] 안의 파이썬 타입은 DB 타입으로 자동 변환됩니다.
| 파이썬 타입 | DB 타입 (일반적 매핑) | 비고 |
|---|---|---|
int | INTEGER | |
str | VARCHAR | 길이 제한은 mapped_column(String(30))으로 |
float | FLOAT | |
bool | BOOLEAN | SQLite에서는 INTEGER 0/1 |
datetime | DATETIME / TIMESTAMP | 시간대 포함은 DateTime(timezone=True) |
Decimal | NUMERIC | 금액 컬럼의 정답 |
bytes | BLOB / BYTEA |
그리고 가장 중요한 규칙 하나가 있습니다. Optional[str](또는 str | None)이면 NULL 허용, 아니면 NOT NULL입니다. 널 허용 여부가 타입 힌트에서 자동으로 결정되므로, 모델 코드와 실제 스키마와 타입 검사가 항상 일치합니다. 위 모델에서 nickname만 NULL을 허용하는 이유입니다. mapped_column(nullable=...)으로 명시적으로 덮어쓸 수도 있지만, 타입 힌트와 어긋나게 쓰면 혼란만 남습니다.
자주 쓰는 컬럼 설정 #
from sqlalchemy import Text, text
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
# 인덱스: 검색 조건으로 자주 쓰는 컬럼에
author_name: Mapped[str] = mapped_column(String(30), index=True)
# 파이썬 쪽 기본값: 객체 생성 시점에 적용
view_count: Mapped[int] = mapped_column(default=0)
# 서버 쪽 기본값: DDL의 DEFAULT 절로 들어감
status: Mapped[str] = mapped_column(String(20), server_default=text("'draft'"))
# 길이 제한 없는 텍스트
body: Mapped[str] = mapped_column(Text)defaultvsserver_default:default=0은 파이썬이 INSERT 문에 값을 채워 넣는 것이고,server_default는 CREATE TABLE의 DEFAULT 절이 되어 DB가 채웁니다. SQLAlchemy를 거치지 않는 접근(수동 SQL, 다른 서비스)까지 고려하면server_default가 안전하고, ORM만 쓰는 값이면default로 충분합니다.created_at류는server_default=func.now()가 정석입니다.index=True: 해당 컬럼에 단일 인덱스를 만듭니다. 복합 인덱스는__table_args__에Index("ix_post_author_status", "author_name", "status")처럼 선언합니다. 인덱스가 조회 성능에 왜 중요한지는 데이터베이스 인덱스가 하는 일에서 다뤘습니다.unique=True: 유니크 제약을 만듭니다. 이메일, 사용자명처럼 중복이 곧 버그인 컬럼은 애플리케이션 검사에 맡기지 말고 DB 제약으로 겁니다.
네이밍 컨벤션: 제약 이름을 처음부터 통일합니다 #
제약 조건(유니크, 외래 키 등)에 이름을 지정하지 않으면 DB가 임의의 이름을 붙입니다. 문제는 나중에 Alembic으로 제약을 삭제하거나 변경할 때 그 이름을 알아야 한다는 것입니다. DB마다 이름 규칙이 달라서, 처음에 네이밍 컨벤션을 박아 두는 것이 표준 관행입니다.
from sqlalchemy import MetaData
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)이렇게 하면 user_account.email의 유니크 제약은 어느 DB에서든 uq_user_account_email이라는 예측 가능한 이름을 갖습니다. 프로젝트 첫날에 넣어야 하는 설정입니다. 테이블이 이미 만들어진 뒤에 도입하면 기존 제약 이름과 어긋나서 마이그레이션이 번거로워집니다.
모델과 테이블은 같은 것의 두 표현입니다 #
선언한 모델 뒤에는 2편에서 본 Table 객체가 그대로 들어 있습니다. User.__table__로 꺼내 볼 수 있고, Base.metadata에는 모든 모델의 테이블이 등록되어 있습니다. ORM 모델은 결국 Table 정의 + 클래스 매핑의 묶음이라는 것을 기억하면, Core와 ORM을 오가는 코드가 어색하지 않게 됩니다.
print(User.__table__) # Table('user_account', MetaData(), ...)
Base.metadata.create_all(engine) # 등록된 모든 테이블 생성정리 #
- 모델은
DeclarativeBase를 상속한Base를 만들고, 그 하위 클래스로 선언합니다.Base.metadata가 모든 테이블 정의를 들고 있습니다. Mapped[타입]이 컬럼 매핑과 타입 힌트를 겸하고, 세부 설정은mapped_column()이 맡습니다.Optional이면 NULL 허용, 아니면 NOT NULL입니다.- 기본값은 ORM 전용이면
default, DB 스키마 차원이면server_default를 씁니다. 중복 금지는unique=True로 DB 제약을 겁니다. - 네이밍 컨벤션은 프로젝트 첫날에 설정합니다. 나중에 마이그레이션에서 제약 이름 때문에 고생하지 않는 유일한 방법입니다.
- 다음 편은 이 모델로 실제 CRUD를 하는 세션입니다. ORM이 변경을 추적해서 SQL을 대신 만들어 주는 원리를 다룹니다.