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 쿼리로 풀어요. 전자는 행 곱셈 문제가 있고 후자는 없어요. 옵션 이름이 라이브러리마다 달라서, 실제로 나가는 쿼리를 찍어 보는 게 확실합니다.
반대로 과하게 즉시 로딩하면 안 쓰는 데이터까지 가져와요. 목록에서 안 보여 주는 관계까지 미리 다 불러오면 그것도 낭비예요.
처방
- 조인·IN·데이터로더로 한 번에 가져오세요. 일대일이나 다대일은 JOIN 이나 IN 이 좋고, 일대다는 별도 IN 쿼리로 나눠 가져오는 편이 대개 낫습니다.
- 눈으로는 절대 못 잡으니 자동화하세요. 이게 이 편의 핵심 처방이에요. 개발 환경에서 요청당 쿼리 수를 로그로 찍고, 임계치를 넘으면 테스트가 실패하게 만드세요. Rails 의 bullet, Django 의 nplusone 같은 도구가 이 일을 합니다.
- 통합 테스트에 쿼리 수 단언을 넣으세요. "이 엔드포인트는 쿼리 5번 이하"라고 못 박아 두면 회귀가 바로 잡혀요. 성능 테스트보다 훨씬 싸고 확실해요.
- GraphQL 이면 DataLoader 를 기본으로 깔고, 쿼리 깊이와 복잡도 제한을 함께 거세요.
- 실제로 나가는 쿼리를 개발 중에 항상 보이게 하세요. 로그 레벨을 올려 두거나 개발용 미들웨어로 요청 하단에 쿼리 목록을 붙이는 것만으로도 많이 잡힙니다.
- 운영에서는 pg_stat_statements 로 calls 가 비정상적으로 큰 단순 쿼리를 찾으세요. 그게 N+1 의 흔적이에요. mean_exec_time 은 1ms 인데 calls 가 수백만인 쿼리가 있으면 거의 확실합니다.
- 목록 크기에 상한을 두세요. 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 처럼 횟수를 보는 도구로 찾아야 해요.