김코딩

트러블슈팅 - 전체 일정 조회 시 N+1 문제 발생 본문

TIL

트러블슈팅 - 전체 일정 조회 시 N+1 문제 발생

김코딩딩 2025. 5. 21. 17:33

불과 하루 전 튜터님의 세션에서

"JPA를 사용하면 N+1 문제가 발생할 가능성이 있어서 이 부분을 조심해주셔야해요!"
라는 말을 들었다.

 

나는 개발을 진행하면서

"내가 N+1 문제에 직면할 일이 있을까...?"
라고 생각했지만, 바로 다음 날 이 문제에 정통으로 맞았다.

N+1 문제란?

JPA를 사용하다 보면, 조회 쿼리가 생각보다 많이 나가는 상황을 겪게 됩니다.
예를 들어 회원 리스트를 한 번 불러왔을 뿐인데,
각 회원이 가진 일정까지 순회하는 순간 갑자기 쿼리가 수십 번 더 발생하는 일이 있죠.
이 현상이 바로 N+1 문제입니다.

 


상황 설명

전체 일정 리스트를 조회하는 기능을 개발하고 있었고,
별다른 의심 없이 아래처럼 구현했다.

public List<ScheduleResponseDto> findAll() {
    return scheduleRepository.findAll().stream()
            .map(ScheduleResponseDto::new)
            .toList();
}

하지만 콘솔에 찍힌 Hibernate 로그는 충격적이었다.

Hibernate: 
    /* <criteria> */ select
        s1_0.id,
        s1_0.contents,
        s1_0.created_at,
        s1_0.member_id,
        s1_0.modified_at,
        s1_0.title 
    from
        schedule_v2 s1_0
Hibernate: 
    select
        m1_0.id,
        m1_0.created_at,
        m1_0.email,
        m1_0.modified_at,
        m1_0.password,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        m1_0.id,
        m1_0.created_at,
        m1_0.email,
        m1_0.modified_at,
        m1_0.password,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        m1_0.id,
        m1_0.created_at,
        m1_0.email,
        m1_0.modified_at,
        m1_0.password,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.id=?

Schedule을 한 번에 불러온 다음,
각 Schedule마다 연관된 Member를 반복해서 조회하고 있던 것이다.


문제의 핵심: 연관관계 + LAZY 로딩

Schedule 엔티티는 이렇게 구성되어 있었다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

그리고 DTO 변환 과정에서 schedule.getMember().getUsername()을 호출하면서,
지연 로딩(LAZY)된 Member에 접근 -> 쿼리 N번 발생

public ScheduleResponseDto(Schedule schedule) {
    this.id = schedule.getId();
    this.username = schedule.getMember().getUsername();  <- 이 부분이 원인이다!
    this.title = schedule.getTitle();
    this.contents = schedule.getContents();
}

해결 고민

N+1 문제를 해결하는 방법에 대해서 고민을 해보았다.

join fetch를 이용하거나 @EntityGraph를 이용하면 된다고 하였다.

 

join fetch를 이용한 방법

JPQL에서 fetch join을 명시해줌으로써,
연관된 엔티티를 한 번의 쿼리로 같이 가져올 수 있다.

 

- ScheduleRepository

@Query("SELECT s FROM Schedule s JOIN FETCH s.member")
List<Schedule> findAllWithMember();

 

- ScheduleService

public List<ScheduleResponseDto> findAll() {
    return scheduleRepository.findAllWithMember().stream()
            .map(ScheduleResponseDto::new)
            .toList();
}

결과

Hibernate: 
    /* SELECT
        s 
    FROM
        Schedule s 
    JOIN
        
    FETCH
        s.member */ select
            s1_0.id,
            s1_0.contents,
            s1_0.created_at,
            s1_0.member_id,
            m1_0.id,
            m1_0.created_at,
            m1_0.email,
            m1_0.modified_at,
            m1_0.password,
            m1_0.username,
            s1_0.modified_at,
            s1_0.title 
        from
            schedule_v2 s1_0 
        join
            member m1_0 
                on m1_0.id=s1_0.member_id

@EntityGraph를 이용한 방법

@EntityGraph는 Spring Data JPA에서 제공하는 방법으로,
JPQL을 직접 작성하지 않아도 연관 엔티티를 미리 로딩할 수 있도록 설정할 수 있다.

 

- ScheduleRepository

@EntityGraph(attributePaths = "member")
List<Schedule> findAll();

 

  • ScheduleRepository의 메서드 위에 붙여서 사용
  • attributePaths에 로딩할 연관 필드를 명시

결과

Hibernate: 
    /* <criteria> */ select
        s1_0.id,
        s1_0.contents,
        s1_0.created_at,
        s1_0.member_id,
        m1_0.id,
        m1_0.created_at,
        m1_0.email,
        m1_0.modified_at,
        m1_0.password,
        m1_0.username,
        s1_0.modified_at,
        s1_0.title 
    from
        schedule_v2 s1_0 
    join
        member m1_0 
            on m1_0.id=s1_0.member_id

마지막으로..

N+1 문제는 "언젠가는 마주치겠지" 했던 문제가  바로 다음 날 찾아왔다.
내가 .getMember().getUsername() 딱 한 줄 썼을 뿐인데
이렇게 많은 쿼리가 나갈 줄은 몰랐다.

이번 경험을 통해 느낀 점은:

  • JPA에서 연관관계는 단순히 매핑이 아닌 쿼리 성능까지 연결되는 영역이고,
  • 항상 쿼리 로그를 확인하며 동작을 검증해야 한다는 것이다.

앞으로는 연관된 엔티티를 다룰 때,
무조건 "이게 N+1 문제를 일으키진 않을까?"를 먼저 의심하는 습관을 가지기로 했다.