Backend/Spring Boot

[JPA] N+1의 개념과 해결 방법(이라 쓰고 머리에 남기기 위한 사투라고 부른다)

코코무 2026. 2. 12. 16:25
반응형

제미나이한테 '그' 프롬프트를 쳤다.

> based on everything you know about me, roast me and don’t hold back. 한글로 대답해줘.

 

그랬더니 이게 글쎄...

 

 

너무 아프다. 챗지피티는 선녀였다. 맞아보지 못한 팩폭이다.

 

그래...공부해야겠다 싶어서 내 실력을 향상시켜달라 했더니

제미나이 선생님이 이런 문제를 내주셨다. 무려 입단 테스트?><

 

JPA의 N+1에 대한 내용인 건 알겠는데 사실 어떻게 해결하는지 이론적인 건 다 까먹었다.

이놈의 공부는 해도해도 머리에 남아있지를 않고

자꾸 집을 나간다. 야속한 것...

 

제미나이 선생님과 함께 하는 개발 공부, 같이 해요^^

왠지 이래놓고 항상 시리즈로 가는 건 실패하는 것 같지만...작심삼일을 꾸준히 하다보면 그게 성실이어요...:)


🗂️N+1 문제란?

>> 1번의 쿼리 + N번의 추가 쿼리가 발생하는 상황

 

웅? 이게 먼말임? 웬 추가 발생? 그럴 수 있음 ㅇㅇ

 

게시글 10개를 조회한다고 가정해보자.

List<Post> posts = postRepository.findAll();

 

이때 Post 안에 작성자(User)가 연관되어 있다고 하자.

for (Post post: posts) {
	System.out.println(post.getUser().getName());
}

 

겉보기엔 단순하나, 실제 실행 쿼리는 아래와 같다.

 

1. 게시글 조회

select * from post; <!-- List<Post> posts = postRepository.findAll(); -->

 

2. 첫 번째 게시글의 user 조회

select * from user where id = 1;

 

3. 두 번째 게시글의 user 조회

select * from user where id = 3;

... 이 과정이 N번 반복된다. 그래서 N+1(게시글 조회)인 것이다.

 

 

아니 근데 왜 이렇게 조회 됨??? 이미 가져온 조회 리스트로 사용자 찾는 것 아니었음??

할 수 있다.

이것도 예시로 설명해 보겠다(오래오래 기억나라 ㅈㅂㅈㅂ).

 

 

엔티티 A가 있고, A는 엔티티 B를 참조한다고 해보자.

B는 LAZY 로딩 상태다.

이 N+1 문제는 이놈 때문이라고 봐도 무방하다.

@ManyToOne(fetch = FetchType.LAZY)
private User user;

 

???: 아저씨는 누구세요?

 

게시글(post)을 조회할 때 사용자(user)는 일단 안 가져오고, 진짜 필요할 때만 가져온다.

그러니까 user 객체는 진짜 객체가 아니라 가짜(Proxy)였던 것이다...!

 

Hibernate 입장에서는 user를 가져오긴 해야 하는데

지금은 주인님이 그냥 전체 데이터만 불러오라 하네? user에 LAZY 썼으니까 흐린눈 하자~ 이거다.

 

좀 딥해지긴 하는데(아닌가?), JPA는 실제 user 대신 이런 걸 넣어둔다.

User$HibernateProxy

 

이 친구는 id는 알고 있지만 나머지는 모르는 가짜다.

 

진짜 데이터는 언제 데려오냐?

위에서 봤던

post.getUser().getName();

// 또는 html에서
${post.user.name}

 

이 코드가 실행될 때이다.

 

1. getUser()를 호출하면서 proxy 반환,

2. getName()을 호출하는 순간

Hibernate가 일하기 시작한다. "실제 user 값이 필요하네? 그럼 DB에서 진짜를 데려와야겠다."

그래서 이 쿼리가 실행되는 것이다.

select * from user where id = ?;

 

그럼 이게 이제 게시글 갯수만큼 실행될 거고 그래서 N+1이 될 수 밖에 없는 것이다.

 

 

엥 그럼 LAZY 쓰지마!

라고 하면 곤란하다.

 

EAGER로 바꾸게 되면 findAll()을 실행할 때부터 user도 같이 가져오려고 한다.

 

Hibernate가 내부적으로 join을 써서 한 번에 가져오거나,

상황에 따라 또 여러 번 나눠서 가져오기도 한다.

 

그렇게 되면 쓸데없는 join을 사용하고 데이터를 낭비하게 되며 성능이 저하된다.


🗂️N+1 문제란?

그럼 뭐 어쩌라고?

가장 대표적인 해결책 3가지를 소개한다.

 

1. Fetch Join

@Query("select p from Post p join fetch p.user")
List<Post> findAllWithUser();
select p.*, u.*
from post p
join user u on p.user_id = u.id;

 

findAll() 같은 거 대신에 JPQL을 쓸 때 JOIN FETCH를 사용한다.

한 번의 쿼리로 연관된 엔티티까지 싹 다 긁어와서 11번 나갈 쿼리를

딱 1번으로 줄여준다.

근데 페이징 처리 시 메모리가 터질 수도 있으니 주의해야 한다.

 

2. EntityGraph

@EntityGraph(attributePaths = {"user"})
List<Post> findAll();

 

어노테이션으로 어떤 연관 관계를 한 번에 가져올지 설정하는 방식을 사용한다.

 

3. DTO 조회

1) DTO 클래스

public class PostListDto {

    private Long postId;
    private String title;
    private String userName;

    public PostListDto(Long postId, String title, String userName) {
        this.postId = postId;
        this.title = title;
        this.userName = userName;
    }
}

 

2) JPQL로 바로 조회

@Query("""
    select new com.example.PostListDto(
        p.id,
        p.title,
        u.name
    )
    from Post p
    join p.user u
""")
List<PostListDto> findPostList();

 

3) 실행되는 SQL

select p.id, p.title, u.name
from post p
join user u on p.user_id = u.id;

 

쿼리 1번만 돌고, 추가조회도 없고, 프록시도 없고, 영속성 컨텍스트 영향도 없다.


이걸 한 번 더 다른 사람한테 설명하면 머리에 쏙쏙 들어올 것 같다. 굿~