우편번호 마스터 동기화 배치(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 커밋에서 수정됐다.
그런데 수정된 코드로 재배포해도 여전히 같은 에러가 반복됐다.
StepBuilderFactory에 .transactionManager()를 지정하지 않으면,
TaskletStep은 StepBuilderFactory에 주입된 트랜잭션 매니저를 그대로 쓴다.
이 프로젝트에서는 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 트랜잭션이 커밋을 시도하다 터졌다.
// 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에 참여하는 구조가 됐고 충돌이 발생했다.
// 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에서 트랜잭션 매니저를 제어하는 방법은 세 가지다.
@Bean
public Step myStep(PlatformTransactionManager txManager) {
return stepBuilderFactory.get("myStep")
.transactionManager(txManager) // ← 명시적 지정
.tasklet(myTasklet)
.build();
}
Step마다 어떤 트랜잭션 매니저를 쓸지 명확하게 제어할 수 있다.
@Bean
public BatchConfigurer batchConfigurer(PlatformTransactionManager txManager) {
return new DefaultBatchConfigurer(dataSource) {
@Override
public PlatformTransactionManager getTransactionManager() {
return txManager; // ← 모든 Step의 기본값이 됨
}
};
}
모든 Step에 동일한 트랜잭션 매니저를 적용하고 싶을 때 쓴다.
아무것도 안 하면 @Primary PlatformTransactionManager 빈이 주입된다.
어떤 매니저가 쓰이는지 명시적이지 않아서 이번처럼 의도치 않은 충돌이 발생할 수 있다.
.transactionManager()를 항상 명시하자.addrDailySync()가 try-catch로 예외를 삼키는 구조가 문제를 더 복잡하게 만들었다. 예외를 삼킬 때는 트랜잭션 상태가 어떻게 되는지 반드시 고려해야 한다.| 구성 요소 | 트랜잭션 매니저 | 비고 |
|---|---|---|
| Spring Batch 인프라 (JobRepository) | ResourcelessTransactionManager | BatchConfigurer 설정 |
StepBuilderFactory 기본값 | JpaTransactionManager | @Primary 빈 주입 |
zipcdSyncTaskletStep (수정 후) | JpaTransactionManager | .transactionManager() 명시 |
@Transactional (Service) | JpaTransactionManager | @Primary, transactionManagerRef |
JdbcTemplate | JpaTransactionManager tx에 참여 | 동일 DataSource 사용 |