올리브영에서는 지금도 수많은 배치가 수행되고 있습니다.
이 배치들 중에는 선후관계가 분명한 것들도 있어서 A 배치가 수행이 안 되면 B 배치도 진행될 수 없는 경우도 있죠.
그래서 배치가 제시간에 수행되고 종료되는 것은 매우 중요한데요. 오늘은 그 배치 중 하나가 멈춰버렸던(!) 날에 대해서 포스팅하고자 합니다.
어? 배치가 왜 안 끝나지?
평소대로라면 진작에 종료됐어야 할 배치가 끝나지 않는 걸 발견했습니다. 올리브영에서는 데이터독으로 배치의 시작과 종료를 모니터링하고 있어서, 배치가 정상 수행이 안 되면 슬랙으로 알림을 받을 수 있습니다. 그런데 이날은 배치 하나가 이상했습니다. 정상 종료 시각이 지났는데도 끝나지 않는 거에요! 급히 로그를 확인해 보니, 특정 데이터를 업데이트한 후로 로그가 전혀 올라오지 않는 것을 발견했습니다.
제일 먼저 든 생각은 역시 DB 데드락(DeadLock)이었습니다.
DB 데드락(Deadlock)이란?
데이터베이스에서 여러 트랜잭션이 서로 자원을 점유하면서, 상대방이 점유한 자원을 필요로 하는 상태가 되어 아무 작업도 진행할 수 없는 상태.
특히 해당 배치는 빠른 처리를 위해 멀티스레드를 사용하고 있었기 때문에 Row Level Deadlock이 의심스러웠죠.
Row Level Deadlock이란?
특정 행에 대한 트랜잭션이 상호 배타적으로 자원을 점유하고 있을 때, 다른 트랜잭션이 해당 행에 접근하려고 시도하면서 데드락이 발생하는 상황입니다.
예를 들어, 두 트랜잭션이 각각 행 A와 행 B를 잠금(lock)하고 있고, 이후 각 트랜잭션이 서로가 점유하고 있는 행에 접근하려고 할 때 발생할 수 있습니다.
트랜잭션 1이 행 A를 잠금하고 작업을 진행 중입니다.
트랜잭션 2가 행 B를 잠금하고 작업을 진행 중입니다.
트랜잭션 1이 행 B에 접근하려고 시도하지만, 이미 트랜잭션 2가 점유하고 있어 기다립니다.
동시에 트랜잭션 2도 행 A에 접근하려고 시도하지만, 이미 트랜잭션 1이 점유하고 있어 대기 상태가 됩니다.
이 때문에 두 트랜잭션이 서로의 자원을 기다리며 무한 대기에 빠지게 되고, Row Level Deadlock이 발생합니다.
로그를 통해 1개 이상의 스레드가 업데이트를 위해 동일한 데이터에 접근한 것은 확인했지만, 업데이트 대상이 단일 row였기 때문에 단순한 잠금 대기(lock wait)를 유발할 뿐 순차적으로 처리가 되어야 하는 상황이었습니다.
그렇다면 무엇이 배치를 멈추게 한 걸까
일단 배치 소스를 확인해보았습니다. 해당 배치는 Spring Batch의 Tasklet 기반으로 작업을 수행하고 있었는데, 먼저 눈에 띈 건 트랜잭션 분리가 제대로 안 된 점이었습니다. 이슈가 발생한 서비스 메소드에 @Transactional 설정이 빠져있었죠. 아래 코드 중 someService.processBusinessLogic() 메소드가 트랜잭션 없이 처리되고 있었던 겁니다. 이 경우에 Spring Batch는 Tasklet 단위로 트랜잭션이 적용되고, 이 경우 전파 레벨은 기본값인 Propagation.REQUIRED입니다.
@Transactional Propagation
Propagation.REQUIRED는 트랜잭션이 존재하면 해당 트랜잭션에 참여하고, 없으면 새로운 트랜잭션을 생성합니다.
Propagation.REQUIRES_NEW는 반대로 항상 새로운 트랜잭션을 생성해 독립적인 작업을 수행할 수 있습니다.
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final SomeService someService; // 비즈니스 로직을 처리할 서비스
@Bean
public Job sampleJob() {
return jobBuilderFactory.get("sampleJob")
.start(sampleStep())
.build();
}
@Bean
public Step sampleStep() {
return stepBuilderFactory.get("sampleStep")
.tasklet(sampleTasklet())
.build();
}
@Bean
public Tasklet sampleTasklet() {
return new Tasklet() {
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
try {
// 비즈니스 로직 수행
someService.processBusinessLogic(); // Spring Batch 트랜잭션 기본값 Propagation.REQUIRED 적용
return RepeatStatus.FINISHED;
} catch (Exception e) {
log.error("Tasklet 실행 중 오류 발생: ", e);
throw e;
}
}
};
}
public class SomeService {
// @Transactional 없음
public void processBusinessLogic() {
// 비즈니스 로직
}
}
추가로 해당 Tasklet에선 ThreadPoolExecutor를 사용해 멀티스레드를 생성해서 작업을 병렬 처리하고 있었습니다. 그렇다면 이렇게 생성된 스레드들은 하나의 트랜잭션으로 처리될까요?
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(...);
List<DataDto> dataList = someService.processBusinessDataList();
try {
for(DataDto data : dataList){
// 비즈니스 로직을 멀티 스레드로 수행
executor.execute(new Runnable() {
public void run() {
someService.processBusinessLogic(data);
}
});
}
} catch (Exception e) {
log.error("Tasklet 실행 중 오류 발생: ", e);
throw e;
} finally {
try {
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.MINUTES)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
return RepeatStatus.FINISHED;
}
}
그건 아닙니다. Spring의 TransactionSynchronizationManager는 스레드로컬(ThreadLocal)을 사용하여 트랜잭션과 관련된 데이터를 스레드별로 독립적으로 저장합니다. 결국, 각 스레드가 수행하는 작업은 서로 다른 트랜잭션 경계를 가지며 독립적으로 커밋되는 상황인 거죠. 의도하진 않았지만 Propagation.REQUIRES_NEW를 설정한 것과 동일한 효과가 발생한 겁니다.
그렇다면 각각의 스레드가 트랜잭션이 분리되어 있으니까 문제가 없는 게 아닌가? 싶지만 여기서 저희가 한가지 놓친 게 있었습니다.
ThreadPoolExecutor를 생성할 때 생성자에 여러 가지 설정 값을 넘겨줘야 하는데, 이 부분에 문제가 있었던 거죠.
무엇이 문제였을까요?
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 코어 스레드 수
4, // 최대 스레드 수
60, // 유휴 스레드 대기 시간
TimeUnit.SECONDS, // 시간 단위
new ArrayBlockingQueue<>(10), // 작업 큐
new ThreadPoolExecutor.CallerRunsPolicy() // RejectedExecutionHandler
);
여기서 잠깐 ThreadPoolExecutor의 작동 방식을 간단하게 짚고 넘어가겠습니다. 위와 같이 executor를 생성한 뒤에 executor.execute 메소드로 새 작업을 제출할 때의 실행 순서는 다음과 같습니다.
- 현재 실행 중인 스레드가 코어 스레드 수(2) 미만이면 → 즉시 새로운 스레드 생성하여 작업 실행
- 코어 스레드가 모두 사용 중이면 → 작업을 큐에 저장 (최대 10개)
- 큐가 가득 찼다면 → 최대 스레드 수(4)까지 스레드 생성하여 작업 실행
- 큐도 가득 차고 최대 스레드 수에 도달했다면 → RejectionPolicy(예: CallerRunsPolicy)에 따라 호출자 스레드에서 작업 실행 또는 예외 발생
여기까지 오니 의심스러운 녀석이 하나 있습니다.
바로 마지막에 전달한 거부된 작업 처리기 RejectedExecutionHandler 라는 놈입니다. 스레드 풀이 꽉 차거나, 큐가 가득 찼을 때 작업을 어떻게 처리할지 결정하는 역할을 하는데, CallerRunsPolicy로 메인 스레드에서도 작업이 수행되도록 설정되었습니다.
ThreadPoolExecutor 가 제공하는 4가지 정책
- AbortPolicy: 작업이 거부되면 RejectedExecutionException 예외를 던집니다.
- CallerRunsPolicy: 메인 스레드에서 작업을 수행하도록 합니다.
- DiscardPolicy: 거부된 작업을 조용히 무시합니다.
- DiscardOldestPolicy: 큐의 가장 오래된 작업을 버리고 새로운 작업을 추가합니다.
이중에서 우리는 CallerRunsPolicy 정책을 선택해서 메인 스레드에서도 작업이 수행되도록 세팅했고, 이를 그림으로 표현하면 이렇습니다.
CallerRunsPolicy에 의해서 메인 스레드에서 여러 개의 작업이 수행될 수 있는 상황이었던 거죠. 사실 서비스 메소드를 Transactional(propagation = Propagation.REQUIRES_NEW)로 제대로 설정했었더라면 이런 상황에서도 트랜잭션은 분리돼서 실행되기 때문에 이슈는 없었을 겁니다.
하지만 이렇게 트랜잭션 분리가 안 된 상태로 작업을 수행하면, 메인 스레드와 신규 스레드 간 트랜잭션이 분리된 상태에서 같은 데이터에 접근하는 경우 경합이 발생할 수 있습니다. 왜냐하면, 메인 스레드에서는 N개의 작업이 한 트랜잭션에 묶여있기 때문에 트랜잭션이 분리된 신규 스레드가 메인 스레드에서 아직 커밋하지 않은 데이터를 수정하려고 하면, 메인 스레드가 커밋할때까지 기다려야 하기 때문이죠.
그러나 메인 스레드는 모든 신규 스레드가 종료되어야만 커밋이 가능하므로, 신규 스레드(작업)가 하나라도 살아 있는 이상 절대로 커밋할 수 없습니다.
결국 신규 스레드는 메인 스레드의 커밋을 기다리고, 메인 스레드는 신규 스레드가 종료되길 기다리니 배치는 끝날 수가 없었던 겁니다.
마무리 : 올바른 트랜잭션 설정 및 스레드 정책
사실, 이날 Lock이 발생하면서 DB 커넥션풀의 가용 커넥션이 감소하고 대기 스레드가 늘어나면서 올리브영 시스템에 전반적인 부하가 발생했습니다. 특히 해당 테이블은 주문할 때마다 빈번하게 업데이트가 발생하는 테이블이었기 때문에, 영향력이 매우 컸습니다. 단순히 배치가 종료되지 않는 것을 넘어서 연쇄 작용으로 전체 시스템이 흔들리는 걸 보면서 다시 한번 적절한 트랜잭션 처리의 중요성을 느끼게 됐죠.
정리해보면, 서비스 메소드에서 트랜잭션을 Propagation.REQUIRES_NEW로 제대로 분리해 설정했더라면 이런 문제를 예방할 수 있었을 겁니다. 추가로 CallerRunsPolicy 정책을 사용하면서도 메인 스레드에서 작업을 수행하지 않으려면 최대 스레드 수를 조정하거나 큐 길이를 무한으로 설정하는 등의 방안도 고려할 수 있습니다.
이처럼 병렬 처리는 복잡성과 오류 가능성이 높아서 웬만하면 사용하고 싶지 않지만 사용할 때 주의가 필요합니다. 특히 올리브영은 규모가 점점 커지면서 처리해야 할 데이터양이 늘어나고 있어서 대량 데이터 처리에 대한 고민도 커지고 있죠. 사실 배송 물류 스쿼드에서는 이 배치를 없애버릴 계획을 하고 있는데, 성공하면 그때 다시 포스팅하겠습니다!