본문 바로가기
Artificial Intelligence/60. Python

[PYTHON] SQLAlchemy N+1 문제 해결을 위한 3가지 로딩 전략 차이와 성능 최적화 방법

by Papa Martino V 2026. 4. 27.
728x90

SQLAlchemy N+1
SQLAlchemy N+1

 

파이썬 백엔드 개발에서 SQLAlchemy는 강력한 도구이지만, ORM의 편리함 뒤에는 'N+1 Problem'이라는 성능의 함정이 숨어 있습니다. 이는 연관된 데이터를 조회할 때 의도치 않게 수많은 추가 쿼리가 발생하는 현상으로, 서비스 규모가 커질수록 데이터베이스 부하의 주범이 됩니다. 본 포스팅에서는 SQLAlchemy 2.0 이상의 최신 문법을 기준으로 N+1 문제를 방지하는 Eager Loading 기법들을 심층 분석하고, 실무에서 즉시 적용 가능한 7가지 고성능 데이터 로딩 패턴을 공유합니다.


1. N+1 Problem의 실체: 왜 발생하는가?

N+1 문제는 객체 간의 관계를 조회할 때 발생합니다. 예를 들어 '사용자(User)' 100명을 조회한 뒤, 각 사용자의 '게시글(Post)' 목록에 접근하면 다음과 같은 일이 벌어집니다.

  • 1번의 쿼리: 전체 사용자 100명을 가져옴 (SELECT * FROM users)
  • N번의 추가 쿼리: 각 사용자마다 게시글을 가져오기 위해 100번의 쿼리가 개별 실행됨

결과적으로 단 1번의 쿼리로 끝날 작업이 101번의 네트워크 왕복을 발생시켜 애플리케이션 응답 속도를 저하시킵니다.


2. SQLAlchemy 로딩 전략(Relationship Loading) 비교

상황에 맞는 최적의 로딩 전략을 선택하는 것이 성능 해결의 열쇠입니다.

전략 명칭 SQL 실행 방식 장점 단점 및 위험성
Joined Loading LEFT OUTER JOIN 사용 단 1번의 쿼리로 모든 데이터 로드 다대다(M:N) 관계에서 결과 집합 팽창
Selectin Loading IN 연산자를 사용한 두 번째 쿼리 일대다(1:N) 관계에서 가장 효율적 너무 많은 ID가 IN 절에 들어갈 경우 제한 발생
Subquery Loading 서브쿼리를 포함한 두 번째 쿼리 복잡한 쿼리 구조 유지 가능 성능 예측이 어렵고 현대 스펙에선 비권장
Lazy Loading 데이터 접근 시점에 쿼리 실행 초기 로딩 시 메모리 절약 N+1 문제 발생의 근본 원인

3. 실무 해결을 위한 7가지 Sample Example

SQLAlchemy 2.0 Style (select 구문)을 활용한 실전 해결 예제입니다.

Example 1: joinedload를 이용한 다대일(N:1) 관계 최적화

게시글을 가져올 때 작성자 정보를 조인하여 한 번에 가져옵니다.

from sqlalchemy import select
from sqlalchemy.orm import joinedload

# Post와 User를 JOIN하여 한 번의 쿼리로 조회
stmt = select(Post).options(joinedload(Post.author))
results = session.execute(stmt).scalars().all()

for post in results:
    print(post.author.name)  # 추가 쿼리 발생 안 함
    

Example 2: selectinload를 이용한 일대다(1:N) 관계 효율화

사용자 목록을 가져올 때 그들의 모든 게시글을 두 번째 쿼리(IN 절)로 효율적으로 로드합니다.

from sqlalchemy.orm import selectinload

# 1:N 관계에서는 joinedload보다 selectinload가 성능상 유리함
stmt = select(User).options(selectinload(User.posts))
users = session.execute(stmt).scalars().all()

for user in users:
    print(len(user.posts))  # 모든 post가 이미 메모리에 로드됨
    

Example 3: 다중 레벨 중첩 로딩 (Nested Loading)

사용자 -> 게시글 -> 댓글로 이어지는 깊은 관계를 한 번에 로드하는 방법입니다.

stmt = select(User).options(
    selectinload(User.posts).selectinload(Post.comments)
)
results = session.execute(stmt).scalars().all()
    

Example 4: 특정 컬럼만 로드하는 load_only와의 결합

N+1 해결과 동시에 메모리 낭비를 줄이기 위해 필요한 컬럼만 가져오는 최적화입니다.

from sqlalchemy.orm import Load

stmt = select(User).options(
    selectinload(User.posts).load_only(Post.title, Post.created_at)
)
    

Example 5: 관계 선언 시 기본 로딩 전략 설정 (lazy='selectin')

자주 사용되는 관계에 대해 모델 레벨에서 N+1을 원천 봉쇄하는 방법입니다.

class User(Base):
    __tablename__ = "users"
    # lazy="selectin" 설정을 통해 명시하지 않아도 항상 Eager 로딩 수행
    posts = relationship("Post", back_populates="author", lazy="selectin")
    

Example 6: 이미 로드된 객체에 대해 추가 로딩 강제 (contains_eager)

수동으로 JOIN 쿼리를 작성했을 때 ORM 객체에 데이터를 채워 넣는 기법입니다.

from sqlalchemy.orm import contains_eager

stmt = (
    select(Post)
    .join(Post.author)
    .filter(User.name == "John")
    .options(contains_eager(Post.author))
)
    

Example 7: 비동기(Async) 환경에서의 로딩 필수 전략

비동기 SQLAlchemy에서는 Lazy Loading이 불가능하므로 반드시 Eager Loading을 강제해야 합니다.

# 비동기 환경에서는 selectinload 사용이 표준임
async def get_users():
    stmt = select(User).options(selectinload(User.posts))
    result = await session.execute(stmt)
    return result.scalars().all()
    

4. 성능 개선 효과 분석 및 결론

N+1 문제를 해결했을 때의 성능 차이는 데이터 양이 많아질수록 기하급수적으로 커집니다. 1,000개의 부모 엔티티를 조회할 때 Lazy 로딩은 1,001회의 쿼리를 발생시키지만, Selectin 로딩은 단 2회의 쿼리로 작업을 마무리합니다.

결론적으로, N:1 관계에서는 joinedload를, 1:N 관계에서는 selectinload를 사용하는 것이 성능 최적화의 골든 룰입니다. ORM이 생성하는 SQL을 수시로 모니터링하여 불필요한 쿼리 중복을 잡아내는 습관을 가지시길 바랍니다.


참고 문헌 및 출처:

  • SQLAlchemy 2.0 Official Documentation - Relationship Loading Techniques
  • Python Software Foundation - Database Optimization Patterns
  • Real Python - Mastering SQLAlchemy ORM Performance
  • Martin Fowler - Patterns of Enterprise Application Architecture (N+1 Problem Section)
728x90