실제 실무 도입은 아니고, 혼자 프로젝트를 만들어보면서 고민했던 포인트를 정리함.
언젠간 실무적으로도 깊게 고민할 날이 오길
도메인 (다대다를 중간 엔티티로 표현)
@Entity
class Post {
@Id @GeneratedValue Long id;
String title;
String content;
// Post ⟷ PostTag(다대일)
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
Set<PostTag> tags = new HashSet<>();
}
@Entity
class PostTag {
@Id @GeneratedValue Long id;
@ManyToOne(fetch = FetchType.LAZY) Post post;
@ManyToOne(fetch = FetchType.LAZY) Tag tag;
}
@Entity
class Tag {
@Id @GeneratedValue Long id;
String name;
}
검색 레포지토리 (제목, 태그명으로 검색, 페이징)
public interface PostRepository extends JpaRepository<Post, Long> {
@Query(value = """
SELECT DISTINCT p
FROM Post p
LEFT JOIN p.tags pt
LEFT JOIN pt.tag t
WHERE
LOWER(function('REPLACE', p.title, ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :keyword, ' ', ''), '%'))
OR LOWER(t.name) LIKE LOWER(CONCAT('%', :keyword, '%'))
""",
countQuery = """
SELECT COUNT(DISTINCT p.id)
FROM Post p
LEFT JOIN p.tags pt
LEFT JOIN pt.tag t
WHERE
LOWER(function('REPLACE', p.title, ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :keyword, ' ', ''), '%'))
OR LOWER(t.name) LIKE LOWER(CONCAT('%', :keyword, '%'))
""")
Page<Post> searchAll(@Param("keyword") String keyword, Pageable pageable);
}
어디서 N+1이 터졌나?
// (가벼운 예시) DTO 변환 과정
PostResponse from(Post p) {
// 여기서 p.getTags()를 건드리는 순간, Post마다 추가 SELECT 발생 → N+1
List<String> tagNames = p.getTags().stream()
.map(pt -> pt.getTag().getName()) // 여기도 LAZY 접근
.toList();
...
}
JOIN vs FETCH JOIN
LEFT JOIN : 필터/조건을 위한 조인(로딩 시점은 그대로 LAZY)
LEFT JOIN FETCH : “이번 쿼리에 같이 가져와” (해당 연관을 즉시 채움)
3-1. 먼저, 쿼리 수를 눈으로 본다
// 개발용 로그 보기
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=debug
// datasource-proxy로 테스트에 “쿼리 수 단언”
// 테스트에서
QueryCountHolder.clear();
service.searchAll("java", PageRequest.of(0, 10));
int selects = QueryCountHolder.getGrandTotal().getSelect();
assertThat(selects).isLessThanOrEqualTo(3); // 상한 고정
운영에서는 Actuator + Micrometer 를 사용해서 확인 하는 방법을 사용하는거 같은데, 일단 테스트 상한부터 걸어두면 체감이 온다.
이건 너무 어렵다... 추가적인 공부가 필요할듯🥲
3-2. 컬렉션을 화면에 뿌릴 땐: 목적에 맞는 방법을 고른다
옵션 A) 두 단계 로딩 (IDs → FETCH JOIN) — 페이징 + 자식 필요할 때 베스트
1. ID만 페이징해서 뽑고
2. 그 ID들만 대상으로 연관을 한 번에 적재(FETCH JOIN)
// 1) 부모 ID만 페이징(검색/필터는 여기에)
@Query(value = """
SELECT DISTINCT p.id
FROM Post p
LEFT JOIN p.tags pt
LEFT JOIN pt.tag t
WHERE
LOWER(function('REPLACE', p.title, ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :kw, ' ', ''), '%'))
OR LOWER(t.name) LIKE LOWER(CONCAT('%', :kw, '%'))
""",
countQuery = """
SELECT COUNT(DISTINCT p.id)
FROM Post p
LEFT JOIN p.tags pt
LEFT JOIN pt.tag t
WHERE
LOWER(function('REPLACE', p.title, ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :kw, ' ', ''), '%'))
OR LOWER(t.name) LIKE LOWER(CONCAT('%', :kw, '%'))
""")
Page<Long> searchPostIds(@Param("kw") String keyword, Pageable pageable);
// 2) 그 ID들만 대상으로 연관까지 한 번에
@Query("""
SELECT DISTINCT p
FROM Post p
LEFT JOIN FETCH p.tags pt
LEFT JOIN FETCH pt.tag t
WHERE p.id IN :ids
""")
List<Post> findWithTagsByIdIn(@Param("ids") List<Long> ids);
// Service: 두 쿼리 오케스트레이션 + 순서 복원 + DTO 변환
Page<Long> idPage = repo.searchPostIds(keyword, pageable);
if (idPage.isEmpty()) return Page.empty(pageable);
List<Post> posts = repo.findWithTagsByIdIn(idPage.getContent());
// IN 절은 순서 보장이 안 됨 → 원래 순서 복원
Map<Long,Integer> order = new HashMap<>();
for (int i = 0; i < idPage.getContent().size(); i++) order.put(idPage.getContent().get(i), i);
posts.sort(Comparator.comparingInt(p -> order.get(p.getId())));
// 이미 컬렉션이 메모리에 채워져 있음 → 추가 SELECT 없이 DTO 변환
List<PostDto> content = posts.stream()
.map(p -> new PostDto(
p.getId(),
p.getTitle(),
p.getTags().stream().map(pt -> pt.getTag().getName()).distinct().toList()
))
.toList();
return new PageImpl<>(content, pageable, idPage.getTotalElements());
결과적으로
- 예전(N+1): 1번(목록) + 부모 수만큼 N번(자식 로딩) = N+1
- 두 단계: 1번(ID 페이징) + 1번(FETCH JOIN) = 딱 2번
옵션 B) DTO 프로젝션(Flat) — 조회 전용, 가볍고 빠름
public record PostRow(Long postId, String title, Long tagId, String tagName) {}
@Query("""
SELECT new path.to.PostRow(p.id, p.title, t.id, t.name)
FROM Post p
LEFT JOIN p.tags pt
LEFT JOIN pt.tag t
WHERE
LOWER(function('REPLACE', p.title, ' ', '')) LIKE LOWER(CONCAT('%', function('REPLACE', :kw, ' ', ''), '%'))
OR LOWER(t.name) LIKE LOWER(CONCAT('%', :kw, '%'))
""")
Page<PostRow> searchAllFlat(@Param("kw") String keyword, Pageable pageable);
옵션 C) @BatchSize — 빠른 완화책(완전 제거 아님)
// (1) 컬렉션 배치: 여러 부모의 "컬렉션 프록시"를 묶어서 초기화
@Entity
class Post {
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
@BatchSize(size = 100) // ← post.getTags()를 '처음' 건드릴 때,
Set<PostTag> tags; // 세션에 있는 다른 Post들의 tags까지 최대 100개를
} // IN (post_id...) 한 방 SELECT로 채움.
// (2) to-one 타겟 배치: 여러 "엔티티 프록시"를 묶어서 초기화
@Entity
@BatchSize(size = 100) // ← pt.getTag().getName()을 '처음' 읽을 때,
class Tag { // 아직 초기화 안 된 Tag 프록시들의 id를 모아
@Id Long id; // IN (tag_id...) 한 방 SELECT로 초기화.
String name;
}
“처음 건드리는 순간” Hibernate가 아직 초기화 안 된 동일 종류의 프록시들을 최대 size개까지 모아
… WHERE … IN ( … ) 한 방 SELECT로 초기화한다.
- 컬렉션 프록시 초기화(여러 부모의 컬렉션을 묶음):
SELECT * FROM post_tag WHERE post_id IN ( ... 최대 size개 ... )- 엔티티 프록시 초기화(여러 to-one 타겟을 묶음):
SELECT * FROM tag WHERE id IN ( ... 최대 size개 ... )
즉, N+1 → N/size + 1 근사로 감소.
3-3. 페이징 + 컬렉션 fetch join은 피한다
처음엔 “fetch = LAZY가 성능 최적화라며? 근데 왜 느려지지?” 싶었는데, 결국 문제는 로딩 방식 자체가 아니라 “언제 어떻게 접근하느냐”였음.
내가 원하는 화면 모양(부모 10개 + 태그 뱃지들)을 기준으로 쿼리 수를 내가 설계해 버리면,
느림/폭증/페이징 왜곡이 다 설명되고, 해결법도 일관되게 정리됐다. 끝!