Develop/spring-data

[Querydsl] 성능개선 - 2편 ( N + 1 )

에디개발자 2021. 2. 1. 07:00
반응형

이 글은 우아한 형제들 콘서트에서 이동욱님의 영상을 보고 정리를 위한 글입니다.

이 글에 작성된 예시는 모두 Github에 올려두었습니다.

나를 닮았다고 한다...

N + 1

Entity 기반인 Jpa, Querydsl 을 사용하다 보면 N + 1은 한번 씩 겪는 문제라고 생각합니다. 

N + 1이 무엇이고 왜 발생하는지 알아보겠습니다.

 

먼저 아래의 코드를 살펴보겠습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@DynamicInsert
public class House {

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

    private String name;

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "house")
    private List<Staff> staffs = new ArrayList<>();

    @Builder
    public House(Long id, String name, List<Staff> staffs) {
        this.id = id;
        this.name = name;
        if (staffs != null) {
            this.staffs = staffs;
        }
    }

    public void addStaff(Staff staff) {
        this.staffs.add(staff);
        staff.changeHouse(this);
    }
}
@Entity
@Getter
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Staff {

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

    private String name;
    private Integer age;
    private String lastName;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "store_id", foreignKey = @ForeignKey(name = "fk_staff_store_id"))
    private Store store;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "house_id", foreignKey = @ForeignKey(name = "fk_staff_house_id"))
    private House house;

    @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    private Bag bag;

    @Builder
    public Staff(Long id, String name, Integer age, String lastName, Store store, House house, Bag bag) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.lastName = lastName;
        this.store = store;
        this.house = house;
        this.bag = bag;
    }

    public void changeName(final String name) {
        this.name = name;
    }

    public void changeHouse(House house) {
        this.house = house;
    }
}
@Slf4j
@Service
public class HouseService {
    private HouseRepository houseRepository;

    public HouseService(HouseRepository houseRepository) {
        this.houseRepository = houseRepository;
    }

    @Transactional(readOnly = true)
    public List<String> findStaffNames() {
        List<House> houses = houseRepository.findAll();
        return filterStaffNames(houses);
    }

    private List<String> filterStaffNames(List<House> houses) {
        return houses.stream()
                .map(house -> house.getStaffs().get(0).getName())
                .collect(Collectors.toList());
    }
}

위 코드를 간단하게 정리하자면 House, Staff 2개의 Entity가 존재합니다. House 는 N 개의 Staff를 가질 수 있습니다.

Service에서 House를 조회 후 Staff를 가져오는 로직입니다. 

 

테스트 코드로 실행시켜 보겠습니다. 

@Test
@DisplayName("staff 명 조회 N + 1 테스트")
void findStaffNames_test() {
    //given
    setStaffTestData();

    //when
    List<String> staffNames = houseService.findStaffNames();

    //then
    assertThat(staffNames).isNotNull();
    assertThat(staffNames).hasSize(10);
}

private void setStaffTestData() {
    List<House> houses = new ArrayList<>();

    for (int i = 0; i < 10; i++) {
        House house = House.builder()
                .name("강남아파트" + i)
                .build();

        house.addStaff(
                Staff.builder()
                        .name("임용태" + i)
                        .build()
        );
        houses.add(house);
    }
    houseRepository.saveAll(houses);
}

 

결과는 아래와 같은 N + 1 입니다.

select
    house0_.id as id1_1_,
    house0_.name as name2_1_ 
from
    house house0_
    
    
select
    staffs0_.house_id as house_id6_2_0_,
    staffs0_.id as id1_2_0_,
    staffs0_.id as id1_2_1_,
    staffs0_.age as age2_2_1_,
    staffs0_.bag_id as bag_id5_2_1_,
    staffs0_.house_id as house_id6_2_1_,
    staffs0_.last_name as last_nam3_2_1_,
    staffs0_.name as name4_2_1_,
    staffs0_.store_id as store_id7_2_1_,
    bag1_.id as id1_0_2_,
    bag1_.name as name2_0_2_,
    store2_.id as id1_3_3_,
    store2_.address as address2_3_3_,
    store2_.name as name3_3_3_ 
from
    staff staffs0_ 
left outer join
    bag bag1_ 
        on staffs0_.bag_id=bag1_.id 
left outer join
    store store2_ 
        on staffs0_.store_id=store2_.id 
where
    staffs0_.house_id=1   // 10개 반복    

이유는 House에서 Staff를 조회할 때 Lazy로 걸려있기 때문입니다.

처음 House를 1번 조회하고 루프를 돌면서 Staff를 조회할 때 N번 조회를 발생시킵니다. 만약 Staff의 개수가 기하급수적으로 늘어나게 된다면 성능저하가 발생할 수 밖에 없습니다.

이런 불필요한 리소스 낭비를 줄이기 위해서 조건성으로 Lazy를 쓰지않고 Eager로 조회할 수 있는 방법에 대해서 알아 보겠습니다.

 

Fetch Join

join 대상인 Entity에 fetch join을 작성하는 것 입니다.

public interface HouseRepository extends JpaRepository<House, Long>, HouseQueryRepository {

    @Query("select h from House h join fetch h.staffs")		// Join fetch 적용
    List<House> findAllByJoinFetch();
}
select
    house0_.id as id1_1_0_,
    staffs1_.id as id1_2_1_,
    house0_.name as name2_1_0_,
    staffs1_.age as age2_2_1_,
    staffs1_.bag_id as bag_id5_2_1_,
    staffs1_.house_id as house_id6_2_1_,
    staffs1_.last_name as last_nam3_2_1_,
    staffs1_.name as name4_2_1_,
    staffs1_.store_id as store_id7_2_1_,
    staffs1_.house_id as house_id6_2_0__,
    staffs1_.id as id1_2_0__ 
from
    house house0_ 
inner join
    staff staffs1_ 
        on house0_.id=staffs1_.house_id

결과는 inner join으로 쿼리를 작성하여 한 번만 실행하는 것을 확인할 수 있습니다.

 

EntityGraph

어노테이션 EntityGraph를 사용합니다. join 대상을 작성해줍니다.

public interface HouseRepository extends JpaRepository<House, Long>, HouseQueryRepository {
    @EntityGraph(attributePaths = {"staffs"})	// 어노테이션 사용
    @Query("select h from House h")
    List<House> findAllEntityGrapeWithStaff();
}
select
    house0_.id as id1_1_0_,
    staffs1_.id as id1_2_1_,
    house0_.name as name2_1_0_,
    staffs1_.age as age2_2_1_,
    staffs1_.bag_id as bag_id5_2_1_,
    staffs1_.house_id as house_id6_2_1_,
    staffs1_.last_name as last_nam3_2_1_,
    staffs1_.name as name4_2_1_,
    staffs1_.store_id as store_id7_2_1_,
    staffs1_.house_id as house_id6_2_0__,
    staffs1_.id as id1_2_0__ 
from
    house house0_ 
left outer join
    staff staffs1_ 
        on house0_.id=staffs1_.house_id

결과는 left outer join으로 쿼리를 작성하여 한 번만 실행하는 것을 확인할 수 있습니다.

 

fetch join && EntityGraph

두 가지 모두 Lazy 관계를 Eager로 조회할 수 있는 방법입니다. 

차이점으로는 fetch join은 inner join 을 사용하고 EntityGraph는 left outer join을 사용합니다.

공통점으로는 카테시안의 곱이 발생합니다. 코드로 살펴보겠습니다.

@Test
@DisplayName("staff 명 조회 N + 1 회피 테스트 (카테시안 곱)")
void findStaffName_cartesian_product_test() {
    //given
    setStaffTestData();

    //when
    List<House> allByJoinFetch = houseRepository.findAllByJoinFetch();
    List<House> allEntityGrapeWithStaff = houseRepository.findAllEntityGrapeWithStaff();

    //then
    assertThat(allByJoinFetch).hasSize(20);
    assertThat(allEntityGrapeWithStaff).hasSize(20);
}

private void setStaffTestData() {
    List<House> houses = new ArrayList<>();

    for (int i = 0; i < 10; i++) {
        House house = House.builder()
                .name("강남아파트" + i)
                .build();

        house.addStaff(Staff.builder().name("임용태"+ i).build());
        house.addStaff(Staff.builder().name("임용태님"+ i).build());

        houses.add(house);
    }

    houseRepository.saveAll(houses);
}

위 쿼리를 실행하면 House 데이터 10개가 조회되지 않고 20개가 조회됩니다. 이유는 House에 관계매핑된 Staff의 데이터가 20개이므로 총 20개가 조회가 됩니다. 

 

문제 해결방법으로는 List 대신 Set을 사용하여 중복값을 제거하는 방법입니다.

public class House {
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "house")
    private Set<Staff> staffs = new LinkedHashSet<>();    // 순서 보장!
}

다른 방법으로는 distinct를 사용하여 중복값을 제거하는 방법입니다.

public interface HouseRepository extends JpaRepository<House, Long>, HouseQueryRepository {
    @Query("select distinct h from House h join fetch h.staffs")
    List<House> findAllByJoinFetch();
}

 

NamedEntityGraph

이 방법은 Entity에 어노테이션 NamedEntityGraph를 작성하는 방법입니다.

이동욱님의 블로그를 참조해보니 조건적으로 Lazy에서 Eager로 조회하는 방법은 Repository, 로직에서 할 일이라고 작성해놓으셨습니다. 

제 생각도 비슷합니다. Entity는 Table의 대한 정보와 관계를 매핑하는 역할이고 그 외적으로는 로직에서 변경해야한다고 생각합니다.

반응형