N+1 문제란?

루프 안에서 쿼리를 날리면 데이터가 늘수록 DB 왕복이 쌓이는 이유
2026년 4월 13일

N+1 문제란?

umc web파트를 할 때도 서버 파트에서 N+1 문제에 대해 언급하는 것을 많이 들었었다. 최근 서버 스터디를 하면서 이 N+1 문제에 다시 듣게 되면서 많이 일어나는 문제인 만큼 정리해보는 것이 좋을 것 같았다.


어떤 상황에서 발생할까?

블로그를 만든다고 생각해보자. 게시글 목록 페이지에서 각 게시글의 댓글도 함께 보여주고 싶다.

Prisma로 코드를 작성하면 이렇게 될 수 있다:

코드만 보면 전혀 이상하지 않다. "게시글 가져오고, 각각의 댓글 가져오고."

그런데 이 코드가 데이터베이스에 보내는 쿼리를 보면 얘기가 달라진다.


N+1, 정확히 뭘 말하는 걸까?

위 코드가 실행되면 이런 일이 벌어진다:

게시글 조회 1번 + 댓글 조회 10번 = 총 11번

이게 N+1 문제다. N개의 데이터를 가져오기 위해 1 + N번의 쿼리가 나가는 것을 의미한다.


쿼리가 많이 나가면 왜 문제가 될까?

"11번정도면 그냥 11번 하면 되는 거 아닌가?" 싶을 수 있다. 하지만 쿼리 하나하나가 단순히 SQL 한 줄 실행이 아니기 때문에 문제가 될 수 있다.

네트워크 왕복

애플리케이션 서버와 데이터베이스는 보통 별도의 서버에 있다. 쿼리 하나를 보낼 때마다 네트워크를 한 번 왕복한다. 요청을 보내고, DB가 처리하고, 결과를 받아오는 과정이 매번 발생하는 것이다.

쿼리 하나의 왕복 시간이 1ms라고 칠 때, 게시글이 1,000개면 1,001ms — 쿼리 실행 시간을 빼고 네트워크 왕복만으로 1초가 넘는다.

커넥션 풀 점유

데이터베이스 연결(커넥션)은 무한하지 않다. 보통 커넥션 풀이라는 것을 만들어서 제한된 수의 연결을 돌려쓴다. 쿼리를 11번 날리는 동안 그 커넥션을 계속 붙잡고 있으면, 다른 사용자의 요청이 커넥션을 못 잡고 대기하게 된다.

동시 접속자가 많은 서비스에서 N+1이 터지면, 한 사람의 요청이 커넥션을 오래 물고 있어서 다른 모든 사람이 느려지는 상황이 생길 수 있다.

응답 시간

결국 이 모든 게 사용자가 체감하는 응답 시간으로 이어진다. 게시글 목록 페이지 하나 열려고 했을 뿐인데, 뒤에서 쿼리가 수백 번 나가고 있으면 페이지가 느려진다. 데이터가 적을 때는 모르다가, 서비스가 커지면서 갑자기 느려질 수 있다. 그때서야 문제를 찾으려고 하면 원인을 찾는데 오래 걸릴 수 있다.


N+1 문제는 왜 생기는 걸까?

N+1 문제를 검색하면 "Lazy Loading 때문"이라는 설명이 많이 나온다. 맞는 말이지만, 모든 ORM에 해당하는 얘기는 아니다.

Lazy Loading이란

Lazy Loading은 "지금 당장 필요하지 않은 데이터는 나중에 가져오자"는 전략이다. 게시글을 조회할 때 댓글까지 미리 다 가져오면 낭비일 수 있으므로, 댓글에 실제로 접근하는 순간에 쿼리를 날리는 방식이다.

이 전략 자체는 합리적으로 보이지만, 문제는 루프 안에서 관계 데이터에 접근하면 매번 쿼리가 나간다는 것이다.

ORM마다 N+1이 생기는 방식이 다르다

여기서 중요한 건, ORM에 따라 N+1이 발생하는 경로가 다르다는 점이다.

JPA는 Lazy Loading이 기본 전략이다. post.getComments()처럼 관계에 접근하기만 해도 자동으로 쿼리가 날아간다. 개발자가 의도하지 않아도 루프 안에서 접근하면 N+1이 "몰래" 발생한다. 코드에는 SQL이 전혀 보이지 않지만, 내부적으로는 쿼리가 반복적으로 실행되고 있다.

반면 Prisma는 Lazy Loading이 없다. 관계 데이터를 가져오려면 반드시 include를 명시하거나, 위 예시처럼 직접 쿼리를 작성해야 한다. 그래서 Prisma에서 N+1이 생기는 건 ORM이 몰래 쿼리를 날려서가 아니라, 개발자가 루프 안에서 직접 쿼리를 날리는 코드를 작성했기 때문이다.

JPA (Hibernate)Prisma
기본 전략Lazy Loading (관계 접근 시 자동 쿼리)관계 데이터를 기본으로 안 가져옴
N+1 발생 원인루프에서 관계 접근 → 자동 쿼리루프에서 직접 쿼리 작성
위험도코드에 안 보여서 놓치기 쉬움직접 작성하므로 인지하기 쉬운 편
해결 방법fetch join, @EntityGraphinclude, select

Prisma가 상대적으로 안전한 편이긴 하지만, 위의 코드 예시처럼 "자연스러운 사고 흐름"대로 코드를 짜면 똑같이 N+1에 빠진다.


해결 방법 (Prisma)

1. include — 연관 데이터를 한번에 가져오기

가장 기본적이고 자주 쓰는 방법이다. Prisma의 include는 연관된 데이터를 추가 쿼리 하나로 한번에 가져온다. 앞서 Lazy Loading이 "나중에 필요할 때 가져오는 것"이라고 했는데, 반대로 조회 시점에 관계 데이터를 미리 가져오는 방식을 Eager Loading이라고 한다. include가 바로 이 Eager Loading을 명시적으로 하는 방식이다.

실행되는 쿼리:

Prisma는 내부적으로 IN절을 사용해서 연관 데이터를 배치로 가져온다. 다른 ORM과 비교하면 내부 동작 방식이 다르다.

ORM문법내부 동작
JPAJOIN FETCH p.commentsJOIN으로 한 번의 쿼리
TypeORMrelations: { comments: true }LEFT JOIN으로 한 번의 쿼리
Prismainclude: { comments: true }쿼리를 나눠서 IN절

JPA와 TypeORM은 JOIN으로 한 번에 합쳐서 가져오는 반면, Prisma는 테이블별로 쿼리를 나눠서 IN절로 가져온 뒤 애플리케이션에서 조합한다. JOIN은 데이터가 많아지면 중복 행이 생기고 결과가 커질 수 있기 때문에, Prisma는 이 방식을 선택한 것이다. 접근 방식은 다르지만 N+1 해결이라는 결과는 같다.

중첩 include

댓글의 작성자 정보까지 필요하다면 중첩해서 쓸 수 있다:

3번의 쿼리로 게시글 → 댓글 → 댓글 작성자까지 전부 가져온다.

include에 조건 걸기

댓글 전체가 아니라 최신 3개만 필요하다면:

불필요한 데이터를 가져오지 않아서 메모리와 네트워크 모두 절약된다.


2. select — 필요한 필드만 골라서 가져오기

include는 해당 테이블의 모든 컬럼을 가져온다. 댓글의 내용만 필요한데 createdAt, updatedAt, authorId 등까지 전부 가져오는 것이다.

select를 쓰면 딱 필요한 필드만 지정할 수 있다:

include vs select, 언제 뭘 쓸까?

includeselect
가져오는 데이터모든 컬럼 + 연관 데이터지정한 컬럼만
코드 간결함간단필드 하나하나 명시
성능좋음더 좋음
적합한 상황대부분의 필드가 필요할 때목록 조회처럼 일부만 필요할 때

3. _count — 개수만 필요할 때

댓글 내용은 필요 없고 개수만 보여주고 싶다면, 댓글 전체를 가져오는 것은 낭비다.

실행되는 쿼리는 버전마다 다를 수 있지만, 개념적으로는 아래와 같다.

1번의 쿼리로 끝난다. 게시글 목록에서 "댓글 3개" 같은 배지를 보여줄 때 맞는 방법이다.


해결 방법 비교

방법쿼리 수가져오는 데이터적합한 상황
루프 조회 (N+1)N+1전체쓰지 말 것
include2연관 테이블 전체 컬럼대부분의 상황
select + 연관2지정한 필드만목록 조회, API 응답 최적화
_count1개수만배지, 통계

마치며

루프 안에서 쿼리를 날리면, 데이터가 늘어날수록 쿼리 수가 선형으로 증가한다는 것이 N+1문제이다.

쿼리의 증가는 네트워크 왕복이 쌓이고, 커넥션을 오래 잡고 있고, 결국 사용자가 느린 응답을 체감하게 되는 연쇄적인 문제라는 것을 알게 됐다.

그리고 이번에 정리하면서 느낀 건, ORM마다 같은 코드를 작성해도 내부적으로 SQL을 처리하는 방식이 다르다는 것이다. ORM이 제공하는 문법에만 의존하지 말고, 실제로 어떤 쿼리가 실행되는지를 함께 생각하면서 공부하는 게 중요할 것 같다!!