[Flowpath] Spring Batch 실행 모델의 Rust식 재설계

주재완·2026년 7월 20일

Flowpath

목록 보기
1/3
post-thumbnail

개요

10만 건의 데이터를 처리하는 배치가 있습니다. 6만 건을 처리한 시점에 프로세스가 종료되었습니다. 배치를 다시 실행한다면 어디서부터 시작해야 할까요? 이미 저장한 데이터는 다시 써도 될까요? 업무 데이터는 저장됐지만 진행 위치를 기록하기 전에 종료됐다면 어떻게 해야 할까요?

정상 처리만 보면 배치는 데이터를 읽고, 변환하고, 저장하는 반복문처럼 보입니다. 하지만 중간에 실패했을 때까지 생각하면 Chunk, 트랜잭션, 체크포인트, 재시도, 재시작이 필요해집니다.

Spring Batch는 이 문제를 오랫동안 다뤄온 프레임워크입니다. Rust에도 Spring Batch의 구조에서 영감을 받은 spring-batch-rs가 있습니다.

Flowpath를 구상하면서 기능 수를 늘리는 것보다 먼저 실행 규칙을 정하기로 했습니다. Spring Batch에서 배치 실행의 의미를 배우되, Rust의 소유권과 타입 시스템, 비동기 Stream에 맞게 경계를 다시 나누는 것이 목표입니다.

Spring Batch에서 발견한 실행 모델

Spring Batch를 처음 보면 Job, Step, ItemReader, ItemProcessor, ItemWriter가 먼저 눈에 들어옵니다. Flowpath를 설계하면서는 객체 이름보다 실패와 재시작을 다루는 실행 모델을 자세히 살펴봤습니다.

논리적 작업과 실행 시도의 구분

Spring Batch의 JobInstance는 논리적인 작업을 나타냅니다. JobExecution은 그 작업을 실행한 한 번의 시도입니다. 같은 작업을 실패 후 다시 실행하면 새 업무를 만드는 대신, 같은 JobInstance 아래에 JobExecution을 추가합니다.

이 구분이 없으면 실패한 실행을 재시작한 것인지, 같은 설정으로 새 업무를 시작한 것인지 판단하기 어렵습니다. Spring Batch는 같은 JobInstance를 다시 사용할 때 이전 ExecutionContext를 복원하고, 새 JobInstance라면 처음부터 시작합니다. Spring Batch Domain Language

버퍼 크기를 넘어선 Chunk의 의미

Spring Batch의 일반적인 Chunk 처리는 다음과 같습니다.

  1. Item을 하나씩 읽습니다.
  2. 커밋 간격만큼 모읍니다.
  3. Chunk 전체를 저장합니다.
  4. 트랜잭션을 commit합니다.

따라서 Chunk 크기는 메모리에 몇 건을 모을지만 정하는 값이 아닙니다. 어디까지를 하나의 트랜잭션으로 확정할지도 정합니다. Spring Batch 공식 문서도 Chunk를 트랜잭션 안에서 한 번에 쓰는 Item 묶음으로 설명합니다. Chunk-oriented Processing

Spring Batch를 사용하더라도 업무 데이터와 실행 메타데이터가 항상 같은 트랜잭션으로 묶이지는 않습니다. 업무 DB와 JobRepository가 서로 다른 트랜잭션 관리자를 사용하면 업무 처리 후 메타데이터를 갱신하기 전에 실패할 수 있습니다. 이때 다시 실행하면 같은 데이터를 두 번 처리할 수 있습니다. 공식 문서도 이런 구성에는 멱등 처리나 외부 트랜잭션 조정이 필요하다고 설명합니다. Configuring a Step

Rust의 기존 선택지, spring-batch-rs

spring-batch-rs 0.3.6은 Spring Batch와 비슷한 구성 방식을 제공합니다.

let step = StepBuilder::new("csv-to-json")
    .chunk::<Order, Order>(100)
    .reader(&reader)
    .processor(&processor)
    .writer(&writer)
    .skip_limit(5)
    .build();

JobBuilder::new()
    .start(&step)
    .build()
    .run()?;

Reader가 Item을 하나씩 읽고, Processor가 변환하거나 걸러내며, Writer가 Chunk를 저장합니다. 입력 타입과 출력 타입의 연결은 컴파일할 때 검사합니다. CSV, JSON, XML뿐 아니라 PostgreSQL, MySQL, SQLite, MongoDB, SeaORM과 여러 Tasklet 어댑터도 제공합니다. spring-batch-rs 0.3.6 API

이름은 같지만 의미에는 차이가 있습니다. 0.3.6의 JobInstance는 식별 파라미터로 정해지는 영속 업무 단위가 아니라 UUID와 Step 목록을 가진 인메모리 실행 객체에 가깝습니다. JobExecutionStepExecution도 실행 시간과 처리 건수를 메모리에 기록합니다. 공개된 코어 API에서는 영속 체크포인트, 재시작 이력, JobRepository, 트랜잭션 관리자 규약을 찾기 어렵습니다.

코어의 Reader, Processor, Writer도 동기 trait입니다. SQLx나 SeaORM처럼 비동기 API를 쓰는 어댑터는 Tokio의 block_in_placeblock_on으로 동기 trait에 연결합니다. API는 단순해지지만 백프레셔, 취소, I/O 동시성을 Runtime 정책으로 다루기는 어려워집니다.

spring-batch-rs는 익숙한 처리 구조와 다양한 어댑터에, Flowpath는 실패 후의 동작을 명확히 정하는 데 초점을 둡니다.

Flowpath의 네 가지 실행 경계

ItemReader를 대신하는 Source와 Cursor

Flowpath의 Source는 Item 하나를 돌려주는 동기 read() 대신 비동기 Stream을 공개하려 합니다.

  1. Source + 이전 Cursor
  2. Stream
  3. Runtime이 Chunk를 수집합니다.
  4. 커밋 성공 시 다음 Cursor를 저장합니다.

Cursor는 Source에서 안전하게 다시 시작할 위치를 나타냅니다. PostgreSQL Source라면 (created_at, id) 같은 키셋 커서가 될 수 있고, 파일 Source라면 바이트 오프셋과 줄 번호가 될 수 있습니다. Source마다 Cursor 타입을 따로 둡니다.

중요한 점은 Source가 읽은 위치와 저장이 끝난 위치를 구분하는 것입니다. Source는 다음 Item을 미리 읽을 수 있지만, 저장된 Cursor는 Chunk 커밋이 끝난 뒤에만 다음 위치로 갱신해야 합니다.

Source가 한 번에 조회하는 크기와 Runtime이 커밋하는 Chunk 크기도 나눕니다. 한 번에 1,000건을 조회하더라도 1,000건 전체를 하나의 트랜잭션으로 처리할 필요는 없습니다.

Pipeline과 Runtime의 분리

Flowpath의 Pipeline은 무엇을 처리할지 나타내는 값입니다.

let pipeline = Pipeline::from(source)
    .map(validate)
    .then(enrich)
    .filter(accepted)
    .chunks(1_000)
    .commit_with(committer);

let report = runtime.run(pipeline).await?;

Pipeline은 변환 과정과 커밋 경계를 정의합니다. Runtime은 Pipeline을 실행합니다. 순차 실행, 동시 실행 수, 취소, 처리 순서 같은 정책은 Runtime이 맡습니다.

이렇게 실행 계획과 실행 상태를 나누면 Runtime 구현이 달라져도 Pipeline의 의미를 유지할 수 있습니다.

Writer를 넘어선 Committer

Writer가 성공했다고 해서 Chunk 처리가 모두 끝난 것은 아닙니다. 재시작하려면 다음 변경을 함께 다뤄야 합니다.

BEGIN
-- 업무 데이터를 저장합니다.
-- 체크포인트를 저장합니다.
-- 실행 메타데이터를 갱신합니다.
COMMIT

Flowpath에서는 이 경계를 Committer로 표현합니다.

업무 데이터, 체크포인트, 실행 메타데이터를 같은 트랜잭션으로 처리할 수 있다면 AtomicCommitter를 사용합니다. 파일 출력과 DB 메타데이터처럼 하나의 트랜잭션으로 묶을 수 없다면 CoordinatedCommitter로 구분합니다.

Coordinated 방식에는 Atomic과 같은 보장을 붙이지 않습니다. 대신 안정적인 작업 ID와 멱등성, 세그먼트와 매니페스트, 정합성을 복구하는 절차가 필요합니다. 같은 trait을 구현해도 저장소가 다르면 보장 범위 역시 다르다고 보기 때문입니다.

Partition과 Split의 구분

병렬 처리에서도 업무 단위와 실행 전략을 분리합니다.

  • Partition - 업무적으로 독립적인 처리 범위
  • Split - Runtime이 병렬화를 위해 나눈 물리 범위

예를 들어 고객 국가가 업무 Partition이라면, 특정 국가의 데이터를 몇 개의 워커에 나눌지는 Split 전략입니다. 워커 수나 Split 수가 바뀌더라도 업무 식별자는 그대로여야 합니다.

이 구분만으로 문제가 모두 풀리지는 않습니다. 재시작할 때 이전 Split 구성을 유지할지, 새 워커 수에 맞춰 다시 나눌지 정해야 합니다. 첫 구현에서는 한 Run 안의 Split 구성을 고정하고 체크포인트 규칙부터 확인할 계획입니다.

Chunk 처리 실패와 재시작

앞선 Chunk의 처리를 마친 뒤, 현재 Chunk에서 실패한 경우를 가정합니다.

  1. 이전 Chunk 커밋 성공 - 마지막 Cursor를 저장
  2. 현재 Chunk 처리 실패 - 현재 변경을 롤백
  3. 재시작 - 같은 Instance에 새 Run을 만들고, 마지막 Cursor 이후부터 다시 시작

업무 데이터와 체크포인트가 같은 DB 트랜잭션에 있다면 현재 Chunk의 변경을 함께 롤백할 수 있습니다. 이전에 커밋한 Chunk는 다시 실행하지 않습니다.

현재 Chunk의 출력이 파일이면 상황이 달라집니다. 파일을 최종 위치에 반영한 뒤 DB 체크포인트를 저장하기 전에 프로세스가 종료될 수 있습니다. 파일 상태를 확인하지 않고 마지막 Cursor 다음부터 다시 실행하면 같은 결과를 두 번 만들 수 있습니다.

이 상황을 exactly-once라고 부르는 것으로 끝내지 않으려 합니다. 같은 트랜잭션에 포함된 저장소의 범위, 일부만 성공한 상태를 찾는 방법, 재시작 전에 정합성을 복구하는 절차를 함께 설명해야 합니다.

설계 단계의 Flowpath

현재(2026.07.20) Flowpath는 설계 단계입니다. Source와 Cursor의 trait 형태, 비동기 트랜잭션의 수명, 필터 결과가 비었을 때의 Cursor 처리 등은 직접 컴파일하고 테스트하면서 정할 예정입니다.

첫 번째 목표는 작은 로컬 Runtime입니다. 처리량을 높이기 전에 다음 규칙부터 확인합니다.

  • 커밋되지 않은 Chunk의 위치는 Cursor에 저장하지 않아야 합니다.
  • Writer나 체크포인트 저장이 실패하면 Atomic Write는 함께 롤백되어야 합니다.
  • 재시작은 기존 Run을 수정하지 않고 같은 Instance에 새 Run을 만들어야 합니다.
  • 재시도 중에는 저장된 체크포인트가 변하지 않아야 합니다.
  • Atomic과 Coordinated의 보장 범위를 구분해야 합니다.

Spring Batch를 살펴본 뒤, 배치 프레임워크의 핵심을 처리 반복문이 아니라 실패 후의 상태 관리로 보게 됐습니다. spring-batch-rs에는 익숙한 처리 구조와 여러 어댑터가 이미 구현돼 있습니다. Flowpath에서는 재시작에 필요한 경계와 보장 범위를 Rust 타입에 드러내는 쪽을 선택했습니다.

참고 자료

profile
데이터베이스, 트랜잭션 구조 설계에 관심이 많은 백엔드 개발자입니다.

0개의 댓글