아무튼, 쓰기
Spring Batch로 187초 → 6.65초: 28배 성능 개선 전과정 본문
들어가며
20만 건의 경매 데이터를 처리하는 과정에서 187초에서 6.65초로 약 28배의 성능 개선을 이뤄낸 경험을 바탕으로, Reader/Writer 선택부터 멀티스레드를 적용한 과정 사이에 겪은 트러블 슈팅이나 공유할만한 인사이트를 적어보겠습니다.
1. Reader 선택
1.1 Cursor vs Paging: 동작 방식의 차이
배치 처리에서 가장 먼저 마주한 선택은 어떤 방식으로 데이터를 읽을 것인가입니다.
CursorItemReader
- DB와 하나의 커넥션을 유지하며 커서를 열어두고 Fetch 단위로 데이터를 스트리밍
- 메모리 효율적: 한 건씩 처리하므로 힙 메모리 사용량이 일정하게 유지
- 단일 커넥션 사용으로 멀티스레드 불가
PagingItemReader
- 페이지 단위로 DB 커넥션을 맺고 끊으며 데이터를 가져옴
- 각 페이지마다 새로운 쿼리 실행
- Lock으로 인해 병렬 처리는 불가하지만, 멀티스레드 환경에서 사용 가능
1.2 PagingItemReader 사용 시 필수 주의사항
PagingReader를 사용할 때 가장 중요한 것은 데이터 순서 보장입니다. 페이징 처리 시 각 쿼리에 Offset과 Limit를 지정해야 하는데, Spring Batch는 PageSize를 지정하면 자동으로 Offset과 Limit를 계산해줍니다. 하지만 여기서 문제가 발생할 수 있습니다.
페이징 처리를 할 때마다 새로운 쿼리를 실행하기 때문에, ORDER BY를 명시하지 않으면 매 쿼리마다 데이터 순서가 달라질 수 있습니다. 첫 번째 페이지에서 읽은 데이터가 두 번째 페이지에서 다시 나타나거나, 반대로 어떤 데이터는 영영 읽히지 않을 수도 있습니다.
// 잘못된 예시: Order By 없음
reader.setQueryString("SELECT a FROM Auction a WHERE a.status = :status");
// 올바른 예시: 고유하고 정렬된 키 사용
reader.setQueryString(
"SELECT a FROM Auction a WHERE a.status = :status ORDER BY a.id ASC"
);
ORDER BY 없이 실행하면 어떻게 될까?
데이터베이스는 ORDER BY가 없으면 가장 효율적으로 탐색할 수 있는 순서대로 데이터를 반환합니다. 이 방식은 예측 불가능하며 여러 요인에 따라 매번 달라질 수 있습니다. 먼저 물리적 저장 순서에 영향을 받는데, InnoDB는 Clustered Index 방식이므로 PK 순서로 저장되지만, 반드시 보장되는 건 아닙니다. 또한 쿼리 옵티마이저가 선택한 인덱스에 따라 스캔 순서가 결정되며, 통계 정보 업데이트나 데이터 변경 등으로 실행 계획이 바뀌면 반환 순서도 함께 바뀝니다.
-- 첫 번째 실행: PK 인덱스 스캔 → id 순으로 반환
SELECT * FROM auction WHERE status = 'ACTIVE' LIMIT 1000 OFFSET 0;
-- 두 번째 실행: 다른 인덱스 선택 → 다른 순서로 반환!
SELECT * FROM auction WHERE status = 'ACTIVE' LIMIT 1000 OFFSET 1000;
정렬 기준은 무엇을 사용해야 할까?
필수 조건: 정렬 컬럼은 고유하고 순서가 있어야 합니다.
// 좋은 예시 1: Primary Key
ORDER BY a.id ASC
// 좋은 예시 2: Unique Index + 생성 시간 (조합으로 고유성 보장)
ORDER BY a.createdAt ASC, a.id ASC
// 주의가 필요한 예시: created_at만 사용
ORDER BY a.createdAt ASC
// 문제: 동일한 시간에 생성된 데이터가 여러 개 있으면 순서가 불안정!
created_at이나 updated_at 같은 timestamp 컬럼만으로 정렬하면 동일한 값이 여러 개 존재할 때 순서가 보장되지 않습니다. 예를 들어 같은 초에 100건의 데이터가 생성되었다면, 이 100건 사이의 순서는 여전히 불안정합니다.
-- 위험: created_at이 같은 데이터들의 순서는 보장 안 됨
SELECT * FROM auction WHERE status = 'ACTIVE'
ORDER BY created_at LIMIT 1000 OFFSET 0;
-- 안전: 고유 키를 추가로 정렬
SELECT * FROM auction WHERE status = 'ACTIVE'
ORDER BY created_at ASC, id ASC LIMIT 1000 OFFSET 0;
따라서 반드시 고유성이 보장되는 컬럼(PK, Unique Index)을 정렬 기준에 포함해야 합니다.
1.3 CursorItemReader 사용 시 유념
JPACursorItemReader는 사용하기 가장 편한 선택지입니다. 그러나, JPACursorItemReader는 한 번의 쿼리로 모든 데이터를 가져온 후 애플리케이션 단에서 하나씩 내어주는 방식이기 때문에 OOM(Out Of Memory) 위험을 내포하고 있습니다.
실제로 수십만 건 이상의 데이터를 처리해야 한다면 JPACursorItemReader는 사용하지 않는 것이 좋습니다. 대신 JdbcCursorItemReader를 사용하면 JDBC 드라이버 레벨에서 스트리밍 방식으로 데이터를 가져오기 때문에 메모리 효율적입니다.
1.4 상태 저장(SaveState)
SaveState를 켜두면 배치 작업이 실패했을 때 어디서부터 재시작할지 저장합니다.
| 구분 | Cursor (순서 기반) | Paging (Key 기반) |
| 저장 상태 | 마지막 읽은 아이템 순번(Index) | 마지막 처리된 아이템 고유 ID |
| 복구 방식 | 처음부터 순번만큼 Skip | ID 이후부터 쿼리 |
| 데이터 변경 영향 | 취약 (순번이 틀어질 수 있음) | 강함 (고유 키 불변) |
PagingReader + saveState(true) 조합은 Key 기반의 안전한 복구를 제공하여 Index 기반보다 훨씬 안정적입니다.
1.5 JPA Reader의 한계
JPA Reader는 근본적인 한계가 있습니다. 영속성 컨텍스트를 거치므로 오버헤드가 발생합니다. 또한 엔티티 전체를 로드하여 불필요한 데이터까지 메모리에 적재합니다. 페이지 단위로 영속성 컨텍스트를 clear하기 때문에 엔티티가 Detached 상태가 되며, 이로 인해 Lazy Loading이 불가능해져 FetchJoin을 필수적으로 사용해야 합니다.
위와 같은 단점들을 타파하기 위해 Projection으로 필요한 속성만 가져오고 JDBC Reader를 사용하는 것을 추천드립니다.
2. JPAPagingReader의 함정
그럼에도 불구하고 간편함을 위해 JPAPagingReader를 쓰실 수 있으니, 주의점을 남겨보겠습니다.
2.1 OFFSET 실수
JPAPagingReader를 사용할 때 가장 많이 마주치는 문제가 바로 데이터 건너뛰기 현상입니다.
문제 상황
20만 건의 데이터를 처리했는데 12만 건만 처리되는 이상한 현상이 발생했습니다.
// Reader에서 조회
SELECT * FROM auction WHERE status = 'ACTIVE' ORDER BY id LIMIT 1000 OFFSET 0
// Writer에서 상태 변경
UPDATE auction SET status = 'ENDED' WHERE id IN (...)
// 다음 Reader 조회 시
SELECT * FROM auction WHERE status = 'ACTIVE' ORDER BY id LIMIT 1000 OFFSET 1000
// 문제: 앞의 1000건이 조건에서 제외되면서 결과셋이 당겨짐
// OFFSET 1000은 이제 원래 2001~3000번째 데이터를 가리킴
원인 분석
- Page 1(Offset 0)을 읽고 상태를 ACTIVE → ENDED로 변경
- 변경된 데이터는 다음 쿼리의 WHERE 조건에서 제외됨
- DB 결과셋 자체가 줄어들어 앞으로 당겨짐
- Reader는 이를 모르고 Page 2(Offset 1000)를 요청
- 결과적으로 1001~2000번 데이터를 건너뛰고 2001~3000번 데이터를 읽음
2.2 해결 방법
Page 0 고정 방식을 추천합니다. 처리된 데이터는 WHERE 조건에서 사라지므로, 항상 첫 페이지를 읽으면 미처리 데이터를 순차적으로 가져올 수 있습니다.
@Bean
public JpaPagingItemReader<Auction> reader() {
JpaPagingItemReader<Auction> reader = new JpaPagingItemReader<>() {
@Override
public int getPage() {
return 0; // 항상 첫 페이지만 읽기
}
};
reader.setQueryString(
"SELECT a FROM Auction a " +
"WHERE a.status = :status " +
"ORDER BY a.id ASC" // 정렬 필수
);
return reader;
}
JdbcItemPagingReader는 No offset 방식이기 때문에 위와 같은 상황은 발생하지 않습니다.
3. Writer 선택
3.1 Processor는 단건 처리
// 비효율적: 단건 처리
public class AuctionProcessor implements ItemProcessor<Auction, Auction> {
@Override
public Auction process(Auction auction) {
auction.setStatus(AuctionStatus.ENDED);
return auction;
}
}
Processor는 아이템 하나씩 처리하므로 비효율적입니다. 그래서 외부 API 호출이나, 외부와 네트워크 통신을 해야 한다면 Chunk 단위로 동작하는 Writer를 활용하는 것이 유리합니다.
3.2 JPA Writer의 한계
JPA Writer는 성능상 불리한 지점이 많습니다. 건마다 개별 쿼리를 실행하기 때문에 대량 처리 시 데이터베이스 왕복이 과도하게 발생합니다. 또한 Dirty Checking 메커니즘으로 인한 오버헤드가 추가됩니다. 앞서 언급했듯이 PagingReader는 페이지 단위로 영속성 컨텍스트를 clear하기 때문에 엔티티가 Detached 상태가 되는데, 이런 엔티티는 merge가 아닌 persist를 사용해야 한다는 점도 복잡도를 높입니다.
3.3 JDBC Writer 좋다..
@Bean
@StepScope
public JdbcBatchItemWriter<AuctionEndDto> auctionWriter() {
log.info("========== Writer 설정: JDBC 배치 업데이트 ==========");
return new JdbcBatchItemWriterBuilder<AuctionEndDto>()
.dataSource(dataSource)
.sql("UPDATE auctions SET status = 'ENDED' WHERE id = :id")
.beanMapped()
.build();
}
JDBC Writer는 Chunk 단위로 배치 처리가 가능하기 때문에 훨씬 효율적입니다. 영속성 컨텍스트를 거치지 않으므로 Dirty Checking 오버헤드가 없으며, Projection 기반으로 필요한 데이터만 다루기 때문에 경량 처리가 가능합니다.
3.4 성능 결과
20만건 업데이트 기준으로
JpaPaingItemReader & JpaItemWriter를 사용했을 때는 187초가 걸렸고
JdbcPagingItemReader & JdbcBatchItemWriter를 사용했을 때는 14초가 걸렸습니다.
4. 멀티스레드 최적화
4.1 동작 방식
@Bean
public Step multiThreadStep() {
return stepBuilderFactory.get("multiThreadStep")
.<AuctionDto, AuctionDto>chunk(1000)
.reader(jdbcReader())
.writer(jdbcWriter())
.taskExecutor(taskExecutor()) // 멀티스레드 활성화
.build();
}
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("batch-thread-");
return executor;
}
멀티스레드 방식에서는 Reader → Processor → Writer가 청크 단위로 스레드 병렬 실행됩니다. 하지만 여기서 주의할 점이 있습니다. Reader는 Lock이 걸려 있어 순차적으로 처리됩니다. 따라서 스레드 수만큼 성능이 비례해서 향상되지는 않으며, Reader가 병목 지점이 됩니다.
4.2 주의사항: saveState(false)
@Bean
public JdbcPagingItemReader<AuctionDto> reader() {
JdbcPagingItemReader<AuctionDto> reader = new JdbcPagingItemReader<>();
reader.setSaveState(false); // 필수!
return reader;
}
멀티스레드 환경에서는 여러 스레드가 동시에 다른 범위를 처리합니다. 스레드 A가 101-110번 항목을 처리하는 동안 스레드 B는 111-120번 항목을 처리할 수 있습니다. 만약 B가 먼저 끝나고 A가 실패하면, Spring Batch는 "어디서부터 재시작할까?"를 결정할 수 없습니다. 순차적인 단일 지점을 정의할 수 없기 때문입니다.
따라서 멀티스레드 환경에서는 saveState(false)로 설정하여 상태 저장을 비활성화해야 합니다. 이렇게 하면 재시작 시 처음부터 시작하게 되므로, 쿼리는 멱등성을 보장해야 합니다. 즉, 같은 데이터를 여러 번 처리해도 문제가 없도록 설계해야 합니다.
4.3 성능 결과
단일 스레드 JDBC: 14.06초 (14,228 건/초)
멀티스레드 (10개): 6.65초 (30,061 건/초) → 약 2.1배 향상
5. 파티셔닝 vs 멀티스레드, 언제 무엇을 사용할까?
5.1 선택 기준
파티셔닝과 멀티스레드 중 어떤 방식을 선택해야 할지는 상황에 따라 다릅니다. Reader가 Thread-safe하지 않는데 병렬 처리를 원한다면 파티셔닝을 사용해야 합니다. 데이터 분할이 명확한 경우, 예를 들어 날짜별로 나눌 수 있는 경우에도 파티셔닝이 적합합니다. 또한 파티션별로 처리 상황을 추적해야 한다면 파티셔닝이 유리합니다.
반면 Reader가 Thread-safe하다면 멀티스레드 방식을 사용할 수 있습니다. 데이터 분할이 불명확한 경우나 설정을 단순하게 유지하고 싶을 때도 멀티스레드가 더 나은 선택입니다.
5.2 파티셔닝의 함정, 데이터 불균등 문제
ID Range 방식으로 파티션을 나누면 심각한 문제가 발생할 수 있습니다. 예를 들어 10개 파티션으로 분할했을 때, Partition 1은 ID 1-20000 범위에서 실제 데이터 100건만 처리하고, Partition 2는 ID 20001-40000 범위에서 5000건을 처리하며, Partition 3은 ID 40001-60000 범위에서 50건만 처리하는 상황이 발생합니다. 이렇게 되면 Partition 2가 전체 처리 시간을 결정하는 Load Imbalance 현상이 나타납니다.
이를 개선하기 위해 Row Count 방식을 사용할 수 있습니다. 실제 데이터 수를 기반으로 균등하게 분할하는 것입니다. 먼저 조건에 맞는 모든 ID를 조회한 후, 이를 파티션 개수로 균등하게 나누어 각 파티션에 할당합니다.
// 실제 데이터 수 기반 균등 분할
SELECT id FROM auction WHERE status = 'ACTIVE' ORDER BY id;
// 결과를 10개로 균등 분할하여 파티션 생성
하지만 이 방식도 문제가 있습니다. 모든 대상 ID를 메모리에 로드해야 하므로 메모리 사용량이 증가하고, 사전 조회 쿼리를 실행해야 하므로 데이터베이스 부하가 추가로 발생하며, Partitioner 구현 및 각 파티션별 Step 설정 등으로 코드 복잡도가 상승합니다.
5.3 실험 결과 비교
멀티스레드 (Paging): 6.65초 (30,061 건/초)
파티셔닝 ID Range (Paging): 14.98초 (13,355 건/초)
파티셔닝 Row Count (Cursor): 7.73초 (25,876 건/초)
결론: 데이터가 불균등할 때는 멀티스레드 방식이 유리합니다. JdbcPagingItemReader가 페이지 단위로 동적 분배하므로 자동으로 부하가 분산됩니다.
마치며
스프링 배치 최적화의 핵심은 기술 자체보다도 데이터의 특성과 처리 요구사항을 얼마나 정확하게 이해하고 있나에 달려 있습니다. 먼저 병렬 처리가 반드시 필요한 상황인지 판단하는 것이 중요합니다. 병렬 처리가 필요하지 않다면, 가장 빠르고 단순하며 커넥션 점유만 관리하시면 되는 JdbcCursorItemReader가 최선의 선택이 될 수 있습니다.
반대로 병렬 처리가 필요한 경우라면 다음 질문은 데이터를 균등하게 분할할 수 있는 명확한 기준이 존재하는가입니다. 예를 들어 1부터 1억까지 결측 없이 연속된 ID처럼 균등하게 나눌 수 있는 키가 있다면, JdbcCursorItemReader를 파티셔닝과 결합한 방식이 이론적으로 가장 높은 처리량을 제공합니다. 그러나 실제 데이터는 중간 ID가 비어 있거나 특정 날짜나 구간에 데이터가 극단적으로 몰려 있는 등 분포가 고르지 않은 경우가 많습니다. 이런 상황에서는 자동으로 부하를 분산할 수 있는 JdbcPagingItemReader + 멀티 스레드 방식이 더 안정적이고 실용적인 선택이 됩니다.
이번 실험을 통해 이러한 의사결정 과정을 실제로 적용해본 결과, 리더와 라이터 변경으로도 JPA 187초 → JDBC 14초(약 13배 향상)라는 큰 성능 개선을 얻을 수 있었습니다. 여기에 병렬 처리를 더하니 14초 → 6.65초(약 2.1배 추가 개선)까지 줄어들며 배치 처리 시간이 눈에 띄게 단축되었습니다. 스프링 배치 최적화는 특정 기법을 무조건 적용하는 일이 아니라, 데이터 분포와 쿼리 특성, 그리고 업무 요구사항을 종합적으로 고려해 가장 적합한 전략을 선택하는 과정임을 다시 한 번 확인할 수 있는 경험이었습니다.
'스프링' 카테고리의 다른 글
| @Qualifier를 붙였는데 왜 무시될까? (스프링 빈 선택 코드 뜯어보기) (0) | 2026.01.03 |
|---|---|
| 실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기 (0) | 2025.11.28 |
| 왜 내 readOnly는 슬레이브로 가지 않는가? (0) | 2025.11.12 |
| [디프만, 밥토리] Hibernate 벡터, "묻고 double[]로 가!" 가 아니라.. float[]로 가! (0) | 2025.11.07 |
| 100명 동시 요청 25분 → 1분, Gemini API 병목 96% 개선기 (feat. Virtual Thread의 함정) (0) | 2025.11.06 |