
- 문제 발생: 스프링 배치에서 알림톡 발송 중 API 호출 실패 시 전체
청크 트랜잭션이 롤백되어, 이미 발송된 알림톡이 재시도로 인해 중복 발송되는 장애 발생- 근본 원인:
청크 트랜잭션내에서 외부 I/O 작업(API 호출)을 처리하여 청크 트랜잭션과 API 호출 I/O가 분리되지 않았고,API 예외가 전체 배치로 전파되어 배치 실패 유발- 해결 방법:
@Transactional(propagation = Propagation.NOT_SUPPORTED)로 API 호출을청크 트랜잭션과 격리하고, 추가로 예외 처리(try-catch)를 통해예외 전파도 차단하여 개별 실패가 전체 배치에 영향주지 않도록 개선
운영중인 프로젝트에서 알림톡 발송 배치에서 장애가 발생했다.
슬랙에서는 해당 배치의 실패 로그가 찍히고 해당 배치를 세번이나 재시도했음에도 실패했다는 로그를 찍어대기 시작했다.
일단 조급한 마음에 재빨리 해당 배치의 실행 로그를 확인했다.

확인해보니 배치 처리 로직중 알림톡 발송여부를 확인하는 API 호출 로직에서 최대 Retry 횟수를 초과하여 발생한 장애 상황이었다.
알림톡이 제대로 발송되었는지 디비에서 알림톡 발송 로그를 확인해보았다.
다행히 해당 배치에 포함된 유저의 알림톡 발송 로그는 디비에 정상 발송 상태로 1건만 저장되어 있었다.

하지만, 알림톡 벤더사 콘솔을 확인해보니 해당 유저에게 동일한 알림톡을 여러번 보내고 말았다...😂

일단 빠르게 해당 유저분에게 양해와 사과의 말씀을 드리고 해당 장애 상황을 해결하기 위해 코드를 확인했다.
// 알림톡 발송 Batch Processor
@Bean(BEAN_PREFIX + "processor")
@StepScope
public ItemProcessor<NotificationEvent, AlimTalkResult> processor(@Autowired CreateDateJobParameter jobParameter) {
return alimTalkNotificationEvent -> {
// 요청 리퀘스트 데이터 파싱
AlimTalkFeignRequest alimTalkFeignRequest = AlimTalkSendRequestFactory.findAlimTalkTemplate(alimTalkTemplate, alimTalkParams);
// 알림톡 발송
AlimTalkLog alimTalkLog = alimTalkManager.sendAlimTalkAndCreateLog(alimTalkNotificationEvent.getToUserId(), alimTalkFeignRequest);
// 발송 상태에 따른 상태 변경
alimTalkNotificationEvent.updateStatus(NotificationStatus.DELIVERED);
return new AlimTalkResult(alimTalkLog, alimTalkNotificationEvent);
};
}
// AlimTalkManager 클래스 내부 알림톡 발송 메서드
public AlimTalkLog sendAlimTalkAndCreateLog(Long userId, AlimTalkFeignRequest request) {
return alimTalkManager.sendAlimTalkAndCreateLog(userId, request);
}
해당 코드는 문제가 생긴 원인 부분을 추상화하여 일부 발췌한 코드로 다음과 같은 작업을 한다.
- 디비에 저장된 알림톡 이벤트를 조회
- 해당 유저에게 예약 메시지를 발송
- 예약 이벤트의 상태를 발송 상태로 변경
- 발송완료된 알림톡 로그와 예약 이벤트를 디비에 저장
언뜻보기에 별 문제가 없어보이는 이 코드에서 뭐가 문제인걸까?
스프링 배치는 Tasklet 방식과 Chunk 기반 방식의 두가지 Step이 존재하는데, 배치 처리중 책임의 분할과 스프링 배치의 청크 기반 방식의 강점(청크 기반 관리 등)을 누리기 위해 청크 방식을 채택하였다.
하지만 Chunk 방식은 주의해야할 점이 있는데, Chunk 단위로 트랜잭션이 관리된다는 것이다.


사진의 콜스택의 빨간색 박스로 표현된 부분이 하나의 청크의 실제 실행 메서드 콜스택 구조이다.
doInTransaction(TransactionStatus TransactionStatus) ← CHUNK 시작
├── beforeChunk(ChunkContext context) ← Chunk 시작 리스너
├── execute(StepContribution, ChunkContext) ← ChunkOrientedTasklet 실행
│ ├── [ItemReader.read() 반복]
│ ├── [ItemProcessor.process() 반복]
│ └── [ItemWriter.write() 배치 처리]
├── incrementCommitCount() ← 커밋 카운트 증가
└── afterChunk(ChunkContext context) ← CHUNK 끝
다음의 코드를 통해 확인해보면 더욱 명확하다.


즉, 기본적으로 청크 단위로 트랜잭션이 관리되어 처리되는 구조를 띄고 있으며 청크 기반 트랜잭션 실행구조를 정리해보면 아래의 시퀀스 다이어그램과 같다.

스프링 배치 공식문서에 따르면 해당 내용을 간단하게 언급하고 있다.

처리가 시작될 때 트랜잭션이 시작됩니다. 또한, read에서 가 호출될 때마다 ItemReader카운터가 증가합니다. 10에 도달하면 집계된 항목 목록이 로 전달되고 ItemWriter트랜잭션이 커밋됩니다.
그렇다면 어째서, 해당 배치의 문제가 발생하는 걸까? 해답은 바로 해당 chunk 트랜잭션에서 처리하는 작업이 알림을 발송하는 I/O 작업이기 때문이다.
트랜잭션과 I/O를 분리해야하는 이유 해당 아티클에서도 언급했듯이, 트랜잭션과 I/O 작업은 같이 처리하게 되면 문제가 되며 분리하거나 별도의 관리 정책이 필요하다.
현재 문제는 알림톡 벤더사와 통신하는 API 호출 메서드에서 예외가 발생했고 해당 예외가 전체 배치로 전파되어 해당 배치가 종료된 상황이다. 배치가 실패처리로 종료되어 배치를 실행하는 스케쥴러가 해당 배치를 연달아 재실행하다가 이런 사달이 난 것이다. 🤦
- Chunk 내에서 일부 성공, 일부 실패 발생
- 청크 10개 중 9개는 알림톡 발송 성공
- 1개에서 API 호출 예외 발생
- Chunk 트랜잭션 롤백의 치명적 문제
- 예외 발생으로 전체 청크 트랜잭션 롤백
- 성공한 9개의 NotificationEvent 상태도 BEFORE_SEND로 되돌려짐
- 하지만 실제 알림톡은 이미 발송된 상태!
- 배치 재시도로 인한 중복 발송
- 스케줄러가 배치 실패로 인식하여 재시도
- BEFORE_SEND 상태로 되돌려진 이벤트들을 다시 처리
- 이미 발송된 유저들에게 또 다시 알림톡 발송
Processor 내에서 외부 API 호출 부분만 청크 트랜잭션에서 격리시켜, API 실패가 전체 청크 롤백에 영향을 주지 않도록 하는 방식이다.
간단한 예제 코드를 작성하면 다음과 같다.
@Bean(BEAN_PREFIX + "processor")
@StepScope
public ItemProcessor<NotificationEvent, AlimTalkResult> processor(@Autowired CreateDateJobParameter jobParameter) {
return alimTalkNotificationEvent -> {
AlimTalkFeignRequest alimTalkFeignRequest = AlimTalkSendRequestFactory.findAlimTalkTemplate(alimTalkTemplate, alimTalkParams);
// 트랜잭션 격리된 API 호출
AlimTalkLog alimTalkLog = sendAlimTalkWithTransactionIsolation(alimTalkNotificationEvent.getToUserId(), alimTalkFeignRequest);
// API 실제 결과에 따른 상태 설정
NotificationStatus status = alimTalkLog.isSuccess() ? NotificationStatus.DELIVERED : NotificationStatus.FAILED;
alimTalkNotificationEvent.updateStatus(status);
return new AlimTalkResult(alimTalkLog, alimTalkNotificationEvent);
};
}
// 다른 클래스
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public AlimTalkLog sendAlimTalkWithTransactionIsolation(Long userId, AlimTalkFeignRequest request) {
try {
return alimTalkManager.sendAlimTalkAndCreateLog(userId, request);
} catch (Exception e) {
// 실패 로그 생성 후 반환
return AlimTalkLog.createFailedLog(userId, request, e.getMessage());
}
}
이 방식은 명시적으로 @Transactional(propagation = Propagation.NOT_SUPPORTED) 을 사용하여 외부 API 호출부를 명시적으로 청크 트랜잭션과 격리시킨다.
장점으로는단점으로는Processor에서 트랜잭션을 제어하므로 트랜잭션의 복잡도가 증가청크 크기를 1로 설정하여 실패 영향도를 최소화하고, Skip 정책을 통해 예외 발생 시에도 배치 전체가 중단되지 않도록 하는 방식이다.
@Value("${chunkSize:1}") // 10 → 1로 변경
private int chunkSize;
@Bean("exampleStep")
@JobScope
public Step step() {
return stepBuilderFactory.get(BEAN_PREFIX + "step")
.<NotificationEvent, AlimTalkResult>chunk(chunkSize)
.reader(exampleReader())
.processor(exampleProcessor())
.writer(writer())
.faultTolerant()
.skip(Example.class) // 여기에 해당 배치 처리시 발생하는 에러중 Skip 하고 싶은 에러 추가
.skipLimit(100) // 적절한 skip 한계 설정
.listener(new SkipFailureLogger()) // 실패 건 로깅
.allowStartIfComplete(true)
.build();
}
// 실패 건 로깅 리스너
public class SkipFailureLogger implements SkipListener<NotificationEvent, AlimTalkResult> {
@Override
public void onSkipInProcess(NotificationEvent item, Throwable t) {
log.error("알림톡 발송 실패 - 사용자 ID: {}, 사유: {}", item.getToUserId(), t.getMessage());
// DB에 실패 로그 저장
saveFailureLog(item, t);
}
}
이 방식은 Spring Batch의 Skip 옵션을 사용하고 chunk의 사이즈를 1로 한정지어서 각각의 아이템의 실패 여부를 다른 아이템으로 전파되지 못하게 방지하는 방식이다.
장점단점으로는결과적으로 나는 위 해결책중 1번을 채택하였다.
2안이 비교적 간단하지만, 현재 알림 발송 배치를 운영중인 상황에서 처리량 유지하는것이 중요한 고려해야할 포인트라고 생각하였다. 또한, 해결책 2는 성능상으로도 느리지만 근본적으로 I/O 작업과 트랜잭션을 격리시키지 못한 방법이라고 생각했기 때문이다.
해당 기능을 만들면서
성능(처리량)과안정성두가지가 중요하다고 생각했다.
현재 구조에서 개선이 필요한 것은 다음의 두가지이다.
1. 알림 발송 청크의 트랜잭션 관리
2. 특정 청크 실패시 전체 배치가 실패 방지
완벽하게 성공할수는 없지만, 관리 가능한 실패를 만드는 알림 배치 구조를 만드는게 목표다.
해당 해결책을 작성하기전에 먼저 해당 원하는 요구사항을 구현하기 위한 테스트 코드를 작성했다.
테스트 시나리오는 다음과 같다.
@Test
void 모든_알림톡_발송이_성공하면_모든_스캐쥴_이벤트의_상태가_DELIVERED_된다() throws Exception {
// given
JobParameters jobParameters = new JobParametersBuilder()
.addString("createDate", "2025-05-28")
.toJobParameters();
// Mock 동작 설정
when(mockAlimTalkApiClientManager.sendAlimTalk(any(AlimTalkFeignRequest.class)))
.thenAnswer(invocation -> {
// 매번 호출될 때마다 새로운 객체 생성
AlimTalkResponseMsg successResponseMsg = new AlimTalkResponseMsg(
UUID.randomUUID().toString(), // 매번 새로운 UUID
"82",
"010-0000-0000",
"테스트 메시지",
null, null, null, null
);
return new SendFormMsgResponse(
"test-request-id-" + UUID.randomUUID(), // 이것도 매번 다르게
LocalDateTime.now(),
"202",
"success",
List.of(successResponseMsg)
);
});
// Mock 동작 설정
when(mockAlimTalkApiClientManager.getSentAlimTalkMsgStatus(any(String.class)))
.thenAnswer(invocation -> new GetFormMsgResponse(
"test-request-id-" + UUID.randomUUID(), // 이것도 매번 다르게
"test-message-id-" + UUID.randomUUID(), // 이것도 매번 다르게
LocalDateTime.now(),
LocalDateTime.now(),
"202",
"success",
"정상 발송",
"010-0000-0000",
"82"
));
List<NotificationEvent> processableEvents = getProcessableEvents();
log.info("배치 실행 전 처리 대상: {}개", processableEvents.size());
// when
JobExecution result = jobLauncher.run(noticeAlimTalkJob, jobParameters);
// then
assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);
List<UUID> processedEventsId = processableEvents.stream().map(NotificationEvent::getId).toList();
List<NotificationEvent> processedEvents = getProcessedEvents(processedEventsId, NotificationStatus.DELIVERED);
log.info("배치 실행 후 성공한 대상: {}개", processedEvents.size());
assertThat(processedEvents.size()).isEqualTo(processableEvents.size());
}
}
테스트를 위해 API 호출 메서드 부분은 @MockBean 설정을 통해 Mocking 성공 처리한다.
@Test
void 일부_API_호출_실패시_해당_아이템만_FAILED_되고_나머지는_DELIVERED가_된다() throws Exception {
// Given: 특정 조건에서만 API 실패하도록 Mock 설정
JobParameters jobParameters = new JobParametersBuilder()
.addString("createDate", "2025-05-29")
.toJobParameters();
// Mock 동작 설정
when(mockAlimTalkApiClientManager.sendAlimTalk(any(AlimTalkFeignRequest.class)))
.thenAnswer(invocation -> {
if(apiCallCount %3 == 1){
apiCallCount++;
SendFormMsgResponse failedErrorResponse = createFailedErrorResponse();
throw new AlimTalkUnHandleException(
ErrorCode.ALIMTALK_UNHANDLE_ERROR,
"에측 불가능한 에러 발생",
failedErrorResponse
);
}
if(apiCallCount %3 == 2){
apiCallCount++;
SendFormMsgResponse failedErrorResponse = createFailedErrorResponse();
throw new AlimTalkApiRequestException(
ErrorCode.ALIMTALK_API_REQUEST_ERROR,
"API 호출 에러 발생",
failedErrorResponse
);
}
// 매번 호출될 때마다 새로운 객체 생성
AlimTalkResponseMsg successResponseMsg = new AlimTalkResponseMsg(
UUID.randomUUID().toString(), // 매번 새로운 UUID
"82",
"010-0000-0000",
"테스트 메시지",
null, null, null, null
);
apiCallCount++;
return new SendFormMsgResponse(
"test-request-id-" + UUID.randomUUID(), // 이것도 매번 다르게
LocalDateTime.now(),
"202",
"success",
List.of(successResponseMsg)
);
});
// Mock 동작 설정
when(mockAlimTalkApiClientManager.getSentAlimTalkMsgStatus(any(String.class)))
.thenAnswer(invocation -> new GetFormMsgResponse(
"test-request-id-" + UUID.randomUUID(), // 이것도 매번 다르게
"test-message-id-" + UUID.randomUUID(), // 이것도 매번 다르게
LocalDateTime.now(),
LocalDateTime.now(),
"202",
"success",
"정상 발송",
"010-0000-0000",
"82"
));
List<NotificationEvent> processableEvents = getProcessableEvents();
log.info("배치 실행 전 처리 대상: {}개", processableEvents.size());
// When: 배치 실행
JobExecution result = jobLauncher.run(noticeAlimTalkJob, jobParameters);
// Then:
// - 배치 상태 = COMPLETED (전체는 성공)
// - 실패 조건 아이템 = FAILED
// - 나머지 아이템 = DELIVERED
assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);
List<UUID> processedEventsId = processableEvents.stream().map(NotificationEvent::getId).toList();
List<NotificationEvent> successProcessedEvents = getProcessedEvents(processedEventsId, NotificationStatus.DELIVERED);
List<NotificationEvent> failedProcessedEvents = getProcessedEvents(processedEventsId, NotificationStatus.FAILED);
List<NotificationEvent> needRetryProcessedEvents = getProcessedEvents(processedEventsId, NotificationStatus.RETRY_NEEDED);
log.info("배치 실행 후 성공한 대상: {}개", successProcessedEvents.size());
log.info("배치 실행 후 실패한 대상: {}개", failedProcessedEvents.size());
log.info("배치 실행 후 리트라이 필요한 대상: {}개", needRetryProcessedEvents.size());
int totalCount = processableEvents.size();
int quotient = totalCount / 3;
int remainder = totalCount % 3;
int expectedSuccessCount = quotient + (remainder >= 1 ? 1 : 0);
int expectedFailedCount = quotient + (remainder >= 2 ? 1 : 0);
int expectedRetryCount = quotient;
assertThat(successProcessedEvents.size()).isEqualTo(expectedSuccessCount);
assertThat(failedProcessedEvents.size()).isEqualTo(expectedFailedCount);
assertThat(needRetryProcessedEvents.size()).isEqualTo(expectedRetryCount);
}
apiCallCount 변수를 전역적으로 사용하여 apiCallCount의 값에 따라 성공/실패 여부를 다르게 처리하여 예외가 발생하도록 작성하였다.
해당 테스트를 위해 사용한 에러의 종류는 두가지로 설정하였다.
AlimTalkUnHandleException
: 예측 불가능한 에러 - ex) API 응답 파싱 에러, 네트워크 에러 등AlimTalkApiRequestException
: 알림톡 발송 응답 서버의 에러 - ex) 외부 서버가 500 응답을 뱉는 경우 등
/**
* 트랜잭션을 격리한 알림톡 발송 Batch
*/
@Bean(BEAN_PREFIX + "processor")
@StepScope
public ItemProcessor<NotificationEvent, AlimTalkResult> processor(@Autowired CreateDateJobParameter jobParameter) {
return alimTalkNotificationEvent -> {
// 알림 발송
AlimTalkLog alimTalkLog = alimTalkManager.sendAlimTalk();
// 상태 업데이트
alimTalkNotificationEvent.updateStatus(NotificationStatus.DELIVERED);
return new AlimTalkResult(alimTalkLog, alimTalkNotificationEvent);
};
}
@Component
@RequiredArgsConstructor
@Slf4j
public class AlimTalkManager {
private final AlimTalkApiClientManager alimTalkApiClientManager;
/**
* [알림톡 발송 메서드]
* - 알림톡 발송 api 호출
* - 트랜잭션 격리 수준 Propagation.NOT_SUPPORTED 으로 설정하여 외부 트랜잭션과 격리
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public AlimTalkLog sendAlimTalk(UUID userId,
AlimTalkFeignRequest alimTalkFeignRequest,
NotificationEvent alimTalkNotificationEvent) {
// 알림톡 발송
SendFormMsgResponse sendFormMsgResponse = alimTalkApiClientManager.sendAlimTalk(alimTalkFeignRequest);
// 알림톡 발송 api 호출 결과 리턴
return createRequestLog(sendFormMsgResponse);
}
}
@Transactional(propagation = Propagation.NOT_SUPPORTED) 옵션을 사용하여 알림 발송 메서드를 청크 트랜잭션과 격리시켰다.
해당 옵션은 트랜잭션을 실행하지 않고, 현재 트랜잭션이 있는 경우 이를 일시 중단후 NOT_SUPPORTED 옵션의 메서드가 종료되면 이전 트랜잭션이 다시 실행된다.
트랜잭션이 제대로 격리되는지 TransactionSynchronizationManager.isActualTransactionActive(); 메서드를 통해 각 메서드에서 로그를 찍고,
@Aspect
@Component
@Profile("dev")
public class TransactionMonitor {
private static final Logger log = LoggerFactory.getLogger(TransactionMonitor.class);
@Around("@annotation(org.springframework.transaction.annotation.Transactional)")
public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("Transaction started for method: " + joinPoint.getSignature().getName());
Object result = joinPoint.proceed();
log.info("Transaction ended for method: " + joinPoint.getSignature().getName());
return result;
}
}
해당 AOP 클래스를 통해 트랜잭션이 어떻게 실행되고 종료되는지를 로그를 통해 확인해봤다.

해당 설정을 통해 Processor 내부에서는 트랜잭션이 활성화 되어있지만, API 호출 메서드 내부에서는 트랜잭션이 비활성화 되어있는것을 확인해 볼 수 있다.
그러면 위에서 작성한 시나리오대로 잘 작동하는지 테스트를 통해 확인해보자

해피케이스는 별다른 문제없이 잘 실행된다.

예상과는 달리, 외부 API 호출부에서 발생한 예외가 외부 청크 트랜잭션까지 전파되어 테스트가 실패처리가 된다.
여기서 한번 트랜잭션의 격리의 의미에 대해 다시한번 되짚어 보도록 하자.
트랜잭션이 격리된다는 것은 스프링 트랜잭션에서 각각의 트랜잭션의 커밋과 롤백이 각각 독립적으로 실행되는 것을 의미한다.
하지만, @Transactional 옵션을 통해 명시적 트랜잭션의 범위를 지정한다고 두개의 트랜잭션이 완벽하게 격리된 게 맞을까?
여기서 한가지 간과한 것이 있다. 명시적인 트랜잭션의 범위가 격리되더라도 Exception(예외)는 전파된다는 점이다.
현재 코드의 문제점은 트랜잭션의 범위가 분리되었지만 내부 메서드 예외를 throw 해 외부 메서드로 전파되고 있다는 점이다. 해당 부분은 위의 엣지케이스 테스트 실패 로그를 통해 확인해볼 수 있다.


외부 API 발송 메서드에서 발생한 Exception이 Processor 로 전파되어 해당 메서드 내부에서 롤백이 일어나 전체 Batch가 실패되는 현상이 발생한 것이다.
이를 이해하기 위해 아래의 간단한 예제를 보자
@Transactional // 외부 트랜잭션
public void outerMethod() {
insertUser("user1"); // DB 작업 1
innerMethod(); // 내부 메서드 호출
insertUser("user2"); // DB 작업 2
// 여기서 커밋
}
// 다른 클래스의 메서드 (같은 클래스 메서드에서는 AOP가 적용되지않음)
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새로운 트랜잭션
public void innerMethod() {
insertUser("user3"); // DB 작업 3
throw new RuntimeException("실패!"); // 예외 발생
}

@Transactional // 외부 트랜잭션
public void outerMethod() {
insertUser("user1"); // DB 작업 1
try {
innerMethod(); // 내부 메서드 호출
} catch (Exception e) {
log.error("내부 메서드 실패, 하지만 계속 진행");
}
insertUser("user2"); // DB 작업 2
// 여기서 커밋
}
// 다른 클래스의 메서드 (같은 클래스 메서드에서는 AOP가 적용되지않음)
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새로운 트랜잭션
public void innerMethod() {
insertUser("user3"); // DB 작업 3
throw new RuntimeException("실패!"); // 예외 발생
}

핵심 포인트:
‼️ 즉, 트랜잭션 격리 처리 뿐만 아니라 예외 격리도 추가로 처리해줘야 진정한 트랜잭션 격리가 실현되는 것이다.
(REQUIRES_NEW에 대한 오해와 주의할 점 - 해당 내용에 더 자세히 나와있는 블로그 참조)
/**
* 알림톡 발송 실패시 예외가 외부 메서드로 전파되지 않도록 예외 처리 추가
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public AlimTalkLog sendAlimTalk(UUID userId,
AlimTalkFeignRequest alimTalkFeignRequest,
NotificationEvent alimTalkNotificationEvent) {
try {
SendFormMsgResponse sendFormMsgResponse = alimTalkApiClientManager.sendAlimTalk(alimTalkFeignRequest); // 알림톡 발송
return createRequestLog(userId, alimTalkFeignRequest.getTemplateCode(), sendFormMsgResponse); // 알림톡 발송 api 호출 결과 엔티티 생성
} catch (ExampleException exampleException){
// 예외가 외부 메서드로 전파되지 않도록 핸들링 로직 처리
return createRequestLogIfThrowExampleExcetion();
}
// ... 여기에 추가로 필요한 예외 핸들링
}
위와 같이 외부 API 발송 메서드에서 발생할 수 있는 예외를 따로 잡아서 핸들링하고 상황에 맞는 결과를 리턴하도록 코드를 변경하였다.

개선한 코드의 실행 흐름을 정리하면 다음과 같다. 외부 API 호출부에서 발생하는 예외도 try-catch 로 격리해서 완전히 청크 트랜잭션과 격리에 성공한다.


해당 내용을 적용하고 실행하니 목표한 시나리오 테스트에 성공했고 운영환경에서도 문제없이 계속 제대로 동작하는 것을 확인했다! 😄
이번 장애를 해결하는 과정을 통해 스프링 배치의 Chunk 트랜잭션 구조와 트랜잭션이 맞물려있을때 트랜잭션을 격리시키는 방법 에 대해 배웠습니다.
해결하는 과정에서 모르는 내용도 많았고 해결하는데 오래걸리긴 했지만 생각보다 많은 내용을 배워서 알찬 경험이었습니다. 이번 경험을 기회로 트랜잭션 전파 속성에 대해 좀 더 깊게 공부해 봐야겠습니다.