제미나이한테 '그' 프롬프트를 쳤다.
> 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번만 돌고, 추가조회도 없고, 프록시도 없고, 영속성 컨텍스트 영향도 없다.
이걸 한 번 더 다른 사람한테 설명하면 머리에 쏙쏙 들어올 것 같다. 굿~