본문 바로가기
Spring/관련 지식

N+1 문제의 개념과 해결 방법

by 정구정구 2026. 7. 9.

N+1 문제란?

처음 조회를 위해 1번의 SQL이 실행되고, 연관된 데이터를 조회하기 위해 N번의 SQL이 추가로 실행되는 현상

  •  지연 로딩(Lazy Loading) 된 연관 엔티티를 반복적으로 조회할 때 발생
  • 처음에는 부모 엔티티만 조회하고, 연관 엔티티에 접근할 때마다 추가 SQL이 실행되기 때문에 데이터 개수(N)만큼 쿼리가 증가

 

지연 로딩 (Lazy Loading) 이란?

연관된 엔티티를 처음부터 조회하지 않고, 실제로 사용할 때 조회하는 방식

 

 

프로젝트 구조 (ERD)

 

 

N+1 과의 1차전 (@EntityGraph 활용)

// 댓글 모두 조회
    @Transactional(readOnly = true)
    public List<GetCommentResponse> getAll(Long scheduleId) {

        List<Comment> commentList  = commentRepository.findBySchedule_Id(scheduleId);
        List<GetCommentResponse> dtoList = new ArrayList<>();

        for (Comment comment : commentList) {
            dtoList.add(new GetCommentResponse(
                    comment.getId(),
                    comment.getContents(),
                    comment.getUser().getName(), // User Entity 호출!
                    comment.getCreatedAt(),
                    comment.getModifiedAt()));
        }

        return dtoList;
    }

 

 특정 일정에 속한 모든 댓글 데이터를 가져오는 기능이다. 문제는 

comment.getUser().getName(),

 

여기서 발생한다. 지연 로딩(Lazy Loading)에 의해 for문에서 각 comment의 user.getName()을 요청할 때마다 DB에 user에 대해 select sql을 쏘게된다.

 

즉 N + 1이 발생한다! (N : user 테이블에 대한 select문) (1 : comment list를 호출한 select 문)

 

이를 해결하기 위해, CommentRepository에 구현된 findBySchedule_Id에 다음과 같은 어노테이션을 추가해줬다.

public interface CommentRepository extends JpaRepository<Comment,Long> {

    // N+1 방지
    @EntityGraph(attributePaths = "user")
    List<Comment> findBySchedule_Id(Long id);
}

 

엔티티 그래프(@Entity Graph)란?

더보기

JPA에서 제공하는 기능으로, Entity를 조회할 때, 매핑된 Entity를 어떤 방식으로 가져올지 정의하는 방법

명시적으로 패치 전략(Fetch Strategy)을 설정하고, 지연 로딩(Lazy Loading)과 즉시 로딩(Eager Loading)을 제어할 수 있다. 

* 원하는 연관 관계만을 선택적으로 즉시 로딩할 수 있다! * <- 이게 핵심이다!

@EntityGraph(attributePaths = "user")

 

즉 위 어노테이션은 매핑된 User에 대한 데이터는 Comment가 호출될 때 즉시 로딩하라는 의미이다.

 

결과

select
        c1_0.id,
        c1_0.contents,
        c1_0.created_at,
        c1_0.modified_at,
        c1_0.schedule_id,
        c1_0.user_id,
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.modified_at,
        u1_0.name,
        u1_0.pw 
    from
        comments c1_0 
    join
        users u1_0 
            on u1_0.id=c1_0.user_id 
    where
        c1_0.schedule_id=?

 

commentRepository.findBySchedule_Id(scheduleId)가 호출되는 타이밍에 DB에 보내는 SQL을 확인해보니 join을 통해 User의 데이터를 즉시 로딩하는 것을 확인 할 수 있었다.

 

 

 

N+1 과의 2차전 (@EntityGraph -> JPQL)

    @Transactional(readOnly = true)
    public GetScheduleResponse getOne(Long id) {
        Schedule schedule = scheduleRepository.findById(id).orElseThrow(
                ()->new ScheduleNotFoundException("존재하지 않는 일정 입니다.")
        );

        List<GetCommentResponse> getCommentResponseList = new ArrayList<>();
        for (Comment comment : schedule.getComments()) {
            getCommentResponseList.add( new GetCommentResponse(
                    comment.getId(),
                    comment.getContents(),
                    comment.getUser().getName(),
                    comment.getCreatedAt(),
                    comment.getModifiedAt()
            ));
        }

        return new GetScheduleResponse(
                schedule.getId(),
                schedule.getTitle(),
                schedule.getContents(),
                schedule.getUser().getName(),
                getCommentResponseList,
                schedule.getCreatedAt(),
                schedule.getModifiedAt());
    }

 

 ScheduleComment 양방향으로 매핑(지연 로딩) 되어 있어서 schedule.getComments()를 호출할 경우,

    select
        c1_0.schedule_id,
        c1_0.id,
        c1_0.contents,
        c1_0.created_at,
        c1_0.modified_at,
        c1_0.user_id 
    from
        comments c1_0 
    where
        c1_0.schedule_id=?

 

위의 SQL이 호출되어 Schedule 내부의 List<Comment>를 채워준다. (+1)

그 뒤, 1차전과 마찬가지로 comment.getUser().getName()에서 N+1 문제가 발생한다.

 

결국 이 getOne 함수에서는 1번의 SQL로 처리 될 수 있는데, N+1+1번의 SQL이 발생하고 있다.

 

 

엔티티 그래프(@Entity Graph)로 해결! 인줄 알았는데 ... 

public interface ScheduleRepository extends JpaRepository<Schedule,Long> {

    @EntityGraph(attributePaths = {
            "user",
            "comments",
            "comments.user"
    })
    Optional<Schedule> findById(@NonNull Long id);
}

 

 위처럼 Schedule에 대해서 엔티티 그래프를 이용해 3개의 연관 관계에 대해서 즉시 로딩을 설정해주면, 

select
        distinct s1_0.id,
        s1_0.comment_count,
        c1_0.schedule_id,
        c1_0.id,
        c1_0.contents,
        c1_0.created_at,
        c1_0.modified_at,
        c1_0.user_id,
        u2_0.id,
        u2_0.created_at,
        u2_0.email,
        u2_0.modified_at,
        u2_0.name,
        u2_0.pw,
        s1_0.contents,
        s1_0.created_at,
        s1_0.modified_at,
        s1_0.title,
        s1_0.user_id,
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.modified_at,
        u1_0.name,
        u1_0.pw 
    from
        schedules s1_0 
    left join
        users u1_0 
            on u1_0.id=s1_0.user_id 
    left join
        comments c1_0 
            on s1_0.id=c1_0.schedule_id 
    left join
        users u2_0 
            on u2_0.id=c1_0.user_id 
    where
        s1_0.id=?

 

 기존 getOne에서 호출하던 모든 데이터를 이 SQL 하나로 로딩할 수 있다. 문제가 해결된 것처럼 보였다.

 

그런데 다음 함수를 보던 중... 

@Transactional
    public UpdateScheduleResponse update(Long id, SessionUser sessionUser, UpdateScheduleRequest request) {
        Schedule schedule = scheduleRepository.findById(id).orElseThrow(
                ()->new ScheduleNotFoundException("존재하지 않는 일정 입니다.")
        );

        if (!schedule.getUser().getId().equals(sessionUser.getId())) {
            throw new UnauthorizedException("자신의 일정만 수정할 수 있습니다.");
        }

        schedule.update(request.getTitle(), request.getContents());

        return new UpdateScheduleResponse(
                schedule.getId(),
                schedule.getTitle(),
                schedule.getContents(),
                schedule.getUser().getName(),
                schedule.getCreatedAt(),
                schedule.getModifiedAt());
    }

 

Schedule을 업데이트 하는 함수에서도 scheduleRepository.findById(id)를 쓰고 있었다.

 

추가한 엔티티 그래프 어노테이션은 사용되는 모든 scheduleRepository.findById(id) 에 적용되므로, update 함수에서 필요도 없는 Comment의 대한 데이터를 로딩하게 된다.

  • 이런 필요 없는 데이터까지 로딩하게 되는 것을 오버 페칭(Over Fetching)이라고 한다.
  • user는 N+1 문제 해결을 위해 즉시 로딩해야 한다.

그래서 getOne 함수와 update 함수에 있는 scheduleRepository.findById(id)를 분리하기 위해서 JpaRepository에 JPQL로 새로운 함수를 만들어야 했다. 

 

JPQL란?

더보기

JPQL(Java Persistence Query Language) 은 JPA에서 엔티티를 조회하기 위해 사용하는 객체 지향 쿼리 언어다.

 JPA는 Repository의 함수 이름으로 쿼리를 만들기 때문에, 함수의 이름을 마음대로 지을 수 없다. 하지만 @Query 어노테이션으로 직접 쿼리를 작성한다면, 함수 이름을 마음대로 지을 수 있게 된다. 

 

 * JpaRepository에 원하는 이름의 함수를 만들고 싶다면 JPQL로 직접 쿼리를 넣어야 한다 *

public interface ScheduleRepository extends JpaRepository<Schedule,Long> {

    // Entity Graph
    @EntityGraph(attributePaths = "user")
    Optional<Schedule> findById(@NonNull Long id);

    // JPQL
    @Query("""
        select distinct s
        from Schedule s
        left join fetch s.user
        left join fetch s.comments c
        left join fetch c.user
        where s.id = :id
    """)
    Optional<Schedule> findDetailById(@Param("id") Long id);

}

 

위 두 함수는 id를 통해 Schedule을 찾는 함수지만, 아래와 같은 차이점을 가진다.

  • findById()는 user에 대해서만 즉시 로딩을 한다. -> update 함수에 적용
  • findDetailById()는 user, comment, comment.user에 대해서 즉시 로딩을 한다. -> getOne 함수에 적용

결과

위 두 함수를 각각 update와 getOne에 적용 시키고 SQL을 확인했다.

getOne의 findDetailById()의 SQL은 위에서 작동한 것과 같았으며, update에서 실행한 findById()는 아래와 같은 SQL을 요청했다.

select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.modified_at,
        u1_0.name,
        u1_0.pw 
    from
        users u1_0 
    where
        u1_0.id=?
        
 // user만 join으로 즉시 로딩

 

 

N+1 과의 3차전

    // 전체 조회 (페이지네이션)
    @Transactional(readOnly = true)
    public List<GetAllScheduleResponse> getAll(int page) {

        Pageable pageable = PageRequest.of(page, 5, Sort.by(Sort.Direction.DESC, "modifiedAt"));
        Page<Schedule> schedules = scheduleRepository.findAll(pageable);

        List<GetAllScheduleResponse> dtos = new ArrayList<>();
        for (Schedule schedule : schedules) {

            GetAllScheduleResponse dto = new GetAllScheduleResponse(
                    schedule.getId(),
                    schedule.getTitle(),
                    schedule.getContents(),
                    schedule.getUser().getName(), // N+1 발생
                    schedule.getComments().size(), // N+1 발생
                    schedule.getCreatedAt(),
                    schedule.getModifiedAt()
            );
            dtos.add(dto);
        }

        return dtos;
    }

 

schedule.getComments().size()

 

혹시 size() 정도는 N+1 문제 없이 구할 수 있을까 했지만, 어김없이 N+1 문제가 발생했다.

@Getter
@Entity
@Table(name = "schedules")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Schedule extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

   ...

    @OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
    private final List<Comment> comments = new ArrayList<>();

    private int commentCount;

    public Schedule(String title, String contents, User user) {
        this.title = title;
        this.contents = contents;
        this.user = user;
        this.commentCount = 0;
    }

    public void addComment(Comment comment) {
        this.comments.add(comment);
        commentCount++;
    }
 }

 

결국 댓글의 수를 따로 int commentCount라는 변수를 만들어 관리하는 구조로 Entity를 설계했고, 문제 없이 잘 작동하는 것을 확인 했다.