-
성능 개선: 대량 데이터 Batch Insert 최적화와 MyBatis 캐시 문제 해결Spring 2025. 8. 24. 23:19
이번 프로젝트에서 기존 구현되어 있는 코드 성능을 개선하는 작업을 진행했습니다. 카드 발급은 단건부터 수십만 건까지 대량 데이터를 처리될 수 있습니다. 그러나 기존 방식은 데이터를 한 건씩 DB에 저장하는 구조라 10만 건 발급 시 843초가 걸릴 정도로 성능이 떨어지고, 모든 데이터를 한 번에 메모리에 적재하면 OOM(Out Of Memory) 예외까지 발생할 수 있었습니다. 이를 해결하기 위해 데이터를 1,000건 단위로 분할하여 MyBatis와 JDBC Batch를 활용한 배치 처리 방식을 적용했고, 각 배치 처리 후 캐시를 초기화하여 메모리 안정성을 확보했습니다. 그 결과, 10만 건 처리 시간이 약 843초에서 5초로 단축되며 약 99.4%의 성능 개선 효과를 얻었고, 기존 방식과 비교했을 때 메모리 사용량과 성능 모두 크게 개선되었습니다.
대량 데이터 처리 과정에서 발생한 문제
초기 구현에서 카드 발급 시 여러 데이터를 하나씩 저장하지 않고, 문자열 리스트에 모아둔 위 한 번에 DB 저장과 파일 생성을 수행되고 있었습니다. 또한 파일 생성과 DB 저장을 하나의 기능에서 수행되고 있습니다.
이 방식은 다음과 같은 문제가 있습니다.
- 10만 건 이상의 데이터가 발생한 경우, 한번에 메모리에 적재되면서 OOM(Out Of Memory) 예외가 발생할 수 있습니다.
- DB 저장과 파일 생성이 동시에 실행되어 유지보수성과 확장성이 좋지않습니다.
- 모든 데이터를 하나의 파일에 몰아넣어 I/O 비효율적입니다.
해결 과정
1. BufferWriter를 통한 I/O 최적화
파일 저장은 BufferWriter로 단위 쓰기를 적용해 대량 데이터를 한 번에 메모리에 적재하지 않고 스트리밍 방식으로 처리하여 I/O 효율을 개선했습니다.
2. DB 로직과 파일 로직 분리
초기 구조에서는 하나의 트랜잭션에서 DB Insert와 파일 쓰기가 동시에 일어났습니다. 이를 각각 기능에 맞게 Service로 분리했습니다.
3. 단건 Insert -> Batch Insert 전환
기존 Insert 방식에서는 for문을 통해 데이터를 한 건씩 처리 되고 있었습니다. 이를 JDBC Batch Insert로 변경해 DB 라운드 트립 횟수를 줄이고 성능을 크게 개선했습니다.
4. 대량 데이터 분할 처리
10만 건 이상을 한 번에 데이터를 저장하면 여전히 OOM 예외가 발생할 수 있습니다. 따라서 데이터를 1000건 단위로 분할하여 Insert를 처리했습니다.
판단 근거
이번 카드 발급 데이터 처리에서 가장 중요하게 고려한 포인트는 메모리 사용량과 실행 시간 사이의 균형이었습니다. 모든 데이터를 한 번에 메모리에 적재하는 대신, 일정 단위마다 flush를 수행해 메모리 부담을 줄이고 성능을 안정적으로 유지하는 것이 목표였습니다.
1. JDBC와 JVM Heap 고려
JDBC 드라이버 버퍼와 JVM Heap 크기를 기준으로 할 때 1000건 단위 flush가 성능과 메모리 사용량의 안정성에서 가장 적정 사이즈 라고 판단했습니다.
즉, 너무 작은 단위는 실행 시간이 길어지고, 너무 큰 단위는 메모리 사용량이 극격히 증가하는 문제가 발생합니다.
2. 참고 문헌 및 공식 가이드
Oracle JDBC 공식 문서와 StackOverflow 논의에 따르면, 권장 Batch Size는 50~100 건 수준으로 언급되어 있습니다. 이 범위를 넘어서면 메모리 사용량만 불필요하게 증가할 뿐, 성능 개선 효과는 거의 없으며, 오히려 지나치게 큰 배치 크기는 성능 저하를 유발할 수 있다고 명시되어 있습니다.
Oracle 공식문서 중 일부 내용
For both standard update batching and Oracle update batching, Oracle recommends you to keep the batch sizes in the general range of 50 to 100. This is because though the drivers support larger batches, they in turn result in a large memory footprint with no corresponding increase in performance. Very large batches usually result in a decline in performance compared to smaller batches.참고:
https://docs.oracle.com/cd/E11882_01/java.112/e16548/oraperf.htm#JJDBC28754
Performance Extensions
35/54 23 Performance Extensions This chapter describes the Oracle performance extensions to the Java Database Connectivity (JDBC) standard. This chapter covers the following topics: Update Batching You can reduce the number of round-trips to the database,
docs.oracle.com
https://stackoverflow.com/questions/66356929/how-to-select-optimal-batch-size-in-jdbc
How to select optimal batch size in JDBC?
I have a CSV file with 50000 entries which I want to import in SQL using batch in JDBC. What should be the optimal batch size for it?
stackoverflow.com
3. Mybatis 캐시 이슈와 개선 방법
Mybatis는 기본적으로 1차 캐시(LocalCacheScope=SESSION)가 활성화되어 있어, 세션 종료되기 전까지 객체가 계속 참조 상태로 남습니다. 이 경우 GC 입장에서 객체가 reachable 상태이므로 회수하지 않아 OOM 발생 가능성이 높습니다.
따라서 아래와 같이 두 가지 방법으로 해결할 수 있습니다.
- 쿼리 단위로 flushCache="true" 옵션을 주어 실행 후 캐시를 초기화
- 전역 설정 (mybatis-config.xml)에서 localCacheScope=STATEMENT로 지정해 캐시가 실행 후 즉시 해제 되도록 변경
참고:
어랏!! 여기에서 OOM이 발생할 줄이야…
OOM 에러 케이스와 함께 Mybatis의 캐시 정책과 GC의 동작방식 이해하기
techblog.lotteon.com
테스트
위 내용에 대한 테스트를 진행했습니다.
10만건 데이터를 대상으로 배치 크기를 달리해 테스트한 결과입니다.
Batch 크기 실행 시간(ms) GC 이벤트 수 메모리 사용량(MB) 50 23,805 35 196 100 14,517 28 237 1,000 4,180 27 247 5,000 4,594 29 323 10,000 4,804 29 529 배치 사이즈 50과 100은 메모리 사용량은 적었으나 실행 시간이 지나치게 길었고, 5,000 이상에서는 실행 시간이 1000과 큰 차이가 없는 반면 메모리 사용량이 증가했습니다. 따라서 실행 성능과 메모리 안정성을 모두 고려했을 때, 1,000이 최적의 배치 사이즈라고 판단했습니다.
구현 과정
대량 Insert 최적화에는 두 가지 대표적인 선택지가 있습니다.
1. Mybatis Bulk Insert
Mybatis는 foreach 태그를 이용한 Bulk Insert 를 지원합니다. 하나의 SQL 문으로 여러 건을 Insert 할 수 있어 구현이 간단하다는 장점이 있습니다.
<insert id="bulkInsertCards" parameterType="list"> INSERT INTO card (card_id, product_id, issue_date, expiry_date, card_state) VALUES <foreach collection="list" item="card" separator=","> (#{card.cardId}, #{card.productId}, #{card.cardIssueDate}, #{card.cardExpiryDate}, #{card.cardState}) </foreach> </insert>하지만 이 방식은 JDBC 드라이버 수준의 배치 처리와는 다릅니다. 단일 SQL이 매우 커질 수 있기 때문에, 데이터 건수가 많을 경우 DB 또는 드라이버에서 SQL 길이 제한에 걸리거나 메모리 사용량이 급격히 늘어날 수 있습니다. 즉, 대량 데이터(수만~수십만 건) 처리에는 OOM 발생 가능성과 성능 저하 위험이 존재합니다.
2. MyBatis + JDBC Batch
이번 프로젝트에서는 선택한 방식은 MyBatis + JDBC Batch 입니다. MyBatis Mapper를 그대로 사용하면서, 세션을 ExecutorType.BATCH 모드로 열어 JDBC Batch Insert를 직접 제어하는 방식입니다.
SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false); CardDao batchDao = session.getMapper(CardDao.class); Configuration configuration = sqlSessionFactory.getConfiguration(); configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); for (int i = 1; i <= cardList.size(); i++) { batchDao.insert(cardList.get(i - 1)); // 1000건 단위 flush & cache clear if (i % 1000 == 0 || i == cardList.size()) { session.flushStatements(); session.clearCache(); } } session.commit();- ExecutorType.BATCH: JDBC Batch 모드로 실행하여 SQL을 모아두었다가 한 번에 전송
- flushStatement(): JDBC 드라이버 버퍼에 모인 SQL을 DB로 전송 (이번 프로젝트에서는 1000건 단위로 flush)
MyBatis는 기본적으로 1차 캐시(LocalCacheScope=SESSION)을 사용한다고 위에서 설명했습니다. 이 경우 세션이 종료될 때까지 객체가 참조 상태로 유지되기 때문에, 대량 데이터 Insert 시에는 GC가 메모리를 회수하지 못해 OOM(Out Of Memory) 예외가 발생할 수 있습니다.
이를 방지하기 위해 이번 프로젝트에서는 다음 두 가지 전략을 적용했습니다.
1. 캐시 범위를 STATEMENT로 변경
Configuration configuration = sqlSessionFactory.getConfiguration(); configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);- SESSION: 캐시가 세션 단위로 유지
- STATEMENT: 쿼리 실행 후 캐시 즉시 해제
2. 배치 전송 후 캐시 초기화
if (i % 1000 == 0 || i == cardList.size()) { session.flushStatements(); session.clearCache(); // 1차 캐시 해제 }참고:
성능 분석 — SqlSession BATCH > MyBatis foreach > update
Java Spring 환경에서 대량의 데이터를 업데이트할 때, MyBatis의 foreach 구문을 사용한 배치 업데이트와 개별 update를 반복 실행하는 방식 사이에는 상당한 성능 차이가 존재합니다. 각 방법의 특징과
medium.com
https://blog.harawata.net/2016/04/bulk-insert-multi-row-vs-batch-using.html
Bulk insert (multi-row vs. batch) using JDBC and MyBatis
I have seen some developers are using multi-row insert in MyBatis to perform bulk insert. If performance is your concern, though, you shoul...
blog.harawata.net
결론
이번 프로젝트의 목표는 대량 카드 발급 시 발생하는 성능 저하와 OOM 문제를 해결하는 것이었습니다.
기존 단건 Insert 방식은 10만 건 처리에 843초가 걸릴 정도로 비효율적이었고, 모든 데이터를 한 번에 적재하면 OOM(OutOfMemory) 예외까지 발생할 수 있었습니다.이를 해결하기 위해 1,000건 단위 분할 처리 + MyBatis + JDBC Batch 방식을 적용했습니다.
- Batch Insert를 통해 DB 라운드 트립 횟수를 최소화
- 주기적 flush & clearCache로 JDBC 버퍼와 MyBatis 캐시를 안정적으로 관리
- 캐시 범위 STATEMENT 전환으로 메모리 누수 방지
그 결과, 10만 건 데이터를 843초 → 5초(약 99.4% 단축) 만에 처리할 수 있었고, 기존 방식 대비 성능과 메모리 사용량 모두 크게 개선되었습니다.
마인드맵으로 내용 정리

카드 발급 성능 개선 마인드맵 정리 'Spring' 카테고리의 다른 글
싱글 스레드에서 멀티스레드 성능 최적화: 스레드 풀과 큐의 역할 (1) 2025.08.29 캐싱 최적화: Redis를 사용하지 않고 Local Cache로 캐싱하는 방법 (0) 2025.08.25 Enum find 메서드 최적화: 10배 빠르게 만드는 HashMap 활용법 (2) 2025.08.12 Transaction: ACID, 격리 수준, 락, 전파 옵션 (4) 2025.08.12 Jackson JSON ↔ DTO 직렬화·역직렬화 (0) 2025.08.12