[Spring] 게시판 실습 : Post의 N+1 문제 해결(inner join fetch)

이지연·2026년 1월 27일

개요

게시글 목록 조회 시 서버가 터지는 N+1 문제를 실제 코드와 쿼리 로그로 분석하고, inner join fetch로 완벽 해결하는 과정을 정리한다.


왜 N+1 문제가 발생할까?

발생 상황 재현

사전 세팅: 게시글 5~6개 + 서로 다른 Author 등록

// 문제가 되는 DTO 변환 코드
public static PostListDto fromEntity(Post post) {
    return PostListDto.builder()
            .id(post.getId())
            .title(post.getTitle())
            .category(post.getCategory())
            .authorEmail(post.getAuthor().getEmail())  // N+1 발생 지점!
            .build();
}

쿼리 로그 분석

1. select * from post where del_yn = 'NO'; // 1번 쿼리 (게시글 목록)
2. select * from author where id = 1; // 2번 쿼리 (1번글 작성자)
3. select * from author where id = 2; // 3번 쿼리 (2번글 작성자)
4. select * from author where id = 3; // 4번 쿼리 (3번글 작성자)
...

결과: 게시글 N개1 + N번 쿼리서버 성능 폭발적 저하


N+1 발생 위치 정확히 파악

@ManyToOne (자식 → 부모): N+1 발생 ❌

Post → Author (한 게시글이 한 명의 작성자)
- 게시글 목록 조회: 1번 쿼리
- 각 게시글마다 작성자 조회: N번 쿼리

@OneToMany (부모 → 자식): 1+1 쿼리 ✅

Author → Posts (한 명이 여러 게시글 작성)
- 작성자 조회: 1번 쿼리  
- 해당 작성자의 게시글 조회: 1번 쿼리 (where author_id=?)

핵심: 자식 엔티티에서 부모 엔티티 접근 시 N+1 발생!


해결책: inner join fetch

1. 기존 방식 (문제 있음)

@Query("select p from Post p " +
       "inner join p.author " +           // 단순 join (Post만 반환)
       "where p.delYn = 'NO'")
List<Post> findAllInnerJoin();

결과:

select p.*, a.* from post p 
inner join author a on p.author_id = a.id 
where p.del_yn = 'NO';
+ post.getAuthor().getEmail() 호출 시 → 별도 N번 쿼리 발생! 

2. fetch join 사용 (완벽 해결)

@Query("select p from Post p " +
       "inner join fetch p.author " +     // Post + Author 즉시 로딩!
       "where p.delYn = 'NO'")
List<Post> findAllFetchInnerJoin();

결과:

select p.*, a.* from post p 
inner join author a on p.author_id = a.id 
where p.del_yn = 'NO';
+ post.getAuthor().getEmail() 호출 → 추가 쿼리 0번! 


쿼리 수 비교표

방법쿼리 수실제 SQLgetAuthor() 호출 시
기본1+Nselect * from post; + N번 authorN번 추가 쿼리
inner join2+NPost join 후getAuthor()시 N번 추가
inner join fetch1번select p.*,a.* from post p inner join author a추가 쿼리 없음

실제 동작 확인

문제 코드 실행 시 (N+1 발생)

TRACE - findAll() - 시간: 15ms, 쿼리: 7개 (1+6)

fetch join 적용 후

TRACE - findAllFetchInnerJoin() - 시간: 3ms, 쿼리: 1개

성능: 15ms → 3ms (5배 향상), 쿼리 7개 → 1개 (7배 감소)


추가 최적화 팁

1. @EntityGraph 활용

@EntityGraph(attributePaths = {"author"})
List<Post> findAllWithAuthor();

2. @OneToMany에서 지연로딩 유지

@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Post> posts;

3. 실무 패턴: 페이징 + Fetch Join

@Query("select p from Post p " +
       "inner join fetch p.author a " +
       "where p.delYn = 'NO' " +
       "order by p.createdAt desc")
Page<Post> findAllWithAuthor(Pageable pageable);

핵심 정리

N+1 발생 조건
- 자식(@ManyToOne) → 부모 접근
- 지연로딩(LAZY) + 반복 접근

해결 핵심
- `inner join fetch` = 즉시로딩 강제
- 1번 쿼리로 부모+자식 동시 조회

성능 효과
- 쿼리: 1+N → 1
- 시간: N배 → 1/N

fetch 키워드 하나로 N+1 해결!

profile
Eazy하게

0개의 댓글