멋쟁이 사자처럼

[멋사 백엔드 부트캠프] 게시판 기능 구현 중 발생한 N+1 문제 해결

sunm2n 2025. 7. 19. 09:48

N + 1 문제에 대해서는 아래에 정리해 두었다.

 

https://sunm2n.tistory.com/35

 

[Spring] N + 1 문제

N+1 문제란? N+1 문제란, 하나의 쿼리(1)를 실행한 이후에, 연관된 엔티티를 가져오기 위해 추가로 N개의 쿼리가 실행되는 현상이다.이로 인해 총 1 + N개의 쿼리가 실행되어 성능 저하가 발생한다.

sunm2n.tistory.com

 

 

package com.dosion.noisense.module.comment.entity;

import com.dosion.noisense.common.entity.BaseEntity;
import com.dosion.noisense.module.board.entity.Board;
import com.dosion.noisense.module.user.entity.Users;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comment")
public class Comment extends BaseEntity {

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

  @Column(name = "content", nullable = false, length = 500)
  private String content;

  /**
   * Board 연관관계
   * 게시글 삭제 시 댓글도 함께 삭제되도록 CASCADE 설정
   */
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "board_id", nullable = false)
  @OnDelete(action = OnDeleteAction.CASCADE)
  private Board board;

  /**
   * Users 연관관계
   * 유저 삭제 시 댓글도 함께 삭제되도록 CASCADE 설정
   * 필요에 따라 CASCADE 대신 NULL 처리 방식으로 변경 가능
   */
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id", nullable = false)
  @OnDelete(action = OnDeleteAction.CASCADE)
  private Users user;

  /**
   * 유저의 닉네임을 편하게 얻기 위한 Getter
   */
  public String getNickname() {
    return user != null ? user.getNickname() : null;
  }
}

 

다음은 내 comment 엔티티에 관련된 코드이다.

 

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

@ManyToOne(fetch = FetchType.LAZY)
private Board board;

 

comment 엔티티는 다음과 같이 두 연관 필드를 가지고 있다.

 

public String getNickname() {
  return user != null ? user.getNickname() : null;
}

그리고  toDto() 내부에서 user.getNickname()에 접근하고 있는 게 확실하다. 

 

이 메서드는 DTO 변환 시 사용될 가능성이 매우 높다.

 

따라서 findByBoardId()로 댓글 리스트를 불러온 후, 각 댓글에 대해 .getUser().getNickname()이 호출되면
댓글 N개당 추가 쿼리 N번 발생 → N+1 문제 발생한다.

 

 

 

예시를 보자

예를 들어 댓글이 3개 있을 때 다음과 같은 쿼리가 실행된다.

  1. select * from comment where board_id = ?
  2. select * from users where user_id = ? ← 1번째 댓글
  3. select * from users where user_id = ? ← 2번째 댓글
  4. select * from users where user_id = ? ← 3번째 댓글

4번의 쿼리 발생 (1 + N) → N+1 문제이다.

 

 

EntityGraph 방법을 사용할 것이다.

 

장점

1. JPA 표준 기능이며 깔끔하고 명시적

 

@EntityGraph는 JPA 표준 사양입이다.
→ Spring Data JPA에서 지원하며 별도 JPQL 없이 메서드 이름 기반 쿼리와 함께 쓸 수 있다.

@EntityGraph(attributePaths = {"user"})
List<Comment> findByBoardId(Long boardId);

가독성도 좋고 JOIN FETCH처럼 JPQL을 작성할 필요가 없다.

 

 

2. 지연 로딩(LAZY)의 이점은 유지하면서, N+1 문제는 제거

 

 

기본적으로 엔티티는 LAZY로 설정해두고, 필요한 경우에만 fetch graph로 가져오자는 철학과 잘 맞는다.

즉, 평소에는 성능 최적화를 위해 LAZY를 유지하고, 딱 필요한 서비스 로직에서만 EntityGraph로 fetch join을 걸 수 있다.

 

 

3. 유지보수성과 재사용성이 뛰어남

 

 

여러 메서드에서 동일한 fetch 그래프를 재사용할 수 있고,
필요시 @NamedEntityGraph로 재사용 가능하게 관리할 수 있다.

 

 

4. 동적 쿼리(QueryDSL)과도 잘 어울림

 

QuerydslJpaPredicateExecutor를 함께 사용할 때도 EntityGraph가 연동 가능하며,
복잡한 조건 필터링 + fetch join을 동시에 처리할 수 있어 유연하다.

 

 

 

 

해결과정

package com.dosion.noisense.module.comment.repository;

import com.dosion.noisense.module.comment.entity.Comment;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface CommentRepository extends JpaRepository<Comment, Long> {

  @EntityGraph(attributePaths = {"users", "board"})
  List<Comment> findByBoardId(Long boardId);
}

 

@EntityGraph(attributePaths = {"users", "board"})

다음 어노테이션을 추가해서 fetch join을 해준다.

이제 이 메서드는 Comment와 연관된 Users를 한 번에 로딩한다.
→ 즉, getUser().getNickname() 호출 시 추가 쿼리가 발생하지 않는다.

 

private CommentDto toDto(Comment comment) {
  return new CommentDto(
    comment.getId(),
    comment.getBoard().getId(),   // ← 여기서 board 접근함 ✅
    comment.getUser().getId(),
    comment.getNickname(),
    comment.getContent(),
    comment.getCreatedDate(),
    comment.getUpdatedDate()
  );
}

또한 board를 넣은 이유도 comment.getBoard().getId()를 호출하고 있으므로,

board를 LAZY 로딩하고 있어 N개의 추가 쿼리가 발생한다.

 

따라서 위와 같이 users와 board 를 넣어주면 N+1 문제를 해결할 수 있다.

 

서비스 코드는 그대로 유지해도 된다.