공부하고 기록하는, 경제학과 출신 개발자의 노트

프로그래밍/이것저것_개발일지

Python SQLAlchemy의 many to many relation에 soft delete 기능 적용하기

inspirit941 2024. 3. 25. 02:18
반응형

FastAPI + SQLAlchemy로 API 서비스 만들면서 해결한 사안 정리하기.


soft delete가 필요한 이유?

  • 삭제 요청이 들어올 때마다 물리적으로 DB에서 row 정리해 버리면, 삭제된 데이터를 복원한다던가 / 삭제된 데이터의 이력이나 히스토리 파악하려면 데이터베이스 엔진 레벨에서 작업해야 한다.
  • 민감정보 다루는 게 아니고, 서버로그처럼 많이 쌓여서 주기적으로 삭제해야 하는 entity 같은 성격이 아니라면
  • 간단한 비즈니스 로직이나 api에서는 soft delete이 주는 편익이 비용보다 훨씬 크다고 생각함.

GORM은 soft delete 기능 도입이 엄청 쉽고, soft delete 적용한 object끼리 relation을 조합하면 ORM에서 자동으로 쿼리를 생성해준다.

예컨대 아래 코드를 보면

// User model
type User struct {
    gorm.Model
    Name       string
    Email      string `gorm:"unique"`
    DeletedAt  gorm.DeletedAt `gorm:"index"`
    Roles      []Role `gorm:"many2many:user_roles;"`
}

// Role model
type Role struct {
    gorm.Model
    Name        string
    DeletedAt   gorm.DeletedAt `gorm:"index"`
    Users       []User `gorm:"many2many:user_roles;"`
}

// Create some roles
role1 := Role{Name: "Admin"}
role2 := Role{Name: "User"}
db.Create(&role1)
db.Create(&role2)

// Create a user and associate roles
user := User{Name: "test", Email: "test@example.com"}
db.Create(&user)
db.Model(&user).Association("Roles").Append(&role1, &role2)

// Soft delete a role
db.Delete(&role2)

db.Preload("Roles").Find(&users)

User와 Role을 M:N으로 매핑하고, gorm.DeletedAt 필드를 정의하기만 하면

  • delete 함수 호출 시 soft delete가 적용되고,
  • Preload.Find() 메소드를 사용했을 때 soft deleted된 객체는 쿼리에 잡히지 않는다.
  • soft delete된 값을 찾고 싶을 경우에는 Unscoped() 메소드를 chaining으로 명시해줘야 한다. (https://gorm.io/gen/delete.html#Find-soft-deleted-records)

그런데, SQLAlchemy의 경우 soft delete를 기본적으로 제공해주지 않는다. 사용자가 직접 적용해야 한다.

  • 개별 object에 soft delete 로직이 적용된 mixin을 추가하고
  • soft delete가 적용된 object끼리 relation이 있을 때, soft delete 조건을 추가로 구현해줘야 한다.

SQLAlchemy에서 마주한 문제

SoftDeleteMixin을 직접 만들어도 되지만, 누군가가 구현해둔 softdelete easy soft-delete라는 pip 패키지를 사용한다.

import받은 패키지의 메소드를 써서 softDeleteMixin class를 만들면 되는데,

from sqlalchemy_easy_softdelete.mixin import generate_soft_delete_mixin_class
from datetime import datetime


class SoftDeleteMixin(generate_soft_delete_mixin_class(
    delete_method_name="soft_delete"
)):
    # type hint for ide
    deleted_at: datetime
  • SQLAlchemy의 기본 삭제 메소드인 session.delete()와 헷갈릴 수 있어서 soft_delete 수행하는 메소드 이름을 soft_delete로 정의했다.
  • 삭제 요청 시, deleted_at 컬럼의 datetime 필드가 요청 시점의 datetime으로 업데이트된다.
  • 문서에 써있는 것과 같이
    • SoftDeleteMixin을 상속받은 클래스에서 sqlalchemy query를 생성하면 WHERE fruit.deleted_at IS NULL 가 자동 생성되고
    • 옵션을 무시하려면 session.query(Fruit).execution_options(include_deleted=True).all() 처럼 option에서 include_delete=True 파라미터를 넘겨주면 된다.

그런데.. 문제는

  • softDeleteMixin 클래스를 상속받은 SQLAlchemy Object끼리 relation을 연결하면
  • relation이 걸려 있는 object 조회할 때는 soft delete 옵션이 자동으로 적용되지 않는다.
class User(SoftDeleteMixin, Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    name = Column(String)
    email = Column(String, unique=True)

    roles = relationship('Role', secondary='user_roles', back_populates='users')

class Role(SoftDeleteMixin, Base):
    __tablename__ = 'roles'

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

    users = relationship('User', secondary='user_roles', back_populates='roles')

class UserRole(SoftDeleteMixin, Base):
    __tablename__ = 'user_roles'

    user_id = Column(Integer, primary_key=True)
    role_id = Column(Integer, primary_key=True)


# Create some roles
role1 = Role(name='Admin')
role2 = Role(name='User')
session.add_all([role1, role2])
session.commit()

# Create a user and associate roles
user = User(name='John Doe', email='john@example.com')
session.add(user)
user.roles.append(role1)
user.roles.append(role2)
session.commit()

# Soft delete a role
role2.soft_delete()
session.commit()

users = session.query(User).all()
for user in users:
  print(len(user.roles)) ## returns 2

위 코드에서 role2를 soft delete 처리했는데, user.roles 로 related object를 조회하면 roles가 2개 조회된다.

  • 서비스 로직에서 deleted_at 필드를 조건문으로 걸어서 제거할 수는 있지만,
  • 모든 서비스코드에 deleted_at 조건문을 추가할 거라면 Mixin을 만든 의미가 없다.

해결한 방법: PrimaryJoin, SecondaryJoin 사용

https://docs.sqlalchemy.org/en/20/orm/join_conditions.html

  • 공식문서에서는 Relation을 연결했을 때, PrimaryJoin과 SecondaryJoin 파라미터에서 join시 수행할 쿼리를 직접 커스텀할 수 있다.

아래와 같이 코드를 바꾸면 된다.

class User(SoftDeleteMixin, Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    name = Column(String)
    email = Column(String, unique=True)

    roles = relationship(
      'Role', secondary='user_roles', back_populates='users',
      primaryjoin="and_(User.id == UserRole.user_id, User.deleted_at == None)",
      secondaryjoin="and_(Role.id == UserRole.role_id, Role.deleted_at == None)"
    )

class Role(SoftDeleteMixin, Base):
    __tablename__ = 'roles'

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

    users = relationship(
      'User', secondary='user_roles', back_populates='roles',
      primaryjoin="and_(Role.id == UserRole.user_id, Role.deleted_at == None)",
      secondaryjoin="and_(User.id == UserRole.role_id, User.deleted_at == None)"
    )

class UserRole(SoftDeleteMixin, Base):
    __tablename__ = 'user_roles'

    user_id = Column(Integer, primary_key=True)
    role_id = Column(Integer, primary_key=True)

PrimaryJoin과 SecondaryJoin 옵션을 사용하면 된다. primary와 secondary의 차이부터 간단히 짚고 넘어가자면

  • primaryjoin: 조회하려는 entity와 relation table 사이에서 수행될 query.
    • 예컨대 user -> role 간 relation을 확인하려면 user -> user_role(relation table) -> role 순서로 조회해야 하는데
    • user -> user_role에서 적용될 query를 말한다.
    • and_(User.id == UserRole.user_id, User.deleted_at == None) : user가 deleted_at 상태가 아니면서, relation table에 primary key 매칭되는 object만 조회한다.
  • secondaryjoin: relation 걸려 있는 entity와 relation table 사이에서 수행될 query.
    • 예컨대 user -> role 간 relation을 확인하려면 user -> user_role(relation table) -> role 순서로 조회해야 하는데
    • user_role -> role 에서 적용될 query를 말한다.
    • and_(Role.id == UserRole.role_id, Role.deleted_at == None) : role이 deleted_at 상태가 아니면서, relation table에 primary key 매칭되는 object만 조회한다.
반응형