반응형

1. 들어가며

ORM(Object Relational Mapping)은 개발자가 SQL을 직접 작성하지 않고 객체지향적으로 DB를 다룰 수 있게 해줍니다.
하지만 ORM을 처음 쓰면 거의 모두가 마주치는 문제가 있습니다. 바로 N+1 문제입니다.

저 역시 프로젝트를 진행하면서 이 문제를 겪었고, 단순한 최적화를 넘어서 실제 데이터가 많아지면 어떻게 되는지 궁금했습니다.
그래서 이번 글에서는 N+1 문제 → 로딩 전략 선택 → 실제 AWS 환경에서 간단히 실험한 결과를 공유하려 합니다.


2. N+1 문제를 직접 겪다

예제로 User(작성자) – Post(게시글) 구조를 생각해 봅시다. User와 Post는 1:N 관계로 한 유저가 여러 포스트를 작성할수 있어요.

users 테이블

id name
1 Choi
2 Kim
3 Yim
4 Lee

posts 테이블

id title user_id content
1 Hello World 1 안녕??....................
2 ORM is awesome 2 ORM 은 너무 멋지다~..........
3 N+1 problem sucks 1 N+1 뭐냐 ..........
4 SQLAlchemy Tips 3 SQLalchmey는 이렇게 사용할 수 있어요! ...
5 Python is beautiful 4 Python은 너무 아름답다 그 이유는 ...

 

SQLalchemy를 활용해 ORM Model은 아래처럼 만들 수 있어요.

from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(50), nullable=False)

    # 역참조 (User.posts 로 접근 가능)
    posts = relationship("Post", back_populates="user")


class Post(Base):
    __tablename__ = "posts"

    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(200), nullable=False)
    content = Column(Text)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)

    # User와 관계 설정 (Post.user 로 접근 가능)
    user = relationship("User", back_populates="posts")

 

N+1 발생 상황

모든 포스트에 대해서, 포스트 제목과 포스트를 작성한 사람의 이름을 같이 출력할 때 아래와 같이 작성할 수 있습니다. 포스트가 100개라고 가정할 때,

# N+1 발생 예시 (SQLAlchemy)
posts = session.query(Post).limit(100).all()
for post in posts:
    print(post.title, post.user.name)  # 여기서 user 접근 시마다 추가 쿼리

 위 경우 실제 실행되는 SQL 쿼리문은 아래와 같아요.

SELECT * FROM posts LIMIT 100;             -- 1번
SELECT * FROM users WHERE id=1;            -- 2번
SELECT * FROM users WHERE id=2;            -- 3번
SELECT * FROM users WHERE id=3;            -- 4번
...
SELECT * FROM users WHERE id=N;          -- N+1번

100+1번(N+1번) 쿼리 실행 → 데이터가 늘어날수록 기하급수적으로 느려집니다. 저도 실제로 이 문제 때문에 API 응답 속도가 수십 초까지 늘어나는 경험을 했습니다.

 

3. 해결 방법: 로딩 전략 선택

ORM에서는 relationship을 가져올 때 다양한 로딩 전략을 선택할 수 있습니다. 대표적인 것이 두 가지입니다.

3.1 Fetch Join (joinedload)

fetch join은 JPA/Hibernate(JAVA ORM)에서 온 개념인데, join을 통해 연관된 엔티티(객체)를 미리 가져온다는 개념입니다. SQLAlchemy도 같은 개념을 차용해서 joinedload 옵션을 제공하고, 이것을 흔히 "Fetch Join"이라고 부릅니다.

from sqlalchemy.orm import joinedload

posts = (
    session.query(Post)
    .options(joinedload(Post.user))
    .limit(100)
    .all()
)

for post in posts:
    print(post.title, post.user.name)  # 여기서 user 접근 시마다 추가 쿼리 없음

 

위 코드는 Postgres 기준으로 아래의 SQL query문이 실행됩니다.

SELECT posts.id AS posts_id,
       posts.title AS posts_title,
       posts.content AS posts_content,
       posts.user_id AS posts_user_id,
       users_1.id AS users_1_id,
       users_1.name AS users_1_name
FROM posts
LEFT OUTER JOIN users AS users_1 ON users_1.id = posts.user_id
LIMIT 100;

쿼리 1번으로 Post와 User를 JOIN 해서 모두 가져오는 의미로 Python에서 post.user.name 접근할 때 추가적으로 쿼리가 발생하지 않습니다.

Fetch Join은 쿼리 1번으로 해결되지만, 1:N 관계에서는 row 중복이 발생할 수 있습니다. 위와 달리, User 기준으로 posts를 가져올때, 아래와 같이 joinedload를 쓸수있는데요.

from sqlalchemy.orm import joinedload

users = (
    session.query(User)
    .options(joinedload(User.posts))
    .limit(100)
    .all()
)

for user in users:
    for post in user.posts:
        print(user.name, post.title)

 

실행되는 SQL

SELECT users.id AS users_id, users.name AS users_name,
       posts_1.id AS posts_1_id, posts_1.title AS posts_1_title, posts_1.content AS posts_1_content, posts_1.user_id AS posts_1_user_id
FROM users
LEFT OUTER JOIN posts AS posts_1 ON users.id = posts_1.user_id
LIMIT 100;

 

결과테이블

users.id  users.name  posts_1_id  posts_1_title posts_1_content posts_1_user_id
1 Choi 1 Hello World 안녕??.............. 1
1 Choi 3 N+1 problem sucks N+1 뭐냐 .......... 1
2 Kim 2 ORM is awesome ORM 은 너무 ........ 2
3 Yim 4 SQLAlchemy Tips SQLalchmey는 ... 3
4 Lee 5 Python is beautiful Python은 ..... 4

여기서 문제는 User 데이터가 Post 수만큼 중복 포함된다는 점입니다.

  • Choi가 2개의 글을 썼다면 row 2개에서 Choi가 중복됨.
  • 만약 Choi가 1만 개의 글을 썼다면, User 데이터도 1만 번 반복됨.
  • LIMIT 기준도 row에 적용돼서 의도한 User 개수와 맞지 않을 수 있음.

 

3.2 Select IN (selectinload)

위와 같이 User기준으로 Posts를 가져올때는 selectedinload를 사용하는게 더 효과적입니다.

from sqlalchemy.orm import selectinload

users = (
    session.query(User)
    .options(selectinload(User.posts))
    .limit(100)
    .all()
)

for user in users:
    for post in user.posts:
        print(user.name, post.title)

실행되는 SQL

-- 1. users 조회
SELECT users.id AS users_id, users.name AS users_name
FROM users
LIMIT 100;

-- 2. posts 조회 (IN 조건)
SELECT posts.id AS posts_id, posts.title AS posts_title, posts.user_id AS posts_user_id
FROM posts
WHERE posts.user_id IN (1, 2, 3, 4, 5);

쿼리가 2번실행되지만 User와 Post를 분리해서 전송하기 때문에 User 관련 중복데이터 전송이 없습니다.

 

4. 실제 AWS 환경에서의 실험

이론적으로만 이해하는 것이 아니라, 실제로 데이터가 많아지면 얼마나 성능 차이가 나는지 확인해보고 싶었습니다. 그래서 간단한 실험 환경을 AWS에 구성했습니다.

실험 환경

  • EC2: Ubuntu 20.04, Python 3.10
  • RDS: PostgreSQL, db.t3.medium
  • 라이브러리: SQLAlchemy, psycopg2
  • 데이터 규모:
    • Users: 1,000명 (고정)
    • Posts: 1천 → 1만 → 5만 → 10만 개까지 점진적으로 증가

4.1 실험 방법

  1. N+1: Post를 불러온 뒤 post.user에 접근 → User 쿼리가 매번 실행됨
  2. Fetch Join (joinedload): Post와 User를 JOIN으로 한 번에 가져옴
  3. Select IN (selectinload): Post를 먼저 불러온 뒤, 필요한 User만 IN 조건으로 추가 조회

각 방식으로 같은 수의 Post를 조회하고, 실행 시간을 비교했습니다.

4.2 실험 결과

아래 표는 같은 Post 수를 조회했을 때 걸린 시간입니다.

Posts 수 N+1 방식 joinedload selectinload
1,000 1.55초 0.023초 0.038초
10,000 2.73초 0.17초 0.12초
50,000 3.72초 0.77초 0.66초
100,000 6.34초 1.47초 1.50초

 

4.3 결과 해석

  • N+1
    • Post가 만개만 되어도 이미 2초 가까이 소요.
    • 5만 건부터는 3초 이상 걸림
    • → 작은 데이터셋에서는 눈치 못 채다가, 데이터가 늘면 성능이 폭발적으로 나빠지는 전형적인 N+1 문제.
  • Fetch Join
    • User를 JOIN해서 한 번에 가져오기 때문에 N+1보다 압도적으로 빠름.
    • Post 수가 늘어도 안정적으로 1초~2초 수준에서 처리 가능.
  • Select IN
    • Post를 먼저 조회하고 필요한 User를 IN 조건으로 가져오기 때문에, Fetch Join보다 쿼리 한 번이 더 많음.
    • 하지만 User 데이터가 중복 포함되지 않아서 대량 데이터에서 더 안정적.
    • 이번 실험에서 10만 건 기준으로 Fetch Join과 비슷한 속도를 보임

 

5. 마무리

이번 글에서는 ORM을 사용할 때 흔히 맞닥뜨리는 N+1 문제를 직접 재현하고,
SQLAlchemy의 Fetch Join(joinedload) 과 Select IN(selectinload) 을 통해 성능이 얼마나 개선되는지 확인했습니다.

중요한 것은 “쿼리 속도 몇 초 차이”가 아니라,
N+1 구조는 데이터가 커지면 절대 확장될 수 없다는 점입니다.
따라서 ORM을 쓸 때는 반드시 로딩 전략을 의식적으로 선택해야 하며, 상황에 맞는 방법을 쓰는 습관이 필요합니다.

 

반응형

+ Recent posts