이 포스터에서는 회사에서 배치를 짜다가 만난 당황스러운 문제를 해결했던 경험을 공유한다.

 

우선 다음과 같은 테이블이 있다고 가정해보자.

create table test  
(  
    id bigint primary key,  
    name varchar(255) not null,  
    CONSTRAINT test_name_unique_key UNIQUE (name)  
);

insert into test value(1, '홍길동');

생성된 테이블

 

뻔한 테이블이다. 다만 특이한점이 하나 있다면 name에 unique 제약조건이 걸려있다는 것이다. 집중하자. 모든 문제는 이 UNIQUE 제약조건에서 출발한다.

 

 

이제 해당 테이블에 접근하는 서비스 로직을 작성해보자. 나는 '홍길동'이라는 이름의 레코드를 제거하고, 새로운 '홍길

동'이라는 이름을 가지는 레코드를 생성할 예정이다. 간단하다. 한번 로직을 작성해보자.

 

@Transactional
public void testServiceMethod() {
    // TestEntity.name은 unique 제약조건이 걸려있다.
    TestEntity testEntityList = new TestEntity(2L, "홍길동");

    testRepository.deleteByName("홍길동");
    testRepository.save(testEntityList);
}

 

그리고 이제 Controller와 Repository, Entity를 붙여서 이 코드를 실행해보자.

(글이 길어져서 좋을거 하나 없기 때문에 해당 내용은 생략한다)

 

이 코드를 보고 난 다음과 같은 절차 SQL이 실행되는 것을 기대했다.

1. delete * from test where name='홍길동'  - [이름이 홍길동인 레코드 제거]

2. insert into test (name, id) values ('홍길동', 2)  - [이름이 홍길동인 레코드 삽입]

 

그럼 부푼 마음으로 코드를 실행해보자!

 

그런데,,, 기대와 달리 코드는 에러를 뱉었다. 

java.sql.SQLIntegrityConstraintViolationException: (conn=366) Duplicate entry '홍길동' for key 'test_name_unique_key'

 

아니! 내가 분명 홍길동인 레코드를 제거한 후에 홍길동인 레코드를 삽입 했는데! 데체 왜! 이런 오류가 발생하는걸까!

 

일단.. 너무나도 바쁘니까 두 쿼리 사이에 flush()를 넣어서 문제를 해결했다. 

 

자.. 여기까지가 문제 상황이었다. 이제.. Spring 코드를 까보자.

 


Spring Data JPA에서는 지연 쿼리 저장소를 이용해서 Transaction이 Commit되는 시점에 쿼리들을 DB에 날린다. 그런데, 이 오류가 발생하는건 쿼리의 실행 순서가 내가 정의한 순서와 다르다는 뜻인걸까?

 

일단 단서를 찾기 위해 @Transaction어노테이션이 수행하는 AOP 코드를 찾아보자.

(진짜 하나 하나 코드가 수행되는 순서대로 디버그 모드로 읽어갔다. 코드 분석다는 다른 좋은 방법을 안다면 꼭 알려주길 바란다.)

 

코드를 모두 옮겨올 수 없기에, 간단한 흐름만 설명하자면 다음과 같다.

1. @Transaction이 걸려있는 Method는 TransactionInterceptor를 통해 AOP가 수행된다.

2. @Transaction이 걸려있는 우리의 서비스 메서드(testServiceMethod)가 수행되고 나면 TransactionAspectSupport클래스의 commitTransacitonAfterReturning Method가 실행된다.

3. 몇몇 작업을 하고,, 적절한 구현체를 찾아고는 과정을 수행한 후 JdbcCoordinatorImpl.beforeTransactionCompletion()을 호출한다.

4. 이 함수에서는 SessionImpl.beforeTransactionCompletion()을 호출하는데, 이 함수는 지연쿼리 저장소에 있던 모든 쿼리를 DB에 반영한다.

 

자 이제 진짜 문제가 나온다. SessionImpl에서 저장되어있던 모든 쿼리를 수행하는 방법을 살펴보자.

private void doFlush() {
    this.pulseTransactionCoordinator();
    this.checkTransactionNeededForUpdateOperation();

    try {
        if (this.persistenceContext.getCascadeLevel() > 0) {
            throw new HibernateException("Flush during cascade is dangerous");
        } else {
            FlushEvent event = new FlushEvent(this);
            this.fastSessionServices.eventListenerGroup_FLUSH.fireEventOnEachListener(event, FlushEventListener::onFlush);
            this.delayedAfterCompletion();
        }
    } catch (RuntimeException var2) {
        RuntimeException e = var2;
        throw this.getExceptionConverter().convert(e);
    }
}

우선 SessionImpl은 내부적으로 doFlush()를 호출하고, doFlush()에서는 FlushEventListener의 onFlush()라는 함수를 호출한다. 

 

이제 DefaultFlushEventListner.onFlush() 메서드를 살펴보자.

public void onFlush(FlushEvent event) throws HibernateException {
    EventSource source = event.getSession();
    PersistenceContext persistenceContext = source.getPersistenceContextInternal();
    if (persistenceContext.getNumberOfManagedEntities() <= 0 && persistenceContext.getCollectionEntriesSize() <= 0) {
        if (source.getActionQueue().hasAnyQueuedActions()) {
            this.performExecutions(source);
        }
    } else {
        try {
            source.getEventListenerManager().flushStart();
            this.flushEverythingToExecutions(event);
            this.performExecutions(source);
            this.postFlush(source);
        } finally {
            source.getEventListenerManager().flushEnd(event.getNumberOfEntitiesProcessed(), event.getNumberOfCollectionsProcessed());
        }
        this.postPostFlush(source);
        StatisticsImplementor statistics = source.getFactory().getStatistics();
        if (statistics.isStatisticsEnabled()) {
            statistics.flush();
        }
    }
}

# 생략...

protected void performExecutions(EventSource session) {
    LOG.trace("Executing flush");
    PersistenceContext persistenceContext = session.getPersistenceContextInternal();
    JdbcCoordinator jdbcCoordinator = session.getJdbcCoordinator();

    try {
        jdbcCoordinator.flushBeginning();
        persistenceContext.setFlushing(true);
        ActionQueue actionQueue = session.getActionQueue();
        actionQueue.prepareActions();
        actionQueue.executeActions();
    } finally {
        persistenceContext.setFlushing(false);
        jdbcCoordinator.flushEnding();
    }
}

 

 onFlush 함수를 살펴보면 실제로 DB에 요청을 날리는 것은 performExecutions 메서드라는 것을 알 수 있는데, 여기서 이상한 객체가 등장한다.

 

ActionQueue가 바로 그 이상한(?)놈 인데, 특히 actionQueue.executeActions()에서 Action을 직접 수행하고 있는 것 같다. 이놈이 의심스러우니 조금 더 살펴보자.

 

private static final OrderedActions[] ORDERED_OPERATIONS = ActionQueue.OrderedActions.values();


public void executeActions() throws HibernateException {
    if (this.hasUnresolvedEntityInsertActions()) {
        throw new IllegalStateException("About to execute actions, but there are unresolved entity insert actions.");
    } else {
        OrderedActions[] var1 = ORDERED_OPERATIONS;
        int var2 = var1.length;

        for(int var3 = 0; var3 < var2; ++var3) {
            OrderedActions action = var1[var3];
            this.executeActions(action.getActions(this));
        }

    }
}

private static enum OrderedActions { # OrderedActions는 많이 생략되었다. 궁금하면 찾아보자.
    OrphanCollectionRemoveAction,
    OrphanRemovalAction,
    EntityInsertAction,
    EntityUpdateAction,
    QueuedOperationCollectionAction,
    CollectionRemoveAction,
    CollectionUpdateAction,
    CollectionRecreateAction,
    EntityDeleteAction;
}

 

잡았다. 지금 저장되어있는 모든 Action을 순서대로 수행하는게 아니라 OrderedActions Enum에 정의되어 있는 순서대로 DB에 Action을 수행하고 있던 것이다.

 

자세한 내용은 길어져서 생략했지만, ActionQueue는 OrderedAdctions에 정의되어 있는 값들을 List로 누적해서 가지고 있다. 간략하게 보여주면 다음과 같다.

public class ActionQueue {
    ...
    private ExecutableList<AbstractEntityInsertAction> insertions;
    private ExecutableList<EntityDeleteAction> deletions;
    private ExecutableList<EntityUpdateAction> updates;
    private ExecutableList<CollectionRecreateAction> collectionCreations;
    private ExecutableList<CollectionUpdateAction> collectionUpdates;
    private ExecutableList<QueuedOperationCollectionAction> collectionQueuedOps;
    private ExecutableList<CollectionRemoveAction> collectionRemovals;
    private ExecutableList<CollectionRemoveAction> orphanCollectionRemovals;
    private ExecutableList<OrphanRemovalAction> orphanRemovals;
    ...
    ...
}

 

즉, 우리의 서비스 코드에서 작성한 쿼리 순서대로 Queue에 쌓이는게 아니라, 각 쿼리를 분류해서 List에 쌓고 있던 것이다!

그리고 분류된 각 명령어들은 순서대로 수행되는데, 이때 OrderedAdctions Enum에 변수가 정의된 순서대로 DB에 Query를 날리는 것이었다...!!

 

 


자.. 자.. 그래서 이런 오류를 만났는데, 가장 좋은 해결 방법은 뭐냐?

쿼리의 순서를 보장하고 싶으면, entityManager.flush()를 호출해라.

 

Query가 실행되는 순서를 명시적으로 지정해 둔 것은 Hibernate에서 자체적으로 성능을 최적화 하기 위함이라고 한다. 하지만,, 그 이외의 자세한 정보는 나오지 않는다.

(열심히 찾아보면 나올지도..)

 

 

 

참고 : Hibernate 포럼에 올라온 비슷한 질문

'Spring' 카테고리의 다른 글

Spring Data Jpa에서 단일 쿼리로 조회하기  (1) 2023.04.28

이 글은 현재 진행하고있는 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

+ Recent posts