Programming/JPA

[JPA] n + 1 문제와 inner join, outer join

hyeonic 2021. 5. 10. 21:56

개요

프로젝트를 진행하던 도중 말로만 듣던 n + 1 문제와 마주하게 되었다. 이에 대한 해결방안으로 공부한 것은 fetch join과 Spring Data JPA 사용 시 @EntityGraph 애노테이션을 활용하는 방법이다. 하지만 두 가지 방법에는 아주 큰 차이가 있었다. 그 둘의 차이점을 알아보기 위해 예시를 작성하였다.


프로젝트 구조

Person.java

package me.hyeonic.join.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Person {

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

    private String name;

    @OneToMany(mappedBy = "person")
    private List<Song> songs = new ArrayList<>();

    @Builder
    public Person(String name, List<Song> playList) {
        this.name = name;
        if (playList != null) {
            this.songs = playList;
        }
    }

    public void addSong(Song song) {
        this.songs.add(song);
        song.updatePerson(this);
    }
}

 

Song.java

package me.hyeonic.join.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "title"})
public class Song {

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

    private String title;
    private String singer;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "person_id")
    private Person person;

    @Builder
    public Song(String title, String singer, Person person) {
        this.title = title;
        this.singer = singer;
        this.person = person;
    }

    public void updatePerson(Person person) {
        this.person = person;
    }
}

Person은 사람을 나타내는 엔티티 클래스이고 Song은 person이 선택한 노래를 playList에 담기 위한 엔티티 클래스이다.

 

위와 같이 person은 여러 개의 song을 가질 수 있고, song은 하나의 person을 가질 수 있다.

 

PersonRepository.java

package me.hyeonic.join.repository;

import me.hyeonic.join.domain.Person;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PersonRepository extends JpaRepository<Person, Long> {
}

SongRepository.java

package me.hyeonic.join.repository;

import me.hyeonic.join.domain.Song;
import org.springframework.data.jpa.repository.JpaRepository;

public interface SongRepository extends JpaRepository<Song, Long> {
}

 

간단한 예제 구현을 위하여 Spring Data JPA를 활용하여 repository를 구성하였다.


n + 1 테스트

의도적으로 n + 1 쿼리를 발생 시키기 위한 테스트 코드이다.

 

SongRepositoryTest.java

package me.hyeonic.join.repository;

import me.hyeonic.join.domain.Person;
import me.hyeonic.join.domain.Song;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.EntityManager;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
class SongRepositoryTest {

    @Autowired PersonRepository personRepository;
    @Autowired SongRepository songRepository;
    @Autowired EntityManager em;

    @BeforeEach
    void setUp() {
        for (int i = 0; i < 10; i++) {
            Person person = Person.builder()
                    .name("person" + i)
                    .build();

            personRepository.save(person);

            Song song = Song.builder()
                    .person(person)
                    .singer("가수" + i)
                    .title("제목" + i)
                    .build();

            songRepository.save(song);
        }
    }
    
    @Test
    @DisplayName("n + 1 문제를 확인하는 테스트")
    void findAll() {
        
        // give
        em.flush();
        em.clear();
        List<Song> songs = songRepository.findAll();

        // when
        for (Song song : songs) {
            System.out.println("person의 name에 접근 -> " + song.getPerson().getName());
        }

        // then
        assertEquals(songs.size(), 10);
    }
}

우선 @BeforeEach 애노테이션을 활용하여 test를 실행하기 전에 값을 세팅해주는 초기화 작업을 진행한다. 10명의 person이 각각 1개의 song을 가지고 있다고 가정한다.

 

em.flush(), em.close()를 활용하여 영속성 컨텍스트를 깔끔하게 비워주었다.

 

그 다음 모든 song을 조회한 후 각각의 person에 name 필드에 접근한다. song 엔티티에 person은 lazy loading 이기 때문에 getName을 통하여 사용되는 시점에 쿼리가 실행된다.

 

songRepository.findAll을 통하여 1번의 쿼리가 발생하였지만 각각의 person의 필드에 접근하면서 n번 (예제에서 10번)의 쿼리를 발생 시키게 된다. 

 

최초에 song 목록을 조회 하기 위해 1번 쿼리가 실행 되었지만, 매번 person의 name을 얻기 위해 select 쿼리가 N번 발생하게 된다. 이것을 방지 하기 위해서는 연관관계가 설정된 엔티티를 한번에 가져와야 한다.


fetch join

DB에서 조회 시 바로 가져오고 싶은 엔티티를 join fetch로 지정하는 방법이다.

 

 

그 다음 이전에 작성한 테스트 코드를 다시 한번 실행하였다.

 

위 select 문을 살펴보면 join을 활용하여 person의 정보도 함께 가져오는 것을 알 수 있다! 1번의 쿼리 실행 필요로 하는 엔티티를 미리 fetch 하였기 때문에 기존에 추가적으로 발생한 N번의 쿼리를 실행하지 않은 것을 알 수 있다.

 

여기서 한 가지 더 주목해야 할 점은 inner join이라는 키워드이다.


@EntityGraph

두 번째 방법은 Spring Data JPA의 @EntityGraph를 활용한 방법이다.

 

 

EntityGraph (Spring Data JPA 2.5.0 API)

 

docs.spring.io

위와 같이 attributePaths 속성에 필드명을 명시하면 해당 엔티티를 fetch join과 동일하게 1번에 조회한다.

 

위와 동일하게 1번의 쿼리로 person의 정보까지 함께 조회하는 것을 알 수 있다. 하지만 fetch join과는 다른 점이 있다. 바로 left outer join 이라는 것이다.


inner join, left outer join

inner join과 left outer join을 표현한 그림이다. inner join은 두 테이블에 같은 값만 가져오고, left outer join의 경우 왼쪽에만 값이 있어도 조회되는 큰 차이가 있다. 만약 값이 없다면 단순히 null 값을 채워진 채로 조회된다.

 

위의 코드에서 의도적으로 person이 없는 song이 DB에 있다고 수정해보았다.

 

package me.hyeonic.join.repository;

import me.hyeonic.join.domain.Person;
import me.hyeonic.join.domain.Song;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.EntityManager;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
class SongRepositoryTest {

    @Autowired PersonRepository personRepository;
    @Autowired SongRepository songRepository;
    @Autowired EntityManager em;

    @BeforeEach
    void setUp() {
        for (int i = 0; i < 10; i++) {
            Person person = Person.builder()
                    .name("person" + i)
                    .build();

            personRepository.save(person);

            Song song = Song.builder()
                    .person(person)
                    .singer("가수" + i)
                    .title("제목" + i)
                    .build();

            songRepository.save(song);

            Song song1 = Song.builder()
                    .singer("가수는" + i)
                    .title("제목은" + i)
                    .build();

            songRepository.save(song1);
        }
    }
    
    @Test
    @DisplayName("n + 1 문제를 확인하는 테스트")
    void findAll() {
        
        // give
        em.flush();
        em.clear();
        List<Song> songs = songRepository.findAll();

        // when
        for (Song song : songs) {
            System.out.println("person에 접근 -> " + song.getPerson());
        }

        // then
        assertEquals(songs.size(), 10);
    }
}

 

setup부분에 person이 세팅되지 않은 song 값들이 추가적으로 저장되었다. (song1)

 

SongRepository에 findAll의 경우 @EntitiyGraph를 통하여 조회를 하기 때문에 left outer join을 사용할 것이다.

 

테스트를 실행해보았다.

 

 

person이 존재하는 song과 person이 존재하지 않는 song에 모든 행을 가져왔다.


정리

fetch join@EntityGraph 모두 연관된 엔티티를 함께 조회하기 위해 사용한다. 하지만 각각이 기본적으로 적용되는 join 방식에 차이가 있었고, 그것을 예제를 통하여 간단하게 확인할 수 있었다. 또한 sql join에 대해서도 다시 한번 확인할 수 있었다.

 

그 밖에도 inner join과 outer join에는 큰 차이가 있는데 좀 더 공부한 후에 추가적인 포스팅을 남겨야 겠다.


References.

 

JPA N+1 문제 및 해결방안

안녕하세요? 이번 시간엔 JPA의 N+1 문제에 대해 이야기 해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와 세미

jojoldu.tistory.com