여는 글

안녕하세요. 오늘은 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");
        }
    }
}

예방을 위한 베스트 프랙티스

  1. ExecutionContext는 가볍게: 상태 정보와 체크포인트만 저장
  2. 큰 데이터는 별도 관리: 캐시나 임시 테이블 활용
  3. 정기적 모니터링: 컨텍스트 크기 추적
  4. 테스트 환경에서 검증: 대용량 데이터로 사전 테스트
  5. 스키마 여유 확보: LONGTEXT 사용으로 확장성 보장

맺는글

Spring Batch의 ExecutionContext 크기 제한 문제는 배치 시스템이 성장하면서 자연스럽게 마주치는 문제입니다. 중요한 것은:

  1. 근본 원인 이해: ExecutionContext의 역할과 저장 방식 파악
  2. 즉시 해결: 스키마 수정으로 당장의 문제 해결
  3. 장기적 최적화: 코드 개선으로 근본적 해결
  4. 지속적 모니터링: 재발 방지를 위한 관찰 체계 구축

이번 경험을 통해 Spring Batch의 내부 동작 방식을 더 깊이 이해할 수 있었고, 앞으로는 ExecutionContext 사용 시 더욱 신중하게 접근하게 되었습니다. 비슷한 문제를 겪는 개발자들에게 이 경험이 도움이 되길 바랍니다.