Notice
Recent Posts
Recent Comments
Link
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Tags more
Archives
Today
Total
관리 메뉴

밤빵's 개발일지

[WIL]20240901 N+1문제해결 : 프로젝트 코드에서 해결하기 본문

개발Article

[WIL]20240901 N+1문제해결 : 프로젝트 코드에서 해결하기

최밤빵 2024. 9. 1. 18:47

 

N+1 문제를 굳이굳이 발생시키고도 감을 못잡아서 결국 기술매니저님께 숙제를 다시 받아버렸다. 꼭 해결해보기로! 직접 해결방법까지 보여주셨지만 또 버벅거려서 시온님께 다시 여쭤보고 현재 진행하고 있는 팀 프로젝트에 적용해봤다. 

🤓N+1 문제 발생과 해결 과정

▶ 기술매니저님이 보여주신 예시 분석!

TestEntity 클래스가 ProductEntity와 @OneToMany 관계로 설정되어 있고, fetch = FetchType.LAZY로 지연 로딩이 설정되어 있다. 

 

▷ 관련 설명 

 

Service Layer (TestService):
TestService 클래스의 test() 메서드는 TestEntity를 조회한 후 entity.getTestEntities()를 호출하고 있다. fetch = FetchType.LAZY 설정 때문에 getTestEntities()를 호출할 때마다 관련된 ProductEntity들을 개별 쿼리로 가져오게 되고, 이는 여러 개의 TestEntity를 조회할 때, 각 TestEntity에 대해 추가적인 쿼리가 발생하게 되어 N+1 문제가 발생할 수 있다.

 

Entity Class (TestEntity):
TestEntity 클래스는 ProductEntity와 @OneToMany 관계로 매핑되어 있고, fetch = FetchType.LAZY로 설정되어 있어 기본적으로 지연 로딩 방식으로 동작한다. 지연 로딩은 관련 엔티티를 처음 접근할 때 데이터를 가져오므로, 여러 개의 연관된 데이터를 한 번에 가져올 때 N+1 문제가 발생할 수 있다.

 

▷ 해결상황 생각해보기 

→ fetch = FetchType.EAGER로 변경하거나, JPQL의 JOIN FETCH를 사용하여 문제를 해결할 수 있지않을까...? 아니면 EntityGraph 등을 사용하여 필요한 데이터만 한 번의 쿼리로 가져오도록 최적화한다던지..? 

 

▶ 내 문제 상황

회원(Members)와 관련된 의뢰(Commission) 데이터를 조회할 때, N+1 문제가 발생했다. N+1 문제란 하나의 메인 쿼리로 데이터를 조회한 후, 연관된 데이터를 조회하기 위해 추가적인 N개의 쿼리가 발생하는 문제를 말한다. 이는 불필요한 쿼리 호출로 인해 성능 저하를 불러올 수 있다.

 

▽ 문제가 발생한 코드

// MembersService.java

@Transactional(readOnly = true)
public void fetchMembersAndCommissions() {
    List<Members> membersList = membersRepository.findAll();
    for (Members member : membersList) {
        List<Commission> commissionsList = member.getCommissions(); // N+1 문제 발생
        for (Commission commission : commissionsList) {
            System.out.println("Commission ID: " + commission.getId());
        }
    }
}

→ 이 예시 코드는 모든 회원을 조회한 후, 각 회원과 연관된 의뢰 데이터를 가져오면서 N+1 문제가 발생한다.  findAll()로 모든 회원을 가져온 뒤, 각 회원의 getCommissions()를 호출할 때마다 의뢰 데이터를 개별적으로 가져오기 때문에 여러 번의 추가 쿼리가 발생한다.

 

▽ 문제가 발생한 로그

2024-08-28 17:21:54.851 [task-2] DEBUG org.hibernate.stat.internal.StatisticsImpl - HHH000117: HQL: [CRITERIA] select rt1_0.id,rt1_0.email,rt1_0.expired,rt1_0.revoked,rt1_0.token,rt1_0.token_type from tokens rt1_0 where rt1_0.email=?, time: 145ms, rows: 15

→ 현재 로그는 N+1 문제 발생 시의 SQL 실행 시간(145ms)과 실행된 쿼리 수(15개)를 보여준다.

 

▶문제 원인

  • MembersRepository.findAll()을 통해 전체 회원을 조회하고, 각 회원마다 member.getCommissions()를 호출하여 각 회원의 연관 데이터를 조회하는 방식 때문에 N+1 문제가 발생했다.
  • 모든 회원을 조회한 후, 각 회원의 연관된 데이터를 개별 쿼리로 다시 조회하면서 불필요한 쿼리가 다수 발생했다.

▶문제 해결

N+1 문제를 해결하기 위해 JPQL의 FETCH JOIN을 사용하여 한 번의 쿼리로 모든 연관 데이터를 가져오는 방식으로 변경했다. 그리고 엔티티 관계에서의 FetchType 설정을 지연 로딩(FetchType.LAZY)로 변경했다.

 

▽ 해결된 코드: Members 엔티티

@Entity
public class Members {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;
    private String nick;

    @OneToMany(mappedBy = "members", fetch = FetchType.LAZY) // 지연 로딩 설정
    private List<Commission> commissions;

    // Getter 생략 
}

 

▽ Commission 엔티티

@Entity
public class Commission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
    @JoinColumn(name = "members_id")
    private Members members;

    // 필드, Getters 생략 
}

→ @OneToMany와 @ManyToOne 관계의 fetch 속성을 FetchType.LAZY로 설정하여, 연관된 엔티티를 즉시 가져오지 않고 필요할 때 가져오도록 변경하였다.

 

▽ MembersRepository 코드

@Query("SELECT m FROM Members m LEFT JOIN FETCH m.commissions")
List<Members> findAllWithCommissions();

→ LEFT JOIN FETCH를 사용하여 회원과 연관된 의뢰 데이터를 한 번의 쿼리로 가져오도록 했다.

 

▽ MembersService 코드

@Transactional(readOnly = true)
public void fetchMembersAndCommissions() {
    List<Members> membersList = membersRepository.findAllWithCommissions(); // 해결된 코드
    for (Members member : membersList) {
        List<Commission> commissionsList = member.getCommissions();
        for (Commission commission : commissionsList) {
            System.out.println("Commission ID: " + commission.getId());
        }
    }
}

→ 위 예시 코드에서는 LEFT JOIN FETCH를 사용하여 회원과 연관된 의뢰 데이터를 한 번의 쿼리로 가져오도록 했다. 이로 인해 불필요한 추가 쿼리가 발생하지 않으며, 성능이 크게 개선되었다😆

 

▽ 해결된 후 로그:

2024-09-01 02:47:17.155 [http-nio-8080-exec-5] DEBUG org.hibernate.stat.internal.StatisticsImpl - HHH000117: HQL: SELECT m FROM Members m LEFT JOIN FETCH m.commissions, time: 16ms, rows: 1

→ 위 로그는 N+1 문제가 해결된 후 SQL 실행 시간(16ms)과 실행된 쿼리 수(1개)를 보여준다.

 

▷ LEFT JOIN FETCH 사용

→ LEFT JOIN FETCH를 사용하면 Members 엔티티를 조회할 때, Members에 매핑된 Commissions가 없는 경우에도 Members 데이터를 모두 가져올 수 있다. 

→ Members 엔티티가 Commissions 엔티티와 관계를 가지고 있지 않더라도, Members 데이터는 항상 조회되며 Commissions가 없는 경우 해당 부분이 NULL로 표시된다. 

→ 쿼리 실행 시간이 단축되고, rows 수가 제대로 나타나며 필요한 경우에도 Members와 관련된 Commissions를 모두 가져올 수 있게 된다.

→ N+1 문제를 방지하면서도 필요한 모든 데이터를 한 번의 쿼리로 가져올 수 있어 성능이 개선된다. 

 

▶ 성능 비교 및 결론

N+1 문제가 발생했을 때의 쿼리 실행 시간은 145ms였으나, FETCH JOIN과 FetchType.LAZY 설정을 사용한 후에는 16ms로 성능이 개선되었다. 문제 해결 전에는 15개의 쿼리가 각각 발생하여 성능 저하가 있었지만, 해결 후에는 단일 쿼리로 데이터를 가져옴으로써 성능이 향상되었다. 결론은 JPQL의 FETCH JOIN과 지연 로딩(FetchType.LAZY)을 통해 N+1 문제를 방지하는 것이 중요하다고 할 수 있다. 

 

추가+)

 

▶ 해결하기까지 겪었던 상황

쿼리가 0개였고... 그저 시간 줄었다고 좋아하고있었다. 

 

→ 문제상황 ( 쿼리 0개 발생...) :

N+1 문제를 해결하기 위해 JOIN FETCH를 사용했을 때, 의도했던 Members 엔티티에 매핑된 Commissions 엔티티가 없는 상황이 발생했다. 이로 인해 JOIN FETCH 쿼리가 실행되었지만, 매핑된 결과가 없기 때문에 쿼리는 실행되었어도 반환된 데이터는 없었다. 이런 경우 rows는 0이 되고, 이는 성능 개선의 유효성을 확인하기 어려운 상황을 초래했다. row가 0개인건 생각도 안하고 그저 시간줄었다고 성공했다!! 하면서 좋아하고있었다...

 

→ 문제의 원인? :

JOIN FETCH를 사용했지만, 실제로 매핑된 데이터가 없는 경우에 발생한 문제로 Members 엔티티에 연관된 Commissions 엔티티가 전혀 없는 상태에서 JOIN FETCH가 수행되서, 결과로 가져올 데이터가 없었기 때문에 rows: 0이 되었다. INNER JOIN FETCH를 사용했는데, 연관된 Commissions가 없는 Members는 결과에서 제외되기 때문에 rows가 0이 되었다. 테스트를 한다고 의도적으로 만든 상황이 이렇게 만든건가..? 

 

▽ 레포지토리 코드 

package com.clean.cleanroom.members.repository;

import com.clean.cleanroom.members.entity.Members;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface MembersRepository extends JpaRepository<Members, Long> {

    // JOIN FETCH를 사용하여 Members와 연관된 Commissions를 가져오는 쿼리
    @Query("SELECT m FROM Members m JOIN FETCH m.commissions")
    List<Members> findAllWithCommissions();
}

 

→ 문제 해결 상황? :

LEFT JOIN FETCH를 사용하면 Members에 대한 정보는 가져오면서, 연관된 Commissions가 없을 경우에도 NULL로 채워진 Members 결과를 반환하기에 Members 데이터를 모두 포함하도록 해야한다. (위의 해결상황 코드)

 

▽ 문제상황 로그

2024-09-01 01:25:28.220 [http-nio-8080-exec-3] DEBUG org.hibernate.stat.internal.StatisticsImpl - HHH000117: HQL: SELECT m FROM Members m JOIN FETCH m.commissions, time: 28ms, rows: 0
        2024-09-01 01:25:28.222 [http-nio-8080-exec-3]  INFO com.clean.cleanroom.members.service.MembersService - fetchMembersAndCommissions executed in 113 ms
2024-09-01 01:25:28.234 [http-nio-8080-exec-3]  INFO org.hibernate.engine.internal.StatisticalLoggingSessionEventListener - Session Metrics {
        7208900 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    1917900 nanoseconds spent preparing 1 JDBC statements;
    9412200 nanoseconds spent executing 1 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    121369643352401 nanoseconds spent executing 1 pre-partial-flushes;
    24200 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
}

첫 번째 로그 (쿼리 실행):
SELECT m FROM Members m JOIN FETCH m.commissions 쿼리가 실행되었고, time: 28ms 동안 실행되었지만 rows: 0이라는 결과를 가져왔다. Members 엔티티는 조회되었으나 Commissions 엔티티와의 매핑 결과가 없음을 의미한다.

두 번째 로그 (서비스 실행 시간):
MembersService에서 fetchMembersAndCommissions 메서드가 실행된 전체 시간은 113ms이다. 

세 번째 로그 (Hibernate 통계):
세션 메트릭스를 보면, 1개의 JDBC 연결을 얻는 데 7208900 나노초가 소요되고, 1개의 JDBC 문을 준비하고 실행하는 데 각각 1917900 나노초와 9412200 나노초가 걸렸음을 보여준다.

 

▶정리 : 영호매니저님 예시 

→ 이렇게 써보라고 알려주셔서 join fetch에 집착하게 됐는데, 엔티티그래프도 해볼걸 그랬다. 여러가지 해결방법을 해보고 비교하는 방법도 좋았을 것 같다.