Spring Batch 트랜잭션 매니저 충돌 트러블슈팅

Do Hyun ·2026년 7월 3일

트러블슈팅

목록 보기
5/5

Spring Batch + JPA + JdbcTemplate 혼용 환경에서 트랜잭션 매니저 충돌 트러블슈팅

개요

우편번호 마스터 동기화 배치(ZipcdSyncTasklet)를 신규 개발 후 Jenkins에서 실행했더니,
cmm_road_cd_d 테이블은 정상적으로 bulk upsert 되는데 cmm_road_addr_d는 계속 실패했다.

에러 로그만 보면 SQL 파라미터 바인딩 문제처럼 보였지만, 실제 원인은 트랜잭션 매니저 충돌이었다.


프로젝트 구성

트랜잭션 매니저 설정

이 프로젝트는 JPA와 JdbcTemplate을 혼용하며, 두 곳에서 트랜잭션 매니저를 설정한다.

1. DefaultDataSourceConfig — JPA 트랜잭션 매니저 (@Primary)

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = {"com.gsshop.greenhouse.crocus.domain"},
    entityManagerFactoryRef = "entityManagerFactory",
    transactionManagerRef = "transactionManager"
)
public class DefaultDataSourceConfig {

    @Primary
    @Bean(name = "dataSource")
    @ConfigurationProperties(prefix = "default.datasource")
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = "entityManagerFactory")
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(...) { ... }

    @Primary
    @Bean(name = "transactionManager")
    public PlatformTransactionManager transactionManager(
            @Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);  // ← JPA용 @Primary
    }
}

2. CrocusBatchApplication — 배치 인프라 설정

@EnableBatchProcessing
public class CrocusBatchApplication {

    // JPA 트랜잭션 매니저 (도메인과 동일하게 @Primary)
    @Primary
    @Bean(name = "transactionManager")
    public PlatformTransactionManager transactionManager(
            @Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    // 배치 인프라(JobRepository, JobLauncher 등)용 설정
    @Bean
    public BatchConfigurer batchConfigurer() {
        return new BatchConfigurer() {
            private PlatformTransactionManager transactionManager;
            // ...

            @PostConstruct
            public void initialize() {
                if (this.transactionManager == null) {
                    // JobRepository 등 배치 인프라는 ResourcelessTransactionManager 사용
                    this.transactionManager = new ResourcelessTransactionManager();
                }
                MapJobRepositoryFactoryBean jrf =
                    new MapJobRepositoryFactoryBean(this.transactionManager);
                // ...
            }
        };
    }
}

핵심 포인트: 배치 인프라(JobRepository)는 ResourcelessTransactionManager를 쓰고, 실제 도메인 트랜잭션은 JpaTransactionManager(@Primary)를 쓴다.

호출 구조

ZipcdSyncTasklet.execute()                     ← @Transactional 없음
  └─ AddrDailyUpdateService.addrDailySync()    ← @Transactional 없음, try-catch로 예외 삼킴
       └─ AddressToZipcdServiceImpl
               .processRoadAddr()              ← @Transactional (JpaTransactionManager)
            └─ CmmRoadAddrDBulkRepository
                    .batchUpsert()             ← @Transactional + JdbcTemplate

에러 로그

2026-07-03 12:54:55.474 ERROR
  ❌ batchUpdate 중 에러 발생!!
  org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback;
    nested exception is java.sql.SQLException: No value specified for parameter 13

2026-07-03 12:54:55.479 ERROR
  addrDailyUpdate Batch Exception: ... No value specified for parameter 13

2026-07-03 12:54:55.543 INFO
  ******************** BATCH END ********************   ← 배치는 정상 종료처럼 보임

2026-07-03 12:54:57.681 ERROR
  org.springframework.orm.jpa.JpaSystemException:
    Transaction was marked for rollback only; cannot commit

삽질 — 코드 버그에 낚임

에러 메시지를 보고 "파라미터 13 바인딩이 안 됐다"는 것에 집중했다.

git 히스토리를 보니 실제로 중간 커밋에서 파라미터 13~16이 주석 블록 안에 들어간 버그가 있었다:

// 47c5a7d 커밋 상태 (12:52 커밋, 12:54 Jenkins 실행)
ps.setString(12, item.getUseYn());

/*
    ps.setTimestamp(13, toTimestamp(item.getCreatedAt()));  // ← 주석 안에 갇힘
    ps.setString(14, item.getCreatedBy());
    ps.setTimestamp(15, toTimestamp(item.getModifiedAt()));
    ps.setString(16, item.getModifiedBy());
*/

이 버그는 5분 뒤 ed61ba9 커밋에서 수정됐다.
그런데 수정된 코드로 재배포해도 여전히 같은 에러가 반복됐다.


실제 원인 — 트랜잭션 매니저 충돌

Step에 트랜잭션 매니저를 명시하지 않으면 어떻게 되나

StepBuilderFactory.transactionManager()를 지정하지 않으면,
TaskletStepStepBuilderFactory에 주입된 트랜잭션 매니저를 그대로 쓴다.

이 프로젝트에서는 BatchConfigurer가 배치 인프라용으로 ResourcelessTransactionManager를 쓰도록 설정되어 있지만, StepBuilderFactory는 Spring Context의 @Primary 빈인 JpaTransactionManager를 주입받는다.

즉 Step의 기본 트랜잭션 매니저 = JpaTransactionManager.

문제 흐름

[Batch Step] JpaTransactionManager tx 시작
  └─ addrDailySync() — @Transactional 없음, try-catch 있음
       └─ processRoadAddr() @Transactional (REQUIRED)
            └─ 이미 JPA tx가 있으므로 기존 tx에 참여
            └─ batchUpsert() @Transactional (REQUIRED)
                 └─ 이미 JPA tx에 참여
                 └─ JdbcTemplate 실행 중 SQLException 발생
                    → Spring @Transactional AOP: tx를 rollback-only 마킹
                    → 예외 rethrow

       ← processRoadAddr() @Transactional AOP: RuntimeException
            → REQUIRED 참여자이므로 직접 rollback 불가
            → rollback-only 마킹 유지, 예외 rethrow

  ← addrDailySync() try-catch에서 예외를 잡아버림 (rethrow 안 함!)
       → systemLogService.errorWithNewTransaction() 호출 (REQUIRES_NEW로 별도 tx 커밋)
       → 정상 return

[Batch Step] TaskletStep이 JPA tx 커밋 시도
  → tx는 이미 rollback-only 상태
  → "Transaction was marked for rollback only; cannot commit" 💥

핵심: addrDailySync()가 예외를 잡고 rethrow하지 않아서 Step은 "성공한 것처럼" 흘러갔고, 마지막에 Step의 JPA 트랜잭션이 커밋을 시도하다 터졌다.

cmm_road_cd_d는 왜 됐나

// processRoadCd — @Transactional 없음
public List<CmmRoadCdDDTO> processRoadCd(String toDate) throws Exception {
    // ...
    for (List<CmmRoadCdD> chunk : Lists.partition(cmmRoadCdDList, BULK_CHUNK_SIZE)) {
        cmmRoadCdDBulkRepository.batchUpsert(chunk);  // 청크별 독립 tx
    }
}

processRoadCd()에는 @Transactional이 없다.
cmmRoadCdDBulkRepository.batchUpsert()@Transactional(REQUIRED)은 진행 중인 JPA tx가 없으므로 자기 자신이 tx를 열고 닫는다. 청크별로 독립적인 트랜잭션이 생성·완료되기 때문에 문제가 없었다.

반면 processRoadAddr()에는 @Transactional이 붙어 있어서, 내부의 batchUpsert()외부 Step tx에 참여하는 구조가 됐고 충돌이 발생했다.


해결 — Step에 트랜잭션 매니저 명시

// CrocusBatchConfig.java

@Autowired
private PlatformTransactionManager transactionManager; // JPA용 @Primary

// Before — 트랜잭션 매니저 미지정 (StepBuilderFactory 기본값 사용)
@Bean
public Step zipcdSyncTaskletStep() {
    return stepBuilderFactory.get("zipcdSyncTaskletStep")
            .tasklet(zipcdSyncTasklet)
            .build();
}

// After — JPA 트랜잭션 매니저 명시
@Bean
public Step zipcdSyncTaskletStep(
        @Qualifier("transactionManager") PlatformTransactionManager txManager) {
    return stepBuilderFactory.get("zipcdSyncTaskletStep")
            .transactionManager(txManager)   // ← 명시
            .tasklet(zipcdSyncTasklet)
            .build();
}

@Bean
public Job zipcdSyncTaskletJob() {
    return jobBuilderFactory.get("zipcdSyncTaskletJob")
            .start(zipcdSyncTaskletStep(transactionManager))
            .build();
}

.transactionManager(txManager)를 명시하면 Step이 어떤 트랜잭션 매니저를 쓰는지 명확해지고, 예외 발생 시 Step 레벨에서 롤백 처리가 의도대로 동작한다.


트랜잭션 매니저 설정 방법 정리

Spring Batch에서 트랜잭션 매니저를 제어하는 방법은 세 가지다.

1. Step 빌드 시 직접 지정 (권장)

@Bean
public Step myStep(PlatformTransactionManager txManager) {
    return stepBuilderFactory.get("myStep")
            .transactionManager(txManager)   // ← 명시적 지정
            .tasklet(myTasklet)
            .build();
}

Step마다 어떤 트랜잭션 매니저를 쓸지 명확하게 제어할 수 있다.

2. BatchConfigurer 커스터마이징

@Bean
public BatchConfigurer batchConfigurer(PlatformTransactionManager txManager) {
    return new DefaultBatchConfigurer(dataSource) {
        @Override
        public PlatformTransactionManager getTransactionManager() {
            return txManager;   // ← 모든 Step의 기본값이 됨
        }
    };
}

모든 Step에 동일한 트랜잭션 매니저를 적용하고 싶을 때 쓴다.

3. @EnableBatchProcessing 기본 동작 의존 (비권장)

아무것도 안 하면 @Primary PlatformTransactionManager 빈이 주입된다.
어떤 매니저가 쓰이는지 명시적이지 않아서 이번처럼 의도치 않은 충돌이 발생할 수 있다.


교훈

  • Spring Batch Tasklet Step을 만들 때는 .transactionManager()를 항상 명시하자.
  • addrDailySync()가 try-catch로 예외를 삼키는 구조가 문제를 더 복잡하게 만들었다. 예외를 삼킬 때는 트랜잭션 상태가 어떻게 되는지 반드시 고려해야 한다.
  • 에러 로그에 명백한 코드 버그(파라미터 바인딩)가 보이면 거기에만 집중하게 된다. 트랜잭션 에러가 함께 찍히면 두 에러의 인과관계를 먼저 파악하는 게 중요하다.
  • 마이그레이션 때도 같은 이슈로 고생했는데 또 당했다. 이번엔 기록으로 남긴다.

관련 구성 요약

구성 요소트랜잭션 매니저비고
Spring Batch 인프라 (JobRepository)ResourcelessTransactionManagerBatchConfigurer 설정
StepBuilderFactory 기본값JpaTransactionManager@Primary 빈 주입
zipcdSyncTaskletStep (수정 후)JpaTransactionManager.transactionManager() 명시
@Transactional (Service)JpaTransactionManager@Primary, transactionManagerRef
JdbcTemplateJpaTransactionManager tx에 참여동일 DataSource 사용
profile
우당탕탕

0개의 댓글