회사에서 간단하게 조회하는 API를 구성했는데, 성능 저하가 의심되어 로그를 확인해보니 단 한번의 쿼리가 발생하는 것이 아닌 여러 개의 쿼리가 날라가는 것을 확인할 수 있었다. 즉, 요구한 데이터 외에 연관된 데이터까지 한번에 불러오는 것이었다. 조회하고자 하는 엔티티에서는 다른 엔티티와 일대일 관계로 @OneToOne
이 구성되어있다. 여기서 왜 연관된 데이터까지 가져오는지 정리해보고자 한다.
JPA에서는 데이터를 조회할 때 즉시 로딩(EAGER)과 지연 로딩(LAZY)가 있다. 즉시 로딩은 데이터를 조회할 때 연관된 데이터까지 한번에 불러오는 것이고, 지연 로딩은 필요한 시점에 연관된 데이터를 불러오는 것이다.
내가 참고한 레퍼런스에서는 예시로 Member와 Team을 다대일 매핑하여 두 로딩 방식의 차이를 정리해놓았는데, 각 방식별로 동작한 쿼리만 가져와보자. (참고로 @xxToOne
은 기본적으로 즉시 로딩으로 동작하고, @xxToMany
에서는 지연 로딩이 기본이라고 한다!)
// 멤버를 조회하는 쿼리
select
member0_.id as id1_0_,
member0_.team_id as team_id3_0_,
member0_.username as username2_0_
from
Member member0_
// 팀을 조회하는 쿼리
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
// 팀을 조회하는 쿼리
select
member0_.id as id1_0_,
member0_.team_id as team_id3_0_,
member0_.username as username2_0_
from
Member member0_
여기서 조금 더 과장하자면, Team 엔티티에 연관되어있는 엔티티가 1000개이고 즉시 로딩으로 동작할 경우, Team만을 조회할 뿐인데도 1000개의 추가적인 쿼리가 발생하게 된다.
1개의 쿼리를 날리느냐 1000개의 쿼리를 날리느냐를 따진다면, 당연히 1개의 쿼리를 날리는 것이 성능적으로 좋다는 것은 누구나 생각할 수 있듯이, 즉시 로딩보다는 지연 로딩이 성능적인 이점을 가지고 있으므로 가능하면 지연 로딩을 사용하는 편이 좋은 것 같다. (물론 지연 로딩이 무조건적으로 좋다는 것은 아니다!)
나 또한 내가 구성한 API에 지연 로딩으로 옵션을 주었지만 결과는 변하지 않았다. 찾아보니 @OneToOne
관계에서는 지연 로딩이 동작하지 않는 문제가 있다고 한다. 더 정확하게는 연관 관계 주인에서는 지연 로딩이 정상적으로 잘 동작하지만, 주인이 아닌 곳에서는 지연 로딩이 아닌 N+1 쿼리가 발생하는 것이다.
왜 그럴까? 🤔
이 문제는 JPA의 구현체인 Hibernate에서 프록시 기능의 한계로 지연 로딩을 지원하지 못하기 때문에 발생한다고 한다.
Member와 Team의 관계에서 Member가 Team의 FK를 지니고 있는 상황을 생각해보자. Member에는 Team의 정보인 FK를 가지고 있지만, Team에서는 Member에 관한 값이 존재하지 않는다. 따라서 Team에서 Member 값을 가져오려면 Member를 조회해야 한다.
JPA도 마찬가지다. 프록시 객체를 만들기 위해서는 연관 객체에 값이 있는지 없는지 알아야 하는데 Team에서는 Member에 대한 값이 없기 때문에 만들지 못한다는 의미다. 결국 Member에 대한 쿼리가 추가적으로 발생하기 때문에 Hibernate는 프록시 객체를 만들 필요가 없어진다. 따라서 지연 로딩으로 설정해도 소용이 없다는 것이다.
반대로 Member에서 Team을 조회할 때는 Team의 FK - 즉, Team에 대한 정보를 가지고 있기 때문에 프록시 객체를 생성하고 지연 로딩으로 쿼리를 수행한다.
내가 읽은 레퍼런스에 따르면, 이 문제의 해결책으로 Fetch Join과 Entity Graph를 말했다. 이 두 개의 기술은 명칭만 다를 뿐 동작은 동일하게 동작하는데 - 간단하게 이해하자면, 단 하나의 쿼리로 조회하는 개념으로 생각하면 된다. 레퍼런스에 적혀있는 사용 방법을 가져와보자. (만약 쿼리가 복잡하면 직접 작성을 해야하기에 QueryDSL이나 JPQL을 사용하여 Fetch Join을 사용할 수 밖에 없다고 한다.)
interface LockerRepository: CrudRepository<Locker, Long> {
/* fetch join example */
@Query("select l from Locker l left join fetch l.member where l.id = :lockerId")
fun findByIdWithFetchJoin(lockerId: Long): Locker
/* entity graph example */
@EntityGraph(attributePaths = ["member"])
fun findTopById(lockerId: Long): Locker
}
결과적으로 앞에서 두 개의 조회문이 날라가는 것에 비해 하나의 쿼리문으로 동작하는 것을 확인할 수 있다.
select
locker0_.locker_id as locker_i1_0_0_,
member1_.member_id as member_i1_1_1_,
locker0_.name as name2_0_0_,
member1_.locker_id as locker_i3_1_1_,
member1_.username as username2_1_1_,
from
locker locker0_
left outer join
member member1_
on locker0.locker_id=member1_.locker_id
where
locker0_.locker_id=?
현재 내 능력으로는 이 기술을 사용하는 것이 최선이라고 생각하지만 더 좋은 해결책이 있을 것 같다. 여기서는 문제의 원인을 찾고 이해한 것만으로 만족하자. 🔥