접속기록 배치가 느린 이유 (1편) - 문제 진단과 병목 분석
서론
출근해서 잔디를 켰는데, 빨간색 알림이 쌓여 있었습니다.
[Jenkins] 배치 작업 실패: access-log-batch
원인: Build timed out (3시간 초과)
실행 시간: 3시간 12분새벽에 돌아야 할 접속기록 배치가 젠킨스 타임아웃에 걸려서 실패한 겁니다.
이 배치는 매일 새벽 2시에 자동 실행되는데, 최근 들어 점점 느려지더니 결국 타임아웃까지 터졌습니다. 처음엔 1시간 정도면 끝났던 배치가 이제는 3시간을 넘기고 있었죠.
문제는 데이터가 계속 늘어나고 있다는 것이었습니다. 처음엔 수백만 건이었는데, 이제는 1000만 건을 넘어 5000만 건까지 쌓이는 중이었습니다.
“데이터가 많아지면 느려지는 게 당연한 거 아닌가?”
동료가 이렇게 말했지만, 정말 그럴까요? 개선할 수 있는 부분이 있을 것 같았습니다.
이번 포스팅에서는 이 배치를 분석하면서 발견한 문제점들을 공유합니다. 다음 편에서는 이걸 어떻게 20배 빠르게 만들었는지 다루겠습니다.
Spring Batch 기본 개념
먼저 Spring Batch의 기본 개념부터 정리하겠습니다.
Job과 Step
@Configuration
public class BatchJobConfiguration {
@Bean
public Job userAccessLogJob(JobRepository jobRepository,
Step step1,
Step step2) {
return new JobBuilder("userAccessLogJob", jobRepository)
.start(step1)
.next(step2)
.build();
}
}Spring Batch는 Job과 Step으로 구성됩니다.
- Job: 배치 작업 전체를 의미합니다. 여러 Step으로 구성됩니다.
- Step: Job을 구성하는 독립적인 작업 단위입니다.
Step은 순차적으로 실행됩니다.
step1이 끝나야 step2가 시작되는 구조죠.
Tasklet vs Chunk
Step을 구현하는 방법은 두 가지입니다.
// 1. Tasklet 방식
@Bean
public Step taskletStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("taskletStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
// 작업 로직
return RepeatStatus.FINISHED;
}, transactionManager)
.build();
}
// 2. Chunk 방식
@Bean
public Step chunkStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ItemReader<User> reader,
ItemProcessor<User, UserDto> processor,
ItemWriter<UserDto> writer) {
return new StepBuilder("chunkStep", jobRepository)
.<User, UserDto>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}Tasklet: 단순한 작업에 적합합니다. 한 번에 모든 로직을 실행합니다.
Chunk: 대용량 데이터 처리에 적합합니다. 데이터를 일정 크기(chunk)로 나눠서 처리합니다.
기존 배치 구조
저희 배치는 5개의 Step으로 구성되어 있었습니다.
@Configuration
@RequiredArgsConstructor
public class AccessLogBatchConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
@Bean
public Job accessLogJob() {
return new JobBuilder("accessLogJob", jobRepository)
.start(dailyAccessStep()) // 1. 일별 접속 통계
.next(weeklyAccessStep()) // 2. 주별 접속 통계
.next(monthlyAccessStep()) // 3. 월별 접속 통계
.next(deviceTypeStep()) // 4. 디바이스별 통계
.next(channelAccessStep()) // 5. 채널별 접속 통계
.build();
}
@Bean
public Step dailyAccessStep() {
return new StepBuilder("dailyAccessStep", jobRepository)
.<AccessLog, DailyAccessStat>chunk(1000, transactionManager)
.reader(accessLogReader())
.processor(dailyAccessProcessor())
.writer(dailyAccessWriter())
.build();
}
// 나머지 Step들도 유사한 구조...
}각 Step은 다음과 같은 작업을 했습니다:
- 일별 접속 통계: 사용자별 일일 접속 횟수, 시간 집계
- 주별 접속 통계: 주간 활성 사용자, 접속 패턴 분석
- 월별 접속 통계: 월간 사용자 트렌드 분석
- 디바이스별 통계: 모바일/PC/태블릿 접속 비율
- 채널별 접속 통계: 팀별, 채널별 활동량 분석
문제 진단
배치가 느린 이유를 파악하기 위해 각 Step의 실행 시간을 측정했습니다.
StepExecutionListener로 시간 측정
@Slf4j
@Component
public class StepTimingListener implements StepExecutionListener {
@Override
public void beforeStep(StepExecution stepExecution) {
log.info("=== Step 시작: {} ===", stepExecution.getStepName());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
long duration = stepExecution.getEndTime().getTime()
- stepExecution.getStartTime().getTime();
log.info("=== Step 종료: {} ===", stepExecution.getStepName());
log.info("실행 시간: {} 분", duration / 1000 / 60);
log.info("읽은 데이터: {} 건", stepExecution.getReadCount());
log.info("쓴 데이터: {} 건", stepExecution.getWriteCount());
return stepExecution.getExitStatus();
}
}
// Step에 리스너 등록
@Bean
public Step dailyAccessStep() {
return new StepBuilder("dailyAccessStep", jobRepository)
.<AccessLog, DailyAccessStat>chunk(1000, transactionManager)
.reader(accessLogReader())
.processor(dailyAccessProcessor())
.writer(dailyAccessWriter())
.listener(stepTimingListener) // 리스너 추가
.build();
}측정 결과
=== 배치 시작: 2023-10-14 02:00:00 ===
=== Step 시작: dailyAccessStep ===
실행 시간: 42분
읽은 데이터: 5,230만 건
쓴 데이터: 180만 건
=== Step 시작: weeklyAccessStep ===
실행 시간: 38분
읽은 데이터: 5,230만 건
쓴 데이터: 95만 건
=== Step 시작: monthlyAccessStep ===
실행 시간: 35분
읽은 데이터: 5,230만 건
쓴 데이터: 42만 건
=== Step 시작: deviceTypeStep ===
실행 시간: 29분
읽은 데이터: 5,230만 건
쓴 데이터: 220만 건
=== Step 시작: channelAccessStep ===
실행 시간: 33분
읽은 데이터: 5,230만 건
쓴 데이터: 340만 건
=== 배치 종료: 2023-10-14 04:57:00 ===
총 실행 시간: 2시간 57분문제가 명확해졌습니다.
각 Step이 모두 5,230만 건의 데이터를 읽어서 처리하고 있었습니다. 그리고 이 5개 Step이 순차적으로 실행되고 있었죠.
병목 분석
문제 1: 순차 실행
// 현재 구조
.start(dailyAccessStep()) // 42분 대기
.next(weeklyAccessStep()) // 38분 대기
.next(monthlyAccessStep()) // 35분 대기
.next(deviceTypeStep()) // 29분 대기
.next(channelAccessStep()) // 33분 대기
// 총 177분 (약 3시간)각 Step이 끝날 때까지 다음 Step은 대기합니다. CPU는 놀고 있는데 작업은 하나씩만 처리되는 구조였습니다.
문제 2: 중복된 데이터 읽기
더 큰 문제는 5개 Step이 모두 같은 데이터를 읽는다는 것이었습니다.
// 각 Step의 Reader
@Bean
public JdbcPagingItemReader<AccessLog> accessLogReader() {
return new JdbcPagingItemReaderBuilder<AccessLog>()
.name("accessLogReader")
.dataSource(dataSource)
.queryProvider(queryProvider())
.selectClause("SELECT *")
.fromClause("FROM access_log")
.whereClause("WHERE log_date = :targetDate")
.sortKeys(Map.of("id", Order.ASCENDING))
.pageSize(1000)
.rowMapper(new BeanPropertyRowMapper<>(AccessLog.class))
.build();
}5개 Step이 각각 5,230만 건을 읽으니까, 총 2억 6천만 번 데이터를 읽고 있었던 겁니다.
물론 페이징 쿼리라서 메모리 문제는 없었지만, DB 부하는 심각했습니다.
문제 3: 독립적인 작업인데 의존 관계로 묶임
각 Step을 자세히 보니 서로 의존 관계가 없었습니다.
// 1. 일별 통계 → daily_access_stat 테이블에 저장
// 2. 주별 통계 → weekly_access_stat 테이블에 저장
// 3. 월별 통계 → monthly_access_stat 테이블에 저장
// 4. 디바이스 통계 → device_type_stat 테이블에 저장
// 5. 채널 통계 → channel_access_stat 테이블에 저장각 Step은:
- 같은 원본 데이터(access_log)를 읽음
- 서로 다른 테이블에 결과를 저장
- 다른 Step의 결과를 참조하지 않음
완벽하게 독립적인 작업이었습니다.
그런데 왜 순차적으로 실행하고 있었을까요? 단순히 Spring Batch의 기본 구조를 따랐기 때문이었습니다.
개선 전략 수립
문제를 정리하면 이렇습니다:
현재 상황:
- 5개 Step이 순차 실행
- 각 Step이 5,230만 건 처리
- 총 실행 시간: 약 3시간
개선 방향:
- 5개 Step을 병렬 실행
- 독립적인 작업이므로 동시 실행 가능
- 이론상 가장 오래 걸리는 Step 시간으로 단축 (약 42분)
병렬 처리 가능 조건 확인
병렬 처리를 하기 전에 확인해야 할 것들이 있습니다.
각 Step이 서로 다른 데이터를 수정하는가?
→ 예. 서로 다른 테이블에 저장
트랜잭션 충돌 가능성이 없는가?
→ 예. 각각 독립적인 트랜잭션
공유 자원(파일, 캐시 등)을 사용하지 않는가?
→ 예. DB만 사용
실행 순서가 중요하지 않은가?
→ 예. 순서 무관
서버 리소스(CPU, 메모리)가 충분한가?
→ 예. CPU는 여유로움 (각 Step이 순차 실행 중일 때 CPU 20% 미만)모든 조건을 만족했습니다. 병렬 처리가 가능했습니다.
병렬 처리 방법 고민
Spring Batch에서 병렬 처리를 하는 방법은 여러 가지가 있습니다.
1. Parallel Step (Spring Batch 기본 기능)
@Bean
public Job parallelJob() {
Flow flow1 = new FlowBuilder<Flow>("flow1")
.start(dailyAccessStep())
.build();
Flow flow2 = new FlowBuilder<Flow>("flow2")
.start(weeklyAccessStep())
.build();
return new JobBuilder("parallelJob", jobRepository)
.start(flow1)
.split(new SimpleAsyncTaskExecutor()) // 병렬 실행
.add(flow2)
.end()
.build();
}간단하지만, Flow를 일일이 정의해야 하고 5개를 병렬로 만들려면 코드가 복잡해집니다.
2. @Async
@Async
public void executeStep1() {
// Step 실행
}
@Async
public void executeStep2() {
// Step 실행
}간단하지만, Spring Batch의 Job 관리 기능을 사용할 수 없습니다. Step 실행 이력, 재시작, 트랜잭션 관리 등이 복잡해집니다.
3. ForkJoinPool (선택)
ForkJoinPool customPool = new ForkJoinPool(5); // 5개 스레드
List<Runnable> tasks = List.of(
() -> executeStep(dailyAccessStep),
() -> executeStep(weeklyAccessStep),
() -> executeStep(monthlyAccessStep),
() -> executeStep(deviceTypeStep),
() -> executeStep(channelAccessStep)
);
customPool.submit(() ->
tasks.parallelStream().forEach(Runnable::run)
).join();
customPool.shutdown();Spring Batch의 Job 관리 기능을 유지하면서 병렬 처리가 가능합니다. 스레드 수를 명시적으로 제어할 수 있고, 안전하게 반납할 수 있습니다.
저희는 ForkJoinPool을 선택했습니다.
다른 병목은 없을까?
병렬 처리만으로 충분할까요? 다른 병목 지점도 확인해봤습니다.
1. DB 커넥션 풀
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 10 # 현재 설정5개 Step이 동시에 실행되면, 각 Step이 커넥션을 사용합니다. 최소 5개 이상의 커넥션이 필요하죠.
현재 풀 사이즈가 10이니까 충분했습니다. 여유를 위해 20으로 늘렸습니다.
spring:
datasource:
hikari:
maximum-pool-size: 202. JVM 메모리
각 Step이 chunk 방식으로 1,000건씩 처리하고 있었습니다.
.<AccessLog, DailyAccessStat>chunk(1000, transactionManager)5개 Step이 동시에 실행되면, 최대 5,000건이 메모리에 있을 수 있습니다.
AccessLog 객체 크기를 측정해보니 약 500 bytes였습니다.
5,000건 × 500 bytes = 약 2.5MB
처리 과정에서 중간 객체들이 생성되니 여유를 봐서 약 10~20MB 정도 추가 메모리가 필요할 것 같았습니다.
현재 JVM 힙 메모리가 4GB였으니 문제없었습니다.
3. 쿼리 최적화
각 Step의 Reader 쿼리를 확인했습니다.
-- 기존 쿼리
SELECT *
FROM access_log
WHERE log_date = '2023-10-14'
ORDER BY id
LIMIT 1000 OFFSET ?log_date 컬럼에 인덱스가 있는지 확인했습니다.
SHOW INDEX FROM access_log;다행히 인덱스가 있었습니다. EXPLAIN을 돌려보니 인덱스를 잘 타고 있었습니다.
EXPLAIN SELECT * FROM access_log WHERE log_date = '2023-10-14';+----+-------------+------------+------+---------------+------------------+---------+-------+----------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+------------+------+---------------+------------------+---------+-------+----------+-------------+
| 1 | SIMPLE | access_log | ref | idx_log_date | idx_log_date | 3 | const | 52300000 | Using where |
+----+-------------+------------+------+---------------+------------------+---------+-------+----------+-------------+쿼리 최적화는 따로 필요없었습니다.
결론
배치가 느린 이유를 요약하면 이렇습니다:
핵심 문제: 독립적인 작업을 순차 실행
5개 Step이 서로 독립적인데도 순차적으로 실행되고 있었습니다. 각 Step이 40분 내외로 걸리니까, 순차 실행하면 200분(3시간 20분)이 걸립니다.
병렬로 실행하면 이론상 가장 긴 Step 시간(42분)만 걸립니다.
예상 개선 효과:
- Before: 약 3시간
- After: 약 40~50분
- 개선율: 약 4~5배
실제로는 CPU와 메모리를 더 효율적으로 사용하니까 각 Step도 조금 더 빠르게 끝날 것 같았습니다.
왜 이렇게 만들어졌을까?
사실 이 배치는 제가 만든 게 아니었습니다. 이전 개발자가 만든 코드였죠.
처음 만들 때는 데이터가 수백만 건 수준이었고, 배치 시간도 30분 정도였을 겁니다. 그땐 순차 실행으로도 충분했을 거예요.
그런데 서비스가 성장하면서 데이터가 폭증했고, 배치 시간이 3시간까지 늘어난 겁니다.
기술 부채는 이렇게 쌓입니다.
처음엔 괜찮던 설계가, 규모가 커지면서 병목이 되는 거죠.
중요한 건 “처음부터 완벽하게 만들어야 한다”가 아닙니다. “성장하면서 병목을 찾아내고 개선하는 것”이 더 중요합니다.
다음 편 예고
다음 편에서는 실제로 병렬 처리를 구현한 과정을 다룹니다. ExecutorService로 5개 Step을 동시에 실행하고, Chunk 크기를 최적화하는 과정입니다.
예상 개선율은 4~5배였는데, 실제로는 20배 이상 빨라졌습니다. 왜 예상보다 훨씬 더 빨라졌는지, 그 이유와 과정을 다음 편에서 공유하겠습니다.
참고 :
Spring Batch Documentation - Parallel Steps
Java ForkJoinPool API Documentation
Spring Batch Performance Tuning Best Practices
