개요
MSA 구조로 개발하면서 서비스는 점점 작은 기능 단위로 나눠지고 그로 인해 데이터 구조도 단순해지면서 MySQL과 같은 RDB에서 MongoDB 같은 NoSQL로 이동하고 있고, Spring에서도 RDB만 지원 기능이 점점 MongoDB도 지원하도록 기능이 추가되고 있다. (Spring Batch에서 MongoDB를 JobRepository로 제공 예정 https://github.com/spring-projects/spring-batch/issues/877)
MongoDB를 사용하는 곳이 늘어 남에 따라 Spring Batch에서도 지속적으로 MongoDB 관련 기능이 추가되고 있는데 그중 MongoDB에서 데이터를 조회하는 MongoCursorItemReader에 대하여 알아본다.
MongoPagingItemReader
Spring Batch 5.1 이전 버전에서는 MongoPagingItemReader를 기본으로 제공해 줬다. MongoPagingItemReader는 수집하는 데이터가 적은 경우에는 큰 문제가 없지만 데이터 사이즈가 큰 경우 데이터의 마지막으로 갈수록 느려지는 단점이 있다. MongoPagingItemReader는 Paging 기능을 사용하여 데이터를 나눠서 수집한다. 이 방법은 데이터가 적은 경우 문제는 없지만 데이터 사이즈가 커서 페이징이 많아지는 경우 문제가 발생한다.
protected Iterator<T> doPageRead() {
....
Pageable pageRequest = PageRequest.of(page, pageSize);
query.with(pageRequest);
if (StringUtils.hasText(collection)) {
return (Iterator<T>) template.find(query, type, collection).iterator();
}
else {
return (Iterator<T>) template.find(query, type).iterator();
}
Paging 기능에서 페이지 번호가 커질수록 느려지는 이유
페이징 기능에서 페이지 번호가 커질수록 조회 속도가 느려지는 이유는 skip과 limit이 작동하는 방식으로 인해 발생하는 일반적인 문제다.(일반적인 RDB에서 동일한 문제가 있다.)
예를 들어 가장 최근에 가입한 사용자 1000명의 정보를 가져오는 쿼리는 아래처럼 작성한다.
db.user.find({deleted : fase})
.sort({created: 1})
.skip(0)
.lmiit(1000)
SQL ex) SELECT * FROM user WHERE delted=false ORDER BY create LIMIT 0, 1000
DB는 인덱스를(delete : 1, created : 1) 사용하여 1000개의 문서를 빠르게 찾고 1000개의 문서를 반환한다. 모든 것은 날짜별로 정렬되어 있어서 빠르게 반환한다. 다음 페이지도 비슷하게 작동하지만 Skip 0 대신 1000을 건너뛴다. DB는 2000개의 문서를 찾아 1000개를 반환한다. skip과 limit은 이렇게 작동한다. 그럼 5,000 페이지를 조회하는 경우는 5,000,000개를 건너뛰고 1000개를 반환한다. 1,000개의 문서를 반환하기 위해 5,001,000개의 문서를 찾아야 하니 위와 같은 페이징 방법은 오래 걸리는 것이 당연하다. (이를 방지하기 위해 bucket pattern을 사용하는데 다음에 포스팅하겠다.)
위에서도 언급했지만 MongoDB만 느리지 않고 동일한 방법은 사용하는 RDB에서도 동일한 이슈가 있다.
JdbcPagingItemReader
Spring Batch에서 RDB의 경우 JdbcPagingItemReader를 제공하지만 동작 방식은 MongoPagingItemReader와 전혀 다르다.
@Override
protected void doReadPage() {
if (getPage() == 0) {
......
query = getJdbcTemplate().query(firstPageSql, rowCallback,
getParameterList(parameterValues, null).toArray());
......
}
else if (startAfterValues != null) {
......
query = getJdbcTemplate().query(remainingPagesSql, rowCallback,
getParameterList(parameterValues, startAfterValues).toArray());
......
}
......
}
private class PagingRowMapper implements RowMapper<T> {
@Override
public T mapRow(ResultSet rs, int rowNum) throws SQLException {
startAfterValues = new LinkedHashMap<>();
for (Map.Entry<String, Order> sortKey : queryProvider.getSortKeys().entrySet()) {
startAfterValues.put(sortKey.getKey(), rs.getObject(sortKey.getKey()));
}
return rowMapper.mapRow(rs, rowNum);
}
}
코드만 보면 MongoPagingItemReader처럼 doReadPage에 페이징 하는(page++) 로직이 없다. JdbcPagingItemReader의 경우 skip, limit으로 페이징을 하지 않고 sort 키로 정의된 칼럼을 기준으로 이전 페이지에 마지막 값을 기준으로 쿼리가 변경된다.
# 1 페이지
SELECT * FROM user WHERE delete = false ORDER BY created LIMIT 1000
# 2 페이지
SELECT * FROM user WHERE delete = false AND created > {1 페이지 마지막 row에 created} ORDER BY created LIMIT 1000
대략 SQL 예제처럼 페이징을 함으로 SKIP 과정이 생략되어 페이지 번호가 늘어나도 여러 페이지를 찾지 않아도 되고, index를 사용하여 정렬되어 있는 delete, created 칼럼의 값을 비교하는 시간은 B-Tree 구조상 페이지가 뒤로 가더라도 첫 페이지와 비슷한 시간이 필요하다.
MongoCursorItemReader
MongoDB에 대량의 데이터 조회를 위해서 Spring Batch에서 제공하는 MongoPagingItemReader를 사용하지 않고 JdbcPagingItemReader와 동일한 동작을 하도록 별도의 ItemReader를 작성하여 사용했었는데. Spring Batch 5.1 이후에는 MongoDB에서 대략의 데이터를 조회할 수 있는 MongoCursorItemReader가 추가되었다.
MongoCursorItemReader는 위 방식처럼 이전 페이지에 데이터를 사용하는 방법이 아닌 Cursor(Stream) 기능을 사용하여 데이터를 조회한다.
@Override
protected void doOpen() throws Exception {
Query mongoQuery;
if (queryString != null) {
mongoQuery = createQuery();
}
else {
mongoQuery = query;
}
Stream<? extends T> stream;
if (StringUtils.hasText(collection)) {
stream = template.stream(mongoQuery, targetType, collection);
}
else {
stream = template.stream(mongoQuery, targetType);
}
this.cursor = streamToIterator(stream);
}
@Override
protected T doRead() throws Exception {
return cursor.hasNext() ? cursor.next() : null;
}
@Override
public <T> Stream<T> stream(Query query, Class<T> entityType, String collectionName) {
return doStream(query, entityType, collectionName, entityType);
}
https://spring.io/blog/2023/11/23/spring-batch-5-1-ga-5-0-4-and-4-3-10-available-now
New cursor-based MongoItemReader
Up to version 5.0, the MongoItemReader provided by Spring Batch used pagination, which is based on MongoDB's skip operation. While this works well for small/medium data sets, it starts to perform poorly with large data sets.
This release introduces the MongoCursorItemReader, a new cursor-based item reader for MongoDB. This implementation uses cursors instead paging to read data from MongoDB, which improves the performance of reads on large collections.
Reader 성능 비교
데이터는 MongoDB에 저장된 약 710만 건(2.0GB) 데이터 수집하는 로직으로 ItemProcessor는 추가하지 않고 Writer에도 단순 로깅만 추가하여 수집 시간을 비교했다.
Page Size 1000, Chunk 1000
ItemReader | Time |
MongoPagingItemReader | 3h 14m 12s 84ms |
CustomMongoPagingItemReader | 19m 16s 321ms |
MongoCursorItemReader | 10m 0s 642ms |
Page Size 5000, Chunk 5000
ItemReader | Time |
MongoPagingItemReader | 48m 19s 631ms |
CustomMongoPagingItemReader | 12m 9s 69ms |
MongoCursorItemReader | 10m 12s 557ms |
가장 성능이 좋은 것은 MongoCursorItemReader로 Page와 Chunk에 큰 영향 없이 비슷한 속도를 보여줬다.
CustomMongoPagingItremReader의 경우 MongoCursorItemReader보다는 속도가 느렸지만 사용하지 못할 정도의 수준은 아니었고 Page Size에 따라 쿼리를 전송하는 횟수가 늘어야 하기 때문에 페이지 사이즈가 속도에 영향을 준다.
MongoPagingItemReader는 Page 1000의 경우 실행해 놓고 다른 일을 엄청 하다와도 끝나지 않아서 멈춰서 동작 안 하는 줄 알고 MongoDB 쿼리 모니터링도 했었다.
결론
MongoDB의 데이터를 Spring Batch로 수집하는 경우 데이터가 크다면 MongoCursorItemReader를 꼭 사용하자!
MongoPagingItemReader를 사용하고 MongoDB는 느리네라고 하지 않도록!
Spring Batch 5.x 이하를 사용하신다면 MongoPagingItemReader가 없고 MongoItemReader가 있는데 동일한 기능을 하는 클래스다. MongoItemReader는 @Deprecated되어 삭제 예정.
CursorItemReader가 생겨서 MongoItemReader를 네이밍 일관성을 위해 MongoPagingItemReader로 변경함.
'dev > spring' 카테고리의 다른 글
[Spring] Redisson으로 분산락 구현하기(Distrubuted Lock) (1) | 2024.11.21 |
---|---|
Spring AI를 사용한 AI 어시스턴트 구현 RAG - Part 2 (with OpenAI) (1) | 2024.10.03 |
Spring AI를 사용한 AI 어시스턴트 구현 - Part 1 (with OpenAI) (1) | 2024.10.01 |
[Spring] Rest service with Hexagonal architecture (1) | 2024.09.22 |
[Spring] @Async와 Virtual Thread (1) | 2024.09.14 |