[Spring/JPA] N+1 문제 및 N+1 해결 방법 과 즉시 로딩, 지연 로딩 이란?

resilient

·

2022. 11. 24. 14:59

728x90
반응형

이번 게시물에서는 오랜만에 Spring과 관련된 내용을 다뤄보려고 합니다. 

바로 N+1 문제인데요. Spring을 사용하고, JPA를 사용하신다면 한번쯤은 들어봤던 문제일겁니다. N+1문제는 무엇일까요?

 

0. N+1 문제란?

 

JPA에서 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상을 말합니다.

 

직접 실습을 통해서 살펴보겠습니다.

 

DB구조  및, Service, Controller, Dto 설계

 

ERD

DB 구조는 노래(Song)는 한개의 플레이리스트(Playlist)에만 속할 수 있고 테스트 데이터로는 3개의 플레이리스트에 노래 5개씩 총 15개의 노래를 추가했습니다.

 

@Service
public class SongService {

    @Autowired
    private PlaylistRepository playlistRepository;

    public PlaylistResponseDto findPlaylist() {
        Playlist playlist = playlistRepository.findById(1L).get();
        PlaylistResponseDto playlistResponseDto = new PlaylistResponseDto(playlist);

        return playlistResponseDto;
    }

}
@RequiredArgsConstructor
@RestController
public class SongController {

    private final SongService songService;

    @GetMapping("/api/streaming")
    @ResponseStatus(HttpStatus.OK)
    public PlaylistResponseDto streamingTest(){
        return songService.findPlaylist();
    }
}
@Data
public class PlaylistResponseDto {

    private Long id;
    private String title;
    private List<SongResponseDto> songs;

    public PlaylistResponseDto(Playlist playlist){
        this.id = playlist.getId();
        this.title = playlist.getTitle();
        this.songs = playlist.getSongs().stream().map(SongResponseDto::new).toList();
    }
}

 

 

1.  FetchType.EAGER 에서의 N+1

 

자 이제 연관관계를 위한 Annotation 옵션에 FetchType을 EAGER(즉시로딩) 로 설정한 경우를 먼저 살펴보겠습니다.

 

@Entity
public class Playlist {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @OneToMany(fetch = FetchType.EAGER) // 즉시 로딩
    @JoinColumn(name = "playlist_id")
    private List<Song> songs = new ArrayList<>();
    
}

@Entity
public class Song {

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

    private String music_data;

    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
    @JoinColumn(name = "playlist_id", nullable = false)
    private Playlist playlist;
}

 

FetchType을 EAGER로 설정하면 즉시 로딩이 발생합니다.

 

playlistRepository에서 findById method로 호출을 해보면 아래와 같은 쿼리가 실행됩니다. 자동으로 연관관계가 있는 모든 Entity들을 Join해서 값을 한꺼번에 모두 가져오게 되죠.

 

정리해보면 EAGER(즉시로딩)의 특징은 아래와 같습니다.

 

1. findById(0)을 한 순간 select p from playlist p 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from Playlist 이라는 SQL이 생성되어 실행됩니다. ( SQL 로그 중 Hibernate: select playlist0.id as id1_0_, playlist0_.title as title2_0_ from playlist playlist0_ 부분 )


2. DB의 결과를 받아 playlist 엔티티의 인스턴스들을 생성하고


3. playlist 와 연관되어 있는 song 도 로딩을 합니다.

 

 

대부분의 JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회하려고 하기 때문에 EAGER을 사용하면, 실제 조회할 때 한방 쿼리로 다 조회하게 됩니다.

 

여기서 잠깐. 그렇다면 FetchType.EAGER로 설정했을때, N+1 문제가 발생한다고 할 수 있을까요?

 

이 부분이 참 애매한데요. 제가 학습한 바로는 EAGER로 설정했다는 자체가 '나는 JOIN을 해서 모두 가져오길 바래' 라는 의미이기 때문에 원하지 않는 쿼리가 추가되었다고 볼 수 없을 것 같기도 합니다. N+1 자체는 '나는 이것만 가져오라고 했는데 왜 연관관계 있는 것도 다가져와?' 라는 생각에서 부터 시작된다고 생각하기 때문이죠.

 

2.  FetchType.LAZY 에서의 N+1

 

이번에는 연관관계를 위한 Annotation 옵션에 FetchType을 LAZY(지연로딩) 로 설정한 경우를 살펴보겠습니다.

 

@Entity
public class Playlist {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @OneToMany(fetch = FetchType.LAZY) // 지연 로딩
    @JoinColumn(name = "playlist_id")
    private List<Song> songs = new ArrayList<>();

}

@Entity
public class Song {

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

    private String music_data;

    @ManyToOne(fetch = FetchType.LAZY)	// 지연 로딩
    @JoinColumn(name = "playlist_id", nullable = false)
    private Playlist playlist;
}

 

FetchType을 LAZY로 설정하면 지연 로딩이 발생합니다.

 

playlistRepository에서 findById method로 호출을 해보면 아래와 같은 쿼리가 실행됩니다.

 

EAGER(즉시로딩)이 아니기 때문에 JOIN을 사용해서 연관관계가 있는 모든 데이터를 불러오지는 않지만, 쿼리를 보면 playlist 인덱스 0번째를 조회하는 쿼리와 song를 조회하는 쿼리가 추가적으로 발생한 것을 확인할 수 있습니다.

 

위 쿼리와 같이 하위 엔티티들(ex. song entity)을 쿼리 실행시 한번에 가져오지 않고, Lazy Loading으로 필요한 곳에서 사용되어 쿼리가 실행될때 추가적으로 쿼리 요청이 발생하는 문제가 N+1 쿼리 문제입니다.

 

정리해보면 LAZY(지연로딩)의 특징은 아래와 같습니다. 

 

1. findById(0)을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행됩니다. ( SQL 로그 중 Hibernate: select playlist0_.id as id1_0_, playlist0_.title as title2_0_0_ from playlist playlist0_ 부분 )


2. DB의 결과를 받아 playlist 엔티티의 인스턴스들을 생성합니다.


3. 코드 중에서 playlist 의 song 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 song가 있는지 확인하고


4. 영속성 컨텍스트에 없다면 2에서 만들어진 playlist 인스턴스들 개수에 맞게 select * from song where songs0_.playlist_id = ? 이라는 SQL 구문이 생성되게 됩니다.

 

 

즉시로딩(EAGER)과 지연로딩(LAZY)를 한문장으로 정리해보겠습니다.

 

EAGER는 Playlist 객체 안에 실제 song 객체를 db에서 조회해서 넣어두는 반면 LAZY는 Playlist 객체 안에 '프록시' song 객체를 넣어둡니다.(이때는 db 조회 아직 안하는 시점입니다.)


이후에 LAZY에서 Playlist 객체 안에 song 객체의 실제 필드값을 쓰려고 하면 그 값을 조회하기 위해 db에 쿼리 날리게 되는 것이죠.

 

3. 해결 방안

 

N+1 문제를 해결하는 방법에는 Fetch Join과 이 있습니다. Fetch Join을 하는 두가지 방법을 알아보겠습니다.

 

3-1. 쿼리에 join fetch 사용

 

첫번째는 join fetch를 사용하는 것입니다.

 

JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법입니다. (SQL Join 문을 생각하면 됩니다.) 별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 "join fetch 엔티티.연관관계_엔티티" 구문을 만들어 주면 됩니다.

 

아래 예시를 보면 PlaylistRepository에 findAllFetchJoin()이라는 메소드를 만들어서 join fetch를 사용한 JPQL을 직접 만들어줬습니다.

@Repository
public interface PlaylistRepository extends JpaRepository<Playlist, Long> {
    
    @Query("select p from Playlist p join fetch p.songs")
    List<Playlist> findAllFetchJoin();
}

 

다시 요청을 보내보면 아까랑은 다르게 where구문도 없고 하나의 select문으로 데이터를 조회해 오는 것을 알 수 있습니다.

 

위 방법의 단점은 쿼리문이 추가된다는 점입니다.
이 필드는 Eager 조회, 저 필드는 Lazy 조회를 해야한다까지 직접 JPQL을 사용한 쿼리에서 명시하고 사용하는 것은 불필요하다고 생각할 수도 있는데요. 이러한 단점을 극복한 방법이 두번째 Fetch Join 방법인 @EntityGraph 어노테이션사용입니다.

 

3-2. @EntityGraph(어노테이션)

 

두번째 방법은 @EntityGraph 어노테이션을 이용해서 @EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 됩니다. 원본  select 쿼리의 손상 없이 (select p from Playlist p) Eager/Lazy 필드를 정의하고 사용할 수 있게 되었습니다. 아래 예시를 보시죠

 

 

다시 요청을 보내보면 join fetch와 같이 where구문도 없고 하나의 select문으로 데이터를 조회해 오는 것을 알 수 있습니다. 

 

하지만!

 

fetch join를 사용했을 때 결과 쿼리와 @EntityGraph를 사용했을 때의 결과 쿼리를 비교해볼까요? 빨간 밑줄을 보면 전자는 inner join, 후자는 left outer join으로 join이 이루어집니다.

 

결과적으로 left outer join을 사용하게 되면 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 song의 수만큼 playlist가 중복 발생하게 됩니다. 

 

물론 중복을 제거하기 위해서 set 자료구조를 사용해서 메소드 return 타입을 정해주는 방법이 있지만 중복을 제거하게 되더라도 관계가 조금이라도 복잡해지면 사용하기 매우 어렵다고 합니다. 

 

 

3-3. Batch Size

 

Fetch join의 문제는 Pagination을 사용하기 위해 limit을 날릴 때 발생합니다. 쿼리 결과를 전부 메모리에 적재한 뒤 Pagination 작업을 어플리케이션 레벨에서 하기 때문인데요. 실행된 쿼리를 보면 limit에 null이 들어가서 limit 키워드 없이 DB 내에 존재하는 Review Entity를 모두 가져오게 되어 Paging을 통한 성능상 이점을 가져갈 수 없게 되어버립니다.

 

이를 해결하기 위해 사용하는 방법이 Batch Size를 지정해주는 것인데요. JPA의 페이징 API 기능처럼 개수가 고정된 데이터를 가져올 때 함께 사용할 때 유용하게 사용 가능할 듯합니다. 아래와 같이 Entity Column에 @BatchSize를 적용하면 됩니다.

 

@Entity
public class Playlist {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @BatchSize(size = 3)
    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "playlist_id")
    private List<Song> songs = new ArrayList<>();

}

 

4. 정리

이번 시간에는 N+1 문제와 즉시로딩, 지연로딩을 알아보고 해결방법까지 알아보았습니다.

 

N+1을 공부하면서 스프링에 대해 더 깊이 있는 공부가 필요하다는 사실을 다시 한번 깨닫게 되었고, 앞으로 종종 스프링에 대해 깊이 생각해볼 수 있는 글을 작성해보려고 합니다.

 

감사합니다.

반응형