이 글은 우아한 형제들 콘서트에서 이동욱님의 영상을 보고 정리를 위한 글입니다.
이 글에 작성된 예시는 모두 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의 대한 정보와 관계를 매핑하는 역할이고 그 외적으로는 로직에서 변경해야한다고 생각합니다.
'Develop > spring-data' 카테고리의 다른 글
Querydsl Join Table Sort 적용 ( 번외로 Pageable와 비슷한 것을 구현해보자! ) (0) | 2021.03.14 |
---|---|
[Querydsl] 성능개선 - 3편 ( group by, 커버링 인덱스, update ) (2) | 2021.02.04 |
[Querydsl] 성능 개선 1편 (0) | 2021.01.29 |
[Querydsl-JPA] Querydsl JPA를 사용하며.. "*" 아스타리스크 사용방법 (0) | 2020.12.20 |
[Querydsl] Querydsl을 적용하며.. class 파일명 주의 (1) | 2020.12.18 |