python에서 객체지향 방식으로 데이터베이스와 상호작용하기 위해 ORM 라이브러리 중 하나인 SQLAlchemy를 사용하곤 한다.
하나의 객체에서 연관된 다른 객체를 불러올 때 기본적으로 SQLAlchemy는 lazy loading으로 데이터를 불러온다.
객체를 조회할 때는 관련된 데이터를 쿼리하지 않다가, 나중에 실제로 그 데이터를 참조할 때 별도의 쿼리를 발생시키는 방식
Author 객체와 Book 객체를 1대 N으로 정의
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True)
name = Column(String)
books = relationship("Book", back_populates="author")
class Book(Base):
__tablename__ = 'books'
id = Column(Integer, primary_key=True)
title = Column(String)
author_id = Column(Integer, ForeignKey('authors.id'))
author = relationship("Author", back_populates="books")
아래 코드에서 author.books를 호출하는 시점에 처음에 가져온 Author와 연관된 Book 데이터를 가져오는 쿼리문을 추가 호출
author = session.query(Author).first()
print(f"Author: {author.name}")
[print(book.title) for book in author.books] # .books 호출 시점에 추가 쿼리문 호출
아래 로그를 보면 authors 테이블을 조회 후 author.books 호출 시점에 books 테이블을 추가적으로 조회한 것을 볼 수 있다.

데이터베이스 규모가 작을 경우에는 lazy loading을 사용하지 않아도 크게 문제가 되지 않을 것이다.
대규모 데이터를 다룬다고 생각해보자.
비즈니스 로직에 따라서 당장 연관 데이터가 필요하지 않은 데 가져올 경우 필요없는 데이터 로드로 메모리의 낭비가 커질 수 있으며, 불필요하게 데이터베이스와 네트워크에 부하를 줄 수 있다.
lazy loading을 적절하게 사용하면 자원 사용을 최적화 할 수 있다 !
비즈니스 로직에 따라서 lazy loading이 득이 될수도 실이 될 수도 있다는 사실을 알았다.
Author(저자)를 상위 엔티티라 하고 Book(책)을 하위 엔티티라 할 때
N개의 상위 엔티티와 그와 연관된 하위 엔티티를 조회하고 싶은 상황이 있다고 해보자.
그렇다면 아래처럼 코드가 작성될 것이다.
authors = session.query(Author).all() # 1번의 쿼리로 N개의 author를 불러온다.
for author in authors:
print(f"Author: {author.name}")
for book in author.books:
print(f" Book: {book.title}")
lazy loading이 적용되어 있다면 author.books가 호출 될 때마다 추가 쿼리가 발생할 것이다.
즉, 모든 author를 불러오는 쿼리 1번과 N개의 author와 연관된 books를 가져오는 추가 쿼리가 N번 발생한다. (N + 1)

위 문제를 해결하는 방법으로 join문을 통해서 한번의 쿼리로 N개의 author와 그와 연관된 books를 한번에 쿼리하는 방법을 생각해볼 수 있다.
joinedload 사용하기
joinedload를 사용하면 쿼리 실행 시점에 함수 파라미터로 들어온 속성을 Eager Loading(즉시 로딩) 처리하여 모든 데이터를 한번의 쿼리로 가져와 객체에 매핑한다.
authors = session.query(Author).options(joinedload(Author.books)).all()
for author in authors:
print(f"Author: {author.name}")
for book in author.books:
print(f" Book: {book.title}")
아래처럼 엔티티 클래스의 lazy 속성을 joined로 설정해도 되지만 엔티티의 설정하는 경우는 엔티티 관계를 정의하는 시점에 설정이 되기에 특정 쿼리 실행 시점에만 join을 적용하는 상황에서는 적절하지 않을 수 있다.
author = relationship("Author", back_populates="books", lazy='joined') # 관계 정의 시점에 즉시 로딩 설정
join 사용하기
joinedload가 아닌 join을 사용해서도 한번에 데이터를 불러올 수 있다.
다만 joinedload와 달리 relationship()를 통해 연관된 속성(books 속성)에 자동 매핑하지 않는다.
즉, 가져온 author의 author.books를 호출할 경우 추가 쿼리가 발생할 수 있어 이미 반환된 데이터를 잘 활용해야 한다.
results = session.query(Author, Book).join(Book).all()
for author, book in results:
print(f"Author: {author.name}")
print(f" Book: {book.title}")
contains_eager를 활용한다면 join으로 가져온 데이터를 중복없이 연관 엔티티와 함께 orm 객체에 매핑할 수도 있다. (author.books 호출 시 추가 쿼리 발생 안함.)
results = session.query(Author).join(Book).options(contains_eager(Author.books)).all()
for author in results:
print(f"Author: {author.name}")
for book in author.books:
print(f" Book: {book.title}")
joinedload와 join의 차이는 뭘까 ?
두 함수의 차이가 궁금해 두 방법을 비교해보았다.
먼저 joinedload의 경우 출력된 쿼리문은 아래와 같다.
SELECT authors.id AS authors_id, authors.name AS authors_name, books_1.id AS books_1_id, books_1.title AS books_1_title, books_1.author_id AS books_1_author_id
FROM authors LEFT OUTER JOIN books AS books_1 ON authors.id = books_1.author_id
그리고 join을 사용한 경우 아래와 같다.
SELECT authors.id AS authors_id, authors.name AS authors_name, books.id AS books_id, books.title AS books_title, books.author_id AS books_author_id
FROM authors JOIN books ON authors.id = books.author_id
확인 결과
joinedload는 기본적으로 left outer join을 출력하고,
join을 사용한 경우 기본적으로 inner join을 출력한다.
author가 연관 book이 없는 경우도 가져오고 싶다면 join을 outerjoin으로만 바꿔주면 동일한 쿼리문이 출력된다.
join 후 하위 엔티티를 필터링해서 가져와야 하는 경우를 생각해보자.
joinedload
- Code
authors = session.query(Author).options(joinedload(Author.books)).filter(Book.title.like('Book 1%')).all()
- Query
SELECT authors.id AS authors_id, authors.name AS authors_name, books_1.id AS books_1_id, books_1.title AS books_1_title, books_1.author_id AS books_1_author_id
FROM authors LEFT OUTER JOIN books AS books_1 ON authors.id = books_1.author_id, books
WHERE books.title LIKE %(title_1)s
outerjoin
- Code
results = session.query(Author, Book).outerjoin(Book).filter(Book.title.like('Book 1%')).all()
- Query
SELECT authors.id AS authors_id, authors.name AS authors_name, books.id AS books_id, books.title AS books_title, books.author_id AS books_author_id
FROM authors LEFT OUTER JOIN books ON authors.id = books.author_id
WHERE books.title LIKE %(title_1)s
두 쿼리문을 비교해보면 joinedload의 경우 authors와 books 테이블을 left outer join한 테이블과 별개로 books 테이블을 사용한다. 즉 두개의 books테이블을 사용한다. 그리고 별개의 books 테이블에 where 절을 적용하고 있다.
반면에 outerjoin의 경우 left outer join한 테이블 하나만을 사용한다.
그리고 join한 테이블에 where 절을 적용하고 있다.
위 결과를 토대로 joinedload의 경우 하위 엔티티를 적절하게 필터링하지 못한다는 것을 알 수 있다. 하위 엔티티에 대한 세부 쿼리를 작성하거나 할때는 의도치 않은 동작을 수행할 수 있다.
상황에 따라 joinedload와 join의 동작방식을 이해하고 적용하면 적절한 최적화를 할수 있지 않을까 생각한다.
SQLAlchemy는 기본적으로 lazy loading 방식으로 동작하며 상황에 따라 N+1 문제가 발생할 수 있다는 것을 알게 되었다.
주요 해결 방법으로는 joinedload와 join이 있으며, 각각의 동작 방식을 직접 확인해보았다.
이를 통해 동작 방식을 명확히 이해하는 것이 상황에 맞는 적절한 해결책을 선택하는 데 중요하다는 점을 느꼈다.