본문 바로가기

server/spring

[Spring] N+1 문제 알아보기

이번 방학부터 자바 Spring을 학습하면서 프로젝트에 내용을 하나씩 적용해보고 있는데, 이 과정에서 첫 번째로 마주친 문제는 N+1 문제이다. 

이번 포스팅에서는 N+1 문제가 무엇이며 왜 발생하는지를 알아보고, 이를 해결하기 위한 방안들과 그 중에서 어느 해결법을 적용했는지 기록하고자 한다. 

 

📋 문제 상황

현재 프로젝트에서 Scrap 객체와 Dataset 객체는 아래와 같이 다대일 관계를 갖고 있다. 

// domain/Scrap.java

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Scrap {
    // ...
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "datasetId")
    private Dataset dataset; // 스크랩 - 데이터셋은 다대일 관계
}
// domain/Dataset.java

@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Dataset {
    // ...
    @OneToMany(mappedBy = "dataset", fetch = FetchType.LAZY,cascade = CascadeType.ALL)
    private List<Scrap> scrapList = new ArrayList<>();
    // ...
}

 

그리고 클라이언트 측에서 스크랩 데이터를 요청할 때의 Response Dto는 아래와 같다. 

@Data
public class ResScrapDto {
    /**
     * scrap id
     */
    private Long id;
    
    /**
     * 스크랩한 dataset
     */
    private Long datasetId;
    
    /**
     * 스크랩한 dataset의 제목
     */
    private String title;
    
    /**
     * 스크랩한 dataset의 설명
     */
    private String description;
    
    /**
     * 스크랩한 dataset의 리소스 타입
     */
    private String type;
    
    /**
     * 스크랩한 dataset의 조직
     */
    private String organization;

    public ResScrapDto(Scrap scrap) {
        this.id = scrap.getScrapId();
        this.datasetId = scrap.getDataset().getDatasetId();
        this.title = scrap.getDataset().getTitle();
        this.description = scrap.getDataset().getDescription();
        this.organization = scrap.getDataset().getOrganization();
    }
}

 

ResScraoDto의 생성자 메서드를 살펴보면, Scrap 인스턴스와 연관된 Dataset을 요청하여 response dto를 구성하고 있는 것을 확인할 수 있다. 

 

이와 같이 코드를 작성한 후, 간단한 테스트를 진행하기 위해 아래와 같이 임시 데이터를 생성하였다. 

유저는 두 개의 scrap 데이터를 가지고 있다.
각각의 scrap 데이터에는 서로 다른 dataset이 하나씩 연관되어 있다.

 

해당 유저의 모든 스크랩 내역을 요청한 request에 대한 response body를 확인하면 이해가 더 수월할 것이다. 

{
  "success": true,
  "msg": "ok",
  "result": [
    {
      "id": 9,
      "datasetId": 2,
      "title": "제목1",
      "description": "설명1",
      "type": null,
      "organization": "조직1"
    },
    {
      "id": 10,
      "datasetId": 1,
      "title": "제목2",
      "description": "설명2",
      "type": null,
      "organization": "조직2"
    }
  ]
}

 

그렇다면 해당 response를 반환할 때 어떤 query가 실행될까? 아래 사진은 위의 response를 응답하는 과정에서 실행된 쿼리문을 로그로 출력한 것이다. 

모든 scrap 내역을 GET 요청할 시 발생하는 쿼리문

 

출력된 로그을 확인해보면 dataset을 가져오는 쿼리문이 총 두 번 실행된 것을 확인할 수 있다.

첫 번째(id = 9) 스크랩 내역과 연관되어있는 데이터 셋을 호출하는 쿼리와, 두 번째(id = 10) 스크랩 내역과 연관되어있는 데이터 셋을 호출하는 쿼리를 실행했다. 즉, 응답 스크랩 개수( = 응답 row)만큼 데이터셋 호출 쿼리가 실행된 것이다. 

 

🧐 문제 발생의 원인

그렇다면 위와 같은 N+1 문제는 왜 발생하는 것일까?

이를 알아보기 위해, 우선 모든 스크랩 내역을 요청할 때 실행되는 findAllByUserEmail 메소드를 살펴본다. 

// repository/ScrapRepository.java

@Repository
public interface ScrapRepository extends JpaRepository<Scrap, Long> {
    // ...
    @Query("select s from Scrap s join fetch s.user u where u.email=:email")
    List<Scrap> findAllByUserEmail(String email);
}

 

유저의 이메일 정보를 통해 해당 유저와 연관된 스크랩 내역을 모두 가져오기 위해서 JPQL join 쿼리문을 작성했다. 

 

여기서 JPQL은 객체지향 쿼리 언어를 제공함으로써 엔티티 객체메소드 명을 통해 쿼리문을 이해할 수 있다. 이와 같은 특징 때문에 JPQL은 findAll- 과 같은 쿼리를 실행할 때, 서로 다른 엔티티 사이에 존재하는 연관관계를 고려하지 않고 쿼리를 실행하게 된다. 

 

따라서 이 경우에도 findAll- 쿼리를 실행하는 과정에서 select * from Scrap 쿼리만 실행할뿐, 각각의 Scrap 엔티티와 연관되어있는 Dataset 엔티티는 조회하지 않은 것이다.

그렇기 때문에 Entity 생성 과정에서 서로 연관된 엔티티의 경우, fetchType으로 연관 데이터를 어느 시점에 조회할 것인지 명시해야 한다. (여기서 Scrap과 Dataset은 지연로딩으로 설정해두었다 - 포스팅 상단 도메인 코드 참조)

 

참고로 지연로딩이 N+1 문제를 해결할 수 없는 이유는, 지연로딩은 연관 객체를 프록시 객체로 저장해두었다가 해당 객체의 메소드를 호출하는 시점에 데이터베이스에 접근하기 때문이다.

따라서 이 경우에도 각각의 스크랩 객체와 연관된 데이터셋 객체를 프록시 객체로 저장해두었다가, response dto 생성 시점에 프록시 객체의 메서드(getDatasetId, getTitle 등)를 호출하면서 데이터베이스에 접근하게 되는 것이다. 즉, 지연로딩과 N+1 문제는 연관이 없다고 볼 수 있다. 

 

📚 해결 방안

이와 같은 N+1 문제를 해결하기 위한 여러 방안이 존재한다. 첫 번째는 fetch join을 이용하는 방법이다. 

- Fetch Join

fetch join을 사용하면 Scrap 객체 호출 시점에 이와 연관된 모든 Dataset 객체도 같이 호출되기 때문에, 앞서 살펴본 연관 Dataset 객체를 조회하는 쿼리의 발생을 막을 수 있다. 

 

이번 예제에서는 기존에 있는 join fech 쿼리에 dataset join을 추가하면 된다. 

// repository/ScrapRepository.java

@Repository
public interface ScrapRepository extends JpaRepository<Scrap, Long> {
    Optional<Scrap> findByDatasetAndUser(Dataset dataset, User user);

    @Query("select s from Scrap s join fetch s.user u join fetch s.dataset where u.email=:email")
    List<Scrap> findAllByUserEmail(String email);
}

 

하지만 이 방법은 사실상 fetch type을 지연로딩으로 설정한 것이 무의미해지는 것이기도 하다. 또한 연관된 모든 객체를 조회하기 때문에, 특정 단위로 페이징하여 객체를 조회할 수 없게 된다.

 

- SUBSELECT

두 번째는 SUBSELECT를 사용하는 방법으로, 서브쿼리를 사용하여 총 두 번의 쿼리로 문제를 해결한다. 즉, 해당하는 데이터를 조회하는 쿼리는 그대로 두고, 그와 연관된 데이터를 조회하는 쿼리를 서브쿼리로 진행하는 것이다. 

// domain/Dataset.java

@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Dataset {
    // ...
    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "dataset", fetch = FetchType.LAZY,cascade = CascadeType.ALL)
    private List<Scrap> scrapList = new ArrayList<>();
    // ...
}

 

위 코드처럼 @Fetch 어노테이션을 사용하여 구현할 수 있다. 이 방식의 경우 이번 예제와 같은 ManyToOne 관계보다는 OneToMany 관계의 데이터를 조회할 경우에 주로 사용할 수 있다. 

 

- BatchSize

세 번째는 batch size를 사용하는 것이다. 이 방법은 IN 쿼리를 사용한다. 즉, 메인쿼리의 id 칼럼 값을 IN 쿼리에 삽입하여 조회한다. 

 

하지만 이번 예제에서는 아래 코드처럼 모든 스크랩 객체를 가져온 후, 각 스크랩 객체를 ResScrapDto로 각각 따로 변환해주고 있기 때문에 BatchSize를 적용하더라도 IN 쿼리의 사용을 확인할 수 없었다.

@Operation(summary = "로그인 유저의 모든 스크랩 내역을 가져옴")
@GetMapping("/api/scrap")
public ResponseEntity<ApiResponse<List<ResScrapDto>>> getScraps(@AuthenticationPrincipal UserDetails userDetails) {
    List<Scrap> scrapList = scrapService.findAllByEmail(userDetails.getUsername());
    List<ResScrapDto> resScrapDtoList = scrapList
            .stream()
            .map(ResScrapDto::new)
            .toList();
    return ResponseEntity.ok(ApiResponse.ok(resScrapDtoList));
}

 

따라서 이를 직접 테스트 해보기 위해 아래와 같이 Dataset 엔티티에 BatchSize 어노테이션을 추가한 후, 테스트 코드를 작성했다.

// domain/Dataset.java

@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@BatchSize(size = 5)
public class Dataset {
    // ...
    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "dataset", fetch = FetchType.LAZY,cascade = CascadeType.ALL)
    private List<Scrap> scrapList = new ArrayList<>();
    // ...
}

 

// ...

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class temp {
    @Autowired
    private EntityManager em;

    @Autowired
    private ScrapRepository scrapRepository;

    @Test
    void temp() {
        em.clear();

        // 모든 스크랩 조회
        System.out.println("------------ SCRAP 전체 조회 ------------\n");
        List<Scrap> scraps = scrapRepository.findAll();
        System.out.println("------------- SCRAP 전체 조회 -------------\n\n");

        // SCRAP과 관련된 DATASET 조회
        System.out.println("------------ DATASET 조회 ------------\n");
        scraps.forEach(scrap -> {
            scrap.getDataset().getTitle();
        });
        System.out.println("------------ DATASET 조회 ------------\n\n");
    }
}

 

이를 실행하면 다음과 같이 출력되는 것을 확인할 수 있다. 

------------ SCRAP 전체 조회 ------------

2024-02-15T20:58:36.511+09:00 DEBUG 71666 --- [    Test worker] org.hibernate.SQL  
    select
        s1_0.scrap_id,
        s1_0.dataset_id,
        s1_0.user_id 
    from
        scrap s1_0
------------- SCRAP 전체 조회 -------------


------------ DATASET 조회 ------------

2024-02-15T20:58:36.527+09:00 DEBUG 71666 --- [    Test worker] org.hibernate.SQL                        : 
    select
        d1_0.dataset_id,
        d1_0.created_date,
        d1_0.description,
        d1_0.download,
        d1_0.organization,
        d1_0.resource_resource_id,
        d1_0.theme,
        d1_0.title,
        d1_0.update_date,
        d1_0.view 
    from
        dataset d1_0 
    where
        d1_0.dataset_id in (?,?,?,?,?)
------------ DATASET 조회 ------------

 

+ 여기서 in 쿼리에 들어가는 값은 앞서 조회된 Scrap 객체의 datasetId 값들

 

이 방식은 fetch join과 달리 페이징이 가능하기 때문에, OneToMany 관계의 데이터를 조회하는 경우에는 BatchSize 를 사용하는 것이 더 유리하겠다. 

 

적용

이번 예제에서는 특정 유저의 스크랩 내역을 불러오는 과정을 살펴보고 있으며 Scrap과 Dataset은 ManyToOne 관계를 갖는다. 따라서 각 Scrap 객체에는 하나의 Dataset만 존재하기 때문에 페이징을 사용할 일이 없으므로, 이에 관한 문제점은 고려하지 않았다.

또한 스크랩 Response Dto에서 해당 스크랩에 연관된 데이터셋을 항상 참조하므로 fetch join을 사용했다.

앞서 두 번의 쿼리가 실행된 것에 반해 한 번의 쿼리만 발생한 것을 볼 수 있다.

 

정리

이번에는 ManyToOne 관계를 갖는 경우를 다뤘기 때문에 Fetch Join을 사용하여 문제를 해결하였다. 하지만 fetch join 방식은 결국 연관된 데이터를 한번에 가져오므로 페이징을 지원하지 않으며, 지연로딩의 의미가 퇴색될 수 있다는 단점이 있다.

따라서 페이징이 필요한 경우 등, 여러 상황을 고려해야 한다면 fetch join 보다는 SUBSELECT 혹은 BatchSize를 활용하는 것이 더 적절할 수 있다. 특히 OneToMany 관계를 갖는 경우에는 SUBSELECT 혹은 BatchSize를 사용하는 것을 권장한다고 한다. 

 

 

관련하여 정리가 잘 되어있는 포스팅을 발견하여 해당 포스팅도 참고하면 좋을 것 같다. 

https://ttl-blog.tistory.com/1135