이 글은 현재 진행하고있는 Saltit 프로젝트 수행 중에 만난 문제에 대해서 정리한다.
n+1문제는 잘 알려진 문제이다. 이를 위한 기본적인 해결책으로 join fetch를 사용하는 것 역시 아주 잘 알려져 있다. 하지만 이번에 n+1 문제를 해결하면서 만난 특이한 경험을 공유하려고 한다. 이를 위해서 @OneToOne Mapping이 걸려 있는 경우에 Laze Loading이 작동하지 않는 다는 사실과, 예상하지 못한 횟수의 쿼리가 날아간 사례를 설명하고, 해결했던 방법을 공유하고자 한다.
n+1 문제를 만나다
아래와 같은 Restaurant과 연관된 Entity, Repository, Service가 존재한다.
@Entity
public class Restaurant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "restaurant", cascade = CascadeType.ALL)
private RestaurantLocation location;
@OneToOne(mappedBy = "restaurant", cascade = CascadeType.ALL)
private RestaurantInformation information;
@OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL)
private List<RestaurantMenu> menus = new ArrayList<>();
}
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {
}
@Service
@RequiredArgsConstructor
@Transactional
public class RestaurantService {
private final RestaurantRepository restaurantRepository;
public RestaurantDetailResponse getRestaurantDetail(long restaurantId) {
Restaurant restaurant = restaurantRepository.findById(restaurantId).orElseThrow(NoRecordException::new);
return RestaurantDetailResponse.of(restaurant);
}
}
현재의 상황에서 RestaurantService의 getRestaurantDetail을 호출하면 n+1 문제가 발생한다. 이는 Restaurant 엔티티의menus 필드가 Lazy Loading하도록 설정되어 있기 때문이다.(@OneToMany의 fetch 설정은 Lazy가 기본이다)
따라서 n+1 문제를 해결하기 위해서 다음과 같이 RestaurantRepository에 left join fetch를 JPQL로 선언해둔 메서드를 추가하고, RestaurantService 코드를 수정했다.
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {
@Query(value = "select r from Restaurant r left join fetch r.menus where r.id=:id")
Optional<Restaurant> findByIdWithMenus(@Param(value = "id") long id);
}
@Service
@RequiredArgsConstructor
@Transactional
public class RestaurantService {
private final RestaurantRepository restaurantRepository;
public RestaurantDetailResponse getRestaurantDetail(long restaurantId) {
Restaurant restaurant = restaurantRepository.findByIdWithMenus(restaurantId).orElseThrow(NoRecordException::new);
return RestaurantDetailResponse.of(restaurant);
}
}
그럼 나의 상식대로는 n+1 문제가 해결되었어야 한다! 그런데 그렇지 않았다.
해당 메서드를 실행해 본 결과 다음과같은 쿼리가 수행되었다.(쉬운 이해를 위해 축약되었다.)
select * from restaurant r left join restaurant_menu m on r.id = m.restaurant_id where r.id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
최초에 걱정했던 join했던 menu에 대한 n+1문제는 깔끔하게 해결되었지만, 다른 쿼리들이 날아가는 것이다. 그것도 중복으로 엄청 많이!
@OneToOne Mapping은 Lazy Loading이 안될 수 있다.
결론부터 이야기하면, 결론부터 말하자면 연관관계 주인쪽 엔티티 측에서는 Lazy 로딩이 정상적으로 동작한다. 하지만 연관관계의 주인이 아닌 엔티티측에서는 Lazy로딩이 이루어지지 않는다.
이 이야기를 하기 전에 Hibernate에서 Lazy Loading을 지원하는 방식을 이해할 필요가 있다.(https://thorben-janssen.com/hibernate-tip-lazy-loading-one-to-one/)
Hibernate는 Proxy는 Null을 감싸지 않는다. 이는 간단하게 생각해볼 수 있다. RestaurantLocation의 Proxy를 만드는 과정을 상상해보자.
최초에 Proxy는 RestaurantLocation을 상속받아서 프록시로 만들 것이다. 이때 프록시는 실제 데이터를 가지고 있지 않지만, 단 하나 PK값은 알고 있어야 한다. PK값을 알고 있어야 해당 레코드에 직접 접근해야 하는 시점에 쿼리를 날릴 수 있기 때문이다.
어라? 그럼 그냥 PK값도 모르고 실제 레코드에 접근해야 하는 시점에 쿼리를 달리면 되지 않나요? 아래의 쿼리처럼요 이거처럼요!
slect * from restaurant_location where restaurant_id=1
좋다. 그럼 PK를 저장하지 않고 사용자가 데이터에 직접 접근할 때 해당 쿼리를 수행하는 방식은 어떨까? 사용자가 특정 데이터에 접근할 때(restaurantLocation.getRoadNumber()) 앞서 언급한 쿼리가 날아갈 것이다. 그런데 이 쿼리의 결과가 0개라면 Proxy는 어떤 결과를 돌려줘야 할까? 아마도 Exception일 것이다. 그럼 사용자는 직접 접근하기 전까지 해당 필드가 존재하는지 아닌지 알 수 없다.
Hibernate를 만든 사람은 사용자가 데이터의 존재여부를 확인하는 시점을 최초에 Entity를 조회하는 시점으로 통일하기 위해서 다음과 같은 원칙을 세운듯 하다.
Proxy는 Null이거나, 실제 데이터를 담고 있어야 한다.
이제 다음 원칙을 만족하기 위해서 Hibernate가 @OneToOne Mapping된 필드를 Lazy Loading 조건부로 지원하는 이유를 이해할 수 있다. 만약 해당 필드가 실제 테이블에서 외래키를 가지고 있지 않다면, 그니까 연관관계의 주인이 아니라면 실제 데이터의 존재여부를 확인해서 Null이나 실제 데이터에 접근할 수 있는 Proxy를 반환해야 하는 것이다. 그런데 이를 위해서 한번 데이터를 조회한 이상 Lazy Loading의 의미가 사라졌기 때문에 연관관계의 주인이 아닌 @OneToOne Mapping된 필드는 Lazy Loading을 지원하지 않는 것이다.
그런데 왜 Eager로 설정된 필드를 여러번 조회하지?
앞서 서술한 이유를 차치하더라도 최초에 우리가 확인했던 쿼리는 이상하다. 앞선 쿼리를 다시한번 확인해보자.
select * from restaurant r left join restaurant_menu m on r.id = m.restaurant_id where r.id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
select * from restaurant_information where restaurant_id = 1;
select * from restaurant_location where restaurant_id = 1;
RestaurantInformation, RestaurantLocation 2개의 Entity는 @OneToOne으로 매핑되어있으니 쿼리가 추가적으로 날아가는 것에 의문이 생기지 않았다. 하지만 왜 하필 각각 6번씩 날아가는 것인가?
(이때 PK가 1인 레스토랑의 메뉴는 총 6개이다.)
Spring 코드를 뜯어 본 결과는 Query를 날려서 받아온 데이터의 Low 갯수만큼 반복문을 돌면서 Entity를 Initialize 하기 때문이었다.
아래의 코드는 org.hibernate.sql.results.spi.ListResultsConsumer.consume() 함수의 일부이다.
while ( rowProcessingState.next() ) {
results.addUnique( rowReader.readRow( rowProcessingState, processingOptions ) );
rowProcessingState.finishRowProcessing();
}
while문에서 최초로 rowProcessingState.next()를 호출하면 우리가 의도한 쿼리가 실제로 날아가게 된다. 나의 경우에는 select * from restaurant r left join restaurant_menu m on r.id = m.restaurant_id where r.id=1;
였다. 그리고 아래 줄에서 rowReader.readRow() 함수에서 각 row를 확인해서 Entity를 채워준다. 아래는 org.hibernate.sql.results.internal.StandardRowReader.readRow() 코드이다.
public T readRow(RowProcessingState rowProcessingState, JdbcValuesSourceProcessingOptions options) {
LoadingLogger.LOGGER.trace( "StandardRowReader#readRow" );
coordinateInitializers( rowProcessingState );
final Object[] resultRow = new Object[ assemblerCount ];
for ( int i = 0; i < assemblerCount; i++ ) {
final DomainResultAssembler assembler = resultAssemblers.get( i );
LoadingLogger.LOGGER.debugf( "Calling top-level assembler (%s / %s) : %s", i, assemblerCount, assembler );
resultRow[i] = assembler.assemble( rowProcessingState, options );
}
afterRow( rowProcessingState );
return rowTransformer.transformRow( resultRow );
}
그리고 coordinateInitializers()에서 row에 들어있는 각 데이터를 통해 를 통해 Entity의 필드를 채워주는데, 해당 코드는 다음과 같다.
private void coordinateInitializers(RowProcessingState rowProcessingState) {
final int numberOfInitializers = initializers.size();
...(중략)...
for ( int i = 0; i < numberOfInitializers; i++ ) {
initializers.get( i ).initializeInstance( rowProcessingState );
}
}
여기서 List<Initializer> initializers에서 순서대로 객체를 꺼내와서 initializeInstance를 호출하게 되는데, 꺼내온 Initializer객체들은 각각 DB에서 조회에온 필드를 담고있다. 이 중에서 특히 @OneToOne으로 매핑된 필드의 Initializer는 EntitySelectFetchInitialzer를 구현체로 가지고 있는데, 이 객체가 Entity의 해당 필드를 Initialize하는 방식이 직접 DB에 쿼리 하는 방식이었던 것이다!
Lazy Loading이 안된다면 Fetch Join으로
결국 해당 문제를 해결하기 위해서 Lazy Loading이 되지 않는다면 join fetch을 사용해서 한번에 가져오는 방식으로 문제를 해결했다. 이에대한 다른 해결책으로 Shared Key를 사용하거나, @OneToOne 관계를 @OneToMany/@ManyToOne으로 변형해서 사용하는 방식이 있는데, 최대한 직관적인 방식으로 해결했다.
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {
@Query(value = "select r from Restaurant r left join fetch r.menus left join fetch r.information left join fetch r.location where r.id=:id")
Optional<Restaurant> findByIdWithMenus(@Param(value = "id") long id);
}
궁금한 점이 있으면 언제든 댓글 달아주세요!
'Spring' 카테고리의 다른 글
Spring Data JPA의 Transaction에서 Query를 실행하는 순서 (0) | 2024.10.21 |
---|