Spring/문제 해결 사례

Spring Batch는 왜 내 MariaDB에 데드락을 뿌렸나 — SERIALIZABLE 기본값과 동시 실행의 함정

Chipmunks 2026. 4. 26.
728x90

TL;DR

  • Spring Batch의 JobRepository는 기본적으로 SERIALIZABLE 격리 수준으로 메타 테이블에 접근한다
  • 여러 Job을 동시에 실행하면 BATCH_JOB_INSTANCE 등 메타 테이블과 서비스 테이블에서 갭락 경합이 발생해 데드락이 생긴다
  • 메타 DataSource와 비즈니스 DataSource를 같이 쓰면, SERIALIZABLE이 비즈니스 트랜잭션까지 새어들어 대량 delete→insert 패턴에서 두 번째 데드락이 터진다
  • 해결의 핵심은 세 가지: JobRepository isolation 낮추기, DataSource 분리, delete→insert 패턴 재설계

대상 독자

  • Spring Batch + 스케줄러(Quartz, Spring Scheduler, Jobrunr, k8s CronJob 등)로 배치를 운영 중인 백엔드 개발자
  • 동일 시간대에 여러 Job을 병렬 실행하고 있거나 계획 중인 팀
  • MariaDB/MySQL 환경에서 원인 불명의 메타 테이블 데드락을 겪어본 개발자
  • jOOQ, MyBatis 등으로 대량 delete→insert 패턴의 배치를 작성한 경험이 있는 개발자

인트로

특정 시각에 배치 10개가 동시에 뜨던 어느 날, 메타 테이블과 상품 상태 테이블이 동시에 데드락을 뱉기 시작했다.

로그에는 낯선 메시지들이 쌓였다.

Deadlock found when trying to get lock; try restarting transaction
 

처음엔 단순 재시도로 넘어갔다. 그런데 이 메시지가 멈추지 않았다. 메타 테이블(BATCH_JOB_INSTANCE)에서도, 상품 상태를 갱신하는 비즈니스 테이블에서도 동시에.

구조는 나름 깔끔했다. 스케줄 ID마다 독립된 도메인을 담당하는 배치 Job이 있고, 각 Job은 자기 ID 범위의 데이터만 건드렸다. 도메인이 겹치지 않으니 당연히 충돌도 없을 것이라 생각했다.

그런데 왜 서로를 죽이고 있었을까?

이 글은 그 질문의 답을 찾아가는 과정이다. 원인은 Spring Batch의 기본값 한 줄, MariaDB의 갭락 동작 방식, 그리고 DataSource 하나를 공유하는 작은 설계 결정 안에 모두 숨어 있었다.


1장. 병렬 실행이라는 합리적인 선택

배치 설계에서 가장 먼저 마주치는 질문이 있다.

 

"하나의 Job으로 순차 처리할 것인가, 여러 Job으로 나눠 병렬 처리할 것인가?"

 

순차 처리는 단순하다.

Job 하나가 모든 스케줄 ID를 순서대로 돌린다.

코드가 적고 실행 흐름이 직관적이다.

하지만 스케줄 ID가 늘어날수록 전체 처리 시간이 선형으로 늘어나고, 하나의 ID에서 오류가 나면 이후 ID 전체가 밀린다.

 

병렬 처리는 다르다.

각 스케줄 ID가 독립된 Job으로 실행되면, 장애가 해당 Job 안에서 격리된다.

처리 시간도 ID 수에 관계없이 가장 오래 걸리는 단일 Job 수준으로 수렴한다.

도메인이 ID 단위로 완전히 독립적이라면, 병렬 실행은 합리적인 선택처럼 보인다.

 

실제로도 그렇다.

 

문제는 도메인이 독립적이라고 해서 인프라까지 독립적이지는 않다는 데 있다.

모든 Job은 같은 데이터베이스의 Spring Batch 메타 테이블에 접근한다.

같은 커넥션 풀을 쓴다.

그리고 많은 경우, 같은 DataSource 빈을 공유한다.

병렬 실행은 이 공유 지점에서 예상치 못한 방식으로 충돌하기 시작한다.


2장. 첫 번째 데드락 — 메타 테이블

Spring Batch가 SERIALIZABLE을 기본값으로 쓰는 이유

Spring Batch는 Job 실행 정보를 BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION 같은 메타 테이블에 기록한다.

이 기록은 JobRepository가 담당하며, 해당 트랜잭션의 기본 격리 수준은 ISOLATION_SERIALIZABLE이다.

이유는 명확하다.

 

같은 JobParameters로 동일한 Job이 중복 실행되는 것을 막기 위해서다.

JobInstance 유일성을 DB 레벨에서 보장하려면, "이미 존재하는지 조회 → 없으면 삽입"이라는 흐름이 다른 트랜잭션의 간섭 없이 원자적으로 수행되어야 한다. SERIALIZABLE은 그 보수적 기본값이다.

 

좁은 범위에서는 옳은 선택이다.

그런데 여러 Job이 동시에 이 메타 테이블에 접근하는 순간, 이야기가 달라진다.

 

갭락이 엉키는 구조

MariaDB(InnoDB)에서 SERIALIZABLE 격리 수준으로 범위 조회를 수행하면, 조회한 레코드뿐 아니라 그 범위 사이의 빈 공간(갭) 에도 락이 잡힌다.

이를 갭락(Gap Lock)이라 한다.

예를 들어 BATCH_JOB_INSTANCE에 job_name = 'scheduleJob'으로 조회하면, 현재 존재하는 레코드 주변의 인덱스 갭에 락이 걸린다.

이 상태에서 다른 Job이 같은 테이블에 새 레코드를 삽입하려 하면 갭락에 막힌다.

 

Job이 2개, 3개, 10개가 동시에 뜨면 이 충돌은 기하급수적으로 늘어난다.

각 Job이 서로의 갭락을 기다리는 순환 대기가 형성되고,

MariaDB는 그 중 하나를 희생자(victim)로 선택해 트랜잭션을 강제 종료한다.

-- SHOW ENGINE INNODB STATUS 로그 중
TRANSACTION 1234, ACTIVE 0 sec starting index read
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
------- TRX HAS BEEN WAITING 0 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 87 page no 4 n bits 72 index PRIMARY
of table `batch_meta`.`BATCH_JOB_INSTANCE`
-- gap before rec insert intention waiting
 

gap before rec insert intention waiting — 이 한 줄이 갭락 충돌의 신호다.


3장. 두 번째 데드락 — 비즈니스 테이블

메타 테이블 데드락은 그나마 원인이 명확했다.

당혹스러웠던 건 비즈니스 테이블에서도 데드락이 났다는 점이다.

각 Job은 자기 스케줄 ID 범위의 상품 상태 테이블만 건드렸다.

ID 범위가 겹치지 않으니 레코드 수준 충돌은 이론상 불가능하다.

그런데 왜?

 

같은 DataSource를 공유할 때 생기는 일

Spring Batch는 JobRepository용 트랜잭션에 SERIALIZABLE을 쓴다.

그런데 많은 프로젝트에서 JobRepository와 비즈니스 로직이 같은 DataSource 빈을 공유한다.

커넥션 풀(HikariCP)은 커넥션을 반환받을 때 트랜잭션 격리 수준을 기본값으로 리셋하는 것이 정상이지만, 이 리셋이 항상 보장되지는 않는다.

SERIALIZABLE로 설정된 커넥션이 풀에 반환된 뒤 비즈니스 Step에서 재사용되면,

그 Step의 트랜잭션도 의도치 않게 SERIALIZABLE로 동작할 수 있다.

확인 방법은 간단하다.

비즈니스 Step 트랜잭션 진입 시점에 현재 격리 수준을 찍어보는 것이다.

 
// Step 내부에서 현재 isolation level 확인 (디버깅용)
jdbcTemplate.queryForObject("SELECT @@transaction_isolation", String.class);
// 기대: READ-COMMITTED / 실제: SERIALIZABLE 이 나오면 누수 확정

delete→insert 패턴이 치명적인 이유

비즈니스 Step은 jOOQ로 대략 이런 패턴을 수행하고 있었다.

// Step 내부 (단순화)
dslContext.deleteFrom(SCHEDULE_PRODUCT)
          .where(SCHEDULE_PRODUCT.SCHEDULE_ID.eq(scheduleId))
          .execute();

dslContext.insertInto(SCHEDULE_PRODUCT)
          .values(...)
          .execute();
 

 

이 패턴 자체는 흔하다.

문제는 이게 SERIALIZABLE 격리 수준에서 실행될 때다.

DELETE는 삭제 범위에 해당하는 인덱스 갭 전체에 락을 잡는다.

다른 Job이 다른 scheduleId로 같은 테이블에 INSERT를 시도하면, 갭락 범위가 겹치는 순간 대기가 걸린다.

ID 범위가 달라도 인덱스 구조상 갭이 겹칠 수 있다.

여기서 순환 대기가 만들어지면 두 번째 데드락이 터진다.


4장. MariaDB 갭락이 이 패턴에 치명적인 이유

갭락이란

InnoDB는 인덱스 레코드 사이의 "빈 공간"에도 락을 걸 수 있다.

이를 갭락이라 한다.

갭락은 팬텀 리드를 방지하기 위한 장치로, REPEATABLE READ 이상에서 범위 조건 조회/삭제 시 자동으로 걸린다.

 

예를 들어 schedule_id에 인덱스가 있고 현재 값이 [10, 20, 30]이라면:

 

DELETE WHERE schedule_id = 15 -- (10, 20) 갭에 갭락
INSERT schedule_id = 17       -- 같은 갭에 진입 시도 → 대기

 

15라는 레코드가 없어도 갭락은 (10, 20) 구간 전체를 잠근다.

 

데드락 로그 읽는 법

데드락이 발생하면 MariaDB 에러 로그 또는 아래 명령으로 마지막 데드락 정보를 볼 수 있다.

SHOW ENGINE INNODB STATUS;
 

출력에서 LATEST DETECTED DEADLOCK 섹션을 찾는다.

각 트랜잭션이 어떤 락을 보유하고(HOLDS) 어떤 락을 기다리는지(WAITING FOR) 가 나온다.

gap before rec 키워드가 보이면 갭락 충돌이다.


5장. 해결의 세 축

1. JobRepository isolation 낮추기

Spring Batch가 SERIALIZABLE을 쓰는 목적은 JobInstance 유일성 보장이다.

하지만 실제로는 JobParameters를 Job별로 유일하게 설계하면 DB 락 없이도 중복 실행을 제어할 수 있다.

따라서 메타 트랜잭션의 격리 수준을 READ_COMMITTED로 낮춰도 실용적으로 문제가 없는 경우가 많다.

 

@Bean
public JobRepository jobRepository(DataSource dataSource,
                                   PlatformTransactionManager transactionManager) throws Exception {
    JobRepositoryFactoryBean factory = new JobRepositoryFactoryBean();
    factory.setDataSource(dataSource);
    factory.setTransactionManager(transactionManager);
    factory.setIsolationLevelForCreate("ISOLATION_READ_COMMITTED"); // 핵심
    factory.afterPropertiesSet();
    return factory.getObject();
}

 

이것만으로도 메타 테이블 데드락은 대부분 사라진다.

단, 동시에 실행되는 Job 수를 적절히 제한하는 것(세마포어, ReentrantLock 같은 CAS 기반 Lock, 분산 ShedLock 등)을 함께 적용하는 것이 좋다.

 

2. 메타 DataSource와 비즈니스 DataSource 분리

근본적 해결은 두 DataSource를 완전히 분리하는 것이다.

격리 수준 누수가 물리적으로 불가능해진다.

@Configuration
public class BatchDataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.batch")
    public DataSource batchDataSource() {
        return DataSourceBuilder.create().build(); // 메타 전용
    }

    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.business")
    public DataSource businessDataSource() {
        return DataSourceBuilder.create().build(); // 비즈니스 전용
    }
}
 
 
spring:
  datasource:
    batch:
      url: jdbc:mariadb://host/batch_meta
      hikari:
        maximum-pool-size: 10
    business:
      url: jdbc:mariadb://host/business_db
      hikari:
        maximum-pool-size: 30
 

JobRepository에는 batchDataSource를, jOOQ DSLContext에는 businessDataSource를 주입한다. 이후로는 두 트랜잭션이 물리적으로 다른 커넥션 풀을 사용하므로 격리 수준이 교차할 방법이 없다.

 

3. delete→insert 패턴 재설계

DataSource를 분리하더라도 대량 delete→insert 자체가 갭락을 유발한다는 사실은 변하지 않는다.

세 가지 방향으로 재설계를 고려할 수 있다.

 

UPSERT로 전환

삭제 없이 기존 레코드를 갱신하면 갭락 범위가 대폭 줄어든다.

 
// jOOQ UPSERT (MariaDB ON DUPLICATE KEY UPDATE)
dslContext.insertInto(PRODUCT_STATUS)
          .set(record)
          .onDuplicateKeyUpdate()
          .set(PRODUCT_STATUS.STATUS, newStatus)
          .execute();
 

append-only + 최신 레코드 뷰

상태를 덮어쓰는 대신 이력을 append하고, 조회 시 최신 레코드만 필터링하는 방식이다. 삭제가 없으므로 갭락이 발생하지 않는다. 이력 데이터도 자연스럽게 보존된다.

파티션 스왑

처리 결과를 임시 테이블에 완전히 채운 뒤, 파티션 또는 테이블 교체(RENAME TABLE)로 원자적으로 반영하는 방식이다. 락 경합이 가장 적지만 스키마 설계 변경이 필요하다.


6장. 보강 장치

구조를 바로잡았다면 추가 안전망도 함께 적용할 만하다.

 

시작 시각 Jitter: 정각에 모든 Job이 동시에 시작하면 리소스 경합이 집중된다. 각 Job 시작 시각에 수 초의 무작위 지연을 추가하는 것만으로도 충돌 확률이 크게 줄어든다.

 

ShedLock: 여러 인스턴스가 같은 Job을 중복 실행하지 않도록 분산 락을 제공한다. JobParameters 유일성과 함께 사용하면 중복 실행을 이중으로 방지할 수 있다.

 

커넥션 풀 사이즈: 병렬 실행 Job 수 × Step당 최대 커넥션 수를 기준으로 풀 사이즈를 산정한다. 풀이 부족하면 커넥션 획득 대기 중 타임아웃이 발생하고, 이 타임아웃이 데드락처럼 보이는 경우도 있다.

최소 풀 사이즈 = (동시 실행 Job 수) × (Job당 최대 동시 Step 수) × (Step당 커넥션 수) + 여유분(20~30%)
 

Spring Batch Partitioning: 단일 Step을 여러 파티션으로 쪼개 병렬 처리하는 공식 패턴이다. ID 범위를 파티션 단위로 명확히 나누면 락 범위도 명확해지고, 스레드 간 격리도 자연스럽게 확보된다.


결론

Spring Batch의 SERIALIZABLE 기본값은 잘못된 선택이 아니다.

JobInstance 유일성이라는 좁은 목적을 위한, 의도된 보수적 기본값이다.

 

문제는 이 기본값이 같은 DataSource를 타고 비즈니스 로직까지 흘러 들어갈 때 터진다.

그 위에서 대량 delete→insert를 돌리고, 정각마다 Job 10개가 동시에 실행되면, MariaDB 갭락이 남은 일을 처리한다.

 

배치 설계 체크리스트

  • JobRepository의 isolation 설정이 명시되어 있는가? (기본 SERIALIZABLE인지 확인)
  • 메타 DataSource와 비즈니스 DataSource가 분리되어 있는가?
  • 대량 delete→insert 패턴이 있다면 UPSERT나 append-only로 전환이 가능한가?
  • JobParameters가 Job 실행마다 유일하게 설계되어 있는가?
  • 동시 실행 Job의 시작 시각이 분산되어 있는가?
  • 커넥션 풀 사이즈가 동시 실행 수를 감당하도록 산정되어 있는가?

도메인이 독립적이라고 인프라까지 독립적인 것은 아니다.

병렬 배치의 진짜 경계선은 코드 패키지가 아니라 DataSource와 트랜잭션 설정 에 있다.

댓글