본문 바로가기
Spring/Batch

[Spring Batch] ItemStream의 자원 관리와 상태 복구

by coding_whale 2026. 5. 17.
반응형

1. 도입부 (Introduction)

스프링 배치(Spring Batch)를 사용하여 대용량 데이터를 처리할 때 개발자가 가장 흔하게 직면하는 문제는 자원의 안전한 관리와 예기치 못한 작업 중단에 대응하는 복구 전략 수립이다. 대용량 배치는 웹 애플리케이션과 달리 한 번에 수백만 건 이상의 대규모 리소스를 오랜 시간 점유하기 때문에, 조금만 구조가 비효율적이거나 자원이 정리가 되지 않으면 시스템 전체가 급속도로 멈추는 파국을 초래한다.

이러한 대용량 처리 파이프라인의 생명주기와 복구를 제어하는 핵심 인프라가 바로 ItemStream 인터페이스다. 스프링 배치 내부에 선언된 수많은 Reader와 Writer 구현체를 뜯어보면 공통적으로 ItemStream이라는 규격을 갖추어 작업을 유기적으로 엮어내고 있음을 알 수 있다. 이 인터페이스가 실제 배치 아키텍처 내부에서 어떻게 무거운 자원을 관리하고 장애 상황 시 중단점을 추적하여 복구하는지 면밀히 학습하여 정리한다.

 

[Spring Batch] 위임과 복합 컴포넌트 정리: Composite, Classifier, Mapping

스프링 배치(Spring Batch) 애플리케이션을 개발하다 보면 하나의 스텝(Step)에서 여러 개의 데이터 소스로부터 데이터를 읽어오거나, 읽어온 데이터를 여러 목적지에 동시에 저장해야 하는 요구사

myblog01150.tistory.com

 

 

2. 주요 특징 및 핵심 로직 (Main Features & Logic)

스프링 배치 스텝(Step) 내부에서 ItemStream은 자원의 할당/소멸 주기와 진행 정보를 데이터베이스에 동기화하는 상태 관리 중추 역할을 담당한다. 스텝 빌더가 스텝을 구성할 때, 등록되는 청크 지향 컴포넌트들을 스캔하여 ItemStream 구현체일 경우 이를 자동으로 라이프사이클 관리 명단에 편입시킨다.

스텝의 구동 흐름에 따른 ItemStream의 역할 전달 구조를 직관적으로 설명하기 위해 다음과 같은 흐름도를 기반으로 이해할 수 있다.

ItemStream 인터페이스는 다음 세 가지 메서드를 기본적으로 선언하고 있으며, 각각의 역할은 다음과 같은 특징을 가진다.

  • 자원 초기화 및 해제 (open / close) 배치가 처음 작동될 때 데이터베이스 커넥션 풀을 맺거나 특정 물리 파일을 메모리에 로드(open)하며, 배치가 정상적으로 끝나거나 비정상 종료된 최후의 순간에 해당 자원들을 OS와 가비지 컬렉터로 확실하게 반납(close)하도록 보장한다.
  • 메타데이터 저장 및 상태 추적 (open / update) 작업 성공 이력을 데이터베이스에 유기적으로 동기화하여 중단 시점의 포인터 값을 백업한다. 매 청크 트랜잭션이 최종적으로 데이터베이스에 커밋 완료되기 직전 현재 읽기 진행 횟수를 기록(update)하며, 작업 실패 후 복구 재시작 시 해당 컨텍스트 정보를 전달받아 중단 시점의 지점으로 즉시 롤백 및 워프(open)를 수행한다.

 

 

3. 상세 가이드 및 심층 분석 (Detailed Guide)

1) 자원 초기화 및 해제 (Resource Management)

대용량 배치에서 자원 회수를 소홀히 하면 파일 핸들러 누수, 데이터베이스 커넥션 고갈, 메모리 버퍼 과부하로 인한 OutOfMemoryError 장애를 초래한다. 스프링 배치의 정형화된 리더들은 doOpen과 doClose 메서드를 템플릿 메서드 패턴 구조로 엮어 리소스를 엄밀하게 방어한다.

① FlatFileItemReader (파일 기반)

// FlatFileItemReader.doOpen()
@Override
protected void doOpen() throws Exception {
   if (!resource.exists()) { // 리소스 실존 여부 체크
      if (strict) {
         throw new IllegalStateException("Input resource must exist (reader is in 'strict' mode): " + resource);
      }
      logger.warn("Input resource does not exist " + resource.getDescription());
      return;
   }
   // 메모리 가중을 피하고 안전하게 파일 라인을 추적하기 위해 BufferedReader 가동
   reader = bufferedReaderFactory.create(resource, encoding); 
}
// FlatFileItemReader.doClose()
@Override
protected void doClose() throws Exception {
    if (reader != null) {
        reader.close(); // 자원 해제가 이루어지지 않으면 파일 핸들이 고갈됨
    }
}

 

② JdbcCursorItemReader (DB 커서 기반)

// AbstractCursorItemReader.doOpen()
@Override
protected void doOpen() throws Exception {
   // DB 물리 커넥션 수립
   initializeConnection(); 
   // SELECT 쿼리 수행 후 데이터 스트리밍을 위한 ResultSet 획득
   openCursor(con); 
}
// AbstractCursorItemReader.doClose()
@Override
protected void doClose() throws Exception {
    JdbcUtils.closeResultSet(this.rs); // 1단계: ResultSet 해제
    rs = null;
    cleanupOnClose(con); // 2단계: 커서 닫기
    JdbcUtils.closeConnection(this.con); // 3단계: 커넥션 풀 반납
}

 

2) 메타데이터 관리 및 상태 복원 (State Tracking)

배치 도중 롤백 상황이 오거나 서버가 강제 다운되었을 때 처음부터 데이터를 다시 연산하는 무식한 루프를 도는 대신 실패한 지점부터 정확히 순차 이어 쓰기를 시작한다. 아래의 흐름도를 통해 어떤 방식으로 진행 단계가 영속화되고 복구 과정으로 이어지는지 이해할 수 있다.

① 상태 복원: open(ExecutionContext)

배치가 기동할 때 open()은 이전 실행에서 남긴 잔해인 ExecutionContext를 검토하여 이전에 성공한 누적 지점을 로드한다.

public void open(ExecutionContext executionContext) throws ItemStreamException {
   ...
   if (executionContext.containsKey(getExecutionContextKey(READ_COUNT_MAX))) {
       maxItemCount = executionContext.getInt(getExecutionContextKey(READ_COUNT_MAX));
   }

   int itemCount = 0;
   if (executionContext.containsKey(getExecutionContextKey(READ_COUNT))) {
       itemCount = executionContext.getInt(getExecutionContextKey(READ_COUNT));
   }
   ...
   if (itemCount > 0 && itemCount < maxItemCount) {
       try {
           jumpToItem(itemCount); // 이전 지점으로 읽기 포인터 조절
       }
       catch (Exception e) {
           throw new ItemStreamException("Could not move to stored position on restart", e);
       }
   }

   currentItemCount = itemCount;
}

복구에서 핵심이 되는 기능이 바로 jumpToItem(itemCount)이다. 구현체의 특성에 따라 데이터 복구를 위해 이동하는 기술적 방식과 연산 오버헤드 수준이 완전히 갈린다.

  • FlatFileItemReader: 파일의 특성상 랜덤 액세스 포인터 설정이 불가능하기 때문에, 이전 실패 지점의 물리 인덱스까지 readLine()을 내부적으로 뺑뺑이 루프를 돌면서 목적지까지 빈 전진을 하여 라인을 맞춘다. 대용량 텍스트 파일 복구 시 불필요한 IO 낭비가 수반된다.
  • JdbcCursorItemReader:JDBC 드라이버가 물리적인 absolute 점프를 지원하면 수 밀리초 내로 이동하지만, 대규모 통신에 많이 쓰이는 ResultSet.TYPE_FORWARD_ONLY 기본 옵션 상태에서는 순방향으로만 가므로 루프를 타며 목적 row까지 커서를 일일이 한 로우씩 짚으며 이동한다.
@Override 
protected void jumpToItem(int itemIndex) throws Exception { 
	if (driverSupportsAbsolute) { 
    	rs.absolute(itemIndex); // ResultSet의 다이렉트 점프 
        } 
    else { 
    	moveCursorToRow(itemIndex); // rs.next()를 반복 수행하며 순차 전진 
    } 
}
  • JdbcPagingItemReader:수학적 공식에 기반해 구동된다. 복구해야 할 위치($itemIndex$)와 페이지의 세그먼트 크기($pageSize$)를 가지고 수식을 풀어서 복구 대상의 페이지 번호를 단번에 산출한다.
    •  이전 데이터를 한 로우씩 건너뛰기 위해 물리적인 루프 연산을 수행할 오버헤드가 없으므로, 데이터베이스 재시작 효율과 처리량이 세 방식 중 가장 정교하다.
@Override 
protected void jumpToItem(int itemIndex) throws Exception { 
	this.lock.lock(); 
    try { 
    	page = itemIndex / pageSize; 
        current = itemIndex % pageSize; 
    } finally { 
    	this.lock.unlock(); 
    } 
}

 

② 상태 저장: update(ExecutionContext)

트랜잭션이 성공적으로 커밋 직전에 수행된다. 데이터 가공 가치 판단 흐름 중 에러가 유발되어 트랜잭션 전체가 롤백되면 이 메서드 역시 실행되지 않기 때문에 정합성이 유지된다.

@Override
public void update(ExecutionContext executionContext) throws ItemStreamException {
    ...
    executionContext.putInt(getExecutionContextKey(READ_COUNT), currentItemCount);

    if (maxItemCount < Integer.MAX_VALUE) {
        executionContext.putInt(getExecutionContextKey(READ_COUNT_MAX), maxItemCount);
    }
    ...
}

 

③ 상태 추적 미지원 예외 케이스: RedisItemReader

RedisItemReader의 경우 데이터를 SCAN 명령을 이용하여 순회하기 때문에 해시 슬롯 순회 상 읽어오는 데이터의 인덱스 정합성이 보장되지 않는다. 따라서 재시작 점프 자체가 성립하지 않아 아래처럼 update 메서드는 아예 존재하지 않는 상태로 선언되어 오직 open과 close 자원 회수 기능에만 집중한다.

public class RedisItemReader<K, V> implements ItemStreamReader<V> {
    
    @Override
    public void open(ExecutionContext executionContext) throws ItemStreamException {
        this.cursor = this.redisTemplate.scan(this.scanOptions); // open 시 자원 할당만 처리
    }

    @Override
    public void close() throws ItemStreamException {
        this.cursor.close(); // close 시 커서 수거만 처리
    }
    // update()는 아예 구현하지 않음
}

 

3) ItemStream의 위임 구조 (Delegation Pattern)

CompositeItemReader처럼 내부의 다수 컴포넌트들을 지휘하는 녀석 역시 본인이 ItemStream 계층 아키텍처를 구현하고 있으면서 하부의 자식 컴포넌트들에게 해당 생명주기를 연쇄적으로 전파(Bypass)한다.

🚨 Spring Batch 5.2.1 이하 버전에 상주했던 자원 누수 결함(Bug)

스프링 배치 5.2.1 이하 버전의 close() 선언 구조에는 아키텍처 결함이 있었다.

// 5.2.2 이전의 close() 구현 방식
@Override
public void close() throws ItemStreamException {
  for (ItemStreamReader<? extends T> delegate : delegates) {
     delegate.close(); 
  }
}

만약 delegates 리스트의 5개 컴포넌트 중 2번째 대상 컴포넌트가 close() 처리 중 네트워크 예외를 던져 루프를 탈출하면, 예외가 고스란히 전파되면서 3번째, 4번째, 5번째 대기 자원들의 close()는 영원히 건너뛰어지고 고스란히 물리 자원 누수로 직결되는 결함이 있었다.

이 결함은 스프링 배치 5.2.2 버전에 와서 각 예외를 격리하고 수집하는 형태의 안전 벨트 코드가 추가되며 보완되었다.

// 5.2.2 버전부터 교정된 안전한 close() 구현 코드
@Override
public void close() throws ItemStreamException {
    List<Exception> exceptions = new ArrayList<>();

    for (ItemStreamReader<? extends T> delegate : delegates) {
        try {
            delegate.close(); // 개별적으로 격리 처리하여 close 시도
        }
        catch (Exception e) {
            exceptions.add(e); // 예외가 나더라도 일단 수집 후 루프는 끝까지 관철
        }
    }

    if (!exceptions.isEmpty()) {
        String message = String.format("Failed to close %d delegate(s) due to exceptions", exceptions.size());
        ItemStreamException holder = new ItemStreamException(message);
        exceptions.forEach(holder::addSuppressed); // 예외 수집 후 throw
        throw holder;
    }
}

 

④ 위임 대상 ItemStream 직접 수동 등록

ClassifierCompositeItemWriter 처럼 조건 분기를 처리하는 녀석은 ItemStream 아키텍처를 구현하고 있지 않아서 하위 자식들의 open, close를 대행해 주지 못한다. 만약 하위의 criticalLogWriter 등이 물리 파일 스트림이라면, 다음과 같이 빌더 레벨에서 .stream() 메서드로 수동 우회 선언을 해두어야 스텝이 라이프사이클을 통제한다.

@Bean
public ClassifierCompositeItemWriter<SystemLog> classifierWriter() {
    ClassifierCompositeItemWriter<SystemLog> writer = new ClassifierCompositeItemWriter<>();
    writer.setClassifier(new SystemLogClassifier(criticalLogWriter(), normalLogWriter()));
    return writer;
}

@Bean
public Step systemLogProcessingStep() {
    return new StepBuilder("systemLogProcessingStep", jobRepository)
            .<SystemLog, SystemLog>chunk(10, transactionManager)
            .reader(systemLogProcessingReader())
            .writer(classifierWriter())
            .stream(criticalLogWriter()) // 하위 델리게이트 자원 수동 명시
            .stream(normalLogWriter())   // 하위 델리게이트 자원 수동 명시
            .build();
}

 

 

3. 실무 팁 및 주의사항 (Tips & Notes)

스텝 빌더가 빈 로딩 시점에 자동으로 instanceof ItemStream을 가늠하여 자원들을 스케줄러에 영속화하는 지능형 기능은 개발자에게 매우 유용하지만, 지연 바인딩 환경인 @StepScope나 @JobScope와 결합할 때 의도하지 않은 프록시 트랩(Proxy Trap)에 휘말려 데이터 가공 시작점에서 프로그램이 파괴된다.

① 문제의 발생 원인과 구조

스텝 범위 바인딩 가동 시, 스프링 @Bean 등록 메서드의 리턴 타입을 구현체가 아닌 부모 인터페이스(ItemReader)로 추상화하여 구현해 두면 스프링 내부적으로 JDK Dynamic Proxy를 강제 사용한다.

  • Dynamic Proxy의 타입 단절: JDK Dynamic Proxy 구조는 개발자가 선언한 반환 타입인 ItemReader 인터페이스만을 상속받아 프록시 임시 객체를 가공하기 때문에, 원본 클래스인 MongoCursorItemReader가 지닌 또 다른 정체성인 ItemStream 인터페이스 정보는 프록시의 타입 상속 구조에서 흔적도 없이 누락된다.
  • 자동 편입의 실패: 스텝 빌더가 해당 객체를 조사할 때 instanceof ItemStream 조건을 충족하지 않으므로 자동 수집 명단에서 배제한다.
  • NPE의 초래: 스텝 구동 시 해당 리더의 open()이 불리지 않았으므로 내부 쿼리를 던지기 위한 인프라 커서가 계속 null로 머물게 되며, 실제 조회를 실행하는 doRead() 작동 시 NullPointerException이 무조건 폭발한다.

 

② 프록시 트랩 탈출 방법

이를 회피하기 위한 설계 철칙은 매우 간결하다. @StepScope를 가동하는 리더와 라이터의 경우, Bean 선언 메서드의 리턴 타입을 인터페이스가 아닌 실제 타겟 구체 클래스 타입으로 한 치의 오차도 없이 명확하게 정의하여 리턴해야 한다.

// 해결책: 리턴 타입을 구체 클래스로 명확히 기재해 두어 스프링이 CGLIB 기반의 클래스 프록시를 띄우도록 가이드한다.
@Bean
@StepScope
public MongoCursorItemReader<SuspiciousDevice> cafeSquadReader() {
    ...
}

구체 클래스를 선언해 두면 스프링은 CGLIB 방식으로 원본 클래스의 자식 형태로 온전한 프록시를 감싸므로, ItemReader와 ItemStream 계층 정보가 완벽히 보존되어 자동 스캔 대상에 순조롭게 매칭된다.

 

 

4. 마무리 (Conclusion)

스프링 배치가 대량의 리소스를 안전하게 쥐고 장애 회생(Restart) 구조를 우아하게 풀어내기까지의 모든 이면에는 ItemStream이 자리하고 있다.

자원의 정확한 사용 시점과 생명주기를 추상화하여 제공하는 open/close 영역, 그리고 재시작에 따른 jumpToItem 동작 방식의 선택이 배치 효율성에 직격타를 끼친다는 사실은 매우 중요하다. 나아가 @StepScope 연동 시 인터페이스 타입을 사용했을 때 발생하는 프록시 트랩의 원인을 다각도로 분석하여 방어함으로써 설계 단계에서의 예기치 못한 크래시를 미연에 원천 봉쇄해야 한다. 개발 단계에서 성공하는 코드 뿐만 아니라, 예기치 않은 종료 속에서도 데이터 정합성을 사수하며 견고하게 복구되는 고효율 파이프라인 설계를 위한 근본적 토대가 여기에 있다.

 

 

 

반응형