JAVA/JAVA | Spring 학습기록

[Spring] 스프링 배치 작성하면서 고민했던 부분들 기록

kth990303 2025. 6. 7. 13:55
반응형

특정 시점에 여러 건들을 동시에 처리해야되는 작업을 할 일이 있어서 스프링 배치를 활용한 적이 있다.

배치 코드를 작성하면서 받은 리뷰 및 고찰들을 블로그에 간단히 기록해보려 한다.

(원래 같으면 메모장에 작성했을텐데, 오랜만에 블로그에 남겨보는 듯하다 ㅎㅎ 블로그가 좀 더 시간은 오래 걸리지만, 기록용으로는 좋긴 한듯.)


스프링 배치 동작 다이어그램

출처: 공식 문서 https://docs.spring.io/spring-batch/docs/4.0.x/reference/html/index-single.html#chunkOrientedProcessing

 

사실 스프링 배치 동작 원리는 한글이 익숙하다는 가정 하에, 향로님 블로그(https://jojoldu.tistory.com/331)에서 보는 것이 가장 좋은 듯하다.

 

위 그림은 chunk 단위로 배치 작업을 하는 과정을 나타낸 것이다. 

ItemReader 에서 작업이 필요한 대상을 size만큼 읽어들이고,

ItemWriter 에서 reader에서 읽어온 대상을 작업을 처리한다.

그런데 ItemReader에서 읽어온 대상과, ItemWriter에서 처리할 대상의 데이터 정보가 좀 다를 수 있다. 예를 들어 데이터 타입이 다를 수 있다. ItemReader에서는 entity로 읽어왔으나, ItemWriter에서는 domain 타입을 처리한다든지. 이러한 경우에 ItemProcessor를 사용하면 좋다. ItemProcessor에서 entity <-> domain 매핑해주는 작업을 해주면 된다. 

 

writer에서 processing 작업까지 한번에 한다면?

어차피 entity<->domain 매핑 작업 코드는 크지 않기도 하고, writer에서 처리하는 것도 이상한 그림은 아닐 거 같은데~ 라고 생각할 수 있다. 이 부분이 고민돼 페어분께 의견을 여쭌 적이 있었다.

나는 writer에서는 특정 작업을 처리하는 책임, processing에서는 writer를 하기 위해 데이터를 가공하는 책임이 분리되는 것이 객체지향적인 장점을 챙길 수 있다고 보아, 나누는 것이 좋다고 생각했다. 그래야 다른 개발자분들이 해당 클래스를 보았을 때 더 쉽게 역할을 확인할 수 있을테니.

페어분께서도 공감해주셨고, 한 가지 더 좋은 의견을 말씀해주셨다. writer 에서 processing 까지 챙긴다면 프로세싱 과정에서 매우 많은 비용이 들어가는 작업 (ex. 대용량 파일 작업) 의 경우 OOM 발생이 가능하다는 것이었다. 

스프링에서 read, processing이 chunk단위가 아닌 Item 단위인 이유도 OOM 가능성 때문이 아닐까 말씀해주셨다. 충분히 일리가 있는 내용이었다. 대부분의 개발 환경에선 processing에 큰 비용이 들지 않지만, 파일을 말아올리는 작업의 경우 processing을 chunk 단위로 한다면 애플리케이션 메모리가 뻑날 수 있겠다 싶었다. 


job configuration 코드와 store as project file

chunk 단위로 트랜잭션을 다루기 때문에, 해당 Job Configuration 에서는 보통 아래와 같이 의존성 주입이 이루어진다.

1
2
3
4
5
6
7
@Configuration
@ConditionalOnProperty(name = ["spring.batch.job.names"], havingValue = "jobName")
class JobConfiguration(
  val jobRepository: JobRepository,
  val transactionManager: PlatformTransactionManager,
)
 
cs

 

@ConditionalOnProperty 로 program arguments 에 --spring.batch.job.names=jobName 으로 애플리케이션을 수행할 때에만 해당 Job이 수행되도록 하였다.

 

배치 애플리케이션을 실행할 때에는 configuration 설정 탭에서 Program arguments 항목에 --spring.batch.job.names=jobName 을 걸어주면 된다.

 

Store as project file 기능을 활용하자

이 기능을 활용하면 환경변수, 아규먼트 등 배치 세팅 정보들을 xml 파일로 말아준다.

애플리케이션 configuration 세팅 후 저장 및 실행을 누르면 파일이 생긴다. 가끔 안생기는 경우도 있긴 하더라..

 

이렇게 파일이 생기면, 다른 개발자분들은 별도로 배치 환경 세팅을 해주지 않더라도, 애플리케이션을 수행할 때 자동으로 이 세팅으로 수행할 수 있게 되므로 편리하다.

 

그 외에, JobConfiguration 에서는 job 수행 시 어떠한 step을 수행할지 정보를 적어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Bean(JOB_NAME)
fun job(step: Step): Job =
  JobBuilder("${JOB_NAME}Job", jobRepository)
    .incrementer(RunIdIncrementer())
    .start(step)
    .build()
 
@Bean("$JOB_NAME Step")
@JobScope
fun step(
  reader: JobNameReader,
  processor: JobNameProcessor,
  writer: JobNameWriter,
): Step =
  StepBuilder("${JOB_NAME}Step", jobRepository)
    .chunk<JobEntity, JobDomain>(chunkSize, transactionManager)
    .reader(reader)
    .processor(processor)
    .writer(writer)
    .build()
 
cs

 

약간 다른 얘긴데, 보통 나는 chunkSize는 reader에서 아래와 같이 의존성 주입받아 동적파라미터로 처리하는 것을 좋아한다.

@Value("\${chunkSize:1000}") private val chunkSize: Int,

 

그런데 이 정보를 Configuration에도 넣는게 좋을 거 같기는 하다. 왜냐하면 configuration 은 어찌됐든 설정 정보들 모음 클래스니까.

그래서 JobNameConfiguration 클래스에도 아래 코드가 존재한다.

@Value("\${chunkSize:1000}")
val chunkSize: Int = 1000

 

이 부분이 그렇게 크게 불편하다 느껴지진 않지만, 누군가에겐 중복 코드라고 볼 수도 있을 듯하다. reader에도 configuration에도 chunkSize 코드가 있는 것이니. 하지만 보는 입장에서 더 편리할 수도 있다고 생각이 들어서.. 일단 나는 위와 같이 작성하곤 한다.


ItemReader 초기화와 read 호출 시점 (@StepScope, @JobScope, 빈 라이프사이클)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@StepScope
class JobNameReader(
  private val entityManager: EntityManager,
  @Value("\${chunkSize:1000}"private val chunkSize: Int,
) : ItemReader<JobEntity> {
  private val reader: ItemReader<JobEntity> by lazy {
    logger.info { "종료배치시작, chunkSize: $chunkSize" }
 
    // item reader code
  }
 
 override fun read(): JobEntity? {
    logger.info { "종료배치 read" }
    return reader.read()
  } 
}
 
cs

 

위 코드에서 chunkSize = 1, item 대상이 총 4개일 경우 로그가 어떻게 뜰까?

답은 아래와 같다.

 

  • 종료배치시작, chunkSize: 1 -> 1번
  • 종료배치 read -> 4번 (물론, itemReader cursor offset 정책에 따라 5번 이상 뜰 수도 있음.)

 

중요한 것은 ItemReader 초기화 코드에 있는 로그는 한번만 뜬다는 것이다.

이는 Spring 빈 생명주기가 싱글톤이기 때문에, 애플리케이션 컨텍스트 초기화 시점에 빈이 생성되며 ItemReader는 이 때에만 초기화된다는 점이다. (아직 완벽한 정답은 아니다.)

 

그런데 해당 코드에는 @StepScope가 존재한다.

이 어노테이션은 빈을 Step 실행 시점에 생성되게 하며, Step이 종료되면 해당 빈이 소멸되게 된다.

따라서 bean 생성 시점에 ItemReader가 초기화되면서 로그가 한번 뜨고, item 단위로 read 메서드가 수행되면서 read 로그가 여러 번 뜨는 것이다.

 

@StepScope, @JobScope는 왜 명시해야 할까?

로컬 환경에서만 수행한다면 아마 필요 없을 수도 있을 듯하다.

 

하지만 실제 운영환경에서 배치서버는 (아마도) 24시간 풀가동하고 있을 것이다. @StepScope를 명시하지 않으면, 해당 Job 관련 빈들이 소멸되지 않고 24시간동안 유지되고 있을 것이며 이는 불필요한 리소스 낭비이다. 따라서 배치 Step 및 Job에는 @JobScope, @StepScope를 명시해주는 것이 좋다.

 

만약 @StepScope를 명시하지 않는다면 로그가 의도대로 뜨지 않을 가능성도 농후하다.

ItemReader는 해당 서버를 죽이지 않는 이상 계속 떠있을 것이다. 싱글톤이라면 애플리케이션 컨텍스트가 끝나지 않는 이상 빈이 소멸되지 않고 유지되기 때문이다.

따라서 배치가 특정 시점에 주기적으로 수행된다하더라도 이미 ItemReader는 초기화 및 빈 유지가 돼있으므로, ItemReader 로그는 뜨지 않을 것이다.


사실 배치 코드 작성하면서 이런저런 재미난 이슈들을 많이 겪고 있다.

배치 writer 작업에서 수행하는 메서드에서 이벤트를 발행하여 async 코드를 호출하는 부분이 있는데, 이 때 AOP 동작이 제대로 먹히지 않는 부분이라든지... (async가 있으면 정말 예측이 힘들어지는 것 같다.)

 

아무쪼록 빨리 실력이 상승해서 이런 부분들도 능숙하게 다룰 수 있음 좋겠다.

반응형