spring batch lazy하게 의존하기

sangminLeesangminLee
5 min read

발단

보통 spring batch를 사용할 때, 여러개의 Job들을 Bean으로 띄어놓고 program arguments로 job.name을 지정하여 사용한다. 하지만 이런 경우에 Bean으로 띄우다 보니 initialization이 일어날 때 Bean 내부의 runtime exception이나 해당 bean이 의존하는 bean(datasource 등)에서 runtime exception이 발생한다면 그것과 상관없는 job 또한 실행이 불가능하게 된다

나는 분명 brandDictionaryJob 을 실행시켰지만 이와 상관없는 mongoToMysqlDispCtgSync bean에서 문제가 있으면 brandDictionaryJob도 수행할 수 없다!

ㅇㅇ

다른 예시로 job A는 mysql을 사용하고 job B는 mongodb를 사용한다. 이때 mongodb에 connection 이슈가 있다면 이와 관계없는 job A까지 수행할 수 없다..!

lazy initialization

이를 해결해보기 위해 spring batch 2.2.0 부터 지원하는 옵션인 spring.main.lazy-initialization을 설정해보았다.

spring:
  batch:
    job:
      names: ${job.name:NONE}
  profiles: qa
  main:
    lazy-initialization: true  # LAZY !!

lazy-initialization은 기본적으로 Spring은 애플리케이션 시작 시 모든 빈을 초기화하려하지만 이 기능을 활성화하면 빈들이 처음 요청될 때까지 초기화되지 않는다. 따라서 해당 빈에 runtime exception이 발생할 것이라도 사용하지 않는 빈이면 예외가 발생하지 않는다.

💡
모든 빈 말고 특정 빈에만 적용하려면 @Lazy 어노테이션을 사용하면 된다.

하지만 lazy-initialization을 설정했음에도 동일한 문제가 발생한다!

즉, spring 내부적으로 빈들을 초기화하려는 움직임이 있다는 것이다.

원인

spring batch를 사용하기 위해 별도 구성설정을 하지 않는다면 BatchAutoConfiguration을 통해 자동 설정을 한다. 해당 Configuration의 코드는 다음과 같다.

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ JobLauncher.class, DataSource.class })
@AutoConfigureAfter(HibernateJpaAutoConfiguration.class)
@ConditionalOnBean(JobLauncher.class)
@EnableConfigurationProperties(BatchProperties.class)
@Import(BatchConfigurerConfiguration.class)
public class BatchAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "spring.batch.job", name = "enabled", havingValue = "true", matchIfMissing = true)
    public JobLauncherCommandLineRunner jobLauncherCommandLineRunner(JobLauncher jobLauncher, JobExplorer jobExplorer,
            JobRepository jobRepository, BatchProperties properties) {
        JobLauncherCommandLineRunner runner = new JobLauncherCommandLineRunner(jobLauncher, jobExplorer, jobRepository);
        String jobNames = properties.getJob().getNames();
        if (StringUtils.hasText(jobNames)) {
            runner.setJobNames(jobNames);
        }
        return runner;
    }

    ...
}

그 중 jobLauncherCommandLineRunner 빈을 생성하는 코드가 있다. 마찬가지로 내부 코드를 보면,,!

public class JobLauncherCommandLineRunner implements CommandLineRunner, Ordered, ApplicationEventPublisherAware {
    ...
  @Autowired(
    required = false
  )
  public void setJobs(Collection<Job> jobs) {
    this.jobs = jobs;
  }
}

setJobs 메서드를 통해 모든 Job 빈들을 주입받으려고 하는 것이 보인다! 해당 코드 때문에 모든 Job 빈들을 주입 받으려해서 Job들을 초기화 시도하고 초기화 과정에서 Runtime Exception이 발생하여 문제 없는 다른 Job에도 영향을 끼치는 것이었다!

해결

jobLauncherCommandLineRunner 코드를 수정할 수는 없으니 해당 빈이 생성되는 것을 막고 직접 Runner를 만들어서 job을 수행하도록 해야한다.

다행히도 Spring batch도 이러한 현상을 알아, spring.batch.job.enabled 옵션을 제공하여 jobLauncherCommandLineRunner이 자동으로 생성되지 않도록 할 수 있다.

spring:
  batch:
    job:
      names: ${job.name:NONE}
      enabled: false  # disable !!
  profiles: qa
  main:
    lazy-initialization: true  # LAZY !!

이것만 설정해두면 Job Runner가 존재하지 않기 때문에 spring batch는 아무런 작업도 하지않고 바로 종료가 되버린다. 이를 해결하기 위해 수동으로 Runner를 구성해서 내가 program argument로 넘겨준 Job이 수행되도록 해야한다.

@Component
@RequiredArgsConstructor
public class JobRunner implements CommandLineRunner {

  private final ApplicationContext context;
  private final JobLauncher jobLauncher;
  @Value("${job.name}")
  private String jobName;
  private final JobParametersConverter converter = new DefaultJobParametersConverter();

  @Override
  public void run(String... args) throws Exception {
    Properties properties = StringUtils.splitArrayElementsIntoProperties(args, "=");
    assert properties != null;
    properties.put("time", String.valueOf(System.currentTimeMillis()));
    JobParameters jobParameters = this.converter.getJobParameters(properties);

    jobLauncher.run(context.getBean(jobName, Job.class), jobParameters);
  }
}
  • 넘어온 argument를 StringUtils에서 제공하는 메서드를 활용해 Properties로 바꿔주었다.

  • 기존 jobLauncherCommandLineRunner에서 사용하는 converter를 사용하여 JobParameters로 바꿔주었다.

  • @Value로 job 이름을 받아 Spring application context를 활용해 Bean으로 바꾸었고 이를 실행할 수 있도록 하였다.

이후 brandDictionaryJob을 실행해보면 mongoToMysqlDispCtgSync 에 throw 로 RuntimeException을 발생시켰지만 brandDictionaryJob는 이와 관계없이 정상적으로 수행된다!


그런데 추가이슈

다른 예시로 job A는 mysql을 사용하고 job B는 mongodb를 사용한다. 이때 mongodb에 connection 이슈가 있다면 이와 관계없는 job A까지 수행할 수 없다..!

맨 처음에 말한 이 문제, 이것도 잘 수행되나 테스트해봤는데 왠걸? job A가 수행되지 않는다.

실제 상황에서는 다음과 같다

brandDictionaryJob은 mysql, bigquery를 의존하여 사용하고 있고 mongodb는 사용하지 않는다. 이때 MongoTemplate bean에 의도적으로 RuntimeException을 발생시켰다. 사용하지 않고 Lazy하게 initialization되니 사용하지 않는 MongoTemplate의 RuntimeException은 발생하지 않아야하는데 왜 발생하지?

  @Bean(name = "searchMongoTemplate")
  public MongoTemplate searchMongoTemplate() {
    if (true) {
      // searchMongoTemplate을 사용하지 않는 job에서는 초기화가 이뤄지지 않으므로 아래 에러가 발생하지 않아야한다.
      throw new RuntimeException("searchMongoTemplate 실행");
    }
    SimpleMongoDbFactory mongoDbFactory = new SimpleMongoDbFactory(mongoClient(), database);
    DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDbFactory);
    MongoMappingContext mongoMappingContext = new MongoMappingContext();
    mongoMappingContext.setFieldNamingStrategy(new SnakeCaseFieldNamingStrategy());
    MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, new MongoMappingContext());
    converter.setTypeMapper(new DefaultMongoTypeMapper(null));
    return new MongoTemplate(mongoDbFactory, converter);
  }

KeywordRepository를 사용하지 않음에도 KeywordRepository bean이 초기화되려해서 이에 필요한 searchMongoTemplate bean 또한 초기화하려하는 것이다.

그러면 무엇이 KeywordRepository bean를 불러오는 것일까?

원인은 우리의 특수한 상황에 의해 발생하였다. 우리는 여러개의 MongoDB를 도메인별로 나눠서 각각 사용하고 있는데, 이를 위해 @EnableMongoRepositories으로 사용하고 있었다. 예를 들어

@Configuration
@EnableMongoRepositories(basePackages = {
  "com.test.batch.mongo.repository.A"}, mongoTemplateRef = "AMongoTemplate"
)
@Slf4j
public class AMongoConfig extends AbstractMongoConfiguration {
    ...
    @Bean(name = "AMongoTemplate")
    public MongoTemplate AMongoTemplate() {
        ...
    }
}

spring data를 사용하는 MongoRepository 인터페이스들중에서 A패키지에 속한 것들은 AMongoTemplate을 사용해야하고 B패키지에 속한 것들은 BMongoTemplate을 사용하도록 하기 위해 위와 같이 작성하여 사용 한다. 이때 EnableMongoRepositories는 다음과 같이 동작한다.

Annotation to activate MongoDB repositories. If no base package is configured through either value(), basePackages() or basePackageClasses() it will trigger scanning of the package of annotated class.

어노테이션을 통해 몽고DB 리포지토리를 활성화합니다. value(), basePackages() 또는 basePackageClasses()를 통해 기본 패키지가 구성되지 않은 경우 주석이 달린 클래스의 패키지 검색을 트리거합니다.

즉, EnableMongoRepositories로 인해 MongoRepository가 Scan되어 lazy하지 못하게 된다.

이를 해결하기 위해서는 명시적으로 Lazy 함을 나타내야한다.

단순하게는 @Lazy 어노테이션을 모든 MongoRepository에 붙이면 되는데, 이는 개발자에 따라 놓칠 수도 있고 무엇보다 하나하나 지정하면 귀찮다!

그래서 해당 방식 말고 모든 MongoRepository Bean을 가져와서 구성하기 전에 Bean 설정을 바꿔주면 된다.

@Component
public class UnHandledLazyConfig implements BeanFactoryPostProcessor {
  private static final String MONGO_FACTORY_BEAN_CLS_NAME = "org.springframework.data.mongodb.repository.support.MongoRepositoryFactoryBean";

  /**
   * EnableMongoRepositories 활성화시 스프링 컨텍스트 초기화 시점에 컴포넌트 스캔을 시도하므로 Mongo Repository Lazy 설정하는 것이 필요함.
   *
   * @param beanFactory
   * @throws BeansException
   */
  @Override
  public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
    for (String beanName : beanFactory.getBeanDefinitionNames()) {
      var beanDefinition = beanFactory.getBeanDefinition(beanName);
      String beanClassName = beanDefinition.getBeanClassName();
      if (beanClassName != null && beanClassName.equals(MONGO_FACTORY_BEAN_CLS_NAME)) {
        beanDefinition.setLazyInit(true);
      }
    }
  }
}

이제 Job A에서 사용하지 않은 어떠한 datasource에서 장애가 발생해도 Job A는 이에 대해 의존하는 것이 전혀 없으므로 Job A는 정상적으로 수행이 가능하다!!! 야호~

결론

이 글에서는 Spring Batch에서 발생할 수 있는 빈 초기화 문제와 이를 해결하기 위한 방법을 살펴보았다. spring.main.lazy-initialization 옵션과 spring.batch.job.enabled 옵션을 활용하여 특정 Job만 초기화되도록 설정함으로써 문제를 해결할 수 있었다. 이를 통해 불필요한 빈 초기화로 인한 예외 발생을 방지하고, 원하는 Job을 안정적으로 실행할 수 있다

0
Subscribe to my newsletter

Read articles from sangminLee directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

sangminLee
sangminLee