洪民憙 (홍민희) 블로그

SQLAlchemy에서 쿼리할 때 조인을 직접 명시해야 하는 경우는 별로 없다

오랜만에 특정 라이브러리의 세부사항에 대해 얘기해본다.

SQLAlchemy ORM을 쓰는 경우 Query.join() 메서드를 직접 써야 하는 경우는 사실 별로 없다. 대부분은 기능을 다 알지 못해서 그렇게 쓰는 것이다.

참고로 이 글에서 다루는 내용은 원래 SQLAlchemy 공식 튜토리얼과 가이드 문서의 (API 레퍼런스 말고) 관계 로딩 테크닉 부분에서 훨씬 더 자세히 설명되어 있다.

로딩하면서 관계 객체들도 한번에 같이 가져오고 싶을 때

Query.join() 메서드를 잘못 사용하는 첫번째 케이스는 관계된 객체들까지 함께 eager loading하고 싶을 때이다.

session.query(Post) \
       .join(User, Post.author_id == User.id)

하지만 위와 같이 써서는 원하는대로 작동하지 않는다. 실제로 Post.author 속성에 접근할 때 별도의 쿼리가 따로 나갈 것이다.

그 이전에 위 쿼리는 스타일 상으로도 문제가 있다. 모델 정의 시에 어차피 Post.author 관계 속성에 조인 조건을 명시하거나 자동으로 추론되는데, 해당 조건을 쿼리할 때 중복해서 다시 쓰는 것이다. 이렇게 되면 추후 관계 조건이 달라졌을 때 쿼리 코드를 뒤져가며 함께 고쳐줘야 하는 문제가 생긴다. (십중팔구는 까먹고 안 고쳤다가 논리적인 오류를 만난다.)

일단 중복 문제만 개선하자면 다음과 같이 고칠 수 있다.

session.query(Post).join(Post.author)

보다시피 Query.join() 메서드는 관계 속성 자체를 인자로 받는다. 이렇게 하면 나중에 Post.author의 조건이 달라졌을 때도 의미가 변하지 않을 것이다.

하지만 여전히 Post.author 속성에 접근했을 때 별도 쿼리가 생성되는 문제는 존재한다. 왜냐면 SQLAlchemy에서 쿼리 생성과 로딩 전략은 별도로 취급되기 때문이다. 조인된 Post.author를 메모리 상의 Post 객체를 만들 때 채워주고 싶다면 다음과 같이 Query.options() 메서드에 contains_eager()를 명시해야 한다.

session.query(Post) \
       .join(Post.author) \
       .options(contains_eager(Post.author))

이제 Post.author 속성에도 이미 메모리 상에 로딩된 User 객체가 들어가 있을 것이다.

하지만 굳이 contains_eager()를 쓸 필요 없이, Query.join()contains_eager()를 함께 해주는 joinedload()를 쓰는 편이 낫다.

session.query(Post).options(joinedload(Post.author))

조건 절에 조인된 테이블의 필드를 참조해야 하는 경우

Query.join() 메서드를 쓰는 또다른 경우는 조건 절에서 다른 관계된 테이블의 특정 컬럼을 참조해야 하는 경우이다.

session.query(Post) \
       .join(User, User.id == Post.author_id) \
       .filter(~User.deleted) \
       .filter(User.last_login_time > now() - datetime.timedelta(days=1))

앞서 설명했듯 조인 조건을 굳이 중복해서 쓸 필요가 없으므로 위 쿼리도 다음과 같이 개선할 수 있다.

session.query(Post) \
       .join(Post.author) \
       .filter(~User.deleted) \
       .filter(User.last_login_time > now() - datetime.timedelta(days=1))

Query.filter() 메서드 안에서 관계된 다른 모델의 속성을 참조해야 하는 경우, has-one 관계인 경우 .has() 메서드, has-many 관계인 경우 .any() 메서드를 써서 표현할 수 있다.

session.query(Post) \
       .filter(Post.author.has(
           ~User.deleted |
           (User.last_login_time > now() - datetime.timedelta(days=1))
       ))

단순 == 연산만 필요한 경우에는 아예 키워드 인자로 넘겨버려도 된다. 가령 아래 두 쿼리는 같은 의미다.

session.query(Post).filter(Post.author.has(~User.deleted))

session.query(Post).filter(Post.author.has(deleted=False))