Spring Batch ExecutionContext 저장 오류 해결기
Data too long for column SERIALIZED_CONTEXT 에러 완전 정복
여는 글
안녕하세요. 오늘은 Spring Batch와 Quartz를 사용하는 배치 모듈에서 발생한 예상치 못한 오류와 그 해결 과정을 공유하려 합니다. SERIALIZED_CONTEXT
컬럼 크기 제한으로 인한 오류는 배치 시스템을 운영하면서 종종 마주치는 문제지만, 정확한 원인과 해결 방법을 파악하기 어려운 경우가 많습니다.
사건의 발단 - 갑작스러운 배치 실패
평소와 같이 야간 배치가 실행되던 중, 갑자기 다음과 같은 에러와 함께 작업이 실패했습니다:
org.springframework.batch.core.JobExecutionException: Flow execution ended unexpectedly
at org.springframework.batch.core.job.flow.FlowJob.doExecute(FlowJob.java:136)
...
Caused by: org.springframework.batch.core.step.StepExecutionException: Flow execution ended unexpectedly
at org.springframework.batch.core.step.job.JobStep.doExecute(JobStep.java:131)
...
Caused by: org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [UPDATE BATCH_JOB_EXECUTION_CONTEXT SET SERIALIZED_CONTEXT = ? WHERE JOB_EXECUTION_ID = ?]
...
Caused by: java.sql.SQLSyntaxErrorException: Data too long for column 'SERIALIZED_CONTEXT' at row 1
핵심 에러 메시지: Data too long for column 'SERIALIZED_CONTEXT' at row 1
처음에는 “데이터가 너무 길다고? 배치에서 무슨 데이터를 그렇게 많이 저장하는 거지?” 하며 당황스러웠습니다.
문제 분석 - ExecutionContext의 정체
Spring Batch ExecutionContext란?
Spring Batch는 Job과 Step의 실행 상태를 관리하기 위해 ExecutionContext
를 사용합니다:
// ExecutionContext 사용 예시
@Component
public class SampleTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext context = chunkContext.getStepContext()
.getStepExecution()
.getJobExecution()
.getExecutionContext();
// 실행 중 데이터를 저장
context.put("processedCount", 1000);
context.put("lastProcessedId", 12345L);
return RepeatStatus.FINISHED;
}
}
ExecutionContext의 역할:
- Job과 Step 간 데이터 공유
- 재시작 시 이전 실행 상태 복원
- 진행률 추적 및 체크포인트 관리
데이터베이스 저장 구조
Spring Batch는 ExecutionContext를 다음 테이블들에 직렬화하여 저장합니다:
-- Job 레벨 컨텍스트
CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT (
JOB_EXECUTION_ID BIGINT NOT NULL,
SHORT_CONTEXT VARCHAR(2500) NOT NULL,
SERIALIZED_CONTEXT TEXT, -- 여기가 문제!
PRIMARY KEY (JOB_EXECUTION_ID)
);
-- Step 레벨 컨텍스트
CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT (
STEP_EXECUTION_ID BIGINT NOT NULL,
SHORT_CONTEXT VARCHAR(2500) NOT NULL,
SERIALIZED_CONTEXT TEXT, -- 여기도 문제!
PRIMARY KEY (STEP_EXECUTION_ID)
);
문제의 원인 파악
에러가 발생한 시점의 ExecutionContext를 확인해보니:
// 문제가 된 코드 (개념적 예시)
public class ProblematicProcessor implements ItemProcessor<InputData, OutputData> {
@Override
public OutputData process(InputData item) throws Exception {
ExecutionContext context = getExecutionContext();
// 큰 리스트를 ExecutionContext에 저장 (문제 원인!)
List<DetailData> processedDetails = processDetailData(item);
context.put("detailDataList", processedDetails); // 수만 건의 데이터
// 복잡한 객체도 저장
ComplexBusinessObject businessObj = createBusinessObject(item);
context.put("businessObject", businessObj); // 큰 객체
return transform(item);
}
}
문제점:
- 대량의 리스트 데이터를 ExecutionContext에 저장
- 복잡한 객체들이 직렬화되면서 크기 급증
TEXT
타입의 기본 크기 제한 (MySQL: 65,535 bytes) 초과
실제 크기 확인
-- 현재 저장된 컨텍스트 크기 확인
SELECT
JOB_EXECUTION_ID,
LENGTH(SERIALIZED_CONTEXT) as context_size,
CHAR_LENGTH(SERIALIZED_CONTEXT) as context_length
FROM BATCH_JOB_EXECUTION_CONTEXT
ORDER BY context_size DESC;
-- 결과: 일부 레코드가 65,000+ 바이트
해결 방법 1: 데이터베이스 스키마 수정 (권장)
컬럼 타입 변경
가장 확실한 해결책은 SERIALIZED_CONTEXT
컬럼을 더 큰 타입으로 변경하는 것입니다:
-- Job 실행 컨텍스트 테이블 수정
ALTER TABLE BATCH_JOB_EXECUTION_CONTEXT
MODIFY COLUMN SERIALIZED_CONTEXT LONGTEXT;
-- Step 실행 컨텍스트 테이블 수정
ALTER TABLE BATCH_STEP_EXECUTION_CONTEXT
MODIFY COLUMN SERIALIZED_CONTEXT LONGTEXT;
LONGTEXT의 장점:
- 최대 4GB까지 저장 가능
- ExecutionContext 크기 제한 해결
- 향후 확장성 보장
변경 후 확인
-- 컬럼 타입 변경 확인
DESCRIBE BATCH_JOB_EXECUTION_CONTEXT;
DESCRIBE BATCH_STEP_EXECUTION_CONTEXT;
-- 변경 후 배치 실행 테스트
해결 방법 2: ExecutionContext 사용량 최적화
문제가 된 코드 개선
// 개선 전: 큰 데이터를 ExecutionContext에 저장
public class ImprovedProcessor implements ItemProcessor<InputData, OutputData> {
@Override
public OutputData process(InputData item) throws Exception {
ExecutionContext context = getExecutionContext();
// 개선: 필요한 최소 정보만 저장
context.put("lastProcessedId", item.getId());
context.put("processedCount", getProcessedCount());
// 큰 데이터는 별도 저장소 활용
cacheService.store("detailData_" + item.getId(), processedDetails);
return transform(item);
}
}
ExecutionContext 사용 가이드라인
// 좋은 예: 간단한 primitive 타입과 작은 객체
context.put("currentPage", pageNumber);
context.put("lastProcessedTimestamp", LocalDateTime.now().toString());
context.put("errorCount", errorCount);
// 나쁜 예: 큰 컬렉션이나 복잡한 객체
context.put("allProcessedData", largeList); // ❌
context.put("complexBusinessObject", heavyObject); // ❌
context.put("temporaryCache", cacheMap); // ❌
해결 방법 3: 컨텍스트 정리 Tasklet 구현
정리 작업 추가
@Component
public class ContextCleanupTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext jobContext = chunkContext.getStepContext()
.getStepExecution()
.getJobExecution()
.getExecutionContext();
// 불필요한 큰 데이터 제거
jobContext.remove("temporaryLargeData");
jobContext.remove("cacheData");
// 필수 정보만 유지
Map<String, Object> essentialData = new HashMap<>();
essentialData.put("finalStatus", "COMPLETED");
essentialData.put("processedCount", jobContext.get("processedCount"));
// 컨텍스트 정리 후 필수 정보만 다시 저장
jobContext.clear();
jobContext.putAll(essentialData);
return RepeatStatus.FINISHED;
}
}
Job 설정에 정리 Step 추가
@Bean
public Job optimizedJob() {
return jobBuilderFactory.get("optimizedJob")
.start(dataProcessingStep())
.next(contextCleanupStep()) // 정리 작업 추가
.build();
}
@Bean
public Step contextCleanupStep() {
return stepBuilderFactory.get("contextCleanupStep")
.tasklet(contextCleanupTasklet)
.build();
}
해결 방법 4: 모니터링 및 예방
ExecutionContext 크기 모니터링
@Component
public class ExecutionContextMonitor {
@EventListener
public void handleStepExecution(StepExecutionEvent event) {
ExecutionContext context = event.getStepExecution()
.getJobExecution()
.getExecutionContext();
// 컨텍스트 크기 추정
int estimatedSize = estimateContextSize(context);
if (estimatedSize > 50000) { // 50KB 임계값
log.warn("ExecutionContext size is large: {} bytes. Job: {}",
estimatedSize, event.getStepExecution().getJobExecution().getJobInstance().getJobName());
}
}
private int estimateContextSize(ExecutionContext context) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(context);
return baos.size();
} catch (Exception e) {
return 0;
}
}
}
알림 시스템 구축
@Component
public class BatchErrorNotifier {
@EventListener
public void handleJobExecutionException(JobExecutionException ex) {
if (ex.getMessage().contains("SERIALIZED_CONTEXT")) {
alertService.sendAlert(
"ExecutionContext size limit exceeded in batch job. " +
"Consider optimizing context usage or increasing column size."
);
}
}
}
최종 권장 해결책
경험상 가장 효과적인 조합은 다음과 같습니다:
1단계: 즉시 적용 (스키마 수정)
-- 운영 중단 최소화를 위한 단계별 적용
ALTER TABLE BATCH_JOB_EXECUTION_CONTEXT
MODIFY COLUMN SERIALIZED_CONTEXT LONGTEXT;
ALTER TABLE BATCH_STEP_EXECUTION_CONTEXT
MODIFY COLUMN SERIALIZED_CONTEXT LONGTEXT;
2단계: 점진적 개선 (코드 최적화)
// ExecutionContext 사용 원칙 수립
public class ExecutionContextBestPractices {
// 원칙 1: 필수 정보만 저장
public void storeEssentialOnly(ExecutionContext context) {
context.put("checkpoint", lastProcessedId);
context.put("count", processedCount);
// 상세 데이터는 별도 저장
}
// 원칙 2: 정기적 정리
public void cleanupPeriodically(ExecutionContext context) {
if (shouldCleanup()) {
context.remove("temporaryData");
}
}
// 원칙 3: 크기 제한 체크
public void validateContextSize(ExecutionContext context) {
if (estimateSize(context) > MAX_CONTEXT_SIZE) {
throw new IllegalStateException("ExecutionContext too large");
}
}
}
예방을 위한 베스트 프랙티스
- ExecutionContext는 가볍게: 상태 정보와 체크포인트만 저장
- 큰 데이터는 별도 관리: 캐시나 임시 테이블 활용
- 정기적 모니터링: 컨텍스트 크기 추적
- 테스트 환경에서 검증: 대용량 데이터로 사전 테스트
- 스키마 여유 확보: LONGTEXT 사용으로 확장성 보장
맺는글
Spring Batch의 ExecutionContext 크기 제한 문제는 배치 시스템이 성장하면서 자연스럽게 마주치는 문제입니다. 중요한 것은:
- 근본 원인 이해: ExecutionContext의 역할과 저장 방식 파악
- 즉시 해결: 스키마 수정으로 당장의 문제 해결
- 장기적 최적화: 코드 개선으로 근본적 해결
- 지속적 모니터링: 재발 방지를 위한 관찰 체계 구축
이번 경험을 통해 Spring Batch의 내부 동작 방식을 더 깊이 이해할 수 있었고, 앞으로는 ExecutionContext 사용 시 더욱 신중하게 접근하게 되었습니다. 비슷한 문제를 겪는 개발자들에게 이 경험이 도움이 되길 바랍니다.