[Spring Batch] Spring batch Step실행 시 상태 변수를 사용하면 안된다!

이민호·2025년 3월 31일
post-thumbnail

배경

프로젝트를 진행하며 spring batch관련 itemReader, itemProcessor, itemWrtier를 구현할 때,

package com.pda.community_module.batch.job;


import com.pda.community_module.batch.decider.MidnightDecider;
import com.pda.community_module.batch.validator.TimeFormatJobParametersValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.FlowBuilder;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.Flow;
import org.springframework.batch.core.job.flow.support.SimpleFlow;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
@RequiredArgsConstructor
public class BatchJobConfiguration {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final MidnightDecider midnightDecider;

    @Bean
    public Job batchJob(
            @Qualifier("likeTop10Step") Step likeTop10Step,
            @Qualifier("stockSentimentAnalysisStep") Step stockSentimentAnalysisStep
    ) {
        return new JobBuilder("batchJob", jobRepository)
                .validator(new TimeFormatJobParametersValidator(new String[]{"targetTime"}))
                .incrementer(new RunIdIncrementer())
                .start(midnightDecider)
                .on("MIDNIGHT").to(likeTop10Step)
                .from(midnightDecider)
                .on("NOT_MIDNIGHT").to(stockSentimentAnalysisStep)
                .end()
                .build();
    }
}

org.springframework.beans.factory.BeanCreationNotAllowedException: Error creating bean with name 'applicationTaskExecutor': Singleton bean creation not allowed while singletons of this factory are in destruction (Do not request a bean from a BeanFactory in a destroy method implementation!)

오류가 뜨면서 이어서 지속적으로 stockSentimentAnalysisStep관련 빈등록이 안되는 문제가 발생했다.

처음에는 String에서 이름을 설정 해줄 때 오류가 난 줄 알았지만,

Singleton bean creation not allowed while singletons of this factory are in destruction 이 오류를 보고 Scope와 관련된 오류임을 알게되었다.


오늘은 이와 관련된 @JobScope, @StepScope에 대해 알아보겠다.

Spring Batch에는 Scope와 관련된 어노테이션인

@StepScope와 @JobScope가 있다.

@StepScope란. Spring batch에서 Step이 실행될 때마다, 새로운 Bean을 생성하게 해주는 어노테이션이다. 주로 Step 실행 시점에서 동적으로 관련 Bean을 생성해야 할 때, Step마다 다른 파라미터 주입 등의 필요성이 있을때 사용하고 Step이 끝나면 사라진다.

@StepScope 없이 Singleton(기본값)으로 만들면 ApplicationContext 실행 시 Bean이 생성된다.

@JobScope
Job 실행 시점에 스프링 동적으로 생성되며, Job 실행 동안 유지된다. 주로 여러 Step에서 공유되는 JobParameter 사용 등에 사용되고 Job이 끝나면 사라진다.

문제점

ItemReader, ItemProcessor, ItemWriter에 적용하는 어노테이션(@StepScope)에 따라 각종 문제가 발생했다. 그것에 대해서 하나씩 풀어보겠다.

우선, 소스코드를 보면
likeTop10Step는 itemReader에서 Job Parameter가 필요했고, 매 Step마다 targetTime은 달라지므로 @StepScope를 적용했고, itemReader에서만 사용므로 itemReader에만 달아주었다. 정상적으로 실행되었다.

진짜 문제는 stockSentimentAnalysisStep 이 Step이었는데,
stockSentimentAnalysisStep는 Job Parameter가 필요없기에 아무것도 달아주지 않았다.

그러고 batch 작업을 돌린 결과, 첫 데이터만 전달되어 Step이 한 번만 이루어지고, 그 이후의 Step은 작동되지 않는 모습을 보여주었다.

그래서 뭐가 문제인지 확인해보기 위해 여러가지 테스트를 해봤다.
✔ 1. ItemReader, ItemProcessor, ItemWriter @StepScope 어디에도 사용 x
✔ 2. ItemReader, ItemProcessor, ItemWriter 중 ItemReader에만 @StepScope 사용
✔ 3. ItemReader, ItemProcessor, ItemWriter 모두 @StepScope 작성.

✔1. ItemReader, ItemProcessor, ItemWriter @StepScope 어디에도 사용 x

이 뜻은 모든 ItemReader, ItemProcessor, ItemWriter를 ApplicationContext 실행 시 싱글톤 객체로 만들었다는 뜻이다.

한 번의 Step만 실행된 이유를 생각해보자.

1. Spring Batch는 기본적으로 이미 성공한 Job/Step은 다시 실행하지 않는다.

이때는, Job parameter를 바꾸거나 @StepScope를 붙여 빈을 동적으로 생성해 매번 다른 시행처럼 보이게 해야한다.

하지만 우리는 Job Parameter를 사용하지도 않았을 뿐더러 실제로 매번 다른 DB에 StepExecution이 기록되었고, read, process, write 작업만 이루어 지지 않았기 때문에 이것은 아니라고 판단했다.

2. 싱글톤 빈이 다루는 데이터가 중복이 되어서 그럴 수 있다.

이를 파악하기 위해 나는 직접 빈 안의 객체의 메모리 주소를 찍어보았다.

public class StockSentimentAnalysisReader implements ItemReader<List<Sentiment>> {

    private final SentimentRepository sentimentRepository;
    private boolean hasRead = false;

    @Override
    public List<Sentiment> read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException {

        if (hasRead) {
            return null;
        }

        LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
        System.out.println("oneHourAgo 객체: " + oneHourAgo);
        System.out.println("oneHourAgo 해시 코드: " + System.identityHashCode(oneHourAgo));

        hasRead = true;
        return sentimentRepository.findRecentSentiments(oneHourAgo);
    }
}

oneHourAgo는 매번 다른 객체로 생성되는 것을 확인했다. 다른 메모리가 출력 되었고, 이는 싱글톤 객체가 매번 다른 데이터를 다룬다는 것을 뜻했다.

매번 다른 Step으로 다뤄지기도 하고, 데이터도 다른데 왜 하나만 writer될까를 생각해보다,

위의 코드의 상태를 관리하는 hasRead 변수도 바뀌나? 라는 생각을 했다. 아예 오류자체도 뜨지않고 Step으로 기록되니까 Step을 처리하는 과정에서 null이 반납될 수도 있다고 생각했다.

-> 내부 데이터는 달라졌지만, 모든 Item 객체들이 싱글톤으로 작동 되기 때문에 매 Step마다 싱글톤 빈이 재 사용되는 것이다. 따라서 내부의 변수들은 달라지지않는다. 다음 Step을 실행해도, hasRead가 이미 true로 되어있기 때문에 null을 반납하는 것이다.

✔ 2. ItemReader, ItemProcessor, ItemWriter 중 ItemReader에만 @StepScope 사용

이것도 정상적으로 작동되었다,

아니,, Reader는 동적으로 만들고 나머지는 싱글톤으로 만들면 이상하게 오류나는거 아니야? 라고 생각했다.

public class StockSentimentAnalysisProcessor implements ItemProcessor<List<Sentiment>, List<StockRequest>> {

    @Override
    public List<StockRequest> process(List<Sentiment> item) throws Exception {

        Map<String, List<Sentiment>> groupedByStock = item.stream()
                .filter(sentiment -> sentiment.getPost() != null && sentiment.getPost().getHashtag() != null)
                .collect(Collectors.groupingBy(sentiment -> sentiment.getPost().getHashtag()));

        return groupedByStock.entrySet().stream()
                .map(entry -> {
                    String stockName = entry.getKey();
                    List<Sentiment> stockSentiments = entry.getValue();

                    long totalScore = stockSentiments.stream().mapToLong(Sentiment::getSentimentScore).sum();
                    long postCount = stockSentiments.size();
                    long avgScore = postCount > 0 ? totalScore / postCount : 0;

                    return new StockRequest(stockName, avgScore, postCount);
                })
                .collect(Collectors.toList());
    }

하지만 어디에도 데이터의 상태(State)를 관리하는 변수를 사용하지 않기 때문에 가능했다.

✔ 3. ItemReader, ItemProcessor, ItemWriter 모두 @StepScope 사용.

이는 우리가 실제로 구현했던 방법인데, 당연하게도 모든 Item 객체들이 Step이 실행될때마다 동적으로 생성되므로
실행 빈, 내부데이터, 그 안의 상태 데이터가 모두 바뀌므로 새로운 Step으로 인지되고, 정상적으로 실행된다. Step이 작동되지 않는 문제를 해결할 수 있었다.

결론

Step을 구성할 때, 내부에 상태를 관리하는 변수
예를 들어,
hasRead, isComplete 등의 Boolean 타입,
int, long currentIndex, offset 등 배열의 인덱스 값들..

이런 것들은 @StepScope를 사용하지 않으면 매번 싱글톤 객체가 재사용 되므로 변경되지 않아서 문제가 생길 수 있다는 것을 깨달았다.

  1. Spring의 기본 빈 스코프는 싱글톤이라 여러 Job/Step 실행 간 상태가 공유됨.
  2. 동시에 여러 Job이 돌거나 재시작할 때 이전 상태(싱글톤 빈이므로)가 남아서 이상한 동작을 하게 됨.
  3. 특히 멀티스레딩 환경에서는 동시 접근으로 인해 race condition까지 발생할 수 있음.
profile
효율적으로 살게요.

0개의 댓글