| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- PID
- 파이콘
- 카우치코딩 #couchcoding #6주포트폴리오 #6주협업프로젝트
- expression statement is not assignment or call html
- Machine Learning
- github
- 플젝후체크
- taskkill
- Anaconda
- 클라이언트사이드렌더링
- 모바일웹스킨
- Morphological analysis #Corpus
- github markdown
- khaiii
- gitbash
- 필사
- #스파르타코딩클럽후기 #내일배움캠프후기
- 마크다운
- Technical Writing
- 비동기
- 자바파이썬
- SSR
- 카우치코딩 #couchcoding #6주포트폴리오 #6주협업프로젝트v
- terminate
- 서버사이드렌더링
- 출처: 자바의 신 8장
- address
- Kakao
- 파이썬
- 코딩온라인
- Today
- Total
개발 일기
스프링 배치로 병렬 처리 시 TaskExecutor 설정 본문
환경: SpringBatch 4.3.7
상황
배치 잡을 실행할 때, TaskExecutor 에 ThreadPoolTaskExecutor 를 적용하여 병렬로 동작한다.
이때, 풀사이즈를 조정하던 중에, 배치 Job 종료 이후에도 프로세스가 종료되지 않는 이슈가 있었다.
원래 Job 을 로컬에서 실행할 때, Job Completed 가 된 후, Finished [Job 이름] 메시지와 함께 프로세스가 종료된다. 그런데 종료가 일어나지 않았고, 프로세스에 SIGINT(인터럽트 시그널)를 보내 실행을 중단(interrupt)해야만 종료할 수 있었다.
이를 해결하기 위해서 원인을 찾고, 풀사이즈를 조정한 내용을 정리해보았다.
분석
기존 설정
@Bean
@JobScope
fun saveTransactonStep() = stepBuilderFactory["saveTransactonStep"]
.chunk<TransactionDto, List<TransactionDto>>(chunkSize)
.reader(transactionReader())
.processor(transactionProcessor())
.writer(transactionWriter())
.taskExecutor(customTaskExecutor())
.throttleLimit(2)
.build()
@Bean
fun customTaskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 2
executor.maxPoolSize = 2
executor.initialize()
return executor
}
- throttleLimit: Spring Batch에서 병렬 처리 시 동시에 실행되는 Tasklet 수를 제한하는 설정값. 최대 몇개의 thread 로 병렬 처리할 것인지를 의미한다. 이렇게 함으로써, DB 커넥션 등 리소스에 과부하가 걸리는 것을 방지한다.
In the case of an asynchronous taskExecutor the number of concurrent tasklet executions can be throttled(beyond any throttling provided by a thread pool) The throttle limit should be less than the data source pool size used in the job repository for this step.
- corePoolSize: 항상 유지되는 최소 스레드 수
- maxPoolSize: 최대 스레드 수
개발 환경에서는 2로 설정해두었다. 실시간 확장성에 대응하는 게 중요하다면, max 와 core 에 차이를 두는게 유리할 것이다. 하지만 해당 배치는 1년에 6번 이하로실행되는 배치다. 정해진 데이터를 병렬 처리하며, 시스템 자원 역시도 EC2 인스턴스 1대로 고정되어 있어서 자원을 고정했다.
ThreadPoolTaskExecutor 는 어떻게 동작하는가?
Implementing this interface also indicates that the TaskExecutor.execute(Runnable) method will not execute its Runnable in the caller's thread but rather asynchronously in some other thread. Extended interface for asynchronous TaskExecutor implementations, offering support for Callable
execute(Runnable) 메서드를 호출하면 절대 호출 스레드에서 즉시 실행되지 않고, 다른 스레드 풀(worker thread)에서 비동기로 처리 내부적으로 비동기 쓰레드 풀을 운영한다. ThreadPoolTaskExecutor는 AsyncTaskExecutor 인터페이스를 구현한다. 스레드풀은 한 번 만든 스레드를 재활용하기에, 스레드 생성·소멸 오버헤드가 거의 없다.
StepBuilder 의 default task Executor 는 원래 단일 스레드 이다. (별도 정의를 하지 않는다면 동기 방식이다. 배치에서 ThreadPoolExecutor 를 사용 시, Tasklet 또는 chunk가 다른 쓰레드에서 병렬로 실행 된다.
JStack 적용하여 상태 파악하기
프로세스가 종료되지 않고 있다면, 특정 스레드가 문제가 되었을 것이다. JStack 을 써서, 덤프를 뜨고 현재 상태를 파악해보도록 하자.
Jstack 은 캡처 도구와 같이, 당시 순간의 상태만 캡쳐한다. EC2 에서 로그로 Job completed 가 뜨는 것을 확인한 후, 다음 명령어로 덤프를 만들었다. (현재 개발 환경은 EC2 에서 배치를 띄우므로, 개발 EC2 에 접속해서 확인했다.)
[batch@tax-return-batch]$which jstack
jps -l
[batch@tax-return-batch]$ jps -l
17968 batch-0.0.1-SNAPSHOT.jar
jstack 17968 > 0620-dump.txt
Dump 모양은 대략 이렇다.
원인을 파악해보니, Non-Daemon thread 가 WAITING 상태로 남아있어서, JVM 이 종료가 안된것이 문제였다.
non-daemon 임을 알 수 있는 이유는, 다른 스레드들은 모두 HikariPool-2 housekeeper" #51 daemon prio=5 o 처럼 prio 앞에 daemon 이 써있기 때문이다.
2025-06-22 13:09:54 Full thread dump OpenJDK 64-Bit Server VM (17.0.11+0 mixed mode, emulated-client, sharing)....
taxreturn-batch-thread1" #57 prio=5 os_prio=31 cpu=619.51ms elapsed=66.51s tid=0x0000000159c05200 nid=0x9d13 waiting on condition [0x000000016ff56000] java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(java.base@17.0.11/Native Method) - parking to wait for <0x000000070102b5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(java.base@17.0.11/LockSupport.java:341) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@17.0.11/AbstractQueuedSynchronizer.java:506) at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@17.0.11/ForkJoinPool.java:3465) at java.util.concurrent.ForkJoinPool.managedBlock(java.base@17.0.11/ForkJoinPool.java:3436) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@17.0.11/AbstractQueuedSynchronizer.java:1625) at java.util.concurrent.LinkedBlockingQueue.take(java.base@17.0.11/LinkedBlockingQueue.java:435) at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@17.0.11/ThreadPoolExecutor.java:1062) at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@17.0.11/ThreadPoolExecutor.java:1122) at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@17.0.11/ThreadPoolExecutor.java:635) at java.lang.Thread.run(java.base@17.0.11/Thread.java:840)
taxreturn-batch-thread2" ...
JVM 은 언제 종료될까?
JVM은 모든 non-daemon thread가 종료되어야 종료된다. daemon thread만 남아 있으면 JVM은 자동 종료 되지만,
실행 중인 non-daemon 이 있다면 shutdown() 호출을 통해 명시적으로 정리되어 한다.
즉 여기서는 Spring Context 종료 시, executor가 shutdown 되어야 하는 것이다. 그렇지 않으면 스레드가 남아서 jvm 종료가 되지 않는다.
Since daemon threads are meant to serve user threads and are only needed while they are running, they won’t prevent the JVM from exiting once all user threads have finished their execution.
https://www.baeldung.com/java-daemon-thread
해결
현재 corePoolSize 와 maxPoolSize 값이 동일하다. 현재 살아있는 스레드는 모두 코어스레드이고, 이들은 기본적으로 keepAlive 시간과 관계없이 영원히 살아 있게 된다. ThreadPoolExecutor 에서 setAllowCoreThreadTimeOut(false)가 기본값이기 때문이다. 따라서 아래 두 개 설정을 추가한다. 아래 두 설정 추가만으로도 문제는 해결이 된다.
- setAllowCoreThreadTimeOut(true)
이 설정을 추가하면, 코어 스레드도 유휴 상태가 일정 시간(keepAliveSeconds) 이상 지속됨
- setKeepAliveSeconds(10)
스레드가 10초간 아무 일도 하지 않으면 종료되도록 설정
thread 가 작업 상태인 상황이 있을수 있으니, task 가 완료되기를 기다려줄 수 있도록 설정을 추가한다.
- setWaitForTasksToCompleteOnShutdown : 동작 중인 thread 강제 종료 하지 않고, 지정한 시간 동안 기다려줌.
- setAwaitTerminationSeconds
@Configuration
class BatchConfiguration() {
@Bean
fun taskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 2
executor.maxPoolSize = 2
executor.setAllowCoreThreadTimeOut(true)
executor.setKeepAliveSeconds(10)
executor.setWaitForTasksToCompleteOnShutdown(true)
executor.setAwaitTerminationSeconds(20)
executor.setThreadNamePrefix("batch-thread-")
executor.initialize()
return executor
}
테스트를 위해 ShutDown Logger 를 추가했다.
@Component
class ShutdownLogger {
@PreDestroy
fun onShutDown() {
println("[@PreDestroy] ThreadPoolTaskExecutor Shutdown hook at ${LocalDateTime.now()}")
}
}
keepAliveSeconds 에 10초로 제한하자, 10초가 지나고 shutDown 완료되었다.
2025-06-22 13:28:40.619 INFO 9457 --- [ restartedMain] o.s.b.c.l.support.SimpleJobLauncher : Job: [SimpleJob: [name=saveTaxReturnTransactionJob]] completed with the following parameters: [{=, toId=2100, masterId=69, run.id=414, year=2024, fromId=1500}] and the following status: [COMPLETED] in 7s726ms
🔥 [ShutdownHook] JVM shutdown hook called
2025-06-22 13:28:50.369 INFO 9457 --- [ionShutdownHook] d.t.c.c.c.EmbeddedRedisConfiguration : Embedded redis stopped
🛑 [@PreDestroy] ThreadPoolTaskExecutor Shutdown hook at 2025-06-22T13:28:50.490396
🛑 [Spring] ApplicationContext is shutting down at 2025-06-22T13:28:50.490575
2025-06-22 13:28:50.490 INFO 9457 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2025-06-22 13:28:50.491 INFO 9457 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2025-06-22 13:28:50.495 INFO 9457 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
2025-06-22 13:28:50.495 INFO 9457 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-2 - Shutdown initiated...
2025-06-22 13:28:50.495 INFO 9457 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-2 - Shutdown completed.
참고
- Spring Batch 4.3.7 공식 문서
'Tech > 배워서 남주기' 카테고리의 다른 글
| 스프링 배치 병렬 처리 가능하도록 변경하기. (0) | 2024.11.16 |
|---|---|
| Redis Pipelining 으로 26만개 데이터 한번에 Insert 하기 (0) | 2024.11.04 |
| Redis Clustering 를 통해 FailOver 구현하기 (도커 컴포즈 환경) (0) | 2023.01.25 |
| Insert가 많을 때 소요 시간 개선하기 - (1) myBatis forEach 적용 (0) | 2023.01.16 |
| [JPA/MYSQL] 재고에 동시 접근할 때 일어나는 갱신 분실 문제 해결하기 (0) | 2023.01.09 |