MySQL N+1 query 가이드: ORM 코드가 깔끔한데도 느린 이유와 줄이는 법
DB
마지막 업데이트

MySQL N+1 query 가이드: ORM 코드가 깔끔한데도 느린 이유와 줄이는 법


애플리케이션 코드는 깔끔한데 API는 이상하게 느린 경우가 있습니다. SQL 한 건이 특별히 나쁜 것도 아닌데 응답 시간이 길고, 동시 요청이 늘면 DB 연결도 금방 바빠집니다. 이럴 때 자주 숨어 있는 원인이 N+1 query 문제입니다.

특히 ORM을 쓰면 이 문제가 더 잘 숨습니다. 코드에서는 post.author처럼 자연스러운 속성 접근처럼 보이지만, 실제로는 목록 1번 조회 뒤 관계 데이터 조회가 수십 번, 수백 번 더 나가고 있을 수 있기 때문입니다.

이 글은 아래 질문에 답하기 위해 정리했습니다.

  • N+1 query가 정확히 무엇인지
  • 왜 ORM 환경에서 특히 자주 생기는지
  • 운영에서 어떻게 빨리 찾는지
  • JOIN, eager loading, batch fetch 중 무엇을 선택해야 하는지

짧게 말하면 N+1 문제는 “쿼리 하나가 느린가”보다 “불필요한 round trip이 구조적으로 폭증하는가”의 문제입니다.

N+1 쿼리 문제 시각화


Quick answer

MySQL N+1 query를 빠르게 정리하려면 아래 순서가 가장 실용적입니다.

  1. 목록 화면이나 API 한 번 호출에 몇 개의 SQL이 나가는지 센다
  2. 같은 형태의 SELECT ... WHERE id = ?가 반복되는지 본다
  3. 부모 목록 1건 조회 뒤 관계 조회가 N번 더 붙는 구조인지 확인한다
  4. 필요한 데이터가 정해져 있다면 JOIN이나 eager loading으로 미리 가져온다
  5. 관계가 너무 많거나 row 폭증이 걱정되면 batch fetch로 묶어서 읽는다
  6. 수정 뒤에는 쿼리 개수와 응답 시간을 함께 비교한다

즉, N+1 최적화의 핵심은 SQL 문장 하나를 더 빠르게 만드는 것이 아니라 요청 하나가 DB를 몇 번 왕복하는지 줄이는 것입니다.


1. N+1 query란 무엇인가

가장 전형적인 예시는 부모 목록을 먼저 읽고, 각 부모 row마다 자식이나 관계 데이터를 하나씩 다시 읽는 경우입니다.

예를 들어 게시글 100개를 읽은 뒤 각 게시글의 작성자 정보를 따로 읽는다고 가정해 보겠습니다.

SELECT id, title, author_id
FROM posts
ORDER BY created_at DESC
LIMIT 100;

그다음 애플리케이션이 각 게시글마다 아래 쿼리를 다시 실행한다면:

SELECT id, name
FROM users
WHERE id = ?;

결국:

  • 게시글 목록 조회 1번
  • 작성자 조회 100번

이 되어 총 101개의 쿼리가 나갑니다. 이것이 대표적인 N+1 구조입니다.

이 문제의 핵심은 개별 쿼리 하나가 아주 느릴 필요조차 없다는 점입니다. 5ms짜리 쿼리 100개가 쌓이면, 애플리케이션과 DB는 충분히 체감 가능한 병목을 만들 수 있습니다.


2. 왜 ORM에서 특히 잘 숨는가

ORM이 나쁘다는 뜻은 아닙니다. 오히려 ORM은 생산성을 크게 높여 줍니다. 문제는 ORM이 객체 접근을 너무 자연스럽게 만들어서, 쿼리 수 폭증이 코드에서 잘 보이지 않는다는 데 있습니다.

예를 들어 코드에서는 아래처럼 보일 수 있습니다.

for (const post of posts) {
  console.log(post.author.name);
}

개발자 입장에서는 단순히 속성 접근처럼 보이지만, ORM 설정이나 lazy loading 방식에 따라 내부에서는:

  • posts 1회 조회
  • post.author 접근마다 추가 조회

가 일어날 수 있습니다.

즉, 코드의 가독성과 실제 SQL 수는 전혀 다른 이야기일 수 있습니다.

그래서 ORM 환경에서는 “코드가 깔끔하니 괜찮겠지”보다 **“이 요청 하나가 실제로 몇 번 질의하는가”**를 확인하는 습관이 더 중요합니다.


3. 왜 성능을 망치는가

N+1 문제는 단순히 쿼리 개수만 많아지는 문제가 아닙니다. 운영에서는 아래 비용이 한꺼번에 커집니다.

  • DB round trip 증가
  • 네트워크 왕복 증가
  • connection 점유 시간 증가
  • 애플리케이션 직렬 처리 시간 증가

특히 트래픽이 붙으면 문제가 더 커집니다. 예를 들어 사용자 한 명의 요청에서 100개의 추가 쿼리가 나간다면, 동시 요청 50개만 와도 DB는 짧은 시간에 엄청난 수의 작은 조회를 처리해야 합니다.

즉, N+1은 “개별 쿼리가 조금 비효율적이다”보다 **“시스템 전체가 불필요한 작업을 너무 많이 반복한다”**에 더 가깝습니다.


4. 운영에서 N+1을 빨리 찾는 방법

실무에서는 N+1을 개념보다 증상으로 먼저 만나는 경우가 많습니다. 아래 신호가 보이면 의심해 볼 만합니다.

  • 목록 API 하나가 예상보다 SQL을 너무 많이 실행한다
  • 같은 형태의 SELECT ... WHERE id = ?가 짧은 시간에 반복된다
  • ORM 디버그 로그를 켜면 비슷한 쿼리가 줄줄이 보인다
  • SQL 하나하나는 빠른데 전체 응답 시간은 길다

가장 쉬운 출발점은 “요청 한 번에 SQL이 몇 개 나가는지”를 세는 것입니다.

예를 들어:

  • 목록 20건 API인데 쿼리가 2~3개면 자연스러울 수 있음
  • 목록 20건 API인데 쿼리가 41개, 81개, 121개면 N+1을 강하게 의심

또한 로그에서 아래 패턴이 보이면 거의 정답에 가깝습니다.

SELECT id, title, author_id FROM posts ...;
SELECT id, name FROM users WHERE id = 11;
SELECT id, name FROM users WHERE id = 42;
SELECT id, name FROM users WHERE id = 87;
SELECT id, name FROM users WHERE id = 15;

즉, 부모 목록 한 번 뒤에 관계 조회가 하나씩 반복되는 흐름입니다.


5. 가장 먼저 해야 할 질문: 화면이 정말 어떤 데이터를 필요로 하는가

N+1을 줄일 때 흔히 곧바로 JOIN만 떠올리지만, 그 전에 더 중요한 질문이 있습니다.

“이 화면이나 API는 실제로 어떤 데이터 shape를 한 번에 필요로 하는가?”

예를 들어 게시글 목록 화면이 정말 필요한 것은:

  • 게시글 제목
  • 작성자 이름
  • 작성일

정도일 수 있습니다. 그런데 상세 화면에나 필요한:

  • 작성자 프로필 전체
  • 댓글 전체
  • 태그 전체
  • 좋아요 집계 전체

를 목록 단계에서 모두 끌고 오려 하면, N+1을 없애다가 오히려 과도한 JOIN이나 과도한 payload를 만들 수도 있습니다.

즉, 해결의 첫걸음은 “무조건 한 번에 다 가져오기”가 아니라 **“이 요청이 필요한 최소 데이터 집합을 먼저 분명히 하는 것”**입니다.


6. 해결 방법 1: JOIN이나 eager loading으로 미리 가져오기

관계가 단순하고 화면에서 꼭 필요한 데이터가 명확하다면 가장 쉬운 해결책은 미리 함께 읽는 것입니다.

예를 들어 게시글 목록에서 작성자 이름이 꼭 필요하다면:

SELECT p.id, p.title, u.name AS author_name
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 100;

처럼 한 번의 쿼리로 해결할 수 있습니다.

ORM에서도 보통:

  • eager loading
  • include / preload
  • fetch join

같은 방식으로 같은 효과를 낼 수 있습니다.

이 접근이 좋은 경우는 보통 아래와 같습니다.

  • one-to-one 또는 many-to-one 관계
  • 목록에서 반드시 필요한 관계 데이터
  • row 폭증 위험이 크지 않은 관계

즉, “작성자 이름”, “카테고리 이름”처럼 비교적 단순한 관계는 미리 붙이는 편이 자연스럽습니다.


7. 해결 방법 2: batch fetch로 묶어서 읽기

항상 큰 JOIN이 정답은 아닙니다. 관계가 복잡하거나 one-to-many 폭증이 걱정된다면, 두 단계로 나누되 N번 조회 대신 묶어서 조회하는 편이 낫습니다.

예를 들면:

  1. 게시글 100개를 읽는다
  2. 그 게시글들의 author_id를 모은다
  3. WHERE id IN (...)으로 작성자를 한 번에 읽는다
  4. 애플리케이션에서 매핑한다

SQL 형태로 보면 이런 느낌입니다.

SELECT id, title, author_id
FROM posts
ORDER BY created_at DESC
LIMIT 100;

SELECT id, name
FROM users
WHERE id IN (11, 42, 87, 15, ...);

이 방식의 장점은:

  • 쿼리 수를 크게 줄일 수 있고
  • 거대한 JOIN보다 제어가 쉬우며
  • 관계 데이터 재사용이 편할 수 있다는 점입니다

즉, N+1을 없애는 방법은 “1 쿼리만 허용”이 아니라 **“N번 추가 조회를 소수의 예측 가능한 조회로 바꾸는 것”**입니다.


8. JOIN과 batch fetch 중 무엇을 고를까

실무에서는 이 판단이 중요합니다. 대략 아래 기준으로 생각하면 편합니다.

JOIN이 잘 맞는 경우:

  • 관계가 단순하다
  • 목록에서 바로 필요한 필드가 적다
  • one-to-many row 폭증이 크지 않다

batch fetch가 잘 맞는 경우:

  • 관계가 여러 단계로 이어진다
  • 한 번에 거대한 JOIN을 만들면 row가 너무 불어난다
  • 애플리케이션에서 조합하는 편이 더 단순하다

중요한 것은 “무조건 JOIN”도 아니고 “무조건 ORM include”도 아니라는 점입니다. 핵심은 쿼리 수와 쿼리 복잡도 사이의 균형입니다.

즉:

  • 지금은 쿼리 수가 너무 많은가
  • 아니면 한 번의 JOIN이 너무 큰 row 폭증을 만들고 있는가

를 같이 봐야 합니다.


9. 어디서 특히 자주 터지는가

N+1은 아래 같은 화면이나 API에서 자주 생깁니다.

  • 목록 화면
  • 댓글, 작성자, 태그처럼 관계가 많은 페이지
  • 관리자 페이지 통계/집계 화면
  • serializer나 DTO 변환 과정에서 관계 접근이 반복되는 코드

특히 “부모 목록을 읽고 각 row마다 관계를 따라간다”는 구조가 보이면 거의 항상 의심해 볼 가치가 있습니다.

예를 들어:

  • 게시글 목록에서 작성자/댓글 수/태그를 각각 다시 읽는 경우
  • 주문 목록에서 고객, 배송지, 결제 상태를 각 row마다 다시 읽는 경우
  • 관리자 표에서 행마다 집계 쿼리를 다시 날리는 경우

이런 패턴은 초기 데이터가 작을 때는 잘 안 보이지만, 운영 데이터가 쌓이면 갑자기 급격히 느려질 수 있습니다.


10. 수정 뒤에 무엇을 비교해야 하는가

N+1을 수정한 뒤 “느낌상 빨라진 것 같다”로 끝내면 다음 화면에서 같은 문제가 반복됩니다. 최소한 아래는 같이 비교하는 편이 좋습니다.

  • 요청 1회당 SQL 개수
  • 응답 시간
  • DB connection 사용량
  • 같은 쿼리 반복 횟수

예를 들어 개선 전에는:

  • SQL 101개
  • 응답 1.2초

였는데 개선 후:

  • SQL 3개
  • 응답 220ms

가 됐다면, 단순히 쿼리 수만 줄어든 것이 아니라 DB 왕복과 애플리케이션 대기 시간이 함께 줄어든 것입니다.

즉, N+1 개선은 “쿼리 튜닝”이라기보다 요청 단위 아키텍처를 더 효율적으로 만드는 작업에 가깝습니다.


자주 하는 오해

1. ORM이 알아서 최적화해 줄 것이다

일부 프레임워크는 도움을 주지만, 관계 접근 패턴까지 항상 자동 최적화하지는 않습니다.

2. 각 쿼리가 빠르면 괜찮다

빠른 쿼리 100개는 충분히 큰 병목이 될 수 있습니다.

3. JOIN만 쓰면 항상 해결된다

one-to-many 관계가 많으면 거대한 JOIN이 오히려 row 폭증과 중복 처리 비용을 키울 수 있습니다.

4. 작은 서비스니까 나중에 봐도 된다

초기에는 숨어 있다가 데이터와 트래픽이 늘 때 갑자기 운영 문제로 바뀌기 쉽습니다.


FAQ

Q. N+1은 어떻게 가장 빨리 발견하나요?

요청 단위로 쿼리 수를 세고, ORM 디버그 로그나 SQL 로그에서 비슷한 SELECT ... WHERE id = ? 패턴이 반복되는지 보는 것이 가장 빠릅니다.

Q. eager loading을 쓰면 무조건 괜찮아지나요?

아닙니다. 단순 관계에는 매우 유용하지만, 관계가 많고 row 폭증이 큰 경우에는 batch fetch나 다른 조합이 더 나을 수 있습니다.

Q. EXPLAIN도 같이 봐야 하나요?

네. N+1은 쿼리 수 문제지만, 묶어서 가져온 새 쿼리가 또 비효율적일 수 있으므로 실제 계획도 함께 보는 편이 안전합니다.


먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.

광고