Profile picture

[Python] ORM 활용해보기

JaehyoJJAng2024년 06월 05일

ORM

ORM(Object Relational Mapping, 객체 관계 매핑)은 객체와 관계형 데이터베이스 사이의 매핑을 처리하는 기술로

데이터베이스에서 가져온 데이터를 객체로 변환하고, 객체에서 데이터베이스에 저장된 데이터를 변환하는 작업을 자동으로 처리해준다.

ORM을 사용하면 SQL 질의문을 직접 작성하지 않아도 되므로, 개발자는 구현하려는 객체 모델에만 집중할 수 있게 되어 생산성이 늘어날 수 있다.

대표적으로 쓰이는 파이썬 ORM으로는 Django ORM, SQLAlchemy 등이 있다.


Django ORM의 경우 Django 프레임워크와 함께 제공되는 ORM으로, 간편하게 데이터베이스를 조작할 수 있는 인터페이스를 제공하고 있다.

또한 데이터베이스 스키마(Schema)를 자동으로 생성해주는 기능을 가지고 있어 이 또한 생산성이 높다.


SQLAlchemyDjango ORM와는 달리 데이터베이스 연결에 대한 설정을 직접해야 하지만,

데이터베이스 연결 설정에 대한 유연성이 높다.

SQLAlchemy는 객체지향적인 인터페이스를 제공하며, 데이터베이스 연결 객체를 생성한 후에는 SQL 질의를 처리할 수 있다.

아래 ORM 실습을 진행하고 마지막으로 FastAPI를 사용하여 간단한 CRUD를 구현해보자.


ORM 설정

1. ORM 라이브러리 설치

먼저 프로젝트에 ORM을 도입하려면 ORM 라이브러리를 설치해야 한다.

파이썬에서 가장 많이 사용되는 ORM 라이브러리는 SQLAlchemy이다. 이 실습에서도 해당 ORM을 사용해볼거다.

pip install sqlalchemy

2. 데이터베이스 연결

ORM을 사용하려면 데이터베이스와의 연결 설정이 필요하다.

SQLAlchemy의 경우 create_engine() 함수를 사용하여 데이터베이스에 연결할 수 있다.

예를 들어 MySQL 데이터베이스에 연결하려면 다음과 같이 작성한다.

from sqlalchemy import create_engine

engine = create_engine("mysql+pymysql://<username>:<password>@<host>/<dbname>)

위 코드에서 <username>, <password>, <host>, <dbname> 부분은 각각 사용자 이름, 비밀번호, 호스트이름, 데이터베이스 이름으로 변경되어야 한다.

또한, pymysql 모듈을 사용하여 MySQL 데이터베이스에 연결하도록 작성되었다.
(pymysql가 설치되어 있어야함.)


이번 실습에서는 MySQL 서버를 간단하게 도커로 배포할 것이다.

docker run -d -it --name mysql \
-e MYSQL_ROOT_PASSWORD=orm -e MYSQL_USER=orm \
-e MYSQL_PASSWORD=orm -e MYSQL_DATABASE=orm \
-p 3306:3306 \
-v orm-data:/var/lib/mysql \
mysql:latest

3. 모델 클래스 작성

ORM을 사용하기 위해서는 모델(Model) 클래스를 작성해야 한다.

모델 클래스는 데이터베이스의 테이블과 매핑되는 클래스로,

클래스 내에는 데이터베이스 테이블의 각 컬럼에 해당하는 필드가 선언된다.

SQLAlchemy의 경우, declearative_base() 함수를 사용하여 모델 클래스의 베이스 클래스르 정의하고

이를 상속받아 모델 클래스를 작성한다.

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declearative_base

Base = declearative_base()

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String(50))
    age = Column(Integer)

위 코드에서 User 클래스는 데이터베이스의 users 테이블과 매핑되는 모델 클래스이다.

id, name, age 필드는 각각 users 테이블의 id, name, age 컬럼과 매핑된다.


4. 데이터베이스 연결 및 세션 생성

모델 클래스가 작성되었다면, 이를 이용하여 데이터베이스 연결을 생성하고 세션을 생성해줘야 한다.

SQLAlchemy의 경우 create_all() 함수를 사용하여 모델 클래스에 정의된 테이블을 생성하고, sessionMaker() 함수를 사용하여 세션 객체를 생성할 수 있다.

from sqlalchemy.orm import sessionmaker

Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()

ORM 사용방법

ORM을 사용하는 방법에는 크게 3단계로 나눌 수 있다.

  • 1. 모델 정의하기
  • 2. ORM을 통한 데이터 조작하기
  • 3. 데이터베이스에 반영하기

1. 모델 정의하기

먼저 ORM을 사용하기 위해서 데이터베이스의 테이블과 매칭되는 모델을 정의해야 한다.

일반적으로는 모델 클래스를 만들고 그 클래스의 속성을 통해 각각의 열(column)을 정의한다.

이 때 각각의 속성은 데이터베이스의 컬럼과 매칭된다.

from sqlalchemy import Column, Integer, String

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True)
    name = Column(String(50))
    age = Column(Integer)

위 코드는 Users 클래스를 정의하고 id, name, age 속성을 통해 데이터베이스의 users 테이블과 매칭시킨 예시이다.

__tablename__ 속성은 모델 클래스가 매핑할 데이터베이스의 테이블 이름을 지정한다.


2. ORM로 데이터 조작하기

모델을 정의한 후에는 ORM을 통해 데이터를 조회 , 추가 , 수정 , 삭제 할 수 있다.

SQLAlchemy를 사용하는 경우, 데이터베이스에 접근하려면 Session 객체를 생성해줘야 한다.

from sqlalchemy.orm import Session

session = Session()

2-1. 데이터 조회하기

# 모든 데이터 조회
users = session.query(User).all()

# 조건에 맞는 데이터 조회하기
user = session.query(User).filter_by(name="jaehyo").first()

2-2. 데이터 추가하기

user = User(name="John", age=35)
session.add(user)
session.commit()

2-3. 데이터 수정하기

user = session.query(User).filter_by(name="jaehyo").first()
user.age = 55
session.commit()

2-4. 데이터 삭제하기

user = session.query(User).filter_by(name="jaehyo").first()
session.delete(user)
session.commit()

3. 데이터베이스에 반영하기

ORM을 통해 추가, 수정, 삭제한 데이터는 Session 객체를 통해 데이터베이스에 반영할 수 있다.

session.commit()

또한 모델 클래스의 변경 사항을 데이터베이스에 반영하려면 create_all() 메소드를 사용한다.

Base.metadata.create_all(engine)

FastAPI CRUD

앞서 어떠한 방식으로 ORM을 사용하는지 대강 실습해봤다.

이번에는 FastAPI 프레임워크와 데이터베이스를 연동하여 CRUD까지 구현해보는 작업을 해보자.

ℹ️ MySQL가 설치된 환경에서 진행


pip install sqlalchemy pymysql

pip를 통해 sqlalchemy와 pymysql 패키지를 설치하자.


프로젝트 구조

프로젝트 구조는 아래와 같이 최상단 app 디렉토리가 존재하고 그 하위에 소스 파일이 구성된다.

app
├── crud.py
├── database.py
├── main.py
├── models.py
└── schema.py

데이터베이스 생성

사용 중인 DBMS에서 미리 database를 생성해두자.

MySQL [(none)]> create database myweb;

사용할 테이블의 스키마는 아래와 같으나 app/models.py 파일에서 ORM을 통해 생성된다.

MySQL [(none)]> desc myweb.items;                                             +-------------+-------------+------+-----+---------+----------------+
| Field       | Type        | Null | Key | Default | Extra          |
+-------------+-------------+------+-----+---------+----------------+
| id          | int(11)     | NO   | PRI | NULL    | auto_increment |
| name        | varchar(30) | YES  |     | NULL    |                |
| description | varchar(30) | YES  |     | NULL    |                |
| price       | int(11)     | YES  |     | NULL    |                |
+-------------+-------------+------+-----+---------+----------------+
4 rows in set (0.002 sec)

models.py

from sqlalchemy import Column, Integer, String
from database import Base

class Item(Base):
    __tablename__ = "items"
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(30))
    description = Column(String(30))
    price = Column(Integer)

sqlalchemy를 통해 데이터베이스 모델을 정의한다.


database.py

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

DATABASE_URL :str = "mysql+pymysql://orm:orm@192.168.219.113:3306/web?charset=utf8"
engine = create_engine(url=DATABASE_URL)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

def create_tables() -> None:
    Base.metadata.create_all(bind=engine)
    print("Tables created!")

데이터베이스 연결 및 세션 생성/관리를 담당하는 파일이다. create_tables() 함수는 데이터베이스 내 테이블을 생성한다.


schema.py

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: str
    price: int

해당 파일은 Pydantic 스키마를 정의하는 파일로, 데이터 유효성 검사와 요청/응답 데이터 변환을 담당한다.

여기서 app/models.py 파일에서 이미 데이터베이스 모델을 정의하였는데 왜 app/schema.py에서 유사한 구조를 또 정의하는걸까?

위처럼 하는 이유는 DB를 조회하여 결과를 보여줄 때 필요하지 않은 정보가 포함될 수 있기 때문이다.

대표적으로 id와 같은 값은 INSERT시에는 필요 없지만, 이외의 작업을 할 때에는 필요하다.

Pydantic 모델을 사용하여 응답 데이터에서 필요한 필드만 선택하거나, 필요 없는 필드를 제외하여 클라이언트에게 반환할 정보를 조절하기 위한 의도이다.


crud.py

from sqlalchemy.orm import Session
from models import Item

def get_items(db: Session) -> list[Item]:
    return db.query(Item).all()

def get_item(db: Session, item_id: int) -> Item:
    return db.query(Item).filter(Item.id == item_id).first()

def create_item(db: Session, item: Item) -> Item:
    db_item = Item(**item.model_dump())
    db.add(db_item)
    db.commit()
    db.refresh(db_item)
    return db_item

def update_item(db: Session, item: Item, updated_item: Item) -> Item:
    for key, value in updated_item.model_dump().items():
        setattr(item, key, value)
    db.commit()
    db.refresh(item)
    return item

def delete_item(db: Session, item: Item) -> None:
    db.delete(item)
    db.commit()

데이터베이스의 CRUD 함수들을 포함하고 있다. 데이터베이스에 접근하여 item을 생성, 조회, 업데이트, 삭제하는 함수들이 정의되어 있으며,

app/main.py에서 이러한 데이터베이스 조작 함수들을 호출하여 실제 데이터베이스 작업을 수행한다.


main.py

from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.orm import Session
from fastapi.responses import RedirectResponse
from models import Item
import crud,database,models,schema

app = FastAPI()

# 테이블 초기화
database.create_tables()

def get_db():
    db = database.SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get('/')
async def root():
    return RedirectResponse(url='/items/')

@app.get('/items/')
async def get_items(db: Session = Depends(get_db)):
    items = crud.get_items(db=db)
    return items

@app.get("/items/{item_id}")
async def get_item(item_id: int, db: Session = Depends(get_db)) -> None:
    item = crud.get_item(db=db, item_id=item_id)
    if item is None:
        raise HTTPException(status_code=404, detail="Item not found")

@app.post('/items/')
async def create_item(item: schema.Item, db: Session = Depends(get_db)):
    db_item = crud.create_item(db=db, item=item)
    return db_item

@app.put("/items/{item_id}")
async def update_item(item_id: int, updated_item: schema.Item, db: Session = Depends(get_db)):
    db_item = crud.get_item(db=db, item_id=item_id)
    if db_item is None:
        raise HTTPException(status_code=404, detail="Item not found")
    updated_item = crud.update_item(db=db, item=db_item, updated_item=updated_item)
    print(updated_item)
    return updated_item

@app.delete("/items/{item_id}")
async def delete_item(item_id: int, db: Session = Depends(get_db)):
    db_item = crud.get_item(db=db, item_id=item_id)
    if db_item == None:
        raise HTTPException(status_code=404, detail="Item not found")
    crud.delete_item(db=db, item=db_item)
    return {"message": "Item deleted!"}

애플리케이션의 주요 코드가 정의되어 있는 파일이다.

FastAPI 애플리케이션을 생성하고, API 앤드포인트를 정의하며, 앱 시작 시 데이터베이스 테이블을 생성하는 역할을 담당한다.


yield 제너레이터

def get_db():
    db = database.SessionLocal()
    try:
        yield db
    finally:
        db.close()

...
@app.get('/items/')
async def get_items(db: Session = Depends(get_db)):
    items = crud.get_items(db=db)
    return items
  • FastAPI의 Depends는 의존성 주입 시스템입니다. get_items 함수가 호출될 때 FastAPI는 Depends(get_db)를 통해 get_db 함수를 실행하고, 그 결과로 생성된 session 객체를 get_items 함수의 session 인자로 전달합니다.
  • 이 세션을 통해 데이터베이스 쿼리를 수행하고, 결과를 얻을 수 있습니다.
  • 함수가 끝나면 get_dbfinally 블록이 실행되어 세션이 닫힙니다.

API 테스트

curl 명령을 사용하여 데이터 생성, 조회, 수정, 삭제하는 것을 테스트해보겠다.


POST

curl -X POST \
'http://192.168.219.113:8000/items/' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
  "name": "Keyboard",
  "description": "Easy Keyboard",
  "price": 98000
}'

결과

{"price":98000,"name":"Keyboard","id":5,"description":"Easy Keyboard"

GET

curl -X GET http://192.168.219.113:8000/items/

결과

[{"name":null,"id":1,"price":null,"description":null},{"name":null,"id":2,"price":null,"description":null},{"name":"Keyboard","id":3,"price":98000,"description":"Easy Keyboard"},{"name":"Keyboard","id":4,"price":98000,"description":"Easy Keyboard"},{"name":"Keyboard","id":5,"price":98000,"description":"Easy Keyboard"},{"name":"Keyboard","id":6,"price":98000,"description":"Easy Keyboard"}]

PUT

curl -X PUT \
http://192.168.219.113:8000/items/1 \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
  "name": "마우스",
  "description": "고성능 마우스",
  "price": 139000
}'

결과

{"description":"고성능 마우스","name":"마우스","id":1,"price":139000}

DELETE

curl -X DELETE \\nhttp://192.168.219.113:8000/items/1 \\n-H 'accept: application/json'

결과

{"message": "Item deleted!"}

Loading script...