레거시 차액정산 시스템을 Spring Batch로 현대화하기
크론 기반 Java 배치에서 모듈형 아키텍처로, PG사 연동 3주→1일 달성
여는 글
안녕하세요. 오늘은 크론탭으로 쉘스크립트를 통해 Plain Java를 실행하던 레거시 차액정산 시스템을 Spring Batch 기반으로 전면 재설계한 경험을 공유하려 합니다.
차액정산이란 카드 결제 수수료를 영중소 등급에 맞게 환급하는 프로세스입니다. 영중소 등급은 직전 반기의 매출을 기준으로 여신협회에서 정한 등급이며, 반기 단위로 변경됩니다. 가맹점이 실제 적용받아야 할 수수료율과 기결제된 수수료의 차액을 정산하여 환급하는 것이 핵심입니다.
기존 시스템은 연동할 PG사가 추가될 때마다 전체 코드를 재작성해야 했고, 유지보수업체의 3주라는 공수기간을 기다려야만 했습니다. 다행히 해당 시스템은 코드만 존재하고 실제 거래가 없는 상태였기 때문에, 데이터 이관 없이 전면 재설계가 가능했습니다. 이 글에서는 이 시스템을 어떻게 모듈형 아키텍처로 직접 재설계하여 PG사 연동을 1일로 단축했는지, 그리고 그 과정에서 마주친 기술적 도전들을 다룹니다.
프로젝트 배경 - 레거시 시스템의 한계
기존 시스템 구조
flowchart LR
A[크론탭] --> B[쉘스크립트]
B --> C["java com.app.main.Daemon"]
C --> D[(DB)]
C --> E[PG사별 개별 로직]
E --> F[PG사별 결과 테이블]
문제점
| 문제 | 상세 | 영향 |
|---|---|---|
| 코드 중복 | PG사별로 5단계 전체 프로세스 개별 구현 | 유지보수 비용 증가 |
| 테이블 분산 | PG사별 결과 테이블 개별 관리 | 통합 조회 불가 |
| 비용 발생 | 유지보수업체를 통한 아웃소싱 | 3주 개발 기간 및 신규 개발 비용 발생 |
| 모니터링 부재 | 실패 시 수동 확인 필요 | 장애 대응 지연 |
| 트랜잭션 부재 | 트랜잭션 관리 없이 실행 | 데이터 정합성 위험 |
차액정산 프로세스 이해
flowchart LR
A[1. 데이터 세팅] --> B[2. 파일 생성] --> C[3. 파일 업로드] --> D[4. 결과 수신] --> E[5. 데이터 업데이트]
| 단계 | 설명 |
|---|---|
| 데이터 세팅 | 차액정산 대상 거래 추출 (영중소 등급별 수수료 차액 계산) |
| 파일 생성 | PG사 가이드라인에 맞는 파일 생성 |
| 파일 업로드 | PG사 서버(SFTP/API)로 전송 |
| 결과 수신 | PG사 처리 결과 주기적 폴링 |
| 데이터 업데이트 | 결과 반영 및 상태 변경 |
각 PG사마다 파일 포맷, 인코딩, 전송 방식, 결과 코드가 달라서 기존에는 이 전체 과정을 PG사별로 개별 구현했습니다.
왜 Spring Batch인가?
“크론탭으로 잘 돌아가고 있는데 굳이 바꿔야 하나?”
레거시 시스템의 문제를 직접 개발로도 해결할 수 있습니다. 트랜잭션 관리, 실패 시 재시도, 확장성은 Plain Java로도 충분히 구현할 수 있는 영역입니다. 그렇다면 왜 Spring Batch였을까요?
“구현 가능하다”와 “검증되어 있다”의 차이
| 기능 | 직접 구현 시 | Spring Batch |
|---|---|---|
| 메타데이터 관리 | 별도 테이블 설계 + 로깅 코드 작성 | 6개 메타 테이블 자동 기록 |
| Chunk 처리 | 페이징 로직 + 부분 커밋/롤백 구현 | chunkSize 설정만으로 완료 |
| 재시작 | 체크포인트 저장/복원 로직 개발 | ExecutionContext 자동 저장 |
| 실행 이력 | 별도 이력 테이블 설계 | BATCH_JOB_EXECUTION 제공 |
직접 구현하면 배치 프레임워크 자체를 만드는 것과 다름없습니다. Spring Batch를 사용하면 비즈니스 로직에 집중할 수 있습니다.
차액정산 프로세스와의 매칭
차액정산의 5단계 프로세스가 Spring Batch의 Step 구조와 자연스럽게 매칭되었습니다.
차액정산 프로세스 → Spring Batch 구조
──────────────────────────────────────────────
1. 데이터 세팅 → Step 1 (Reader-Processor-Writer)
2. 파일 생성 → Step 2 (Tasklet)
3. 파일 업로드 → Step 3 (Tasklet)
4. 결과 수신 → Step 4 (Tasklet)
5. 데이터 업데이트 → Step 5 (Chunk)
대량 데이터 처리 시 Chunk 기반 페이징이 필수적이었고, 실패 시 ExecutionContext를 통한 재시작이 안정성을 보장했습니다.
Spring Batch 기반 아키텍처 재설계
아키텍처 재설계 시 가장 중요하게 생각한 것은 신규 PG사 연동의 단순화였습니다. 언제든 새로운 PG사가 추가될 수 있고, 그때마다 전체 코드를 재작성하는 것은 비효율적이기 때문입니다. 그래서 가장 신경 쓴 부분은 추상화입니다.
설계 원칙
- 단일 책임: 각 Step은 하나의 작업만 수행
- 인터페이스 추상화: PG사별 차이점만 구현체로 분리
- 테이블 통합: PG사별 결과를 단일 테이블로 일원화
- 재시작 가능: 실패 지점부터 재실행 가능한 구조
전체 흐름
Quartz Scheduler가 정해진 시간에 Spring Batch Job을 트리거하면, 5개의 Step이 순차적으로 실행됩니다. 각 Step은 차액정산 프로세스의 한 단계를 담당합니다. 데이터 세팅, 파일 생성, 파일 업로드, 결과 수신, 데이터 업데이트까지 하나의 Job으로 묶여 트랜잭션과 실행 이력이 관리됩니다.
Spring의 @Scheduled 대신 Quartz를 선택한 이유는 DB 기반 스케줄 관리와 Misfire 처리 때문입니다. 서버 재시작이나 장애로 스케줄이 누락되었을 때 Quartz가 이를 감지하고 재실행할 수 있습니다.
인터페이스 추상화
핵심은 “모든 PG사에 공통인 부분”과 “PG사마다 다른 부분”을 분리하는 것이었습니다.
파일 생성과 업로드 단계에서 인터페이스를 정의하고, PG사별 구현체가 해당 인터페이스를 구현하도록 설계했습니다. 신규 PG사 연동 시에는 해당 PG사의 가이드라인에 맞는 구현체만 추가하면 됩니다. 나머지 로직은 기존 프레임워크가 그대로 처리합니다.
테이블 통합
기존에는 PG사별로 결과 테이블이 분리되어 있어서 전체 현황을 파악하려면 여러 테이블을 조회해야 했습니다. 재설계 시 PG사 구분 컬럼을 두고 단일 테이블로 통합했습니다. 이로써 전체 PG사의 정산 현황을 한 번의 쿼리로 조회할 수 있게 되었고, 통계나 모니터링도 훨씬 수월해졌습니다.
프로젝트 성과
정량적 성과
| 항목 | 개선 전 | 개선 후 | 개선율 |
|---|---|---|---|
| PG사 연동 소요 시간 | 유지보수업체 - 3주 | 직접개발 - 1일 | 95% 단축 |
| PG사별 코드 중복 | 5단계 전체 구현 | 2단계(파일 생성/업로드) 구현체만 | 약 60% 감소 |
| 개발 비용 | 신규 PG사 연동 비용 지불 | 비용 없음 | 불필요한 운영 지출 비용 해소 |
| 결과 테이블 | PG사별 개별 | 통합 1개 | 관리 일원화 |
| 장애 복구 | 수동 재실행 | 실패 지점 재시작 | 자동화 |
“1일” 범위: 구현체 코드 작성 시간입니다. PG사 가이드라인 분석, PG사와의 커뮤니케이션, 운영 배포 후 모니터링 기간은 제외입니다. 기존 유지보수업체의 3주도 동일한 기준(순수 개발 시간)으로 비교했습니다.
코드 중복 감소율 산정 근거: 차액정산 5단계 프로세스 중 PG사마다 다른 부분은 “파일 생성”과 “파일 업로드” 2단계입니다. 나머지 3단계(데이터 세팅, 결과 수신, 데이터 업데이트)는 공통 로직으로 처리되어 신규 PG사 연동 시 작성할 코드가 5단계 → 2단계로 줄었습니다.
비즈니스 임팩트
- 개발 비용 절감: 신규 PG사 연동 시 외부 아웃소싱 없이 내부 처리 가능
- 운영 효율화: 통합 테이블로 전체 PG사 현황 한눈에 파악
- 안정성 향상: Spring Batch의 재시작 기능으로 장애 시 데이터 손실 방지
- 확장성 확보: 반기별 영중소 등급 변경에 유연하게 대응 가능
운영 중 마주친 문제 - ExecutionContext 오류
시스템 재설계 후 운영 중 예상치 못한 오류가 발생했습니다.
사건의 발단
아침에 출근해 평소와 같이 배치 로그를 체크했는데, 다음과 같은 로그가 찍혀 있었고 해당 Job이 실패한 상태였습니다.
왜 이런 실수가 발생했는가?
솔직히 말하면, Spring Batch를 처음 도입하면서 ExecutionContext의 역할을 잘못 이해했습니다. “Step 간 데이터 공유”라는 설명을 보고, 기존 배치 프로그램에서 메모리에 올려두던 중간 처리 데이터를 그대로 ExecutionContext에 담으면 된다고 생각했습니다. ExecutionContext가 DB에 직렬화되어 저장된다는 것, 그래서 재시작 시 복원할 “체크포인트 정보”만 담아야 한다는 것을 운영 중 장애를 겪고 나서야 제대로 이해했습니다.
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: ExecutionContext 사용량 최적화
근본적인 해결책은 ExecutionContext에 큰 데이터를 저장하지 않는 것입니다. 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); // ❌
해결 방법 2: 컨텍스트 정리 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();
}
해결 방법 3: 데이터베이스 스키마 수정 (보조 조치)
코드 최적화와 별개로, 예상치 못한 상황에 대비해 스키마도 수정했습니다.
컬럼 타입 변경
-- 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까지 저장 가능합니다. 단, 이는 근본 해결이 아닌 안전망입니다. 코드 최적화 없이 스키마만 늘리면 언젠가 또 한계에 부딪힙니다.
해결 방법 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;
}
}
}
예방을 위한 베스트 프랙티스
- ExecutionContext는 가볍게: 상태 정보와 체크포인트만 저장
- 큰 데이터는 별도 관리: 캐시나 임시 테이블 활용
- 정기적 모니터링: 컨텍스트 크기 추적
- 테스트 환경에서 검증: 대용량 데이터로 사전 테스트
- 스키마 여유 확보: LONGTEXT 사용
맺는글
이번 프로젝트는 단순한 기술 스택 변경이 아니라, 비즈니스 요구사항에 맞는 아키텍처 재설계였습니다.
기술적으로 배운 점
- Spring Batch의 ExecutionContext 동작 원리와 한계
- 인터페이스 추상화를 통한 확장 가능한 설계
- 레거시 시스템 분석 및 전면 재설계
비즈니스 관점에서 배운 점
- PG사 연동이라는 복잡한 작업을 단순화하는 것의 가치
- 차액정산이라는 금융 도메인의 복잡성 이해
- 영중소 등급 변경 주기에 맞춘 시스템 설계의 중요성
크론탭과 쉘스크립트로 돌아가던 레거시 배치 시스템을 Spring Batch로 현대화하면서, “돌아가기만 하면 된다”는 레거시의 한계를 넘어 “확장 가능하고 유지보수 가능한” 시스템으로 발전시킬 수 있었습니다.