
1. 들어가며
스프링 + JPA를 쓰다 보면 꼭 한 번은 듣게 되는 단어가 있다. 바로 "N+1 문제".
특히 fetch = FetchType.LAZY 같은 옵션을 줬을 때 갑자기 SQL이 막 쏟아져 나오면서…
"아니? 왜 쿼리가 이렇게 많이 나가? 분명 findAll 한 번 했는데?" 하고 당황하게 된다.
나도 처음에는 그냥 단순히 "쿼리가 좀 더 나가나보다" 하고 넘겼는데,
알고 보니 JPA와 Hibernate가 가진 지연 로딩(Lazy Loading) + 연관관계 매핑의 핵심적인 특징 때문에 생기는 현상이었다.
이번 글에서는
- N+1 문제가 무엇인지
- 어떤 상황에서 발생하는지
- 왜 심각한 성능 이슈가 되는지
- 그리고 어떻게 해결할 수 있는지
를 하나씩 풀어보려 한다.
2. Eager vs Lazy 차이?
JPA 쓰다 보면 제일 많이 듣는 말 중 하나가 바로 즉시 로딩(Eager) 과 지연 로딩(Lazy) 이다.
이 N+1 문제도, Eager 과 Lazy의 차이에 따라 다르게 결과가 나오기 때문에, 이해하고 넘어가야했다.
처음엔 이름만 보고 "아 뭐 그냥 빨리 가져오냐, 늦게 가져오냐 차이겠지" 싶었는데,
막상 코드를 돌려보면 쿼리 찍히는 게 너무 달라서 충격을 받는다.
1) Eager (즉시 로딩)
Eager는 말 그대로 “필요할 수도 있으니 지금 당장 다 불러와” 하는 방식이다.
한 번 조회할 때, 연관된 데이터까지 죄다 끌어오는 거다.
- 장점: 나중에 추가 쿼리를 안 날려도 된다.
- 단점: 정작 안 쓸 데이터까지 다 들고 와서, 쿼리가 무겁고 비효율적일 수 있다.
마트에 갔는데, 오늘 필요한 건 우유 한 통인데 “혹시 몰라서” 치킨, 과자, 라면까지 전부 담아오는 느낌.
2) Lazy (지연 로딩)
Lazy는 반대로 “지금 필요 없는 건 건드리지 말고, 진짜 필요해질 때 불러와” 하는 방식이다.
일단은 껍데기(프록시)만 들고 있고, 실제 데이터는 사용 순간에 쿼리가 나간다.
- 장점: 불필요한 데이터는 아예 안 불러온다.
- 단점: 무심코 반복문 돌리거나 화면에서 한꺼번에 접근하면, 그때마다 쿼리가 나가서 성능 문제가 생길 수 있다.
마트에서 오늘은 우유만 사고, 내일 과자가 땡기면 그때 다시 마트 가는 느낌.
오늘은 유저별 게시글을 관리하는 어드민 페이지에서 발생할 수 있는 N+1문제에 관련하여 적어보려고한다.
페이지 예시는 아래와 같다.

3. FetchType EAGER


1) 테이블 구성
이번에는 직접 예시 코드를 짜보면서 Eager와 Lazy에서 어떤 차이가 나는지 체험해보려고 한다.
먼저 테이블 구조는 아래와 같이 단순하게 만들었다.
- users 테이블
- 유저 3명 (user1, user2, user3)
- posts 테이블
- 각 유저마다 포스트 3개씩 (1,2,3 / 4,5,6 / 7,8,9)
즉, User(1) : Post(N) 관계로 총 3명의 유저와 9개의 포스트 데이터를 넣어둔 상태다.
(이미지에 나온 것처럼 users 테이블에는 3명의 유저가, posts 테이블에는 user_id를 FK로 가지는 9개의 포스트가 들어 있다.)
2) Eager에서 모든 Post 조회하기
// Eager일 때 모든 Post에서 유저 이름 불러오기
public void getPosts() {
List<EagerPost> all = eagerPostRepository.findAll(); // 9개
}
//로그
Hibernate: /* <criteria> */ select ep1_0.id, ep1_0.eager_user_id, ep1_0.title from eager_posts ep1_0
Hibernate: select eu1_0.id, eu1_0.email, eu1_0.name, ep1_0.eager_user_id, ep1_0.id, ep1_0.title from eager_users eu1_0 left join eager_posts ep1_0 on eu1_0.id=ep1_0.eager_user_id where eu1_0.id=?
Hibernate: select eu1_0.id, eu1_0.email, eu1_0.name, ep1_0.eager_user_id, ep1_0.id, ep1_0.title from eager_users eu1_0 left join eager_posts ep1_0 on eu1_0.id=ep1_0.eager_user_id where eu1_0.id=?
Hibernate: select eu1_0.id, eu1_0.email, eu1_0.name, ep1_0.eager_user_id, ep1_0.id, ep1_0.title from eager_users eu1_0 left join eager_posts ep1_0 on eu1_0.id=ep1_0.eager_user_id where eu1_0.id=?
여기까지 보면 단순히 findAll() 한 번 했으니 쿼리도 딱 한 번만 나갈 것처럼 생각된다.
하지만 실제로 로그를 보면…
- select * from posts (포스트 전체 조회) → 1회
- 그 다음에 각 post가 연관된 user를 가져오느라
- select * from users where id = ? → 3회
총 쿼리 4번이 실행된다.
즉, 모든 포스트만 조회해오는 findAll()메서드를 1번 사용했는데,
1 + N회의 추가 쿼리가 발생한다는 것이다. 이것이 바로 N+1 문제이다.
3) 왜 쿼리가 더 나갈까?
이유는 간단하다.
Post 입장에서는 User가 ManyToOne 관계이고, Eager라서 즉시 로딩을 하게 된다.
즉 JPA는 이렇게 생각한다:
- “포스트 9개 가져와야지” → posts 전체 조회
- “근데 Post에는 User도 달려있네? Eager니까 바로 가져와야겠네”
- “아, 그럼 user_id 1, 2, 3 각각에 대해 쿼리를 날려서 User를 불러와야겠다”
그래서 총 쿼리 수가 1 (posts 전체) + 3 (user들) = 4번이 되는 것이다.
4. FetchType Lazy
그럼 이번엔 LAZY일 때는 어떻게 동작할까?
따로 users 테이블과 posts 테이블을 만들고 이 테이블은 LAZY로 설정하여 테스트를 진행해 보았다.
방금과 똑같이 findAll()로 모든 Post를 불러오는 코드를 돌려봤다.


1. 실행결과
//lazy일때 모든 post에서 유저 이름 불러오기
public void getPosts() {
List<Post> all = postRepo.findAll();
}
Hibernate: /* <criteria> */ select p1_0.id, p1_0.title, p1_0.user_id from posts p1_0
위의 Eager와는 다르게 1번의 쿼리가 실행됐다. 왜그럴까?
바로 프록시(Proxy) 객체 때문. 프록시 객체란?
- Lazy는 연관된 User 정보를 당장 가져오지 않고, **User 자리에 프록시 객체(가짜)**만 꽂아둔다.
- 그래서 findAll() 시점에서는 posts 데이터만 딱 가져오고, users 테이블은 전혀 조회하지 않는다.
- "User 데이터가 필요하다!"라는 순간이 올 때까지는 Hibernate가 쿼리를 날리지 않는 것이다.
즉, 지금 상황은 User의 필드에 접근하지 않았기 때문에 users 테이블을 조회할 이유가 없었던 것.
그 결과 쿼리가 1개로 끝난다.
하지만 여기서 post.getUser().getName() 같은 걸 실행하면 이야기가 달라진다.
그때는 프록시가 실제 User 데이터를 불러오기 위해 users 테이블을 뒤져서 추가 쿼리를 날리게 된다.
밑의 예시를 보자.
2. Lazy에서 N+1문제
앞에서는 findAll()만 실행했을 때, Lazy는 쿼리가 단 1번만 실행되는 걸 확인했다.
그런데 여기서 모든 Post의 유저 이름을 출력한다고 하면 어떻게 될까?
// lazy일때 모든 post에서 유저 이름 불러오기
public void getPosts() {
// 모든 포스트를 조회하는 쿼리 1회
List<Post> all = postRepo.findAll(); // 9개
for (Post post : all) {
// 포스트별로 유저 DB 탐색하는 N회 발생
String name = post.getUser().getName();
}
}
Hibernate: /* <criteria> */ select p1_0.id, p1_0.title, p1_0.user_id from posts p1_0
Hibernate: select u1_0.id, u1_0.email, u1_0.name from users u1_0 where u1_0.id=?
Hibernate: select u1_0.id, u1_0.email, u1_0.name from users u1_0 where u1_0.id=?
Hibernate: select u1_0.id, u1_0.email, u1_0.name from users u1_0 where u1_0.id=?
- 처음 findAll() 실행 시 → posts 테이블 전체 조회 쿼리 1회
- 그 뒤, post.getUser().getName()을 호출하는 순간 → 프록시 객체가 실제 User 데이터를 불러오기 위해 users 테이블 조회 시작
- Post가 9개라면, User를 3번 조회하게 되고
- 총 쿼리 수는 1 (posts) + 3 (users) = 4번이 된다.
이것또한... 프록시 객체(Proxy) 때문이다.
- Lazy 모드에서 Post는 User를 실제로 들고 있지 않고, 비어 있는 프록시 객체만 가지고 있음.
- 그래서 getUser()를 호출하는 순간 Hibernate가 “아, 진짜 User 데이터 필요하구나” 하고 쿼리를 날린다.
- 이 과정이 Post마다 반복되면서 N+1 문제가 발생하는 것.
즉, 처음에는 분명히 쿼리 1번만 나가는 깔끔한 구조처럼 보이지만,
실제로 연관된 데이터를 접근하는 순간 추가 쿼리 폭발이 일어나 버린다.
이게 바로 Lazy에서 자주 맞닥뜨리는 N+1 문제의 대표적인 사례다.
5. 해결 방법
앞에서 본 것처럼 EAGER를 쓰든, LAZY를 쓰든 결국 잘못 조회하면 N+1 문제가 터진다.
그렇다면 JPA에서 이 문제를 어떻게 해결할 수 있을까? 대표적인 방법은 fetch join 이다.
1) JPQL + fetch join
@Query("select p from Post p join fetch p.user")
List<Post> findAllWithUser();
//로그
select p.id, p.title, u.id, u.name, u.email
from posts p
join users u on p.user_id = u.id
- 일반적으로 postRepo.findAll()을 하면 Post만 먼저 가져오고, getUser() 호출 시 추가 쿼리가 나간다.
- 하지만 위처럼 join fetch를 쓰면 Post와 User를 한 번에 조인해서 가져오기 때문에 추가 쿼리가 안 나온다.
- 즉, 한 방 쿼리로 끝내서 N+1 문제가 사라진다.
2) QueryDSL 활용
프로젝트에서 QueryDSL을 쓴다면 더 깔끔하게 해결할 수도 있다.
public List<Post> findAllWithUser() {
return queryFactory
.selectFrom(post)
.join(post.user, user).fetchJoin()
.fetch();
}
////로그
select p.id, p.title, u.id, u.name, u.email
from posts p
join users u on p.user_id = u.id
- .fetchJoin()을 붙이면 JPQL에서 join fetch와 같은 역할을 한다.
- 따라서 Post와 User를 한 번에 로딩 → N+1 문제 해결.
https://github.com/YeahyunKim/TodayILearn
해당 코드는 위 링크에 올려두었다.
공부하고싶으신 개발자 분들께서는 사용해보셔도 좋을 듯 하다.
GitHub - YeahyunKim/TodayILearn: 오늘 공부한 내용을 정리하는 레포지토리
오늘 공부한 내용을 정리하는 레포지토리. Contribute to YeahyunKim/TodayILearn development by creating an account on GitHub.
github.com
'JAVASPRING > study' 카테고리의 다른 글
| 영속성 컨텍스트 (0) | 2025.10.15 |
|---|---|
| Spring Bean — 왜 만들어야 하고, 왜 써야 할까? (0) | 2025.10.07 |
| Proxy 객체란? (1) | 2025.09.23 |
| H2 database, 개념 잡기 (0) | 2024.06.27 |
| GlobalException (0) | 2024.06.21 |