7 minute read

개요

FastAPI에서는 특정 Database나 ORM 사용을 강제하지 않습니다. 이번 학습에서는 FastAPI 공식 문서의 예제와 동일하게 SQLite와 SQLModel을 이용하여 관계형 Database를 연동하고 CRUD API를 구현해보았습니다.

이번 글에서는 관계형 Database, ORM, SQLModel, Engine, Session 등의 핵심 개념을 정리하고, Request/Database/Response Model을 분리한 CRUD API를 구현해보겠습니다.


1. 관계형 Database와 SQLite

Relational Database는 데이터를 Table 형태로 저장하고 관리하는 Database입니다. 기본적인 구성 요소는 다음과 같습니다.

용어 의미
Table 같은 종류의 데이터를 저장하는 구조
Row 하나의 데이터 Record
Column 데이터가 가지는 속성
Primary Key 각 Row를 고유하게 식별하는 값

관계형 Database에서는 SQL을 이용하여 데이터를 생성, 조회, 수정, 삭제할 수 있습니다. 이번 실습에서는 SQLite를 사용합니다. SQLite는 PostgreSQL이나 MySQL과 달리 별도의 Database Server를 실행할 필요 없이 하나의 파일에 Database를 저장할 수 있는 관계형 Database입니다. 따라서 별도의 Database Server를 구축하지 않아도 되어 학습이나 간단한 프로젝트에서 사용하기 편리합니다.


2. ORM과 SQLModel

ORM(Object Relational Mapping)은 객체지향 프로그램의 객체와 관계형 Database의 Table을 연결하여 Database 데이터를 객체 형태로 다룰 수 있도록 하는 기술입니다.

예를 들어 Python의 Hero Class와 Database의 hero Table을 연결할 수 있습니다.

Python                         Database

Hero Class       ← ORM →      hero Table

Hero Instance    ← ORM →      하나의 Row

ORM을 사용하면 SQL을 직접 작성하는 대신 Python 객체를 중심으로 Database를 다룰 수 있습니다. 이번 실습에서는 SQLModel을 사용합니다. SQLModel은 SQLAlchemy와 Pydantic을 기반으로 만들어진 Library입니다. 따라서 Database Table과 Python 객체를 연결하는 ORM 기능과 Pydantic 기반의 데이터 Model 기능을 함께 사용할 수 있습니다.


3. Table Model과 일반 Data Model

SQLModel에서는 table=True의 유무에 따라 Model의 역할이 달라집니다.

class Hero(SQLModel, table=True):
    ...

table=True를 지정하면 해당 Model은 실제 Database Table과 연결되는 Table Model이 됩니다.

반면 다음과 같이 table=True가 없는 Model은 Database Table 자체를 나타내지 않는 일반적인 Data Model로 사용할 수 있습니다.

class HeroCreate(SQLModel):
    ...

실제 API에서는 하나의 Model을 Request, Database, Response에 모두 사용하는 것보다 각 역할에 맞게 분리하는 것이 유용합니다.

HeroCreate
→ 생성 Request

Hero
→ Database Table

HeroPublic
→ Response

HeroUpdate
→ 수정 Request

4. Engine과 Session

Engine

Engine은 Python Application과 Database가 통신하기 위한 기반 객체입니다.

engine = create_engine(sqlite_url)

어떤 Database에 연결할 것인지와 Database Connection을 관리하기 위한 기반을 제공합니다.

Session

Session은 ORM 객체를 관리하면서 Database와 조회, 저장, 수정, 삭제 등의 작업을 수행하는 단위입니다.

Endpoint
   ↓
Session
   ↓
Engine
   ↓
Database

대표적인 Session 작업은 다음과 같습니다.

session.add(...)
session.get(...)
session.exec(...)
session.delete(...)
session.commit()

Transaction

Transaction은 Database에서 수행되는 하나의 논리적인 작업 단위입니다. 작업이 정상적으로 완료되면 commit()을 통해 변경사항을 확정하며, 문제가 발생하면 Rollback할 수 있습니다.


5. add(), commit(), refresh()

새로운 ORM 객체를 Database에 저장할 때 다음과 같은 코드를 사용할 수 있습니다.

session.add(hero)
session.commit()
session.refresh(hero)

각 메서드의 역할은 다음과 같습니다.

  • session.add(): 객체를 Session에 등록하여 Database에 저장할 대상으로 관리합니다.
  • session.commit(): 현재 Transaction에서 수행한 변경사항을 Database에 확정합니다.
  • session.refresh(): Database에 저장된 최신 데이터를 다시 읽어 현재 Python 객체에 반영합니다.

6. Database Session과 Dependency Injection

Database Session은 Request마다 생성하여 사용한 뒤 적절하게 종료할 필요가 있습니다.

FastAPI에서는 이전에 학습한 Dependency Injection과 yield를 이용하여 Session의 Lifecycle을 관리할 수 있습니다.

def get_session():
    with Session(engine) as session:
        yield session

그리고 다음과 같이 Dependency로 선언할 수 있습니다.

SessionDep = Annotated[
    Session,
    Depends(get_session)
]

실행 흐름은 다음과 같습니다.

Request
   ↓
get_session()
   ↓
Session 생성
   ↓
yield session
   ↓
Endpoint에 Session 주입
   ↓
Database 작업
   ↓
Endpoint 처리 종료
   ↓
Session 정리

즉 Endpoint에서는 Session을 직접 생성하고 종료하는 과정을 반복하지 않고 필요한 Session을 Dependency로 제공받을 수 있습니다.


7. CURD

CRUD는 Database에서 수행하는 네 가지 기본 작업을 의미합니다.

CRUD 의미 대표 HTTP Method
Create 데이터 생성 POST
Read 데이터 조회 GET
Update 데이터 수정 PUT / PATCH
Delete 데이터 삭제 DELETE

8. 실습 - 역할별 Model을 분리한 CRUD API

이번 실습에서는 Hero 데이터를 대상으로 CRUD API를 구현하였습니다. 먼저 각 Model의 역할을 분리합니다.

class HeroBase(SQLModel):
    name: str = Field(index=True)
    age: int | None = Field(default=None, index=True)


class Hero(HeroBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    secret_name: str


class HeroCreate(HeroBase):
    secret_name: str


class HeroPublic(HeroBase):
    id: int


class HeroUpdate(HeroBase):
    name: str | None = None
    age: int | None = None
    secret_name: str | None = None

각 Model은 다음과 같은 역할을 가집니다.

HeroBase
→ 여러 Model이 공통으로 사용하는 Field

Hero
→ 실제 Database Table Model

HeroCreate
→ 생성 Request Model

HeroPublic
→ Client에게 반환하는 Response Model

HeroUpdate
→ 수정 Request Model

HeroCreate에는 id가 없습니다. id는 Client가 입력하는 값이 아니라 Database에서 생성할 값이기 때문입니다.

반대로 HeroPublic에는 secret_name이 없습니다. Database에는 저장하지만 Client에게 공개하지 않을 데이터를 Response Model에서 제외한 것입니다.

8.1 Create

새로운 Hero를 생성할 때 Client는 HeroCreate 구조로 데이터를 전달합니다.

HeroCreate
    ↓
Hero.model_validate()
    ↓
Hero Table Model
    ↓
Session
    ↓
Database

다음 코드를 통해 Request Model을 Database Table Model로 변환합니다.

db_hero = Hero.model_validate(hero)

그리고 아래 코드를 통해 데이터를 저장합니다.

session.add(db_hero)
session.commit()
session.refresh(db_hero)

실제로 반환되는 객체는 Hero이지만 response_model=HeroPublic을 지정했으므로 Client에게는 secret_name을 제외한 Field만 반환됩니다.


8.2 Read

전체 데이터를 조회할 때는 select()를 이용해 Query를 구성하고 Session을 통해 실행할 수 있습니다.

session.exec(
    select(Hero)
    .offset(offset)
    .limit(limit)
).all()

offset은 앞에서 몇 개의 데이터를 건너뛸지를 의미하고, limit은 최대 몇 개의 데이터를 조회할지를 의미합니다.

특정 Primary Key를 가진 하나의 객체를 조회할 때는 다음과 같이 사용할 수 있습니다.

hero = session.get(Hero, hero_id)

데이터가 존재하지 않는다면 HTTPException을 이용하여 404 Not Found를 반환합니다.


8.3 Update와 부분 수정

이번 실습에서는 PATCH를 이용하여 Hero를 부분 수정합니다. HeroUpdate의 모든 Field는 Optional로 정의되어 있습니다.

class HeroUpdate(HeroBase):
    name: str | None = None
    age: int | None = None
    secret_name: str | None = None

따라서 Client는 변경하고자 하는 Field만 전달할 수 있습니다. Client가 실제로 전달한 Field만 추출하기 위해 다음 코드를 사용합니다.

hero_data = hero.model_dump(exclude_unset=True)

exclude_unset=True를 사용하면 Client가 전달하지 않은 Field는 결과에서 제외됩니다.

이후 아래 코드를 이용하여 해당 Field만 기존 Database 객체에 반영합니다.

hero_db.sqlmodel_update(hero_data)
기존 데이터

name = "Hero"
age = 20
secret_name = "Secret"

        +

PATCH Request

{
    "age": 30
}

        ↓

수정 결과

name = "Hero"
age = 30
secret_name = "Secret"

따라서 Client가 전달하지 않은 기존 데이터가 None으로 덮어쓰이는 것을 방지할 수 있습니다.


8.4 Delete

삭제할 때는 먼저 Primary Key를 이용해 데이터를 조회합니다.

hero = session.get(Hero, hero_id)

존재한다면:

session.delete(hero)
session.commit()

을 통해 데이터를 삭제하고 변경사항을 Database에 확정합니다.


8.5 전체 데이터 흐름

이번 실습을 통해 FastAPI의 Request 처리부터 Database 저장, Response까지의 흐름을 하나로 연결할 수 있습니다.

Client
   ↓
HTTP Request
   ↓
Request Model
HeroCreate / HeroUpdate
   ↓
FastAPI Validation
   ↓
Path Operation Function
   ↓
Depends(get_session)
   ↓
Database Session
   ↓
Hero Table Model
   ↓
SQLModel / SQLAlchemy
   ↓
SQLite
   ↓
ORM Object
   ↓
Response Model
HeroPublic
   ↓
Serialization
   ↓
HTTP Response
   ↓
Client

지금까지 개별적으로 학습했던 Request Validation, Pydantic Model, Dependency Injection, Response Model, Error Handling이 Database 연동 과정에서 함께 사용되는 것을 확인할 수 있습니다.


8.6 전체 실습 코드

from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


class HeroBase(SQLModel):
    name: str = Field(index=True)
    age: int | None = Field(default=None, index=True)


class Hero(HeroBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    secret_name: str


class HeroPublic(HeroBase):
    id: int


class HeroCreate(HeroBase):
    secret_name: str


class HeroUpdate(HeroBase):
    name: str | None = None
    age: int | None = None
    secret_name: str | None = None


sqlite_file_name = "crud_database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

connect_args = {"check_same_thread": False}

engine = create_engine(
    sqlite_url,
    connect_args=connect_args,
)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]

app = FastAPI()


@app.on_event("startup")
def on_startup():
    create_db_and_tables()


@app.post("/heroes/", response_model=HeroPublic)
def create_hero(hero: HeroCreate, session: SessionDep):
    db_hero = Hero.model_validate(hero)

    session.add(db_hero)
    session.commit()
    session.refresh(db_hero)

    return db_hero


@app.get("/heroes/", response_model=list[HeroPublic])
def read_heroes(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
):
    return session.exec(
        select(Hero)
        .offset(offset)
        .limit(limit)
    ).all()


@app.get("/heroes/{hero_id}", response_model=HeroPublic)
def read_hero(hero_id: int, session: SessionDep):
    hero = session.get(Hero, hero_id)

    if hero is None:
        raise HTTPException(
            status_code=404,
            detail="Hero not found",
        )

    return hero


@app.patch("/heroes/{hero_id}", response_model=HeroPublic)
def update_hero(
    hero_id: int,
    hero: HeroUpdate,
    session: SessionDep,
):
    hero_db = session.get(Hero, hero_id)

    if hero_db is None:
        raise HTTPException(
            status_code=404,
            detail="Hero not found",
        )

    hero_data = hero.model_dump(exclude_unset=True)

    hero_db.sqlmodel_update(hero_data)

    session.add(hero_db)
    session.commit()
    session.refresh(hero_db)

    return hero_db


@app.delete("/heroes/{hero_id}")
def delete_hero(hero_id: int, session: SessionDep):
    hero = session.get(Hero, hero_id)

    if hero is None:
        raise HTTPException(
            status_code=404,
            detail="Hero not found",
        )

    session.delete(hero)
    session.commit()

    return {"ok": True}

8.7 실습 코드 테스트

실습 코드를 실행하면 /docs화면에서는 아래 이미지와 같이 보입니다.


  1. Create

    POST /heroes/

     {
       "name": "Deadpond",
       "age": 30,
       "secret_name": "Dive Wilson"
     }
    

    Create 실습 테스트에서 확인해야 할 부분은 “Request에는 secret_name을 보냈는데 Response에는 보이지 않는가?”입니다. 우선 아래 이미지와 같이 Requst를 보내보았습니다.


    그러면 Reponse로 아래 이미지와 같이 출력되며, 우리가 예상한 것과 같이 동작하는 것을 확인할 수 있습니다.


  2. Read All

    /docs의 GET /heroes/ 에서 방금 생성한 Hero가 조회되는지 확인해봅니다.


    출력 결과인 아래 이미지를 보면 추가했던 자료가 출력되는 것을 확인할 수 있습니다.


  3. Read One

    GET /heroes/1로 특정 Primary Key를 이용해 조회합니다.


    아래 이미지와 같이 처음에 추가했던 정보가 출력되는 것을 확인할 수 있습니다.


  4. Update

    PATCH /heroes/1을 이용해 “age”를 수정해 보도록 하겠습니다.


    “age”가 30에서 35로 바뀌어 출력되는 것을 확인할 수 있습니다.


  5. Delete

    DELETE /heroes/1를 이용해 추가했던 자료를 삭제해 봅니다.


    코드에서 설정한 것과 같이 정상적으로 삭제가 됐을 때의 Response를 받아오는 것을 확인할 수 있습니다.


    제대로 삭제되었는지 방금 삭제한 id 값으로 다시 Request를 보내면 아래 이미지와 같이 404에러가 뜨는 것을 확인할 수 있습니다.


9. 정리

이번 학습의 핵심 개념은 다음과 같습니다.

  • Relational Database는 데이터를 Table 형태로 관리하는 Database입니다.
  • SQLite는 별도의 Database Server 없이 하나의 파일을 이용할 수 있는 관계형 Database입니다.
  • ORM은 Python 객체와 Database Table을 Mapping하여 객체 중심으로 Database를 다룰 수 있도록 합니다.
  • SQLModel은 SQLAlchemy와 Pydantic을 기반으로 만들어졌으며 FastAPI와 함께 사용할 수 있습니다.
  • Engine은 Application과 Database가 통신하기 위한 기반 객체입니다.
  • Session은 ORM 객체와 Transaction을 관리하며 실제 Database 작업을 수행하는 단위입니다.
  • table=True를 지정한 SQLModel은 실제 Database Table과 연결되는 Table Model입니다.
  • Request, Database, Response의 역할에 맞게 Model을 분리하면 외부에 노출할 데이터와 내부 저장 데이터를 명확하게 구분할 수 있습니다.
  • Database Session은 Depends()와 yield를 이용하여 Request 단위로 생성하고 정리할 수 있습니다.
  • model_dump(exclude_unset=True)를 이용하면 PATCH 요청에서 Client가 실제로 전달한 Field만 추출하여 부분 수정할 수 있습니다.
  • CRUD는 Create, Read, Update, Delete의 네 가지 기본적인 데이터 처리 작업을 의미합니다.

참조

Comments