Spring Batch 데드락 동시성 문제와 대용량 데이터 처리 최적화 해결

궁금하면 500원·2025년 7월 25일
0

미생의 개발 이야기

목록 보기
55/58

Spring Batch Deadlock 트러블슈팅: 대용량 데이터 처리 최적화

대용량 데이터를 다루는 Spring Batch 애플리케이션에서 DeadlockLoserDataAccessException이 발생했습니다. 이 문제는 뉴스 기사 데이터를 분석하고 업데이트하는 과정에서 발생했으며, 대량의 데이터 동시 처리, 테이블 간 외래키 제약조건, 그리고 기본 트랜잭션 격리 수준이 복합적으로 작용하여 발생한 전형적인 동시성 문제였습니다.


문제 분석: 데드락 발생의 근본 원인

  1. 대규모 동시 업데이트: Spring Batch의 멀티 스레드 기반 병렬 처리가 활성화되면서, 여러 스레드가 동시에 news 테이블과 stats 테이블의 레코드를 업데이트하려고 시도했습니다.
  2. 외래키 제약조건: stats 테이블이 news 테이블에 대한 외래키를 가지고 있어, 두 테이블에 대한 잠금(Lock)이 필요했습니다. 이 과정에서 한 트랜잭션이 news 테이블에 쓰기 잠금을 걸고 stats 테이블을 기다리는 동안, 다른 트랜잭션은 stats 테이블에 잠금을 걸고 news 테이블을 기다리는 교착 상태가 발생했습니다.
  3. READ_COMMITTED 격리 수준: InnoDB의 READ_COMMITTED 격리 수준은 트랜잭션 시작 시점이 아닌 쿼리 실행 시점에 공유 잠금을 획득합니다. 이로 인해 여러 트랜잭션이 동일한 레코드에 접근하려 할 때 데드락이 발생할 확률이 높아졌습니다.

해결 과정: 단계별 최적화 전략

1단계: 문제 가시성 확보 (모니터링)

문제를 명확하게 파악하고 재발 방지책을 마련하기 위해 데드락 발생 시점을 추적하는 로깅 및 메트릭 시스템을 구축했습니다. Spring의 ApplicationEvent를 활용하여 DeadlockLoserDataAccessException이 발생할 때마다 알림을 받고, 데드락 발생 횟수를 Prometheus/Grafana 같은 모니터링 시스템으로 전송하도록 구성했습니다.

@Component
public class DeadlockMonitor {
    private static final Logger log = LoggerFactory.getLogger(DeadlockMonitor.class);
    
    // MeterRegistry는 Prometheus, Micrometer 등을 사용해 통합 메트릭을 관리
    private final MeterRegistry meterRegistry;
    
    public DeadlockMonitor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
    
    @EventListener
    public void handleDeadlockException(DeadlockLoserDataAccessException e) {
        log.error("Deadlock detected on thread {}: {}", Thread.currentThread().getName(), e.getMessage(), e);
        
        // 데드락 발생 횟수 메트릭 증가
        meterRegistry.counter("deadlock.count").increment();
    }
}

2단계: Spring Batch 재시도 및 오류 처리 전략 강화

데드락은 일시적인 문제일 수 있으므로, Spring Batch의 faultTolerant() 기능을 활용해 데드락 발생 시 자동으로 재시도하도록 설정했습니다. **retryLimit(3)**과 **ExponentialBackOffPolicy**를 통해 재시도 간격을 점진적으로 늘려 데드락 재발 가능성을 낮췄습니다. 또한, **skipLimit**을 설정해 데이터 무결성 오류(e.g., DataIntegrityViolationException)가 발생하더라도 작업이 중단되지 않고 건너뛰도록 구성했습니다.

@Configuration
public class OptimizedBatchConfig {
    
    @Bean
    public Step aiAnalysisStep() {
        return stepBuilderFactory.get("aiAnalysisStep")
            .<NewsJpaEntity, NewsJpaEntity>chunk(50) 
            // ... 생략
            .faultTolerant()
            .retryLimit(3)
            .retry(DeadlockLoserDataAccessException.class)
            .backOffPolicy(new ExponentialBackOffPolicy())
            .skipLimit(100)
            .skip(DataIntegrityViolationException.class)
            .build();
    }
}

3단계: 데이터베이스 수준의 최적화

데드락은 종종 불필요한 테이블 또는 레코드 잠금 때문에 발생합니다. 이를 해결하기 위해 SQL 쿼리를 배치 업데이트로 전환하고, 적절한 인덱스를 추가하여 잠금 범위를 최소화했습니다.

  • 배치 업데이트 쿼리: 기존의 개별 UPDATE 문을 IN (:ids) 절을 사용하는 단일 배치 업데이트 쿼리로 변경했습니다. 이는 DB에 대한 I/O 횟수를 획기적으로 줄여 불필요한 트랜잭션 경쟁을 줄이는 효과가 있습니다.

    UPDATE news
    SET ai_sentiment_type = :sentimentType,
    ai_sentiment_score = :sentimentScore,
    ai_overview = :overview,
    updated_at = NOW()
    WHERE id IN (:ids) AND ai_analysis_status = 'PENDING';
  • 인덱스 추가: WHERE 절에 사용되는 컬럼(ai_analysis_status, id)과 ORDER BY에 사용될 수 있는 컬럼(created_at)에 인덱스를 추가했습니다. 인덱스는 쿼리 성능을 높이고, UPDATE 쿼리가 전체 테이블을 스캔하는 것을 방지하여 잠금 범위를 최소화합니다.

    CREATE INDEX idx_news_analysis_status ON news(ai_analysis_status, created_at);

4단계: 트랜잭션 격리 수준 조정 (신중한 접근)

데드락을 방지하는 가장 직접적인 방법은 격리 수준을 조정하는 것입니다. 대부분의 경우 READ_COMMITTED는 충분하지만, 대량의 동시 쓰기 작업이 필요한 배치 작업의 특성을 고려해 격리 수준을 변경하는 방법을 고려했습니다.

  • 전역 설정: JpaTransactionManager의 기본 격리 수준을 READ_COMMITTED로 명시적으로 설정했습니다. 이는 불필요한 REPEATABLE_READ 격리 수준으로 인한 잠금 문제를 방지합니다.
  • 세부 조정: updateNewsAnalysis와 같은 특정 대량 업데이트 메서드에만 READ_UNCOMMITTED 격리 수준을 적용하여 잠금 경쟁을 최소화할 수 있습니다. 하지만, 이 접근 방식은 오염된 읽기(Dirty Read)를 허용하므로, 데이터 일관성이 매우 중요한 경우 신중하게 사용해야 합니다.
@Service
@Transactional(isolation = Isolation.READ_COMMITTED) // 기본 격리 수준
public class OptimizedNewsService {
    
    @Transactional(isolation = Isolation.READ_UNCOMMITTED) // 대량 업데이트 시에만 격리 수준 변경
    public void updateNewsAnalysis(List<NewsJpaEntity> newsList) {
        // ...
    }
}

마무리

결론적으로, Spring Batch에서 발생하는 데드락 문제는 단순히 트랜잭션 격리 수준을 변경하는 것만으로는 해결하기 어렵습니다. 모니터링, 재시도 전략, 배치 업데이트, 인덱스 최적화 등 애플리케이션과 데이터베이스 양쪽의 문제를 종합적으로 고려하는 접근 방식이 필수적입니다. 이 사례를 통해 복잡한 동시성 문제를 해결하기 위해서는 시스템 전반에 걸친 깊은 이해가 필요하다는 점을 다시 한번 확인할 수 있었습니다.

이 글이 유사한 문제를 겪는 분들에게 도움이 되기를 바랍니다. 해결 과정에서 더 좋은 아이디어가 있다면 공유해주세요.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글