[JPA] N+1 문제 해결: 62개의 쿼리를 3개의 쿼리로

들어가며

숙소 예약 서비스를 개발하던 중 가장 많이 호출되는 숙소 검색 API의 응답속도가 느리다는 것을 파악하고, 쿼리를 개선하는 것을 목표로 작업을 하게 되면서 만나게 된 N + 1 문제의 해결 과정을 공유하고자 합니다.


수많은 쿼리 발견

숙소 예약 서버스 API를 개발하고, API 성능을 테스트하기 위해 100만 건의 더미 데이터를 집어넣고 테스트하던 중 유독 응답속도가 느린 API를 발견했습니다.

 

바로 "숙소 검색 API"였습니다.

 

 

응답 속도: 3274 ms

 

숙소 검색 API를 호출해 응답 속도를 확인해 본 결과 약 3초라는 굉장히 느린 응답속도를 확인할 수 있었습니다.

100만 건의 숙소 데이터가 있다고 감안해도 이상할 정도로 느린 응답속도였습니다.

 

서버부하나 네트워크에 문제가 있는 건 아니라는 것을 확인했고, 그럼 데이터베이스 측면에 문제가 있을 거라 판단되어 숙소 검색 API의 쿼리는 어떻게 실행되고 있는지 확인해 봤습니다.

 

쿼리를 확인한 결과 눈을 의심할 수밖에 없는 결과가 나오고 말았습니다.

 

실행된 쿼리

더보기

Hibernate: 
    select
        a1_0.id,
        a1_0.address_id,
        a1_0.bathroom_count,
        a1_0.bed_count,
        a1_0.bedroom_count,
        a1_0.created_at,
        a1_0.currency,
        a1_0.description,
        a1_0.max_guests,
        a1_0.name,
        a1_0.price_per_day,
        a1_0.updated_at 
    from
        accommodation a1_0 
    join
        address a2_0 
            on a2_0.id=a1_0.address_id 
    where
        not exists(select
            1 
        from
            availability a3_0 
        where
            a3_0.accommodation_id=a1_0.id 
            and a3_0.date in (?, ?)) 
        and st_distance_sphere(a2_0.location, ?)<=? 
    order by
        a1_0.price_per_day 
    limit
        ?, ?
Hibernate: 
    select
        count(a1_0.id) 
    from
        accommodation a1_0 
    join
        address a4_0 
            on a4_0.id=a1_0.address_id 
    where
        not exists(select
            1 
        from
            availability a2_0 
        where
            a2_0.accommodation_id=a1_0.id 
            and a2_0.date in (?, ?)) 
        and st_distance_sphere(a4_0.location, ?)<=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?
Hibernate: 
    select
        a1_0.id,
        a1_0.base_address,
        a1_0.country,
        a1_0.detailed_address,
        a1_0.location 
    from
        address a1_0 
    where
        a1_0.id=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id=?

스크롤을 계속 내려도 끝나지 않는 수많은 쿼리들이 쌓여있었습니다.

 

잘못되었음 느끼고, 정확히 어떤 쿼리가 발생했는지 하나하나 확인해 봤습니다.

확인해 본 결과 다음과 같은 쿼리 실행되고 있었습니다.

- 숙소를 조회하는 쿼리 1개

- 숙소의 개수를 조회하는 COUNT 쿼리 1개

- 숙소 주소 정보를 조회하는 쿼리 30개

- 숙소 옵션 정보를 조회하는 쿼리 30개

 

이렇게 해서 총 62개의 쿼리가 발생하고 있었습니다.

 

숙소 검색 API에서 예상했던 쿼리는 숙소를 조회하는 쿼리 1개숙소의 개수를 조회하는 COUNT 쿼리 1개만 실행되는 것이었습니다.

 

근데 추가로 60개의 쿼리라니... 전혀 예상치 못한 쿼리에 참 당황스러웠습니다.

 

불필요하게 많은 쿼리가 발생하는 이유가 무엇일까 궁금증이 발생했고 이를 해결하고자, 인터넷 검색과 JPA 서적을 통해 해당 문제가 "N + 1"이라는 문제임 알게 되었습니다.

 

어떤 문제인지를 알았으니, 현재 숙소 검색 API에서 N + 1 문제가 왜 발생했는지 분석해 보기로 했습니다.


테이블 구조와 연관관계

문제를 분석하기에 앞서 숙소 검색 API 관련 데이터베이스 테이블 구조는 다음과 같습니다.

숙소 검색 API 관련 테이블 ERD

- accommodation: 숙소 정보를 저장하는 테이블

- address: 숙소 주소 정보를 저장하는 테이블

- label: 숙소 옵션 정보를 저장하는 테이블(에어컨, TV, 전자레인지 등등)

 

accommodation과 address 테이블은 1:1 관계이며, 단방향 관계이고

accommodation과 label 테이블은 N:M 관계이며, 양방향 관계이기에 accommodation_label을 중간 테이블로 두었습니다.


문제 원인 분석

"왜 수많은 쿼리 추가로 발생했을까?"라는 궁금증 가운데 우선 Accommodation 엔티티 클래스를 살펴봤습니다.

@Entity
public class Accommodation {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@OneToOne(optional = false, cascade = CascadeType.ALL, fetch = FetchType.LAZY)
	@JoinColumn(name = "address_id", nullable = false)
	private Address address;

	@ManyToMany(fetch = FetchType.LAZY)
	@JoinTable(
		name = "accommodation_label",
		joinColumns = @JoinColumn(name = "accommodation_id"),
		inverseJoinColumns = @JoinColumn(
			name = "label_id",
			columnDefinition = "VARCHAR(50) NOT NULL")
	)
	private List<Label> labels;

	...
}

 

엔티티를 살펴봤을 때, address와 label의 Fetch 전략FetchType.LAZY로 되어있었습니다.

 

JPA 구현체인 Hibernate는 연관 관계 엔티티를 조회할 때 불필요한 오버헤드를 줄이고 성능을 최적화하기 위해 두 가지 Fetch 전략을 제공합니다.

- EAGER(즉시 로딩): 해당 엔티티가 조회되면 연관된 엔티티를 즉시 DB에서 join 하여 조회해 오는 전략

- LAZY(지연 로딩): 해당 엔티티가 조회돼도 연관된 엔티티를 필요할 때 DB에서 조회 오는 전략

 

현재 상황에 대입해서 설명해자면, 다음과 같습니다.

 

EAGER(즉시 로딩)인 경우

accommodation을 조회하면, 연관된 address, label 정보까지 DB에서 함께 조회합니다. 즉, 숙소 정보를 가져올 때마다 연관된 모든 데이터를 한 번에 가져오기 때문에 편리하지만, 불필요한 데이터를 많이 가져올 수 있어 성능에 부담이 될 수 있습니다.

 

LAZY(지연 로딩)인 경우

숙소 정보를 조회하면, accommodation 정보만 DB에서 가져오고, 연관된 address, label 정보는 실제로 필요할 때까지 조회하지 않습니다. 대신, 연관 엔티티 자리에 Proxy 객체를 만들어 둡니다.

 

그리고 필요한 시점에 Proxy 객체를 통해 영속성 컨텍스트에 실제 엔티티 생성을 요청합니다. 요청이 들어오면 영속성 컨텍스트는 DB를 조회해 실제 엔티티를 생성하고, 생성된 엔티티의 참조 값을 Proxy 객체의 target 변수에 저장합니다. 이후 Proxy를 통해 엔티티를 호출하면, target 참조 값을 이용해 실제 데이터를 반환합니다.

 

이런 특성을 고려하여 엔티티를 설계할 때, 숙소의 주소나 라벨 정보를 숙소를 조회할 때마다 매번 가져오는 것은 비효율적이라고 판단하여, 두 연관 엔티티의 Fetch 전략을 LAZY로 설정했습니다.

 

의도한 대로라면, LAZY 전략이 적용되어 있기 때문에 addresslabel 엔티티는 실제로 사용하지 않으면 조회되지 않아야 합니다. 그런데 현재 두 엔티티를 조회하는 쿼리가 발생한다는 것은, 코드 어딘가에서 이 엔티티를 실제로 사용하고 있다는 의미었습니다.

 

정확히 어디서 address와 label이 어디서 사용되고 있는 확인하기 위해, 숙소 검색 API 관련 코드를 살펴보았습니다.

@Transactional(readOnly = true)
public AccommodationPageResponse findFilteredAccommodations(AccommodationSearchRequest request) {
    FilterCondition filterCondition = toCondition(request);
    Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize());

    Page<Accommodation> accommodationPage = accommodationRepository.findFilteredAccommodations(pageable,
        filterCondition);
    return toResponse(accommodationPage);
}
private AccommodationPageResponse toResponse(Page<Accommodation> accommodationPage) {
    return AccommodationPageResponse.builder()
        .page(accommodationPage.getNumber() + 1)
        .pageSize(accommodationPage.getSize())
        .totalElements(accommodationPage.getTotalElements())
        .totalPages(accommodationPage.getTotalPages())
        .accommodationResponses(accommodationPage.getContent()
            .stream()
            .map(AccommodationMapper::toResponse)
            .collect(Collectors.toList()))
        .build();
}
public class AccommodationMapper {
    public static AccommodationResponse toResponse(Accommodation accommodation) {
        return AccommodationResponse.builder()
            .id(accommodation.getId())
            .addressResponse(toResponse(accommodation.getAddress()))
            .labelResponses(toResponses(accommodation.getLabels()))
            .name(accommodation.getName())
            .description(accommodation.getDescription())
            .pricePerDay(accommodation.getPricePerDay())
            .currency(accommodation.getCurrency())
            .maxGuests(accommodation.getMaxGuests())
            .bedCount(accommodation.getBedCount())
            .bedroomCount(accommodation.getBedroomCount())
            .bathroomCount(accommodation.getBathroomCount())
            .createdAt(accommodation.getCreatedAt())
            .updatedAt(accommodation.getUpdatedAt())
            .build();
    }
}

 

 

.addressResponse(toResponse(accommodation.getAddress()))
.labelResponses(toResponses(accommodation.getLabels()))

 

숙소 검색 비즈니스 로직에 DB를 조회하고 마지막 DTO 객체를 생성하는 과정에서 address와 label 엔티티를 사용하고 있는 걸 확인할 수 있었습니다.

 

상황을 정리해 보자면, 숙소 검색 API 요청으로 처음 숙소 정보를 검색할 때는  숙소를 조회하는 쿼리 1개숙소의 개수를 조회하는 COUNT 쿼리 1개만 발생하지만, 그 이후 DTO 객체를 만드는 시점에 getAddress()getLabels()를 요청하게 되면서, 결과로 나온 30개의 숙소와 일치하는 주소와 옵션 정보를 조회하기 위해 추가 각각 30번씩 쿼리를 실행된 것이었습니다.


문제 해결 

문제의 원인을 정확히 파악했으니, 이제 어떻게 이 문제를 해결할지 고민했습니다.

 

N+1 문제를 해결하기 위해서는 3가지 정도의 방법이 존재합니다.

Fetch Join 

- 연관된 엔티티를 JPQL이나 Query DSL에서 join fetch로 함께 한번에 가져오는 방법.

@BatchSize

- Hibernate가 Lazy 로딩 시, 한 번에 여러 건을 IN 절로 묶어서 조회하도록 하는 방법.

@Fetch(FetchMode.SUBSELECT)

- 지연 로딩된 컬렉션을 조회할 때, Hibernate가 한 번의 서브쿼리로 모아서 가져오는 방법

 

이 중 저는 Fetch Join과 @BatchSize를 조합하여 문제를 해결했습니다.

그 이유는 보통 N+1 문제는 Fetch Join을 통해 해결하지만, 현재처럼 페이지네이션을 하고 있는 경우 ToMany 연관관계(1:N, N:M 에서 Fetch join을 할 경우 정확한 결과를 얻지 못하는 경우가 있기 때문입니다.

 

예를 들어 숙소 A에는 옵션 1, 2, 3라는 옵션이 존재하고, 숙소 B에는 옵션 2, 3라는 옵션이 존재한다고 가정하겠습니다.

accommodation와 label이 Fetch Join을 해서 데이터를 가져오고 2개만 페이지네이션 한다고 하면, 숙소 + 옵션 조합의 모든 row를 기준으로 limit 2개를 하기 때문에 결과가 중복될 수도 있고 누락될 수도 있습니다.

숙소A - 옵션1
숙소A - 옵션2  ← 여기까지만 LIMIT 2로 가져오게 됌
숙소A - 옵션3
숙소B - 옵션2
숙소B - 옵션3

 

이러한 이유로 label 같은 경우 N:M 관계이기 때문에 Fetch 조인을 사용하지 않고 @BatchSize를 통해 최대 페이지 크기만큼(30) 설정하여, 추가로 IN절을 통해 배치로 숙소의 옵션 정보를 조회하도록 수정했습니다.

@Entity
public class Accommodation {
	...
    
    @BatchSize(size = 30)
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
        name = "accommodation_label",
        joinColumns = @JoinColumn(name = "accommodation_id"),
        inverseJoinColumns = @JoinColumn(
            name = "label_id",
            columnDefinition = "VARCHAR(50) NOT NULL")
    )
    private List<Label> labels;

	...
}

 

그리고 1:1 관계인 address는 숙소 검색 API의 QueryDSL에 Fetch Join을 추가하여 수정했습니다.

List<Accommodation> accommodations = queryFactory
    .selectFrom(accommodation)
    .join(accommodation.address).fetchJoin() //fetch join으로 같이 주소 정보까지 한번에 조회
    .where(
        minPrice != null ? accommodation.pricePerDay.goe(minPrice) : null,
        maxPrice != null ? accommodation.pricePerDay.loe(maxPrice) : null,
        maxGuests != null ? accommodation.maxGuests.goe(maxGuests) : null,
        JPAExpressions.selectOne()
            .from(availability)
            .where(
                availability.accommodation.eq(accommodation),
                availability.date.in(requestedDates)
            )
            .notExists(),
        Expressions.booleanTemplate(
            "ST_Distance_Sphere({0}, {1}) <= {2}",
            accommodation.address.location,
            Expressions.constant(userLocation),
            RADIUS_METERS
        )
    )
    .orderBy(accommodation.pricePerDay.asc())
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .fetch();

 


테스트와 결과

문제가 제대로 해결되었는지 확인하기 위해 다시 숙소 검색 API를 호출하여, 실행되는 쿼리를 확인해 봤습니다.

Hibernate: 
    select
        a1_0.id,
        a1_0.address_id,
        a2_0.id,
        a2_0.base_address,
        a2_0.country,
        a2_0.detailed_address,
        a2_0.location,
        a1_0.bathroom_count,
        a1_0.bed_count,
        a1_0.bedroom_count,
        a1_0.created_at,
        a1_0.currency,
        a1_0.description,
        a1_0.max_guests,
        a1_0.name,
        a1_0.price_per_day,
        a1_0.updated_at 
    from
        accommodation a1_0 
    join
        address a2_0 
            on a2_0.id=a1_0.address_id 
    where
        a1_0.max_guests>=? 
        and not exists(select
            1 
        from
            availability a3_0 
        where
            a3_0.accommodation_id=a1_0.id 
            and a3_0.date in (?, ?)) 
        and st_distance_sphere(a2_0.location, ?)<=? 
    order by
        a1_0.price_per_day 
    limit
        ?, ?
Hibernate: 
    select
        count(a1_0.id) 
    from
        accommodation a1_0 
    join
        address a4_0 
            on a4_0.id=a1_0.address_id 
    where
        a1_0.max_guests>=? 
        and not exists(select
            1 
        from
            availability a2_0 
        where
            a2_0.accommodation_id=a1_0.id 
            and a2_0.date in (?, ?)) 
        and st_distance_sphere(a4_0.location, ?)<=?
Hibernate: 
    select
        l1_0.accommodation_id,
        l1_1.id,
        l1_1.name 
    from
        accommodation_label l1_0 
    join
        label l1_1 
            on l1_1.id=l1_0.label_id 
    where
        l1_0.accommodation_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

결과는 다음과 같았습니다.

- 숙소를 조회하는 쿼리 1개 (address를 join) 

- 숙소 개수를 조회하는 쿼리 1개

- 숙소 옵션 정보를 조회하는 쿼리 1개

 

총 3가지의 쿼리로 줄어든 것을 확인할 수 있었습니다.

 

이렇게 62개의 쿼리를 3개의 쿼리로 줄이면서, N+1 문제가 해결되었습니다!


마치며

이번 N+1 문제를 해결하면서, JPA에서 Proxy가 내부적으로 어떻게 동작하는지와 그 흐름을 이해할 수 있었고, 왜 다양한 Fetch 전략을 제공하는지, 각 전략이 어떤 방식으로 동작하며 어떤 상황에 적용해야 하는지 깊이 배울 수 있는 시간이었습니다.

 

또한 항상 느끼는 것이지만, 기술을 단순히 사용하는 데 그치지 않고 왜 사용해야 하는지, 내부적으로 어떻게 동작하는지를 이해하는 것이 얼마나 중요하고 성능 측면에서도 큰 차이를 만들어내는지 다시금 깨닫는 계기가 되었습니다.


참고 자료

 

Hibernate ORM 5.4.33.Final User Guide

Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat

docs.jboss.org

 

자바 ORM 표준 JPA 프로그래밍 | 김영한 - 교보문고

자바 ORM 표준 JPA 프로그래밍 | 자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA

product.kyobobook.co.kr