본문으로 건너뛰기
개발 머꼬
개발 노트SQL
hohyeon.dev18

ORM 없이도 N+1 쿼리에 걸리는 이유 — 문제의 본질

  • #Database
  • #Engineering Note
  • #Performance

문제 발생

ORM을 안 쓰고 직접 SQL을 짠 코드에서도 목록 API 응답 시간이 항목 수에 비례해 계속 늘어나는 문제가 있었습니다.

orders = db.execute("SELECT * FROM orders LIMIT 100")
for order in orders:
    customer = db.execute(
        "SELECT * FROM customers WHERE id = %s", (order["customer_id"],)
    )  # 매 반복마다 쿼리 1번씩 추가

원인 분석

N+1 문제는 특정 ORM 라이브러리의 버그가 아니라, "목록을 가져온 뒤(1번) 각 항목마다 연관 데이터를 따로 조회한다(N번)"는 접근 방식 자체의 구조적 문제입니다. ORM에서 자주 언급되는 이유는 지연 로딩(lazy loading)이 이 패턴을 코드에서 눈에 안 띄게 숨기기 때문일 뿐, 원인은 언어나 프레임워크와 무관합니다.

네트워크를 통해 DB에 쿼리를 보내고 응답을 받는 데는 실제 쿼리 실행 시간 외에도 왕복 지연(round-trip latency)이 있습니다. 항목이 100개면 이 왕복 지연만 100번 누적되므로, 각 쿼리 자체는 빨라도 전체 응답 시간은 크게 늘어납니다.

해결 방안

근본 원칙은 언어/ORM에 상관없이 동일합니다 — "N번 왕복"을 "1~2번 왕복"으로 줄이는 것입니다.

  1. JOIN으로 한 번에 가져오기: 관계형 DB라면 가장 직접적인 해결책입니다.
SELECT orders.*, customers.*
FROM orders
JOIN customers ON orders.customer_id = customers.id
LIMIT 100;
  1. IN 절로 배치 조회하기: JOIN이 복잡해지는 경우, 먼저 필요한 ID를 모은 뒤 한 번의 IN 쿼리로 가져와 애플리케이션 코드에서 매핑합니다.
orders = db.execute("SELECT * FROM orders LIMIT 100")
customer_ids = [o["customer_id"] for o in orders]
customers = db.execute(
    "SELECT * FROM customers WHERE id IN %s", (tuple(customer_ids),)
)  # 쿼리 1번으로 전부 조회
customers_by_id = {c["id"]: c for c in customers}
  1. ORM을 쓴다면 각 ORM이 제공하는 즉시 로딩(eager loading) 기능(JPA의 fetch join/@EntityGraph, Django의 select_related/prefetch_related, SQLAlchemy의 joinedload 등)을 씁니다 — 이름은 다르지만 전부 같은 원리(JOIN 또는 배치 조회)를 프레임워크가 대신 해주는 것입니다.
  2. 이 문제는 코드 리뷰만으로는 놓치기 쉽습니다 — 실제 쿼리 로그나 APM 도구로 "이 API 호출 한 번에 쿼리가 몇 번 나가는지"를 직접 확인하는 습관이 가장 확실한 발견 방법입니다.

참고 자료

ORM별 구체적인 즉시 로딩 API는 각 프레임워크의 최신 공식 문서를 확인합니다.

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.