준준의 기록일지

Spring Batch 배치 MyBatis Insert 성능 개선 (ExecutorType Batch) 본문

개발 전체

Spring Batch 배치 MyBatis Insert 성능 개선 (ExecutorType Batch)

junjunwon 2026. 1. 16. 16:32

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."




노션 정리본

- https://tar-surfboard-56a.notion.site/MyBatis-Insert-BatchInsertExecutor-2e9593982afd808bb09be15c225e9be8?source=copy_link

개요

대용량 Excel 데이터(30,000건 이상) 처리 시 발생하던 OutOfMemory (OOM) 문제를 해결하기 위해 BatchInsertExecutor 유틸리티 클래스를 도입하여 메모리 효율적인 bulk insert를 구현했습니다.

문제 상황

발생한 문제

  • OOM 발생: 30,000건의 Excel 데이터 처리 시 -jvmXmx=1024 환경에서 OutOfMemory 발생
  • 높은 메모리 사용량: excelOrderCustomerMapper.insertExcelOrdersBatch 호출 시 약 2.69GB 메모리 사용
  • ExecutorType.SIMPLE 사용 시: 약 3.4GB 메모리 사용
  • ExecutorType.BATCH 사용 시: 약 390MB 메모리 사용 (약 88% 감소)

원인 분석

  1. 대량 데이터 일괄 처리: 한 번에 수만 건의 데이터를 insert하려고 시도
  2. 암호화 함수 사용: AES_ENCRYPT 함수로 인한 쿼리 문자열 크기 증가
    • 각 행마다 암호화된 문자열이 preparedStatement에 포함
    • 30,000건 × 큰 쿼리 문자열 = 엄청난 메모리 사용량
  3. MyBatis preparedStatement 메모리 누적:
    • ExecutorType.BATCH: preparedStatement를 메모리에 쌓아두고 flushStatements() 호출 시 DB로 전송
    • 문제: 30,000건을 한 번에 처리하면 모든 preparedStatement가 메모리에 누적
    • 결과: 메모리 사용량 피크가 매우 높아져 OOM 발생
  4. useGeneratedKeys와 ExecutorType.BATCH 호환성 문제: useGeneratedKeys="true" 사용 시 BATCH 모드에서 UnsupportedOperationException 발생
  5. GC 비효율: 큰 객체들이 Old Generation으로 이동하여 GC 효율성 저하

해결 방안

1. BatchInsertExecutor 유틸리티 클래스 도입

핵심 아이디어:

  • 작은 배치 단위(기본 1000건)로 데이터를 분할하여 처리
  • ExecutorType.BATCH와 flushStatements()를 활용하여 메모리 효율적인 insert
  • useGeneratedKeys 필요 여부에 따라 ExecutorType.SIMPLE 또는 BATCH 자동 선택

2. ExecutorType 선택 전략

상황 ExecutorType 메모리 사용량 비고

useGeneratedKeys 필요 SIMPLE 높음 (3.4GB) generated key 즉시 반환 필요
useGeneratedKeys 불필요 BATCH 낮음 (390MB) 88% 메모리 절감

3. 주요 기능

// 기본 사용 (BATCH 모드, 배치 크기 1000)
batchInsertExecutor.executeBatch(items, mapperClass,
	(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// useGeneratedKeys 필요 시 (SIMPLE 모드)
batchInsertExecutor.executeBatch(items, true, mapperClass,
	(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// 커스텀 배치 크기
batchInsertExecutor.executeBatch(items, 500, mapperClass,
	(mapper, batch) -> mapper.insertXxx(schemaName, batch));

구현 내용

BatchInsertExecutor 클래스 구조

@Component
@RequiredArgsConstructor
publicclass BatchInsertExecutor{
		private static final int DEFAULT_BATCH_SIZE=1000;

    @Qualifier("customerSqlSessionFactory")
		private final SqlSessionFactory customerSqlSessionFactory;

		// 다양한 오버로딩 메서드 제공
		public<T, M>void executeBatch(...){
				// 배치 분할
		    List<List<T>> batches= FulfillmentUtils.chunked(items, batchSize);
		
				// 각 배치마다 처리
				for(List<T> batch: batches){
						try(SqlSession sqlSession= sqlSessionFactory.openSession(executorType)){
		            M mapper= sqlSession.getMapper(mapperClass);
		            insertFunction.accept(mapper, batch);
						
								// BATCH 모드일 때만 flushStatements() 호출
								if(executorType== ExecutorType.BATCH){
				            sqlSession.flushStatements();
				            sqlSession.clearCache();
								}
						}
				}
		}
}

 

핵심 메커니즘

메모리 사용량 감소의 실제 원리

중요한 이해: 데이터 자체(items 리스트)는 부모 메서드에서 메모리에 유지됩니다. 하지만 MyBatis의 preparedStatement 메모리 사용량이 문제였습니다.

  1. 배치 분할: FulfillmentUtils.chunked()로 데이터를 작은 단위로 분할
    • 데이터 자체는 메모리에 있지만, 한 번에 모든 preparedStatement를 생성하지 않음
    • 메모리 사용량의 피크(Peak)를 낮춤
  2. MyBatis preparedStatement 메모리 관리:
    • ExecutorType.BATCH: preparedStatement를 메모리에 쌓아두고, flushStatements() 호출 시 DB로 전송
    • 문제 상황: 30,000건을 한 번에 처리하면 모든 preparedStatement가 메모리에 누적
    • 특히 AES_ENCRYPT 함수 사용 시: 쿼리 문자열이 매우 커져서 메모리 사용량 급증
    • 해결: 배치로 나누어 처리하면, 각 배치마다 flushStatements() 호출로 preparedStatement 메모리 해제
  3. flushStatements()의 역할:
    • 메모리에 쌓인 preparedStatement를 DB로 전송 (commit 아님)
    • 전송 후 preparedStatement 객체를 메모리에서 해제 가능한 상태로 만듦
    • GC가 더 효율적으로 메모리 해제 가능
  4. clearCache(): MyBatis 캐시 클리어로 추가 메모리 해제
  5. GC 효율성 향상:
    • 각 배치 처리 후 메모리 해제로 인해 GC가 더 자주, 더 효율적으로 작동
    • 메모리 사용량이 증가했다가 GC에 의해 감소하는 패턴 반복
    • 피크 메모리 사용량이 낮아져서 OOM 방지

메모리 사용 패턴 비교

개선 전 (30,000건 일괄 처리):

메모리 사용량
    ↑
    |     ╱╲
    |    ╱  ╲
    |   ╱    ╲
    |  ╱      ╲
    | ╱        ╲
    |╱          ╲
    └────────────→ 시간
     (OOM 발생 가능)

개선 후 (1000건씩 배치 처리):

메모리 사용량
    ↑
    |  ╱╲  ╱╲  ╱╲  ╱╲
    | ╱  ╲╱  ╲╱  ╲╱  ╲
    |╱              ╲
    └────────────────→ 시간
     (GC로 주기적 해제)

핵심: 전체 데이터는 메모리에 있지만, preparedStatement의 메모리 사용량 피크를 낮춰서 OOM을 방지합니다.

트랜잭션 관리

  • Spring 트랜잭션과 완벽 통합
  • @CustomerTransactional 또는 @MainTransactional 사용 시 자동으로 같은 Connection 사용
  • 트랜잭션 롤백 시 모든 배치가 함께 롤백됨

적용 사례

1. ExcelDataWriter

// CHUNK_SIZE(1000) 단위로 분할하여 처리
List<List<ExcelOrderAggregate>> chunks= FulfillmentUtils.chunked(schemaAggregates, CHUNK_SIZE);

for(List<ExcelOrderAggregate> chunk: chunks){
    excelOrderService.saveExcelOrdersBulk(schemaName, chunk);
}

2. ExcelOrderServiceImpl

**// saveExcelOrderBulk 실행**
// insertExcelOrdersBatch: useGeneratedKeys 필요 → SIMPLE 모드
batchInsertExecutor.executeBatch(excelOrders, true, ExcelOrderCustomerMapper.class,
		(mapper, batch) -> mapper.insertExcelOrdersBatch(schemaName, batch));

// insertExcelOrderItemsBatch: useGeneratedKeys 불필요 → BATCH 모드
batchInsertExecutor.executeBatch(allOrderItems, false, ExcelOrderCustomerMapper.class,
		(mapper, batch) -> mapper.insertExcelOrderItemsBatch(schemaName, batch));

3. OrderServiceImpl

// insertOrderSeqsBulk: useGeneratedKeys 필요 → SIMPLE 모드
batchInsertExecutor.executeBatch(orderSeqCreateVos, true, OrderCustomerMapper.class,
    (mapper, batch) -> orderCustomerMapper.insertOrderSeqsBulk(schemaName, batch));

// insertOrderEtcsBulk: useGeneratedKeys 불필요 → BATCH 모드
batchInsertExecutor.executeBatch(orderEtcCreateVos, false, OrderCustomerMapper.class,
    (mapper, batch) -> orderCustomerMapper.insertOrderEtcsBulk(schemaName, batch));

📈 성능 개선 효과

메모리 사용량 비교

AS-IS 엑셀 15000 row 데이터 엑셀 발주 메모리 효율

Peak 400MB인 것 같음. or 그 이상, 그리고 메모리 해제가 적절하게 되지 않아 누적

ExcelDataWrite.java

  • excelOrderService.saveExcelOrdersBulk(schemaName, chunk);
    • 메모리 3.57 GB

OrderWriter.java

  • savedOrders = orderService.saveOrdersBulk(schemaName, aggregates);
    • 메모리 3.01 GB

OOM 발생

  • 시점: orderCustomerMapper.insertOrderAddressesBulk(schemaName, orderAddressCreateVos);

TO-BE 엑셀 15000 row 데이터 엑셀 발주 메모리 효율

45초만에 처리 완료

Peack 250MB정도

ExcelDataWrite.java

  • excelOrderService.saveExcelOrdersBulk(schemaName, chunk);
    • 메모리 3.47 GB (유의미한 감소는 아닌것 같음, 하지만 해제율이 중요)

OrderWriter.java

  • savedOrders = orderService.saveOrdersBulk(schemaName, aggregates);
    • 메모리 4.21 GB (더 늘어난 이유도 찾아야겠다. bulk를 내부적으로 해서 그런가? 사실 GC에 의해 batch단위로 메모리를 해제해서 총 메모리에 비해 메모리 해제율이 더 높음)

OOM 발생하지 않음.

처리 성능

  • 배치 크기: 기본 1000건 (필요시 조정 가능)
  • 메모리 사용량 피크 감소: 각 배치마다 flushStatements() 및 clearCache() 호출로 preparedStatement 메모리 해제 가능
  • GC 효율성 향상: 작은 배치 단위로 처리하여 GC가 더 자주, 더 효율적으로 작동
  • 트랜잭션 안정성: Spring 트랜잭션과 완벽 통합으로 롤백 시 모든 배치 일괄 롤백

메모리 사용 패턴

핵심 포인트:

  • 데이터 자체(items 리스트)는 부모 메서드에서 메모리에 유지됨
  • 하지만 MyBatis preparedStatement의 메모리 사용량 피크를 낮춰서 OOM 방지
  • 각 배치 처리 후 메모리 해제로 GC가 더 효율적으로 작동
  • 메모리 사용량이 증가했다가 GC에 의해 감소하는 패턴 반복

기술적 세부사항

ExecutorType.BATCH vs SIMPLE

ExecutorType.BATCH

  • 장점: 메모리 효율적 (약 88% 절감)
  • 단점: useGeneratedKeys와 호환성 문제
  • 사용 시점: useGeneratedKeys가 불필요한 경우

ExecutorType.SIMPLE

  • 장점: useGeneratedKeys 정상 작동
  • 단점: 메모리 사용량 높음
  • 사용 시점: useGeneratedKeys가 필요한 경우
  • flushStatements() 호출: 기술적으로 가능하지만 효과 없음
    • SIMPLE 모드는 각 SQL을 즉시 실행하므로 preparedStatement를 메모리에 쌓지 않음
    • 따라서 flushStatements()를 호출해도 쌓인 것이 없어서 효과가 없음
    • SIMPLE 모드의 높은 메모리 사용량은 useGeneratedKeys로 인한 객체 메모리 유지 때문

flushStatements()의 역할

// BATCH 모드에서만 호출
if(executorType== ExecutorType.BATCH){
    sqlSession.flushStatements();// DB로 쿼리 전송
    sqlSession.clearCache();// 캐시 클리어
}

  • flushStatements():
    • 의미: 메모리에 쌓인 preparedStatement를 실제로 DB에 실행합니다
    • 동작 과정:
      1. insertFunction.accept(mapper, batch) 호출 시: SQL을 메모리에만 쌓아둠 (아직 DB로 전송 안 함)
      2. flushStatements() 호출 시: 쌓인 SQL들을 실제로 DB로 전송하여 실행
      3. 실행 후 preparedStatement 객체가 GC 대상이 됨 (즉시 해제는 아니지만 해제 가능)
    • 중요한 차이점:
      • SQL 실행: flushStatements()는 SQL을 DB로 보내서 실행합니다 (INSERT가 실제로 DB에 반영됨)
      • 트랜잭션 commit 아님: 하지만 트랜잭션은 아직 commit되지 않음
      • Spring 트랜잭션이 끝날 때 (메서드 종료 시) commit/rollback 결정
      • 만약 트랜잭션이 rollback되면, 이미 실행된 INSERT들도 모두 롤백됨
    • 배치로 나누어 처리하면: 각 배치마다 preparedStatement를 해제 가능한 상태로 만들어 메모리 사용량 피크를 낮춤

예시로 이해하기

// ExecutorType.BATCH 사용 시
try(SqlSession sqlSession= sqlSessionFactory.openSession(ExecutorType.BATCH)){
    Mapper mapper= sqlSession.getMapper(MapperClass.class);

// 1단계: SQL을 메모리에만 쌓음 (아직 DB로 전송 안 함)
    mapper.insertBatch(items);// ← 이 시점에는 메모리에만 있음

// 2단계: 쌓인 SQL들을 실제로 DB로 전송하여 실행
    sqlSession.flushStatements();// ← 이 시점에 DB로 전송 및 실행
// 하지만 트랜잭션은 아직 commit 안 됨!

}// 3단계: Spring 트랜잭션이 끝날 때 commit/rollback 결정

비유:

  • insertBatch(): 편지를 쓰기만 함 (메모리에 보관)
  • flushStatements(): 편지를 우체통에 넣음 (실제로 전송)
  • 트랜잭션 commit: 편지가 최종적으로 배달됨 (DB에 영구 저장)
  • 트랜잭션 rollback: 편지를 회수함 (모든 변경사항 취소)
  • clearCache(): MyBatis 캐시를 클리어하여 추가 메모리 해제

왜 메모리 사용량이 감소하는가?

  1. preparedStatement 메모리 누적 방지:
    • 30,000건을 한 번에 처리: 모든 preparedStatement가 메모리에 누적 → 높은 피크 메모리
    • 1000건씩 배치 처리: 각 배치마다 flushStatements() 호출 → 낮은 피크 메모리
  2. GC 효율성 향상:
    • 각 배치 처리 후 메모리 해제로 GC가 더 자주 작동
    • 작은 객체들이 더 빨리 해제되어 Old Generation으로 이동하는 객체 감소
  3. 트랜잭션과의 관계:
    • Spring 트랜잭션은 Connection 레벨에서 관리
    • preparedStatement는 SqlSession 레벨에서 관리
    • 각 배치마다 SqlSession을 닫아도 Connection은 유지되므로 트랜잭션 유지
    • preparedStatement 메모리는 해제되지만, 트랜잭션은 정상 작동

트랜잭션 통합

// Spring 트랜잭션이 활성화되어 있으면 같은 Connection 사용
try(SqlSession sqlSession= sqlSessionFactory.openSession(executorType)){
// autoCommit=false로 생성되며
// TransactionSynchronizationManager를 통해 같은 Connection 사용
// 따라서 트랜잭션 롤백 시 모든 배치가 함께 롤백됨
}

📝 사용 가이드

기본 사용법

// 1. useGeneratedKeys 불필요 (BATCH 모드)
batchInsertExecutor.executeBatch(items, MapperClass.class,
(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// 2. useGeneratedKeys 필요 (SIMPLE 모드)
batchInsertExecutor.executeBatch(items, true, MapperClass.class,
(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// 3. 커스텀 배치 크기
batchInsertExecutor.executeBatch(items, 500,MapperClass.class,
(mapper, batch) -> mapper.insertXxx(schemaName, batch));

주의사항

  1. useGeneratedKeys 판단: Mapper XML에서 useGeneratedKeys="true"가 있으면 true 전달
  2. 배치 크기 조정: 메모리 상황에 따라 배치 크기 조정 (기본 1000건)
  3. 트랜잭션 관리: @CustomerTransactional 또는 @MainTransactional 사용 권장

🎯 결론

BatchInsertExecutor 도입을 통해:

📋 개요

대용량 Excel 데이터(30,000건 이상) 처리 시 발생하던 OutOfMemory (OOM) 문제를 해결하기 위해 BatchInsertExecutor 유틸리티 클래스를 도입하여 메모리 효율적인 bulk insert를 구현했습니다.

🔴 문제 상황

발생한 문제

  • OOM 발생: 30,000건의 Excel 데이터 처리 시 -jvmXmx=1024 환경에서 OutOfMemory 발생
  • 높은 메모리 사용량: excelOrderCustomerMapper.insertExcelOrdersBatch 호출 시 약 2.69GB 메모리 사용
  • ExecutorType.SIMPLE 사용 시: 약 3.4GB 메모리 사용
  • ExecutorType.BATCH 사용 시: 약 390MB 메모리 사용 (약 88% 감소)

원인 분석

  1. 대량 데이터 일괄 처리: 한 번에 수만 건의 데이터를 insert하려고 시도
  2. 암호화 함수 사용: AES_ENCRYPT 함수로 인한 쿼리 문자열 크기 증가
    • 각 행마다 암호화된 문자열이 preparedStatement에 포함
    • 30,000건 × 큰 쿼리 문자열 = 엄청난 메모리 사용량
  3. MyBatis preparedStatement 메모리 누적:
    • ExecutorType.BATCH: preparedStatement를 메모리에 쌓아두고 flushStatements() 호출 시 DB로 전송
    • 문제: 30,000건을 한 번에 처리하면 모든 preparedStatement가 메모리에 누적
    • 결과: 메모리 사용량 피크가 매우 높아져 OOM 발생
  4. useGeneratedKeys와 ExecutorType.BATCH 호환성 문제: useGeneratedKeys="true" 사용 시 BATCH 모드에서 UnsupportedOperationException 발생
  5. GC 비효율: 큰 객체들이 Old Generation으로 이동하여 GC 효율성 저하

✅ 해결 방안

1. BatchInsertExecutor 유틸리티 클래스 도입

핵심 아이디어:

  • 작은 배치 단위(기본 1000건)로 데이터를 분할하여 처리
  • ExecutorType.BATCH와 flushStatements()를 활용하여 메모리 효율적인 insert
  • useGeneratedKeys 필요 여부에 따라 ExecutorType.SIMPLE 또는 BATCH 자동 선택

2. ExecutorType 선택 전략

상황 ExecutorType 메모리 사용량 비고

useGeneratedKeys 필요 SIMPLE 높음 (3.4GB) generated key 즉시 반환 필요
useGeneratedKeys 불필요 BATCH 낮음 (390MB) 88% 메모리 절감

3. 주요 기능

// 기본 사용 (BATCH 모드, 배치 크기 1000)
batchInsertExecutor.executeBatch(items, mapperClass,
	(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// useGeneratedKeys 필요 시 (SIMPLE 모드)
batchInsertExecutor.executeBatch(items, true, mapperClass,
	(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// 커스텀 배치 크기
batchInsertExecutor.executeBatch(items, 500, mapperClass,
	(mapper, batch) -> mapper.insertXxx(schemaName, batch));

🏗️ 구현 내용

BatchInsertExecutor 클래스 구조

@Component
@RequiredArgsConstructor
publicclass BatchInsertExecutor{
		private static final int DEFAULT_BATCH_SIZE=1000;

    @Qualifier("customerSqlSessionFactory")
		private final SqlSessionFactory customerSqlSessionFactory;

		// 다양한 오버로딩 메서드 제공
		public<T, M>void executeBatch(...){
				// 배치 분할
		    List<List<T>> batches= FulfillmentUtils.chunked(items, batchSize);
		
				// 각 배치마다 처리
				for(List<T> batch: batches){
						try(SqlSession sqlSession= sqlSessionFactory.openSession(executorType)){
		            M mapper= sqlSession.getMapper(mapperClass);
		            insertFunction.accept(mapper, batch);
						
								// BATCH 모드일 때만 flushStatements() 호출
								if(executorType== ExecutorType.BATCH){
				            sqlSession.flushStatements();
				            sqlSession.clearCache();
								}
						}
				}
		}
}

핵심 메커니즘

메모리 사용량 감소의 실제 원리

중요한 이해: 데이터 자체(items 리스트)는 부모 메서드에서 메모리에 유지됩니다. 하지만 MyBatis의 preparedStatement 메모리 사용량이 문제였습니다.

  1. 배치 분할: FulfillmentUtils.chunked()로 데이터를 작은 단위로 분할
    • 데이터 자체는 메모리에 있지만, 한 번에 모든 preparedStatement를 생성하지 않음
    • 메모리 사용량의 피크(Peak)를 낮춤
  2. MyBatis preparedStatement 메모리 관리:
    • ExecutorType.BATCH: preparedStatement를 메모리에 쌓아두고, flushStatements() 호출 시 DB로 전송
    • 문제 상황: 30,000건을 한 번에 처리하면 모든 preparedStatement가 메모리에 누적
    • 특히 AES_ENCRYPT 함수 사용 시: 쿼리 문자열이 매우 커져서 메모리 사용량 급증
    • 해결: 배치로 나누어 처리하면, 각 배치마다 flushStatements() 호출로 preparedStatement 메모리 해제
  3. flushStatements()의 역할:
    • 메모리에 쌓인 preparedStatement를 DB로 전송 (commit 아님)
    • 전송 후 preparedStatement 객체를 메모리에서 해제 가능한 상태로 만듦
    • GC가 더 효율적으로 메모리 해제 가능
  4. clearCache(): MyBatis 캐시 클리어로 추가 메모리 해제
  5. GC 효율성 향상:
    • 각 배치 처리 후 메모리 해제로 인해 GC가 더 자주, 더 효율적으로 작동
    • 메모리 사용량이 증가했다가 GC에 의해 감소하는 패턴 반복
    • 피크 메모리 사용량이 낮아져서 OOM 방지

메모리 사용 패턴 비교

개선 전 (30,000건 일괄 처리):

메모리 사용량
    ↑
    |     ╱╲
    |    ╱  ╲
    |   ╱    ╲
    |  ╱      ╲
    | ╱        ╲
    |╱          ╲
    └────────────→ 시간
     (OOM 발생 가능)

개선 후 (1000건씩 배치 처리):

메모리 사용량
    ↑
    |  ╱╲  ╱╲  ╱╲  ╱╲
    | ╱  ╲╱  ╲╱  ╲╱  ╲
    |╱              ╲
    └────────────────→ 시간
     (GC로 주기적 해제)

핵심: 전체 데이터는 메모리에 있지만, preparedStatement의 메모리 사용량 피크를 낮춰서 OOM을 방지합니다.

트랜잭션 관리

  • Spring 트랜잭션과 완벽 통합
  • @CustomerTransactional 또는 @MainTransactional 사용 시 자동으로 같은 Connection 사용
  • 트랜잭션 롤백 시 모든 배치가 함께 롤백됨

📊 적용 사례

1. ExcelDataWriter

// CHUNK_SIZE(1000) 단위로 분할하여 처리
List<List<ExcelOrderAggregate>> chunks= FulfillmentUtils.chunked(schemaAggregates, CHUNK_SIZE);

for(List<ExcelOrderAggregate> chunk: chunks){
    excelOrderService.saveExcelOrdersBulk(schemaName, chunk);
}

2. ExcelOrderServiceImpl

**// saveExcelOrderBulk 실행**
// insertExcelOrdersBatch: useGeneratedKeys 필요 → SIMPLE 모드
batchInsertExecutor.executeBatch(excelOrders, true, ExcelOrderCustomerMapper.class,
		(mapper, batch) -> mapper.insertExcelOrdersBatch(schemaName, batch));

// insertExcelOrderItemsBatch: useGeneratedKeys 불필요 → BATCH 모드
batchInsertExecutor.executeBatch(allOrderItems, false, ExcelOrderCustomerMapper.class,
		(mapper, batch) -> mapper.insertExcelOrderItemsBatch(schemaName, batch));

3. OrderServiceImpl

// insertOrderSeqsBulk: useGeneratedKeys 필요 → SIMPLE 모드
batchInsertExecutor.executeBatch(orderSeqCreateVos, true, OrderCustomerMapper.class,
    (mapper, batch) -> orderCustomerMapper.insertOrderSeqsBulk(schemaName, batch));

// insertOrderEtcsBulk: useGeneratedKeys 불필요 → BATCH 모드
batchInsertExecutor.executeBatch(orderEtcCreateVos, false, OrderCustomerMapper.class,
    (mapper, batch) -> orderCustomerMapper.insertOrderEtcsBulk(schemaName, batch));

📈 성능 개선 효과

메모리 사용량 비교

AS-IS 엑셀 15000 row 데이터 엑셀 발주 메모리 효율

Peak 400MB인 것 같음. or 그 이상, 그리고 메모리 해제가 적절하게 되지 않아 누적

ExcelDataWrite.java

  • excelOrderService.saveExcelOrdersBulk(schemaName, chunk);
    • 메모리 3.57 GB

OrderWriter.java

  • savedOrders = orderService.saveOrdersBulk(schemaName, aggregates);
    • 메모리 3.01 GB

OOM 발생

  • 시점: orderCustomerMapper.insertOrderAddressesBulk(schemaName, orderAddressCreateVos);

TO-BE 엑셀 15000 row 데이터 엑셀 발주 메모리 효율

45초만에 처리 완료

Peack 250MB정도

ExcelDataWrite.java

  • excelOrderService.saveExcelOrdersBulk(schemaName, chunk);
    • 메모리 3.47 GB (유의미한 감소는 아닌것 같음, 하지만 해제율이 중요)

OrderWriter.java

  • savedOrders = orderService.saveOrdersBulk(schemaName, aggregates);
    • 메모리 4.21 GB (더 늘어난 이유도 찾아야겠다. bulk를 내부적으로 해서 그런가? 사실 GC에 의해 batch단위로 메모리를 해제해서 총 메모리에 비해 메모리 해제율이 더 높음)

OOM 발생하지 않음.

처리 성능

  • 배치 크기: 기본 1000건 (필요시 조정 가능)
  • 메모리 사용량 피크 감소: 각 배치마다 flushStatements() 및 clearCache() 호출로 preparedStatement 메모리 해제 가능
  • GC 효율성 향상: 작은 배치 단위로 처리하여 GC가 더 자주, 더 효율적으로 작동
  • 트랜잭션 안정성: Spring 트랜잭션과 완벽 통합으로 롤백 시 모든 배치 일괄 롤백

메모리 사용 패턴

핵심 포인트:

  • 데이터 자체(items 리스트)는 부모 메서드에서 메모리에 유지됨
  • 하지만 MyBatis preparedStatement의 메모리 사용량 피크를 낮춰서 OOM 방지
  • 각 배치 처리 후 메모리 해제로 GC가 더 효율적으로 작동
  • 메모리 사용량이 증가했다가 GC에 의해 감소하는 패턴 반복

🔧 기술적 세부사항

ExecutorType.BATCH vs SIMPLE

ExecutorType.BATCH

  • 장점: 메모리 효율적 (약 88% 절감)
  • 단점: useGeneratedKeys와 호환성 문제
  • 사용 시점: useGeneratedKeys가 불필요한 경우

ExecutorType.SIMPLE

  • 장점: useGeneratedKeys 정상 작동
  • 단점: 메모리 사용량 높음
  • 사용 시점: useGeneratedKeys가 필요한 경우
  • flushStatements() 호출: 기술적으로 가능하지만 효과 없음
    • SIMPLE 모드는 각 SQL을 즉시 실행하므로 preparedStatement를 메모리에 쌓지 않음
    • 따라서 flushStatements()를 호출해도 쌓인 것이 없어서 효과가 없음
    • SIMPLE 모드의 높은 메모리 사용량은 useGeneratedKeys로 인한 객체 메모리 유지 때문

flushStatements()의 역할

// BATCH 모드에서만 호출
if(executorType== ExecutorType.BATCH){
    sqlSession.flushStatements();// DB로 쿼리 전송
    sqlSession.clearCache();// 캐시 클리어
}

  • flushStatements():
    • 의미: 메모리에 쌓인 preparedStatement를 실제로 DB에 실행합니다
    • 동작 과정:
      1. insertFunction.accept(mapper, batch) 호출 시: SQL을 메모리에만 쌓아둠 (아직 DB로 전송 안 함)
      2. flushStatements() 호출 시: 쌓인 SQL들을 실제로 DB로 전송하여 실행
      3. 실행 후 preparedStatement 객체가 GC 대상이 됨 (즉시 해제는 아니지만 해제 가능)
    • 중요한 차이점:
      • SQL 실행: flushStatements()는 SQL을 DB로 보내서 실행합니다 (INSERT가 실제로 DB에 반영됨)
      • 트랜잭션 commit 아님: 하지만 트랜잭션은 아직 commit되지 않음
      • Spring 트랜잭션이 끝날 때 (메서드 종료 시) commit/rollback 결정
      • 만약 트랜잭션이 rollback되면, 이미 실행된 INSERT들도 모두 롤백됨
    • 배치로 나누어 처리하면: 각 배치마다 preparedStatement를 해제 가능한 상태로 만들어 메모리 사용량 피크를 낮춤

예시로 이해하기

// ExecutorType.BATCH 사용 시
try(SqlSession sqlSession= sqlSessionFactory.openSession(ExecutorType.BATCH)){
    Mapper mapper= sqlSession.getMapper(MapperClass.class);

// 1단계: SQL을 메모리에만 쌓음 (아직 DB로 전송 안 함)
    mapper.insertBatch(items);// ← 이 시점에는 메모리에만 있음

// 2단계: 쌓인 SQL들을 실제로 DB로 전송하여 실행
    sqlSession.flushStatements();// ← 이 시점에 DB로 전송 및 실행
// 하지만 트랜잭션은 아직 commit 안 됨!

}// 3단계: Spring 트랜잭션이 끝날 때 commit/rollback 결정

비유:

  • insertBatch(): 편지를 쓰기만 함 (메모리에 보관)
  • flushStatements(): 편지를 우체통에 넣음 (실제로 전송)
  • 트랜잭션 commit: 편지가 최종적으로 배달됨 (DB에 영구 저장)
  • 트랜잭션 rollback: 편지를 회수함 (모든 변경사항 취소)
  • clearCache(): MyBatis 캐시를 클리어하여 추가 메모리 해제

왜 메모리 사용량이 감소하는가?

  1. preparedStatement 메모리 누적 방지:
    • 30,000건을 한 번에 처리: 모든 preparedStatement가 메모리에 누적 → 높은 피크 메모리
    • 1000건씩 배치 처리: 각 배치마다 flushStatements() 호출 → 낮은 피크 메모리
  2. GC 효율성 향상:
    • 각 배치 처리 후 메모리 해제로 GC가 더 자주 작동
    • 작은 객체들이 더 빨리 해제되어 Old Generation으로 이동하는 객체 감소
  3. 트랜잭션과의 관계:
    • Spring 트랜잭션은 Connection 레벨에서 관리
    • preparedStatement는 SqlSession 레벨에서 관리
    • 각 배치마다 SqlSession을 닫아도 Connection은 유지되므로 트랜잭션 유지
    • preparedStatement 메모리는 해제되지만, 트랜잭션은 정상 작동

트랜잭션 통합

// Spring 트랜잭션이 활성화되어 있으면 같은 Connection 사용
try(SqlSession sqlSession= sqlSessionFactory.openSession(executorType)){
// autoCommit=false로 생성되며
// TransactionSynchronizationManager를 통해 같은 Connection 사용
// 따라서 트랜잭션 롤백 시 모든 배치가 함께 롤백됨
}

📝 사용 가이드

기본 사용법

// 1. useGeneratedKeys 불필요 (BATCH 모드)
batchInsertExecutor.executeBatch(items, MapperClass.class,
(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// 2. useGeneratedKeys 필요 (SIMPLE 모드)
batchInsertExecutor.executeBatch(items, true, MapperClass.class,
(mapper, batch) -> mapper.insertXxx(schemaName, batch));

// 3. 커스텀 배치 크기
batchInsertExecutor.executeBatch(items, 500,MapperClass.class,
(mapper, batch) -> mapper.insertXxx(schemaName, batch));

주의사항

  1. useGeneratedKeys 판단: Mapper XML에서 useGeneratedKeys="true"가 있으면 true 전달
  2. 배치 크기 조정: 메모리 상황에 따라 배치 크기 조정 (기본 1000건)
  3. 트랜잭션 관리: @CustomerTransactional 또는 @MainTransactional 사용 권장

🎯 결론

BatchInsertExecutor 도입을 통해:

  • ✅ 메모리 사용량 88% 감소 (3.4GB → 390MB)
  • ✅ OOM 문제 해결
  • ✅ 트랜잭션 안정성 보장
  • ✅ 코드 재사용성 향상
  • ✅ 유연한 ExecutorType 선택

대용량 데이터 처리 시 안정적이고 효율적인 bulk insert가 가능해졌습니다.


작성일: 2025-01-16

작성자: 원준호

관련 파일:

  • BatchInsertExecutor.java
  • ExcelOrderServiceImpl.java
  • OrderServiceImpl.java
  • ExcelDataWriter.java
  • OrderWriter.java