1. 일반적인 경우의 MyBatis Insert.
어떤 로직을 거쳐 생성된 객체 100만개를 DB에 insert 해야 하는 상황이다.
private List<MemoDTO> getMemoList() {
List<MemoDTO> list = new ArrayList<>();
// 100만번 loop를 돌아 100만개의 요소를 생성해 list에 add 한다.
for (int i = 0; i < 1_000_000; i++) {
MemoDTO memo = new MemoDTO();
memo.setMemberId(1);
memo.setMemo("메모 테스트 입니다!메모 테스트 입니다!");
list.add(memo);
}
return list;
}
위와 같이 MemoDTO 객체를 100만개 임의로 생성해서 List에 넣었다.
위 객체를 DB에 insert 할 Mapper는 아래와 같다.
<insert id="insertMemo" useGeneratedKeys="true" keyProperty="id">
insert into memo
(
member_id
, memo
, category
)
values
(
#{memberId}
, #{memo}
, #{category}
)
</insert>
100만개를 한줄씩 insert 한 결과 소요시간은 다음과 같았다.
100만개를 65분만에 insert 하다니. 뛰어난 성능이다. (3시간 이상 걸릴줄 알았다.)
대신 100만번의 DB transaction과 connection이 있었다.
2. MyBatis로 대규모 데이터 insert 시 out of memory 발생.
그런데 insert 문을 한 번에 여러 줄을 실행 할 수 있는 문법이 생각났다!
MyBatis는 foreach를 통해서 다음과 같이 multi row insert 가 가능하다.
<insert id="insertMemoBatch" parameterType="java.util.List" useGeneratedKeys="true">
insert into memo
(member_id, memo, category)
values
<foreach collection="list" item="member" separator=",">
(#{member.memberId}, #{member.memo}, #{member.category})
</foreach>
</insert>
이제 list를 통채로 insertMemoBatch로 넘기면 얼마나 속도가 개선될까 기대 된다. (두근두근)
// 100만개 요소를 보유한 List를 통채로 파라미터로 전달.
mapper.insertMemoBatch(list);
.
.
.
.
.
.
100만개를 insert 하기엔 메모리가 너무 부족했다.
메모리를 추가 구입하자.
실제 상용 서비스에서 위와 같은 에러가 발생해 서버가 셧다운 됐다고 생각해보자..
생각만 해도 두렵다.
100만개를 한 번에 삽입하기엔 사이즈가 너무 컸나보다.
10만개씩 10번 나눠서 넣으면 괜찮을까?
그래. 그럼 5만개면 될까?
.
.
.
.
그렇다. 겸손함이 부족했다.
1만개면 되겠지?
오오. insert가 시작 됐다.
.
.
.
뭔가 빨리 끝난거 같아 로그를 확인해 보니..
insert가 정상적으로 진행되는 듯 보였으나 5번째 insert에서 또다시 OOM 이 발생했다..
뭐가 문제였을까? 한 번에 밀어 넣기엔 1만 개도 많았단 말인가?
그러면 1000개씩 insert 해보겠다.
그래도 100만개를 100만번 insert 하는 것보다 1000배 빨라지지 않을까?
1000배 빨라진다면 그것으로 족하다 하겠다.
가라!
final int CHUNK_SIZE = 1000;
.
.
.
.
55번 째 batch insert가 실행되고 56 번째에 여지없이 Out Of Memory Error가 발생했다.
이쯤 되면 이제 눈치 챌 만 하다.
chunk size는 문제가 아니라는 것을.
2. MyBatis Batch Insert 시 Heap Memory 모니터링.
5000개씩 insert 할 때, 수행 횟수에 따른 Heap 메모리 변화량을 살펴 보자.
수행횟수 | Total Memory | Free Memory | Used Memory | Eden | Survivor | Old Gen |
1 | 247.562 MB | 45.015 MB | 202.548 MB | 68.10 MB | 2.07 MB | 132.39 MB |
2 | 382.293 MB | 148.392 MB | 233.901 MB | 10.51 MB | 13.19 MB | 210.23 MB |
3 | 382.293 MB | 16.224 MB | 366.069 MB | 89.41 MB | 13.19 MB | 263.48 MB |
4 | 494.938 MB | 131.821 MB | 363.116 MB | 100.30 MB | 17.06 MB | 245.76 MB |
5 | 494.938 MB | 134.049 MB | 360.888 MB | 83.43 MB | 0.00 MB | 277.46 MB |
6 | 494.938 MB | 100.557 MB | 394.38 MB | 69.38 MB | 0.00 MB | 325.01 MB |
7 | 494.938 MB | 60.988 MB | 433.95 MB | 92.58 MB | 0.00 MB | 341.37 MB |
8 | 494.938 MB | 10.257 MB | 484.68 MB | 136.50 MB | 6.81 MB | 341.37 MB |
9 | 494.938 MB | 36.296 MB | 458.641 MB | 117.27 MB | 0.00 MB | 341.37 MB |
Total Memory는 JVM이 유동적으로 할당하는 committed memory를 의미한다.
JVM은 1, 2, 3, 4 회차 수행에 따라 committed memory를 늘려서 메모리 부족에 대응 했다.
247MB -> 382MB -> 494MB.
그러나 문제는 오랫동안 쌓여도 사라지지 않았던 Old Generation 영역이다.
132 MB 부터 시작해서 마지막에 341.37 MB 까지 지속적으로 증가한다.
Heap Memory 영역에 대해서 간략히 알아보자.
메모리가 부족해 지면 JVM은 Garbage Collector를 실행한다. (Garbage Collector => 이하 GC)
GC는 참조가 끊겨 길 잃은 객체들을 수거하고 메모리를 정리한다.
쉽게 말해, 더 이상 사용하지 않는 변수값들을 메모리에서 삭제하는 작업을 한다.
Eden 영역에는 이제 막 탄생한 객체들이 머무른다.
Eden과 Survivor 영역에서 수행되는 GC를 minor GC라고 하는데 가벼운 GC를 수행한다.
GC를 여러번 겪고도 살아남은 객체들은 Old Generation으로 이동 된다.
예를 들어 GC 를 10번 수행 했어도 잔존한 객체들은 Eden -> Survivor -> Old 로 이동 하게 되는데,
이 기준 횟수인 10회를 Tenured Threshold라 한다. (이 수치는 JVM의 종류에 따라 기본값이 다르다.)
이 Tenured Threshold는 사용자가 JVM 옵션을 주어 임의로 조절 할 수 있다.
이 Old Gen 영역에서는 Major GC가 수행 된다.
Major GC는 Heap 메모리 영역 전체를 청소하는 무거운 작업을 수행한다.
Old Gen 영역이 임계치에 달해서 Major GC가 수행되는 경우가 늘어날 수록 어플리케이션의 성능은 떨어진다.
여하튼 위 표를 참고하면,
9번째 수행에서는 Eden + Old Gen의 점유 메모리가 458.641 MB로 Total Memory에 가까워 졌고.
10번 째 수행에서는 Total Memory 수치를 넘어서며
Out Of Memory가 발생 했다.
3. 문제해결. SqlSession 의 ExecutorType.BATCH와 flushStatements()
3-1. SqlSession의 ExecutorType.BATCH
이 문제를 해결하기 위해서는 어떻게 해야 할까?
안되겠다.
역시 Official Document 를 참조해서 했어야 했다.
MyBatis3 의 공식 깃헙을 가면 Batch Insert 에 대해서 샘플코드와 함께 가이드를 제공한다.
https://github.com/mybatis/mybatis-3/wiki/FAQ#how-do-i-code-a-batch-insert
그렇다.
별도의 SqlSession을 ExecutorType.BATCH로 설정해서 실행해야 한다.
3-2. flushStatements()
또한 거기에 더해 batch insert 수행 후,
MyBatis가 메모리에 붙잡고 있는 preparedStatement 쿼리를 DB에 insert 해주고 flush 해야 한다.
그렇게 하면 참조를 잃게된 객체들을 GC가 수거해 메모리를 확보할 수 있다.
이는 SqlSession 객체에서 제공하는 flushStatements() 객체를 이용해 수행 할 수 있다.
https://mybatis.org/mybatis-3/java-api.html#sqlSessions
(공식 사이트나 API 문서가 익숙치 않다면, 아직 해당 기술에 익숙하지 않다는 의미이다.
블로그 글이나 유저 친화적인 튜토리얼을 먼저 접한 후에 대략적으로 파악이 됐다면,
그 다음 공식 문서를 접하는 것도 좋을 것 같다.
단, 상용에 배포할 코드는 꼭 공식적인 자료를 통한 검증과정을 거쳐야 한다.)
그래서 최종적으로는 아래와 같은 코드가 된다.
@Test
@DisplayName("multi row insert")
@Transactional
void batchInsertTest() {
List<MemoDTO> list = this.getMemoList(); // 100만개 요소 리스트 할당.
final int CHUNK_SIZE = 10_000; // 10_000개씩 Batch Insert.
try(SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
MemoMapper mapper = sqlSession.getMapper(MemoMapper.class);
// subList 생성 방식.
for (int i = 0; i < list.size() / CHUNK_SIZE; i++) {
int startIndex = i * CHUNK_SIZE;
int endIndex = (i + 1) * CHUNK_SIZE;
mapper.insertMemoBatch(list.subList(startIndex, endIndex));
sqlSession.flushStatements(); // flush 하여 쿼리를 DB로 밀어넣고 캐시를 지워 메모리 확보.
}
sqlSession.commit();
} catch (Throwable e) {
e.printStackTrace();
throw new RuntimeException();
}
}
(편의상 로그 출력 코드는 표시하지 않았다.)
4. 결과.
1만개씩 Batch Insert를 실행해 보자.
100만개 요소의 리스트를 insert 하는데 21초를 소요했다.
최초 3937초에서 99.46% 성능이 개선 되었다.
5. 맺는말.
이 밖에도 Batch Insert시 확인해야 할 부분들은 다음과 같다.
- Database의 Max Allowed Packet Size : DB insert시 한번에 밀어넣을 수 있는 데이터의 크기.
- 서버의 메모리와 JVM Heap 할당 메모리 크기.
- 객체의 크기 변동성 : 객체에 할당할 수 있는 값이 매우 유동적이라면 이 부분도 고려해야 한다.
(해당 객체에 블로그의 본문이 저장된다면, 객체의 크기는 훨씬 커질 수 있다.)
- 메모리 이슈에 대해 정확한 검증과 예측이 어렵다면 jdbcTemplate과 같은 라이브러리를 사용하는 것을 고려 해볼 수 있다. jdbcTemplate은 상대적으로 MyBatis와 같이 객체를 사용하는 Framework에 비해 메모리 이슈에서 자유롭다. (실서비스 환경과 유사한 테스트 환경 구축이 되 있지 않아 검증이 어려울 때.)
성능과 안정성은 Trade-Off 관계에 있는 경우가 많다.
대용량 데이터를 다룰 때는 성능도 중요하지만
더 중요한 것은 안정성인 것 같다.
성능은 개선하면 좋지만,
안정성을 놓쳐 데이터가 유실 되거나, 서비스가 오작동 혹은 중지된다면
비즈니스에 큰 타격을 입게 될 것 이다.
(서비스가 수집하는 데이터가 점점 커질 수록, 상용에 배포하는 코드의 안정성에 주의를 기울이도록 하자!)
속도 저하로 고민하고 있다면,
대규모 데이터 insert 시, batch insert를 통해 속도를 향상 시켜 보자.
'DataBase > My-Batis' 카테고리의 다른 글
MyBatis 쿼리 로그를 이쁘게 정렬 해보자 (0) | 2021.05.30 |
---|---|
[MyBatis/MySQL] Result Map을 조심 하세요. (0) | 2021.03.02 |
[MyBatis/MySQL] 쿼리 작성시 Tip 모음 (0) | 2021.03.01 |
Delete 문에 이상이 없어보이는데 Syntax 에러. (0) | 2021.03.01 |
Spring-boot 와 MyBatis 연결 설정. (0) | 2021.02.27 |