1. 도입부 (Introduction)
현대적인 아키텍처에서 관계형 데이터베이스(RDBMS)의 스키마 제약을 벗어난 NoSQL의 활용도는 매우 높다. 특히 대규모 로그 분석이나 빠른 응답이 필요한 캐시 데이터 처리에는 MongoDB와 Redis가 필수다. 하지만 스프링 배치에서 NoSQL을 다룰 때는 RDB와는 완전히 다른 접근이 필요하다.
각 저장소의 내부 메커니즘인 커서(Cursor), SCAN, 벌크 연산을 정확히 이해하지 못하면 배치 도중 데이터가 누락되거나 시스템 성능이 급격히 추락하는 장애를 겪게 된다. 오늘은 MongoDB와 Redis, 그리고 추상화된 Repository를 통해 배치 처리를 완벽하게 통제하는 전략을 파헤쳐본다.
[Spring Batch] RDB 대용량 데이터 처리의 정석: JDBC vs JPA 분석
배치 처리의 핵심은 '대량의 데이터를 얼마나 안전하고 효율적으로 다루는가'에 있다. 일반적인 웹 애플리케이션의 단건 조회 방식으로는 수천만 건의 데이터를 감당할 수 없으며, 무턱대고 데
myblog01150.tistory.com
2. MongoDB: 도큐먼트 혁명과 데이터 스트리밍
MongoDB는 BSON 형식의 유연한 구조를 가지며 수평 확장이 용이하다. 배치 관점에서 가장 중요한 것은 수백만 건의 도큐먼트를 메모리 부족 없이 안전하게 읽어오는 것이다.

2.1 MongoCursorItemReader: 스트리밍의 핵심
커서 기반 리더는 결과를 한 번에 메모리에 올리지 않고 데이터를 순차적으로 흘려보내는 방식을 취한다.
- 초기 침투와 후속 통신: 쿼리 실행 시 서버는 101개의 도큐먼트를 '선발대'로 먼저 보낸다. 이후 배치가 이를 다 소비하면 설정된 batchSize에 맞춰 추가 데이터를 요청한다.
- 16MB의 물리적 한계: MongoDB의 단일 배치 최대 크기는 16MB다. batchSize를 아무리 크게 잡아도 데이터 총 용량이 16MB를 넘으면 서버는 여러 번에 나누어 응답한다.
- 내부 버퍼링 전략: read() 메서드가 매번 DB와 통신하는 것은 비효율적이다. 리더는 내부 버퍼에 가져온 데이터를 보관하며, 버퍼가 비었을 때만 서버에 새로운 요청을 보낸다.
// MongoDB 커서 기반 리더 설정 예시
@Bean
@StepScope
public MongoCursorItemReader<SecurityLog> securityLogReader(
@Value("#{jobParameters['searchDate']}") LocalDate searchDate
) {
// 쿼리 파라미터 준비 (시작 시간과 종료 시간 계산)
Date startOfDay = Date.from(searchDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
Date endOfDay = Date.from(searchDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
return new MongoCursorItemReaderBuilder<SecurityLog>()
.name("securityLogReader")
.template(mongoTemplate)
.collection("security_logs")
.jsonQuery("""
{
"label": "PENDING_ANALYSIS",
"timestamp": { "$gte": ?0, "$lt": ?1 }
}
""") // 분석 대기 중인 특정 날짜의 로그만 조회
.parameterValues(List.of(startOfDay, endOfDay))
.sorts(Map.of("timestamp", Sort.Direction.ASC)) // 시간 순 정렬 필수
.targetType(SecurityLog.class)
.batchSize(10) // 한 번에 가져올 청크 단위 설정
.build();
}
2.2 MongoPagingItemReader와 'Paging Trap'
skip()과 limit()을 사용하는 페이징 방식은 겉보기엔 편리하지만, 심각한 성능 함정과 데이터 누락 위험이 있다.
- 샤딩 환경의 성능 추락: 샤딩된 환경에서는 각 샤드에서 데이터를 로드하고 건너뛴 뒤, mongos에서 다시 정렬과 스킵을 수행한다. 페이지가 뒤로 갈수록 오버헤드가 기하급수적으로 늘어난다.
- 데이터 누락(Paging Trap): 배치 처리 중 쿼리 조건에 해당하는 필드(예: status='PENDING')를 변경하면, 다음 페이지 조회 시 skip 위치가 밀려나면서 아직 처리하지 않은 데이터를 건너뛰게 된다. 따라서 상태가 변하는 데이터 처리에는 반드시 커서 기반 리더를 써야 한다.
2.3 MongoItemWriter와 BulkWrite
여러 쓰기 작업을 하나의 배열로 모아 단 한 번의 네트워크 요청으로 처리하는 BulkWrite가 핵심이다.
- UPSERT와 복합키: 기본적으로 ID 기반으로 동작하지만, primaryKeys() 설정을 통해 비즈니스 키 조합으로 문서를 특정하여 업데이트할 수 있다.
- Mode 설정: INSERT, UPSERT, REMOVE 모드를 통해 쓰기 연산의 성격을 결정한다.
3. Redis: 인메모리 데이터의 정점과 SCAN 메커니즘
Redis는 디스크 I/O가 없어 압도적으로 빠르지만, 배치의 대량 처리 관점에서는 네트워크 비용과 비연속성이 가장 큰 변수다.

3.1 RedisItemReader: 왜 SCAN인가?
실무 운영 환경의 Redis에서 KEYS *와 같은 명령은 서비스 전체를 중단시키는 재앙을 초래한다. 그래서 배치는 반드시 SCAN 명령을 사용한다.
- 비차단식 조회(Non-blocking): SCAN은 커서 방식을 사용하여 데이터를 조금씩 끊어온다. 한 번의 요청이 Redis를 오랫동안 점유하지 않아 실서비스에 영향을 주지 않는다.
- SCAN-GET 루프의 비효율: 중요하게 이해해야 할 점은 SCAN이 데이터 자체가 아닌 키(Key) 목록만 반환한다는 것이다. 실제 값을 얻으려면 반환된 키마다 GET 명령을 다시 날려야 한다.
- 네트워크 RTT의 저주: 만약 청크 크기가 100이라면, 1번의 SCAN 요청과 100번의 개별 GET 요청이 발생한다. 데이터 1건마다 발생하는 네트워크 왕복 시간(Round Trip Time)이 누적되어, 실제 데이터 처리 시간보다 통신 대기 시간이 훨씬 길어지는 병목 현상이 발생한다.
- STRING 타입 전용: 기본 제공되는 RedisItemReader는 STRING 타입 데이터만 지원한다. HASH나 SET을 처리하려면 HSCAN, SSCAN을 사용하는 커스텀 리더를 구현해야 한다.
3.2 재시작 불가(Non-restartable): 최후의 통첩
Redis SCAN 명령의 가장 치명적인 약점은 데이터 순서를 보장하지 않는다는 점이다.
- 혼돈의 커서: Redis 내부 구조상 데이터가 추가/삭제됨에 따라 SCAN 커서의 순서가 뒤바뀔 수 있다.
- 중간 지점은 없다: 배치가 중간에 실패했을 때 "여기서부터 다시 시작"하는 것이 불가능하다. 따라서 Redis 배치는 실패 시 무조건 처음부터 다시 돌려도 데이터가 중복되거나 망가지지 않는 멱등성(Idempotency) 설계가 필수적이다.
3.3 전역 집계 전략: AttackCounter 패턴
배치는 데이터를 청크 단위로 나누어 처리하므로 "전체 데이터 중 특정 유형의 비율" 같은 통계를 내기 어렵다. 이때 JobScope 빈을 활용한 집계 컴포넌트가 필요하다.
- 수집 단계: ItemWriter에서 각 아이템을 처리할 때마다 집계 컴포넌트에 기록을 남긴다.
- 출력 단계: 수집이 모두 끝나면 다음 스텝(Tasklet)에서 집계된 결과를 종합하여 최종 리포트를 생성하는 2단계 파이프라인을 구축한다.
3.4 RedisItemWriter: 데이터의 기록과 정리
RedisItemWriter는 itemKeyMapper를 통해 객체에서 키를 추출해. 데이터를 SET하거나, delete(true) 설정을 통해 처리 완료된 흔적을 지우는 데 사용된다.
// Redis 데이터를 읽고 분석 후 삭제하는 설정
@Bean
public RedisItemReader<String, AttackLog> redisAttackReader() {
return new RedisItemReaderBuilder<String, AttackLog>()
.redisTemplate(redisTemplate)
.scanOptions(ScanOptions.scanOptions()
.match("attack:*") // attack: 패턴 키만 스캔
.count(10) // SCAN 힌트값 (성능 튜닝 포인트)
.build())
.build();
}
@Bean
public RedisItemWriter<String, AttackLog> redisDeleteWriter() {
return new RedisItemWriterBuilder<String, AttackLog>()
.redisTemplate(redisTemplate)
.itemKeyMapper(log -> "attack:" + log.getId()) // ID로 키 생성
.delete(true) // 분석이 끝난 키는 즉시 삭제 모드
.build();
}
4. Spring Data Repository: NoSQL 추상화의 활용
특정 저장소에 종속되지 않고 익숙한 Repository 패턴을 배치에 이식할 수 있다.
4.1 RepositoryItemReader
PagingAndSortingRepository를 사용하여 데이터를 페이지 단위로 읽어온다.
- 장점: 타입 세이프한 쿼리 작성과 IDE의 자동 완성을 지원받을 수 있어 생산성이 높다.
- 한계: 내부적으로 오프셋 페이징을 사용하므로, 저장소 특성에 따라 대량 데이터 조회 시 성능 이슈가 발생할 수 있음을 인지해야 한다.
4.2 RepositoryItemWriter
CrudRepository의 saveAll() 메서드를 기본적으로 사용하여 아이템을 저장한다.
- 주의사항: methodName()으로 커스텀 메서드를 지정하면 벌크 연산이 깨지고 아이템당 개별 호출로 동작할 수 있다. 특별한 이유가 없다면 기본 saveAll()을 활용하라.
5. NoSQL 트랜잭션 관리와 원자성
NoSQL은 RDBMS와 같은 수준의 완벽한 트랜잭션 보장이 어렵다.
- MongoDB Transaction: 4.0 이상, 레플리카 셋 환경에서만 MongoTransactionManager를 통한 롤백이 가능하다. 단일 인스턴스에서는 작동하지 않는다.
- 지연 쓰기(Buffered Write) 전략: 트랜잭션 매니저를 쓸 수 없는 환경이라면, 배치는 데이터를 메모리에 버퍼링했다가 커밋 직전(beforeCommit)에 BulkWrite를 한 번에 수행한다. 이는 쓰기 도중 에러가 났을 때 불완전한 데이터가 DB에 남는 리스크를 최소화해준다.
6. 마무리하며
NoSQL 배치는 단순히 데이터를 옮기는 작업이 아니라, 저장소의 분산 처리 메커니즘을 제어하는 정교한 설계가 필요한 영역이다. 이번 내용을 정리하며 반드시 기억해야 할 포인트는 다음과 같다.
- MongoDB 대량 조회는 성능과 정합성을 위해 페이징보다는 커서(Cursor)를 최우선으로 고려해야 한다.
- Redis 배치는 네트워크 RTT와 재시작 불가 특성을 반드시 계산에 넣어야 하며, 실패 시 처음부터 다시 돌려도 무방하도록 설계해야 한다.
- 트랜잭션의 한계를 인정하고, 애플리케이션 레벨에서의 벌크 연산과 지연 쓰기 전략을 통해 데이터 무결성을 수호해야 한다.
배치 시스템의 정점에 서기 위해서는 각 저장소의 '내부 동작 원리'를 읽어낼 줄 알아야 한다.
'Spring > Batch' 카테고리의 다른 글
| [Spring Batch] ItemStream의 자원 관리와 상태 복구 (0) | 2026.05.17 |
|---|---|
| [Spring Batch] 위임과 복합 컴포넌트 정리: Composite, Classifier, Mapping (0) | 2026.05.16 |
| [Spring Batch] RDB 대용량 데이터 처리의 정석: JDBC vs JPA 분석 (1) | 2026.05.14 |
| [Spring Batch] 스프링 배치 JSON 읽기/쓰기 전략 (0) | 2026.05.11 |
| [Spring Batch] 객체를 파일로 변환시키는 FlatFileItemWriter (0) | 2026.05.10 |