hibernate batch 옵션 order_inserts와 order_updates는 항상 좋을까 ?

order_inserts와 order_updates를 사용하면 동일한 엔티티 타입이 그룹화되어

특히 연관관계가 설정된 엔티티에 cascade 옵션을 설정해 저장할 경우에 DB에 전송되는 쿼리가 효과적으로 줄어든다.

하지만 공식문서에서도 성능 저하가 발생할 수 있으므로, 이 설정 전과 후의 벤치마크를 통해 실제로 애플리케이션에 도움이 되는지 확인해보라고 한다.

 

물론 수만개의 엔티티를 로드하면 flush 전까지 하이버네이트 세션에 캐싱해놓기 때문에 저장할 엔티티가 너무 많다면 OOM이 발생할 수 있다.

여기에 추가로 메모리가 간당 간당한 상황에서 order_inserts와 order_updates 이 사용되면 더욱 성능에 영향을 미칠 수 있을 것이다.

 

해당 옵션의 기능이 무엇인지? 왜 성능에 영향을 미치는지? 언제 영향을 미칠지? 한번 자세히 알아보려고 한다.

우선 order_inserts와 order_updates 옵션이 무엇인지에 대해 한번 알아보자.


order_inserts와 order_updates가 뭔데?

order_inserts와 order_updates 모두 기본값은 두 옵션 모두 false이다.

활성화 여부에 따라 어떻게 동작하는지 쉽게 이해하기 위해 아래 코드를 보자.

(간단히 이해할 수 있도록 order_inserts 에 대한 예시만 작성했다.)

 

우선 IDENTITY ID 전략은 batch insert가 안되기 때문에 직접 할당해주는 방식으로 간단히 코드를 구성했다.

그리고 team과 member는 1 : 다 관계로 team에 양방향 관계로 members 필드를 두고 CascadeType.MERGE 설정을 한 경우다.

여담이지만 CascadeType.MERGE는 다음과 같다.

save 시에 isNew로 새 엔티티인지 여부를 확인한다.

isNew() 해당 엔티티의 Id가 Null인지로 판단하기 때문에 위 방식은 new 엔티티가 아니게 되며 merge로 준영속 상태의 엔티티를 영속 상태로 변경하기 때문이다.

 

이제 테스트를 위한 코드를 보자.

@Transactional
public void saveAllTeamAndMember() {
    Team team1 = new Team(1L, "Team A");
    Team team2 = new Team(2L, "Team B");
    Team team3 = new Team(3L, "Team C");
    
    Member memberA1 = new Member(1L, "Member A1", team1);
    team1.addMember(memberA1);
    teamRepository.save(team1);

    Member memberB1 = new Member(2L, "Member B1", team2);
    team2.addMember(memberB1);
    teamRepository.save(team2);

    Member memberA2 = new Member(3L, "Member A2", team1);
    team1.addMember(memberA2);
    teamRepository.save(team1);

    Member memberB2 = new Member(4L, "Member B2", team2);
    team2.addMember(memberB2);
    teamRepository.save(team2);

    Member memberA3 = new Member(5L, "Member A3", team1);
    team1.addMember(memberA3);
    teamRepository.save(team1);

    Member memberB3 = new Member(6L, "Member B3", team2);
    team2.addMember(memberB3);
    teamRepository.save(team2);

    Member memberC1 = new Member(7L, "Member C1", team3);
    team3.addMember(memberC1);
    teamRepository.save(team3);
}

 

위 코드는 하나의 트랜잭션 안에서 수행되기 때문에 다음과 같이 동작한다.

  1. Hibernate는 개별 save() 호출을 즉시 데이터베이스에 반영하지 않고, 내부 세션의 ActionQueue에 저장한다.
  2. 이후 flush()가 호출될 때 누적된 명령들을 처리한다.

 

false의 경우 다음과 같이 쿼리가 주어진 순서대로 실행된다.

insert into team (name,id) values ('Team A',1)
insert into team (name,id) values ('Team B',2)
insert into member (name,team_id,id) values ('Member B1',2,2),('Member A2',1,3),('Member B2',2,4),('Member A3',1,5),('Member B3',2,6)
insert into team (name,id) values ('Team C',3)
insert into member (name,team_id,id) values ('Member C1',3,7)

 

true의 경우엔 동일한 엔티티 타입에 대한 INSERT가 그룹화된다.

insert into team (name,id) values ('Team A',1),('Team B',2),('Team C',3)
insert into member (name,team_id,id) values ('Member A1',1,1),('Member B1',2,2),('Member A2',1,3),('Member B2',2,4),('Member A3',1,5),('Member B3',2,6),('Member C1',3,7)

현재는 데이터양이 작지만 많을 경우 같은 외래 키 값을 가진 레코드들이 함께 처리되어 DB와 통신 횟수를 효과적으로 줄일 수 있다.


order_inserts와 order_updates 는 항상 최고의 선택지일까?

언뜻 보기에는 안사용하면 손해라고 보일 수 있다.
하지만 hinbernate에서 경고했듯이 항상 좋은 것 만은 아니였다.

이유가 뭔지 알아보자.

 

우선 해당 옵션이 실행되는 핵심 코드를 보면 다음과 같다.

DefaultFlushEventListener 클래스에서 onFlush()가 호출되면 내부 호출을 따라가

AbstractFlushingEventListener 클래스의 flushEntities()가 호출되고 내부에서

EventSource.getActionQueue().sortActions()를 호출한다.

 

ActionQueue의 핵심만 요약하면 다음과 같다.

public class ActionQueue {

    private ExecutableList<AbstractEntityInsertAction> insertions;

    private enum OrderedActions {
        // ... 생략
        EntityInsertAction {
            @Override
            public ExecutableList<?> getActions(ActionQueue instance) {
                return instance.insertions;
            }
            @Override
            public void ensureInitialized(final ActionQueue instance) {
                if ( instance.insertions == null ) {
                    //Special case of initialization
                    instance.insertions = instance.isOrderInsertsEnabled()
                            ? new ExecutableList<>( InsertActionSorter.INSTANCE )
                            : new ExecutableList<>( false );
                }
            }
        },
	     // ... 생략
     }

    public void sortActions() {
        if ( isOrderUpdatesEnabled() && updates != null ) {
            // sort the updates by pk
            updates.sort();
        }
        if ( isOrderInsertsEnabled() && insertions != null ) {
            insertions.sort();
        }
    }
// ... 생략

ExecutableList는 각 실행 액션을 저장하는 hibernate의 리스트 컬렉션이며 InsertActionSorter 클래스 타입의 액션을 추가한다.

 

order_insert 옵션이 활성화 되어 있으면 정렬을 시작하는데 InsertActionSorter는 내부적으로 다음과 같이 정렬을 한다.

private static class InsertActionSorter implements ExecutableList.Sorter<AbstractEntityInsertAction> {
		public static final InsertActionSorter INSTANCE = new InsertActionSorter();
		
		// ... 생략
		
		/**
		 * Sort the insert actions.
		 */
		public void sort(List<AbstractEntityInsertAction> insertions) {
			final int insertInfoCount = insertions.size();
			// 엔티티 수만큼 InsertInfo 객체 배열 생성
			// 모든 엔티티를 키로 사용하는 IdentityHashMap 생성
			final InsertInfo[] insertInfos = new InsertInfo[insertInfoCount];
			final IdentityHashMap<Object, InsertInfo> insertInfosByEntity = new IdentityHashMap<>( insertInfos.length );			
			// ... 생략
			
			// 엔티티 간의 외래 키 관계에 따라 종속성 그래프 구축
			for (int i = 0; i < insertInfoCount; i++) {
				insertInfos[i].buildDirectDependencies(insertInfosByEntity);
			}
			// ... 생략

			// 엔티티 이름별로 그룹화하기 위한 LinkedHashMap 생성
			final Map<String, EntityInsertGroup> insertInfosByEntityName = new LinkedHashMap<>();
			for (int i = 0; i < insertInfoCount; i++) {
				final InsertInfo insertInfo = insertInfos[i];
				insertInfo.buildTransitiveDependencies( visited );

				final String entityName = insertInfo.insertAction.getPersister().getEntityName();
				EntityInsertGroup entityInsertGroup = insertInfosByEntityName.get(entityName);
				if (entityInsertGroup == null) {
					insertInfosByEntityName.put(entityName, entityInsertGroup = new EntityInsertGroup(entityName));
				}
				entityInsertGroup.add(insertInfo);
			}
			// ... 생략

			// 정렬 후 원래 리스트를 비우고 정렬된 순서로 다시 채움
			insertions.clear();
			for (InsertInfo insertInfo : insertInfos) {
				insertions.add(insertInfo.insertAction);
			}
		}

이 정렬 과정은 외래 키 제약 조건을 위반하지 않는 최적의 삽입 순서를 보장하도록 해준다.

하지만 코드를 통해 공식 문서에서 성능 저하에 대해 주의하라는 것에 대해 추측해보자면 다음과 같다.

  • 그래프 알고리즘을 사용하여 엔티티 간의 모든 종속성 경로를 분석하므로, 엔티티 수와 관계 복잡성에 따라 메모리 사용량이 증가와 빈번한 GC가 일어날 수 있다.
  • 엔티티 간 순환 참조가 있는 경우 알고리즘이 완전한 정렬을 할 수 없어 최적이 아닌 삽입 순서가 생성될 수 있다.

재밌는점은 해당 정렬 코드가 실행되는 시점을 보면 flush가 일어났을 때다.

batch_size로 한 번에 몇 개의 insert 쿼리를 JDBC batch로 묶어서 보낼지를 결정한다해도

flush가 호출될 때 모아둔 모든 쿼리들을 전부 재정렬한다는 것이다.

 

실제로 디버깅한 결과도 다음과 같았다.

총 엔티티는 10개를 저장, batch_size는 2개로 설정했지만 10개의 엔티티를 전부 flush 시점에 재정렬하고 있는 것을 볼 수 있다.

즉, batch_size와 order 옵션은 관련이 없고, batch 처리하는 엔티티가 많을 수록 order 옵션은 성능에 영향을 줄 수 있다는 것이다.

반응형