T_era

N+1 문제와 페치 조인 적용해 해결하기 본문

Programing/Spring

N+1 문제와 페치 조인 적용해 해결하기

블스뜸 2025. 6. 2. 11:56

 

프로젝트 진행 중 연관관계 매핑된 엔티티를 조회할 때 N+1 문제가 발생하였다. User, Post, Comment, Like 테이블이 양방향 매핑으로 구성되어 있었는데, 특히 Comment 조회 시 작성자 정보(User)와 포스트의 정보(Post)를 같이 읽어오는 과정에서 Comment, Post, User를 각각 조회하는 추가 쿼리가 발생하는 문제가 발생하였다. 이는 FetchType.LAZY 설정으로 인해 발생하는 전형적인 N+1 문제에 해당한다.

문제 발생 코드 예시

아래 Comment 엔티티는 User 및 Post와 FetchType.LAZY로 연관되어 있다.

@Getter
@Entity
@Table(name = "comments")
public class Comment extends BaseEntity {

    @Id
    @Column(name = "comment_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long commentId;

    @Column(nullable = false)
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    public Comment() {
    }
}

findCommentByPostId 메서드에서 List<Comment>를 조회한 후, CommentMapper.toDto를 통해 CommentFindResponseDto를 생성하는 과정에서 comment.getUser().getUserName()이 호출될 때마다 지연 로딩으로 인해 User 엔티티를 조회하는 추가 쿼리가 발생한다. 이로 인해 N+1 문제가 발생하게 된다.

@Transactional(readOnly = true)
public List<CommentFindResponseDto> findCommentByPostId(Long postId) {
    List<Comment> comments = commentRepository.findByPost_PostIdAndDeletedFalse(postId);

    return comments
            .stream()
            .map(comment -> CommentMapper.toDto(comment, CommentFindResponseDto.class))
            .toList();
}

BaseCommentResponseDto 생성 시 getUser()와 getPost() 호출로 추가 쿼리가 발생한다.

protected BaseCommentResponseDto(Comment comment) {
    this.commentId = comment.getCommentId();
    this.postId = comment.getPost().getPostId(); // 추가 쿼리 발생 가능성
    this.userName = comment.getUser().getUserName(); // 추가 쿼리 발생
    this.content = comment.getContent();
}

문제 해결: 페치 조인(Fetch Join)

N+1 문제를 해결하기 위한 여러 방법(엔티티 그래프, DTO를 사용한 조회, 배치 사이즈 설정 등)이 있지만, 그 중에서 페치 조인을 적용하여 해결해보자.

페치 조인이란?

  • 일반 조인 사용 시: JOIN은 두 테이블을 연결하여 데이터를 가져오지만, 연관된 엔티티(여기서는 User)를 즉시 로딩하지 않는다. 따라서 List<Post>를 조회한 후, 각 Post에서 getUser()를 호출할 때마다 User 정보를 조회하는 추가 쿼리가 발생하여 N+1 문제가 발생한다.
    // 일반 조인을 사용할 경우
    @Query("SELECT p FROM Post p JOIN p.user")
    List<Post> findAll();
    
  • 페치 조인 사용 시: JOIN FETCH는 연관된 엔티티를 처음부터 한 번의 쿼리로 함께 가져온다. 이로 인해 추가 쿼리 없이 연관된 엔티티의 정보를 즉시 사용할 수 있어 N+1 문제를 효과적으로 해결한다.
    // 페치 조인을 사용할 경우
    @Query("SELECT p FROM Post p JOIN FETCH p.user")
    List<Post> findAll();
    

페치 조인 적용 코드

실제로 문제가 발생한 Comment 조회 코드에 페치 조인을 적용하였다.

// 페치 조인을 하지 않은 기존 코드
List<Comment> comments = commentRepository.findByPost_PostIdAndDeletedFalse(postId);

페치 조인을 적용시킨 Repository 코드:

public interface CommentRepository extends JpaRepository<Comment, Long> {
    @Query("SELECT c FROM Comment c " +
            "LEFT JOIN FETCH c.user " +
            "WHERE c.post.postId = :postId AND c.deleted = false")
    List<Comment> findCommentByPostId(@Param("postId") Long postId);
}

페치 조인을 적용한 서비스 코드:

// 페치 조인을 한 코드
List<Comment> comments = commentRepository.findCommentByPostId(postId);

이와 같이 페치 조인을 적용함으로써 Comment를 조회할 때 연관된 User 정보까지 한 번의 쿼리로 가져오게 되어 추가적인 조회 쿼리 없이 데이터를 사용할 수 있게 된다.

QueryDSL로 전환

JPQL로 작성된 페치 조인 쿼리를 QueryDSL로 전환하였다. QueryDSL은 컴파일 시점에 쿼리 오류를 감지할 수 있고, 동적으로 쿼리를 구성하는 데 유리하여 유지보수성을 높일 수 있다. 생성일 기준 정렬(orderBy(comment.createdAt.desc()))도 추가하여 쿼리를 완성하였다.

@Override
public List<Comment> findCommentByPostId(Long postId) {
    QComment comment = QComment.comment;
    QUser user = QUser.user;

    return queryFactory.selectFrom(comment)
            .leftJoin(comment.user, user)
            .fetchJoin()
            .where(
                    comment.post.postId.eq(postId),
                    comment.deleted.isFalse()
            )
            .orderBy(comment.createdAt.desc())
            .fetch();
}

이러한 해결 방법으로 추가적인 조회 없이 데이터를 사용할 수 있게 되었다.

페치 조인 선택 이유

N+1 문제를 해결하는 다양한 방법 중 페치 조인을 선택한 주된 이유는 다음과 같다.

  1.  페치 조인은 쿼리문에 FETCH 키워드를 추가하는 것만으로 N+1 문제를 해결할 수 있어, 가장 직관적이고 빠르게 적용할 수 있는 방법이다.
  2. 지연 로딩(FetchType.LAZY)으로 인해 별도의 초기화 과정 없이, 조회된 엔티티를 통해 연관된 엔티티의 필드에 즉시 접근할 수 있다이는 DTO 변환과 같이 조회된 데이터를 바로 활용해야 하는 상황에서 매우 유용하다.
  3. JPQL이나 QueryDSL을 통해 페치 조인을 적용할 수 있어, 특정 조건에 맞는 데이터만 선택적으로 페치하거나 여러 연관 관계를 동시에 페치하는 등 유연하게 쿼리를 작성할 수 있다.

물론, 페치 조인이 항상 최선의 해결책은 아니다. 예를 들어, 너무 많은 연관 관계를 한 번에 페치 조인하면 카테시안 곱이 발생하여 성능 저하나 데이터 중복 문제가 생길 수 있다. 하지만 현재 프로젝트의 경우, 댓글 조회 시 작성자 정보만 함께 가져오면 되는 비교적 간단한 상황이었으므로, 페치 조인이 가장 효율적이고 적절한 해결책이라고 판단했다.