[SQLAlchemy] Relationship - CASCADE
SQLAlchemy 를 사용하면 여러 테이블간의 relationship 을 생성하여 운영할 수 있다. 테이블들의 relationship 다루어 운영을 하다보면 하나의 테이블에 대한 동작이 다른 테이블에 영향을 주는 경우가 발생한다. 이러한 경우 DB 를 다루기 위해 복잡하고 반복적인 작업이 필요할 수 있다. SQLAlchemy 에서는 관계가 맺어진 객체들간의 작업을 간편하고 자동화하기 위한 "relationship cascade" 라는 기능을 제공한다. 이 기능을 사용하면 relationship 을 가진 테이블간에 어떻게 동작할 것인지, 어떻게 영향을 끼칠 것인지를 정의할 수 있다.
1. Cascade 란?
Cascade 는 SQLAlchemy ORM 기능으로 객체들간의 관계를 정의할 때 사용할 수 있는 설정이다. cascade 설정을 통해서 관계가 정의된 객체들이 있을 때 한쪽 객체의 변경이나 삭제가 발생한 경우에 다른 쪽의 객체들의 테이블과 관련된 database 동작을 설정할 수 있다.
게시판의 사용자와 사용자가 작성한 글을 의미하는 User 와 Post 라는 테이블들을 예시로 생각해보자. 이들을 객체로 구현하면 1대N 관계를 가지게 된다. 한명의 user 는 여러개의 post 를 작성할 수 있다. 이러한 상황에서 user 가 삭제되는 경우 user 가 작성한 post 는 어떻게 되야할까? delete 쿼리를 통해서 해당 user 가 작성한 post 도 함께 삭제되도록 하거나, 작성한 user 없이도 post 를 유지하도록 할 수 있다. 이러한 동작을 설정하는 기능이 "relationshipt cascade" 이다.
2. Cascade 종류
SQLAlchemy 에서 relationship cascade 로 설정할 수 있는 옵션들은 다음과 같다.
- all
all 옵션은 아래에서 설명될 옵션들중 delete-orphan 을 제외한 모든 옵션들 (save-update, merge, refresh-expire, expunge, delete) 을 모두 합친 옵션이다. 보통 cascade 설정으로 all 또는 all, delete-orphan 으로 많이 사용한다.
- save-update
save-update 옵션은 객체가 session.add() 기능을 통해 session 에 반영될때 해당 객체와 관련된 객체들도 같은 Session 에 반영되도록 하는 옵션이다.
User 와 Post 객체의 예시에서 User 객체의 posts 에 Post 객체를 추가한 후에 User 를 session 에 반영하는 경우 User 객체뿐만 아니라 Post 객체들도 암시적으로 해당 session 에 반영된다.
save-update 는 이미 session 에 포함되어 있는 객체들에도 영향을 끼친다. 만약 이미 session 에 반영된 User 객체에 새로운 Post 객체를 생성하여 추가하면 따로 session.add() 를 호출하여 session 에 반영하지 않아도 새로운 Post 객체가 session 에 포함되게 된다.
save-update 옵션을 양방향 관계에서 사용하는 경우 양쪽 객체가 session 에 반영이 되어있는지 여부에 따라서 동작 결과가 달라질 수 있다. 예를들어 session 에 추가된 객체 o1 과 추가되지 않는 객체 o2 가 있다. 이때 o1 의 items 컬렉션에 o2 를 추가하는 경우에는 o2 가 session 에 자동으로 반영되지만, 반대로 o2 에 o1 을 추가하는 경우에는 o2 가 session 에 추가되지 않는다. 이때는 명시적으로 o2 를 session 에 반영해주어야 한다.
- delete
delete 옵션은 부모 객체가 삭제되었을때, 해당 부모의 자식 객체들도 삭제되도록 하는 옵션이다.
만약 User 와 Post 가 delete 옵션으로 relationship cascade 가 지정되어 있다면, 특정 User 가 삭제될때, User 가 작성한 Post 들도 모두 삭제된다. 만약 cascade 에 delete 를 지정하지 않았다면 Post 에서 User 를 보고있는 Foreign key 가 NULL 로 변경되고 Post 객체가 삭제되지는 않는다.
1대N 관계에서는 보통 delete-orphan 옵션과 함께 많이 사용한다. delete-ophan 은 delete 와 달리 부모와 자식간의 관계가 끊어졌을때도 자식 객체를 삭제한다. 그렇기 때문에 delete 와 delete-orphan 은 같이 사용하면 부모 객체가 삭제되었을때, 또는 부모 객체로부터 자식 객체가 분리되었을때 모두 자식 객체가 삭제되도록 할 수 있다.
- delete-orphan
delete-orphan 옵션은 delete 옵션에 다른 동작을 추가한 옵션이다. delete 의 동작에 더해서, 만약 자식 객체가 부모 객체로부터 분리되는 경우에도 자식 객체가 삭제되도록 한다. 이 옵션은 부모가 자식을 소유하는 형태의 관계이어서 자식 객체의 FK 가 NULL 이 되는 것을 허용하지 않는 상황에서 많이 사용한다.
이러한 상황은 보통 자식이 부모를 하나만 가질 수 밖에 없는 1대N 관계에서 많이 발생하며 다른 관계 형식에서는 많이 나타나지 않는다.
- merge
merge 옵션은 session.merge() 동작시에 parent 에서 참조하는 객체로 session.merge() 동작이 영향을 끼친다.
- refresh-expire
refresh-expire 옵션은 흔하지 않는 옵션이다. 이 옵션은 부모 객체가 session.expire() 동작시에 자식 객체에게 영향을 준다. 만약 session.refresh() 사용사에는 자식 객체가 만료되기만 하고 refresh 는 안됨
- expunge
expunge 옵션은 부모 객체가 session.expunge() 기능을 통해서 session 에서 삭제될때 자식 객체들에게도 동일한 동작이 수행된다.
3. CASCADE 예제
CASCADE 의 종류는 위 항목에서 정리한 것처럼 많지만, 보통 all, delete-orphan 두가지를 조합하여 사용하는 경우가 많은 것 같다. 이 둘을 함께 사용하게 되면 부모 객체에서 발생하는 변경이 모두 자식에게 적용되며, 부모 객체에서 발생하는 변경 삭제뿐만 아니라 delete-orphan 옵션으로 인해 부모와 자식이 관계가 분리되는 경우에도 자식 객체가 삭제되도록 한다.
예제에서는 1:N 관계의 두 객체 Parent 와 Child 를 두고 비교하려한다. 두 객체의 엔티티는 다음과 같다.
class Parent(Base):
__tablename__ = "parent"
id = mapped_column(Integer, primary_key=True)
name = mapped_column(String)
children = relationship(
"Child",
back_populates="parent",
cascade="all, delete-orphan",
)
class Child(Base)
__tablename__ = "child"
id = mapped_column(Integer, primary_key=True)
name = mapped_column(String)
parent_id = mapped_column(Integer, ForeignKey("parent.id"))
parent = relationship(
"Parent",
back_populates="children",
)
두 객체는 sqlalchemy 의 명시적 매핑으로 구현했으며, child 테이블이 parent_id 를 FK 로 참조하고 있다. relationship cascade 는 "all, delete-orphan" 으로 설정하였다.
# parent 객체와 children 객체 생성
parent: Parent = Parent(name="parent")
children: list[Child] = [
Child(name="child1", parent_id=parent.id),
Child(name="child2", parent_id=parent.id),
Child(name="child3", parent_id=parent.id),
]
# parent 에 children 추가
parent.children = children
# parent 와 children 모두 아직 session 에 추가되지 않음
assert not parent in session
for child in children
assert not child in session
# parent 객체를 session 에 추가
session.add(parent)
session.commit()
# parent 와 children 모두가 session 에 추가됨을 확인
assert parent in session
for child in children:
assert child in session
첫번째 에제는 parent 에 child 객체들을 추가한 다음 session.add() 를 호출하여 parent 객체를 session 에 추가했을때의 상황이다.
먼저 parent 와 children 을 생성한 후에 parent 에 children 을 추가하였다. 이때까지는 parent 와 children 모두 session 에 추가되지 않았다. 이후 parent 를 session 에 추가하고 commit 한 후에는 parent 와 child 모두가 session 에 추가된 것을 확인할 수 있다.
이렇게 동작하는 이유는 all 옵션에 save-update 옵션이 포함되어 있기 때문이다. 만약 save-update 또는 all 옵션이 설정되어있지 않는 경우에는 parent 는 session 에 추가되지만 children 에 있는 child 객체들은 추가되지 않는다.
# parent 객체에서 child 객체를 제거 (detach)
parent.children.remove(children[0])
session.add(parent)
session.commit()
# children[0] 이 session 과 DB 에서 제거됨을 확인
# session 에서 제거됨
assert children[0] not in session
# parent 에서 제거됨
assert len(parent.children) == 2
assert children[0] not in parent.children
# DB 에서 조회되지 않음
assert session.query(Child).filter_by(parent_id=parent.id).count() == 2
assert session.query(Child).filter_by(id=children[0].id).count() == 0
다음은 parent 의 children 에서 child 객체를 삭제했을때의 상황이다. 이전 예제에서 parent.children 에 children 의 객체들을 추가하였다. 이 예제에서는 그 중 0번재 인덱스에 있는 객체를 parent 에서 삭제한다. 이후 session.add() 를 통해 parent 의 변경사항을 session 에 적용한다. 그 다음 assert 문들을 통해서 children[0] 이 parent 에서 제거됐을뿐 아니라 session 에서도 삭제되었음을 확인할 수 있다.
parent 의 children 에서 child 객체가 삭제되어 session 에 적용이 되면 child 객체는 parent, parent_id 가 모두 None 이 된다. 이러한 상태를 orphan 이라고 한다. delete-orphan 옵션은 orphan 상태가 된 객체를 삭제한다. 그렇게 때문에 parent 객체와 분리된 children[0] 객체는 session 에서 삭제되게 된다.
[Reference]
- https://docs.sqlalchemy.org/en/20/orm/cascades.html
Cascades — SQLAlchemy 2.0 Documentation
Cascades Mappers support the concept of configurable cascade behavior on relationship() constructs. This refers to how operations performed on a “parent” object relative to a particular Session should be propagated to items referred to by that relation
docs.sqlalchemy.org