데이터베이스 상호작용에서 N+1 쿼리 딜레마 극복하기
Min-jun Kim
Dev Intern · Leapcell

소개
애플리케이션 개발 세계에서 데이터베이스와의 원활한 통합은 매우 중요합니다. 그러나 종종 표면 아래에 일반적인 성능 병목 현상이 숨어 있습니다. 바로 악명 높은 N+1 쿼리 문제입니다. 이 겉보기에는 무해해 보이는 문제는 효율적인 데이터 검색이어야 할 것을 리소스 집약적인 작업으로 변환하여 애플리케이션 속도를 현저히 늦추고 사용자를 좌절시킬 수 있습니다. 이 문제를 이해하고 해결하는 것은 확장 가능하고 고성능 시스템을 구축하는 데 매우 중요합니다. 이 글에서는 N+1 쿼리의 복잡성을 깊이 파고들어 메커니즘을 설명하고, 특히 JOIN
작업 및 배치 로딩과 같은 고급 기술을 사용하여 실용적이고 코드 중심적인 솔루션을 제공합니다.
N+1 쿼리란 무엇인가?
솔루션으로 넘어가기 전에 핵심 개념을 명확히 해봅시다.
N+1 쿼리 문제: N+1 쿼리 문제는 애플리케이션이 부모 엔티티 목록을 검색하기 위해 쿼리를 하나 실행하고, 그런 다음 해당 부모와 관련된 각 자식 엔티티에 대해 N개의 추가 쿼리를 실행할 때 발생합니다. 결과적으로 최적의 단일 또는 몇 개의 잘 최적화된 쿼리 대신 총 N+1개의 쿼리가 실행됩니다.
Authors
목록이 있고 각 Author
가 Books
컬렉션을 가지고 있다고 상상해 보세요. 모든 Authors
(쿼리 1개)를 먼저 쿼리한 다음, 해당 목록의 각 Author
에 대해 해당 Books
(작가 수 N개)를 쿼리하면 N+1 쿼리 문제가 발생합니다.
영향: 주요 영향은 성능 저하입니다. 각 데이터베이스 쿼리에는 네트워크 지연 시간, 데이터베이스 연결 오버헤드, 쿼리 구문 분석 및 실행이 포함됩니다. 관련 데이터에 대해 이러한 단계를 N번 반복하면 페이지 로드가 느려지고 서버 로드가 증가하며 사용자 경험이 저하될 수 있습니다.
예제 시나리오 (개념적):
두 개의 테이블 authors
와 books
가 있다고 가정해 봅시다.
-- authors 테이블 CREATE TABLE authors ( id INT PRIMARY KEY, name VARCHAR(255) ); -- books 테이블 CREATE TABLE books ( id INT PRIMARY KEY, title VARCHAR(255), author_id INT, FOREIGN KEY (author_id) REFERENCES authors(id) );
모든 작가와 그들이 출판한 책의 제목을 나열하려는 Python 애플리케이션(SQLAlchemy 또는 모든 ORM 사용)을 고려해 봅시다.
# Author 및 Book 모델을 사용한 SQLAlchemy 설정 가정 from sqlalchemy import create_engine, Column, Integer, String, ForeignKey from sqlalchemy.orm import sessionmaker, relationship, declarative_base Base = declarative_base() 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") engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() # 샘플 데이터 추가 author1 = Author(name="J.K. Rowling") author2 = Author(name="Stephen King") session.add_all([author1, author2]) session.commit() session.add_all([ Book(title="Harry Potter and the Sorcerer's Stone", author=author1), Book(title="Harry Potter and the Chamber of Secrets", author=author1), Book(title="The Shining", author=author2), Book(title="It", author=author2) ]) session.commit() # N+1 문제 발생 (지연 로딩) print("--- N+1 쿼리 예제 ---") authors = session.query(Author).all() # 쿼리 1: SELECT * FROM authors; for author in authors: print(f"Author: {author.name}") for book in author.books: # N번 쿼리: SELECT * FROM books WHERE author_id = <author.id>; print(f" Book: {book.title}") session.close()
이 예제에서 authors = session.query(Author).all()
줄은 모든 작가를 가져오기 위해 하나의 쿼리를 실행합니다. 그런 다음 루프 내에서 for book in author.books
는 각 작가의 책을 가져오기 위해 별도의 데이터베이스 쿼리를 트리거합니다. 작가가 2명이라면 1(작가) + 2(작가당 책) = 3개의 쿼리가 발생합니다. N명의 작가라면 1 + N개의 쿼리가 됩니다.
N+1 문제 해결
N+1 쿼리 문제를 해결하는 두 가지 주요하고 매우 효과적인 전략은 JOIN
연산 사용 및 배치 로딩 구현(ORM의 즉시 로딩과 같은 기능 사용)입니다.
솔루션 1: JOIN 연산 사용 (즉시 로딩)
JOIN
연산은 두 개 이상의 테이블에서 행을 관련 열을 기준으로 결합할 수 있게 합니다. JOIN
을 사용하면 필요한 모든 부모 및 자식 데이터를 단일의 잘 구조화된 쿼리로 검색할 수 있습니다. 이는 관련 데이터를 미리 로드하는 "즉시 로딩"의 한 형태입니다.
원리: 부모와 자녀를 별도로 쿼리하는 대신, author_id
관계를 사용하여 authors
와 books
테이블을 결합하고 관련된 모든 데이터를 한 번에 가져오도록 데이터베이스에 지시합니다.
구현 (SQLAlchemy 예제):
이것을 달성하기 위해 이전 SQLAlchemy 코드를 joinedload
(또는 컬렉션의 경우 selectinload
)를 사용하도록 수정할 수 있습니다.
print("\n--- 솔루션 1: JOIN 사용 ( `joinedload`를 사용한 즉시 로딩) ---") session = Session() # 깨끗한 예제를 위해 세션 다시 열기 authors_with_books_joined = session.query(Author).options( relationship_loader(Author.books, joinedload('*')) # relationship_loader 및 joinedload 사용 ).all() # 다음은 대략 1개의 쿼리를 실행합니다: # 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; for author in authors_with_books_joined: print(f"Author: {author.name}") for book in author.books: print(f" Book: {book.title}") session.close()
참고: ORM 및 관계 유형에 따라 joinedload
, subqueryload
또는 selectinload
가 더 적합할 수 있습니다. 일대다 관계의 경우 joinedload
가 좋은 선택인 경우가 많지만, 결과 집합에 부모 데이터가 중복으로 포함될 수 있습니다. selectinload
는 부모 엔티티에 대한 모든 관련 컬렉션을 즉시 로드하는 IN
절을 사용하는 두 번째 SELECT
문을 발행하기 때문에 컬렉션에 자주 선호됩니다.
JOIN
을 사용해야 할 때:
- 일대다 관계: 관련된 자녀를 가져오는 데 매우 효과적입니다.
- 소규모에서 중간 규모 데이터셋: 결합된 행 수가 너무 많아지지 않아 대규모 결과 집합이 발생하는 경우 효율적입니다.
- 관련 데이터가 항상 필요할 때: 부모를 가져올 때마다 해당 자녀도 필요한 경우
JOIN
이 자연스러운 선택입니다.
솔루션 2: 배치 로딩 ( IN
절을 사용한 즉시 로딩)
배치 로딩은 ORM의 "selectin" 또는 "preload" 기능을 통해 구현되는 즉시 로딩의 또 다른 형태입니다. 잠재적으로 결과를 비정규화하는 단일 JOIN
대신, 배치 로딩은 두 번의 쿼리를 실행합니다. 첫 번째 쿼리는 부모 엔티티를 가져오고, 두 번째 쿼리는 첫 번째 쿼리에서 검색된 부모 ID 목록에 있는 자식 엔티티를 가져옵니다. 이는 IN
절을 활용합니다.
원리:
- 모든 부모 엔티티를 가져옵니다 (쿼리 1개).
- 모든 가져온 부모 엔티티의 ID를 추출합니다.
WHERE child.parent_id IN (list_of_parent_ids)
를 필터링하여 단일 쿼리에서 모든 관련 자식 엔티티를 가져옵니다 (쿼리 1개). 총: N에 관계없이 2번의 쿼리.
구현 (SQLAlchemy 예제):
SQLAlchemy의 selectinload
는 이 패턴을 위해 설계되었습니다.
print("\n--- 솔루션 2: 배치 로딩 ( `selectinload`를 사용한 즉시 로딩) ---") session = Session() # 세션 다시 열기 authors_with_books_batch = session.query(Author).options( relationship_loader(Author.books, selectinload('*')) # relationship_loader 및 selectinload 사용 ).all() # 다음은 2개의 쿼리를 트리거합니다: # 1. SELECT authors.id, authors.name FROM authors; # 2. SELECT books.author_id, books.id, books.title FROM books WHERE books.author_id IN (<ids_of_fetched_authors>); for author in authors_with_books_batch: print(f"Author: {author.name}") for book in author.books: print(f" Book: {book.title}") session.close()
배치 로딩 (selectinload
)을 사용해야 할 때:
- 컬렉션 (
uselist=True
관계):joinedload
가 많은 부모 행을 반복적으로 반환할 수 있는 일대다 또는 다대다 관계에 특히 좋습니다. - 대규모 데이터셋: 관련된 자녀 레코드 수가 매우 많은 경우,
joinedload
는 엄청나게 넓은 결과 집합을 생성하여 네트워크 전송 및 메모리 사용량을 증가시킬 수 있습니다.selectinload
는 ORM이 부모 및 자녀 데이터를 함께 묶을 때까지 분리된 상태로 유지하기 때문에 일반적으로 더 메모리 효율적입니다. - 깔끔한 SQL을 원할 때: 두 개의 별도 쿼리는 복잡한 다중 테이블
JOIN
과 비교할 때 종종 더 이해하고 개별적으로 최적화하기 쉽습니다.
JOIN
과 배치 로딩 모두 즉시 로딩의 한 형태입니다. 둘 사이의 선택은 종종 특정 ORM, 관계의 성격 및 주어진 데이터베이스 및 애플리케이션 컨텍스트에서의 성능 특성에 따라 달라집니다. 가장 최적의 접근 방식을 결정하기 위해서는 항상 프로파일링이 권장됩니다.
결론
N+1 쿼리 문제는 데이터베이스 기반 애플리케이션에서 지속적인 성능 저하 요인입니다. 관련된 엔티티에 대한 비효율적인 데이터 검색이라는 근본 원인을 이해함으로써 개발자는 사전에 더 나은 전략을 선택할 수 있습니다. 일반적인 지연 로딩에서 명시적인 JOIN
작업 또는 지능적인 배치 로딩 메커니즘(ORM의 즉시 로딩 기능과 같은)으로 전환하면 데이터베이스 쿼리 수를 크게 줄여 훨씬 더 빠르고 확장 가능한 애플리케이션을 만들 수 있습니다. 데이터베이스 상호 작용을 최적화하는 것은 훌륭한 사용자 경험을 제공하고 효율적인 시스템 성능을 유지하는 데 중요합니다.