ORM N+1 문제 — 쿼리가 101번 나가는 이유와 자동으로 잡는 법

목록 API 가 로컬에선 50ms 인데 운영에선 1.5초가 걸려요. 느린 쿼리는 하나도 없는데요. 코드 한 줄이 쿼리 101번이 되는 이유와 이걸 테스트로 미리 잡는 법을 알려 드려요.

목록 API 가 로컬에서는 50ms 인데 운영에서 1.5초예요.

코드를 봐도 반복문 하나뿐이고 쿼리는 잘 짜여 있어요. 인덱스도 다 있고, 느린 쿼리 로그에도 아무것도 안 잡혀요. 개별 쿼리는 전부 1ms 미만이거든요.

느려질 이유가 안 보이는 게 이 문제의 특징이에요. 범인은 한 쿼리가 아니라 쿼리의 개수예요. 쿼리 하나씩 보는 도구로는 절대 안 잡힙니다.

APM 의 트레이스를 열면 그제야 보여요. DB 구간이 촘촘한 막대 수백 개로 채워져 있는 그림이요. 막대 하나하나는 다 짧은데, 그게 수백 개 끊임없이 이어져 있는 그림이에요. 그제야 원인이 개수라는 게 눈에 들어옵니다.

왜 그런가

게시글 100개를 불러온 뒤 각각의 작성자를 조회하면 쿼리가 1 + 100번 나가요. ORM 의 지연 로딩이 기본값일 때 자동으로 생겨요.

문제는 코드에 그게 안 보인다는 거예요. post.author.name 은 속성 접근처럼 생겼지만 실제로는 쿼리예요. 반복문 안에 들어가면 반복 횟수만큼 쿼리가 나가요. 리뷰에서 "쿼리 한 줄"로 보이는 코드가 실행 시점에 101번이 되는 거죠.

왜 로컬에서 안 드러나냐면 왕복 시간 때문이에요. 로컬은 DB 가 같은 머신이라 왕복이 0.1ms 수준이에요. 101번이면 10ms, 못 느껴요. 운영은 네트워크를 건너니 왕복이 1~5ms 고, 101번이면 0.5초가 통째로 사라져요. 같은 코드, 같은 쿼리, 다른 결과예요.

그리고 데이터 양에도 비례해요. 개발 DB 에 게시글이 10개면 11번이라 안 보이고, 운영에서 100개를 보여 주면 101번이 돼요. 데이터가 늘면서 서서히 나타나는 게 아니라, 목록 크기를 늘리는 순간 계단식으로 나빠집니다.

코드로 보면

눈에 안 보이는 100번:

코드
 posts = Post.limit(100)
 posts.each { |p| p.author.name }
 -- author 접근마다 쿼리가 한 번씩
 -- 코드에는 안 보인다

 posts = Post.includes(:author).limit(100)
· ORM 이 IN 절로 한 번에 가져온다

· 또는 직접
 SELECT * FROM authors WHERE id IN (:ids);

· GraphQL 처럼 중첩이 깊으면 DataLoader
 같은 틱에 모인 id 를 배치로 묶어 한 번에 조회

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

더 무서운 건 중첩이에요.

게시글마다 댓글을 돌고, 댓글마다 작성자를 돌면 1 + N + N×M 이 돼요. 게시글 100개에 댓글 10개씩이면 1 + 100 + 1,000 = 1,101번이에요. 화면 하나 그리는 데 천 번의 왕복이면, 왕복 2ms 만 잡아도 2초예요.

GraphQL 에서는 이게 구조적으로 발생해요. 클라이언트가 중첩 쿼리를 자유롭게 요청할 수 있으니, 서버가 방어하지 않으면 요청 하나로 수천 번의 DB 접근이 일어나요. DataLoader 패턴이 여기서 나왔어요 — 같은 이벤트 루프 틱에 모인 id 들을 모아 한 번의 IN 쿼리로 처리하고 결과를 나눠 주는 방식이에요.

해결책에도 함정이 있어요. JOIN 으로 한 번에 가져오면 쿼리 수는 1이 되지만, 일대다 조인은 행이 곱해져요. 게시글 100개에 댓글 10개씩을 조인하면 1,000행이 오고, 게시글 본문이 그 안에서 10번씩 반복돼요. 전송량이 늘고 애플리케이션이 중복을 걷어내야 해요. 그래서 일대다는 JOIN 보다 별도 IN 쿼리 두 번이 나은 경우가 많아요.

ORM 이 제공하는 즉시 로딩도 방식을 확인해야 해요. 어떤 건 JOIN 으로 풀고 어떤 건 별도 IN 쿼리로 풀어요. 전자는 행 곱셈 문제가 있고 후자는 없어요. 옵션 이름이 라이브러리마다 달라서, 실제로 나가는 쿼리를 찍어 보는 게 확실합니다.

반대로 과하게 즉시 로딩하면 안 쓰는 데이터까지 가져와요. 목록에서 안 보여 주는 관계까지 미리 다 불러오면 그것도 낭비예요.

처방

  1. 조인·IN·데이터로더로 한 번에 가져오세요. 일대일이나 다대일은 JOIN 이나 IN 이 좋고, 일대다는 별도 IN 쿼리로 나눠 가져오는 편이 대개 낫습니다.
  2. 눈으로는 절대 못 잡으니 자동화하세요. 이게 이 편의 핵심 처방이에요. 개발 환경에서 요청당 쿼리 수를 로그로 찍고, 임계치를 넘으면 테스트가 실패하게 만드세요. Rails 의 bullet, Django 의 nplusone 같은 도구가 이 일을 합니다.
  3. 통합 테스트에 쿼리 수 단언을 넣으세요. "이 엔드포인트는 쿼리 5번 이하"라고 못 박아 두면 회귀가 바로 잡혀요. 성능 테스트보다 훨씬 싸고 확실해요.
  4. GraphQL 이면 DataLoader 를 기본으로 깔고, 쿼리 깊이와 복잡도 제한을 함께 거세요.
  5. 실제로 나가는 쿼리를 개발 중에 항상 보이게 하세요. 로그 레벨을 올려 두거나 개발용 미들웨어로 요청 하단에 쿼리 목록을 붙이는 것만으로도 많이 잡힙니다.
  6. 운영에서는 pg_stat_statements 로 calls 가 비정상적으로 큰 단순 쿼리를 찾으세요. 그게 N+1 의 흔적이에요. mean_exec_time 은 1ms 인데 calls 가 수백만인 쿼리가 있으면 거의 확실합니다.
  7. 목록 크기에 상한을 두세요. N+1 이 남아 있어도 N 이 20 이면 견디지만 1,000 이면 못 견뎌요. 페이지 크기 상한은 이런 문제의 피해를 줄이는 값싼 보험이에요.

지금 확인해 보세요

반복 쿼리의 흔적 찾기:

코드
· 운영 — calls 가 비정상적으로 큰 단순 쿼리
SELECT calls, mean_exec_time,
       round(total_exec_time) AS total_ms,
       left(query, 60)
  FROM pg_stat_statements
 ORDER BY calls DESC LIMIT 20;
 mean 은 1ms 인데 calls 가 수백만이면 N+1 이다

· 총 시간 기준으로도 본다 — 여기가 진짜 비용이다
 SELECT round(total_exec_time) AS total_ms, calls,
 left(query, 60)
 FROM pg_stat_statements
 ORDER BY total_exec_time DESC LIMIT 20;

· 개발 — 요청당 쿼리 수를 세고 임계치를 건다
 테스트에서 assert query_count <= 5
 넘으면 실패시킨다. 사람 눈보다 확실하다

핵심 한 줄쿼리 개수는 코드 줄 수와 비례하지 않는다.

자주 묻는 것

N+1 은 왜 느린 쿼리 로그에 안 잡히나요?

개별 쿼리는 1ms 미만이라 느린 쿼리로 안 잡혀요. 범인은 쿼리 하나가 아니라 쿼리의 개수라서, 트레이스나 pg_stat_statements 의 calls 처럼 횟수를 보는 도구로 찾아야 해요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

인덱스·쿼리 성능 글 20편 모두 보기 →