SQLAlchemy를 활용하여 PostgreSQL Database에 관계형 Table을 처음 다뤄본 사람이라면, 한 번쯤 relationship의 'backref'와 'back_populates'의 차이점, ForeignKey에 CASCADE를 지정하는 것과 relationship에 CASCADE를 지정하는 것의 차이가 무엇인지 궁금한 적이 있을 거다.
이 글은 위 언급한 개념들의 차이점과 정확한 사용법을 정리하는데 목적이 있다.
본론
1. backref vs. back_populates
관계형 DB를 설명할 때 가장 많이 활용되는 예시는 Parent와 Children일 것이다. 이해하기 매우 직관적이고, 추후 'delete-orphan' 과 같은 CASCADE 옵션의 의미를 이해할 때에도 큰 도움이 되는 예시이다. 그렇기에 여기서도 Parent와 Children의 예시를 통해 backref와 back_populates의 차이점을 보고자 한다.
back_populates를 활용하여 Model Schema 정의하면 아래와 같다.
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, String, Integer, ForeignKey
from sqlalchemy.orm import relationship, Mapped, backref
# initialize Base
Base = declarative_base()
# define table Models
class Parent(Base):
__tablename__ = 'parent'
id = Column(Integer, primary_key=True)
children = relationship("Child", back_populates="parent")
class Child(Base):
__tablename__ = 'children'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'))
parent = relationship("Parent", back_populates="children")
backref를 활용하여 아래와 같이 정의하면, 위 코드와 정확히 동일한 동작을 수행한다.
# define table Models
class Parent(Base):
__tablename__ = 'parent'
id = Column(Integer, primary_key=True)
class Child(Base):
__tablename__ = 'children'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'))
parent = relationship("Parent", backref="children")
즉, backref는 parent.children과 child.parent의 관계 (relationship)를 표현하기 위한 더욱 간단한 방법으로, back_populates와 같이 두 개의 모델 내부에서 모두 정의해줄 필요없이, Parent 혹은 Child 둘 중 하나에서만 정의해주면 된다.
위에서는 Child 모델에 backref를 사용했지만, 아래와 같이 Parent에 정의해주어도 무방하다. 중요한 건 backref를 활용할 경우 둘 중 하나만 해야한다는 것이다.
# define table Models
class Parent(Base):
__tablename__ = 'parent'
id = Column(Integer, primary_key=True)
children = relationship("Child", backref="parent")
class Child(Base):
__tablename__ = 'children'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'))
주의할 것은, Parent와 Child는 1:N의 관계이기에, backref를 어디에서 정의하든간에 ForeignKey는 반드시 Child 모델에서 정의되어야 한다. parent와 child 간의 관계를 정의하는 것은 relationship이 아니라 ForeignKey에 있다.
또한, backref와 back_populates 둘 중 어느 것을 선택하느냐의 문제에 대해서, backref는 legacy로 여겨지며 덜 직관적이라는 이유로, document에서도 back_populates를 사용할 것을 권장하고 있다.
The relationship.backref keyword should be considered legacy, and use of relationship.back_populates with explicit relationship() constructs should be preferred. Using individual relationship() constructs provides advantages including that both ORM mapped classes will include their attributes up front as the class is constructed, rather than as a deferred step, and configuration is more straightforward as all arguments are explicit. New PEP 484 features in SQLAlchemy 2.0 also take advantage of attributes being explicitly present in source code rather than using dynamic attribute generation. (sqlalchemy official docs)
2. CASCADE 지정 방식
CASCADE가 흔한 용어도 아니며, 인지하고 있더라도 그 세부 옵션에 대해 기억하기 쉽지 않으므로 아래와 같이 정리한다.
CASCADE라는 용어는 database에서 ForeignKey Constraint (외래키 제약조건)을 정의할 때 사용된다. RDB를 다루다보면 relationship이 얽히고 얽혀 하나의 Table에 대한 동작이 다른 테이블에 영향을 주는 경우가 발생한다. 예를 들면, 위 예시에서 1번 Parent를 삭제할 때, 이와 연관된 모든 Children을 삭제하고 싶은 경우 2번의 반복 작업이 필요하게 된다.
이러한 작업을 간편하고 자동화해주기 위해 SQLAlchemy에서는 CASCADE라는 기능을 제공하며, 옵션에 따라 두 테이블 간의 연쇄 동작을 정의할 수 있다.
[조은기록 뇌피셜]
CASCADE를 사전에 검색해보면 '폭포'라는 의미를 지닌다고 한다. '연쇄 동작을 정의한다'고 위에서 언급했는데, Database에서 한 행에서 다른 행으로 동작을 전파하는 작업을 연상시킨다는 의미에서 '폭포'라는 이름을 지은 게 아닐까 생각된다.
(틀린 부분이 있거나, 정설을 알고 계신 분은 댓글 부탁드립니다 ^^)
사용할 수 있는 옵션은 아래와 같이 정리할 수 있다. 더 자세한 내용을 확인하고자 한다면 해당 링크를 확인하길 바란다.
Option
Definition
all
save-update, merge, refresh-expire, expunge, delete을 모두 합친 옵션이다. 보통 1:N 관계의 Table의 CASCADE로 'all' 혹은 'all, delete-orphan'으로 많이 사용한다.
save-update
SQLAlchemy를 사용하여 객체를 Table에 추가할 때, session.add() 기능을 통해 session에 변경사항을 반영한다. save-update 옵션은 session.add() 기능이 수행될 때, 추가되는 객체와 연관된 객체들도 session에 반영되도록 하는 옵션이다.
delete
부모 객체가 삭제되었을 때, 자식 객체들도 함께 삭제되도록 하는 옵션이다. 일반적으로 delete 옵션을 사용하지 않는다면, 부모 객체가 삭제될 경우, 자식 객체의 Foreign Key는 Null로 변경된다. 이때 당연하게도 nullable=False로 Table Schema가 정의되어 있을 경우 에러가 발생한다.
delete-orphan
delete 옵션에 추가적으로 부모 객체로부터 자식 객체가 분리되는 경우에도 자식 객체를 삭제하는 옵션이다. 일반적으로 자식 객체의 Foreign Key에 Null을 허용하지 않는 경우에 이 옵션을 지정한다.
merge
부모 객체가 session.merge() 기능을 통해 동작 시에 자식 객체에게도 영향을 미친다. merge 기능은 자주 사용되지는 않지만, 데이터 소스로부터 캐싱 시스템에 있는 데이터를 업데이트할 때 쓰이는 기능이다.
refresh-expire
부모 객체가 session.expire() 기능을 통해 동작 시에 자식 객체에게도 영향을 미친다. 캐싱 시스템에 있는 데이터는 간혹 만기 시간이 도래하면 사용하지 못하는 경우가 존재하는데, refresh-expire 옵션은 만기 시간이 도래하기 이전 최신 상태를 유지시키기 위해 사용한다.
expunge
부모 객체가 session.expunge() 기능을 통해 session에서 제거될 때, 자식 객체도 동일하게 적용이 된다. expunge 옵션은 말 그대로 캐싱 시스템에서 대상을 완전히 삭제하여 메모리를 확보하기 위한 기능이다.
위 개념 중 볼드체로 강조한 부분을 다시 한 번 확인하길 바란다. 아래에서 이에 대해 보다 상세하게 설명할 예정이기 때문이다.
개념을 이해했으니, 위 예시에 이어서 본격적으로 실제 어떻게 사용될 수 있는지 확인해보자.
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, String, Integer, ForeignKey
from sqlalchemy.orm import relationship, Mapped, backref
# initialize Base
Base = declarative_base()
# define table Models
class Parent(Base):
__tablename__ = 'parent'
id = Column(Integer, primary_key=True)
children = relationship("Child", back_populates="parent", cascade="all, delete-orphan")
class Child(Base):
__tablename__ = 'children'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'))
parent = relationship("Parent", back_populates="children")
# define session
engine = create_engine(SQLALCHEMY_POSTGRES_URL)
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
session = Session()
# make parent and children
parent = Parent(name="parent")
children = [
Child(parent_id=parent.id),
Child(parent_id=parent.id)
]
# add children to parent
parent.children = children
# ---- before adding to session ---- #
# no parent, no children in session
assert not parent in session
for child in children
assert not child in session
# add parent to session and do commit
session.add(parent)
session.commit()
# ---- after adding to session ---- #
assert parent in session
for child in children:
assert child in session
# remove children[0] from parent and commit
parent.children.remove(children[0])
session.add(parent)
session.commit()
# ---- after remove children[0] ---- #
assert children[0] not in session
# check if children[0] in parent
assert children[0] not in parent.children
# not exist in database (b.c. cascade and commit)
assert session.query(Child).filter_by(parent_id=parent.id).count() == 1
assert session.query(Child).filter_by(id=children[0].id).count() == 0
cascade에 "all, delete-orphan" 옵션을 추가하였음을 확인할 수 있다.
이 영향으로, "----after adding to session----" 이후, parent만을 session에 추가했음에도 자식 객체인 children까지 session에 추가되었음을 확인할 수 있다. 이는 all 옵션이 save-update 옵션을 포함하기 때문에 가능한 것이다.
다음으로, "----after remove children[0]" 이후에서 볼 수 있듯이, parent에서 children[0]을 제거한 이후에 session에서 더이상 children[0]을 찾을 수 없다는 것을 확인할 수 있다. 이는 delete-orphan 옵션을 지정했기 때문에 가능한 것이다.
부모 객체를 삭제할 경우, 이와 연관된 자식 객체의 parent_id는 모두 None이 된다. 이러한 상태를 Orphan (고아)라고 하는데, 맨 위에서 Parent와 Child라는 예시가 좋다고 언급한 이유가 바로 여기에 있다. (Parent를 잃은 Child는 말 그대로 '고아'이기에, 직관적으로 잘 지은 이름이라고 생각한다.) 이렇게 Orphan 상태가 된 객체를 삭제하는 옵션이 바로 delete-orphan이다.
※ 추가 ※
간혹 relationship에 cascade를 지정하는 것 외에, ForeignKey의 ondelete에 지정하는 방식을 본 적이 있다. 이에 대해서도 짧게 정리하고자 한다.
class Parent(Base):
__tablename__ = "parent"
id = mapped_column(Integer, primary_key=True)
children = relationship(
"Child",
back_populates="parent",
cascade="all, delete",
passive_deletes=True,
)
class Child(Base):
__tablename__ = "child"
id = mapped_column(Integer, primary_key=True)
parent_id = mapped_column(Integer, ForeignKey("parent.id", ondelete="CASCADE"))
parent = relationship("Parent", back_populates="children")
위와 같이 정의된 모델에 대해, session.delete(parent)가 수행될 경우 아래와 같이 동작한다.
The behavior of the above configuration when a parent row is deleted is as follows:
1. The application calls session.delete(my_parent), where my_parent is an instance of Parent. 2. When the Session next flushes changes to the database, all of the currently loaded items within the my_parent.children collection are deleted by the ORM, meaning a DELETE statement is emitted for each record. 3. If the my_parent.children collection is unloaded, then no DELETE statements are emitted. If the relationship.passive_deletes flag were not set on this relationship(), then a SELECT statement for unloaded Child objects would have been emitted. 4. A DELETE statement is then emitted for the my_parent row itself. 5. The database-levelON DELETE CASCADE setting ensures that all rows in child which refer to the affected row in parent are also deleted. 6. The Parent instance referred to by my_parent, as well as all instances of Child that were related to this object and were loaded(i.e. step 2 above took place), are de-associated from the Session. (official docs)
SQLAlchemy에서 cascade가 위와 같이 ForeignKey Constraint와 중복하여 사용되는 경우가 있다. 이러한 경우, 즉 ON DELETE와 relaionship()의 cascade가 함께 사용될 경우에는 두 행위가 충돌하지 않도록 세팅되어야 한다. 여기에 관여하는 것이 passive_deletes이다.
여기서 "passive_deletes"가 가지는 의미는, ORM (SQLAlchemy)이 DELETE/UPDATE 행위를 독자적으로 할 것인지, 아니면 Database ForeignKey Constraint의 Cascade에 얼마나 의존할 것인지를 나타내는 지표이다. 그런 의미에서 ORM 입장에서 생각하여 "passive"라는 명칭을 부여한 것이라 추측되며, 이는 SQLAlchemy가 본질적으로 ORM으로 SQL을 wrapping하여 다루기에 나타난 현상이지 않을까 싶다.
따라서 위와 같이 중복하여 사용되는 경우에는, relationship에 passive_deletes를 반드시 True로 설정하여, Database-level의 ON DELETE CASCADE가 동작할 수 있도록 해야한다.
기본적으로, SQLAlchemy가 parent를 삭제할 때 이상한 현상이 발생한다. 이는 모든 children의 foreign key를 null로 만드는데, 예를 들어 parent 1번을 삭제할 때, 아래의 query가 수행된다는 의미이다.
UPDATE child SET parent_id = NULL WHERE parent_id = 1
만약 이를 수행하게 되면, foreign key에 의존하는 Database 측에서는 ON DELETE CASCADE를 통해 children rows를 삭제할 수 없게 된다. 그렇기에 passive_deletes=True로 설정하는 것은, SQLAlchemy 측에서 기본 세팅으로 되어 있는, Null로 만드는 위의 작업을 오프하는 작업에 해당한다.
이해가 잘 가지 않는다면, 아래 ondelete="CASCADE"가 아래 쿼리에서 "ON DELETE CASCADE"와 같다고 생각하면 비교적 쉽게 이해할 수 있다.
CONSTRAINT child_parent_id_fkey FOREIGN KEY (parent_id)
REFERENCES parent(id) MATCH SIMPLE
ON DELETE CASCADE
이는 parent Table에서 record를 삭제할 때, child Table에 있는 모든 상응하는 record를 삭제함을 의미한다. 이를 SQLAlchemy를 활용하여 ForeignKey에 ondelete를, backref로 relationship을 설정한다면 아래와 같이 할 수 있다.
# on parent model
children = relationship('Child', backref='parent', passive_deletes=True)
# OR on child model
parent = relationship('Parent', backref=backref('children', passive_deletes=True))