티스토리 뷰
안녕하세요. 이번 글에서는 Spring Boot 환경에서 리스트 형태의 데이터를 처리할 때, 실패한 요소만 롤백하고 나머지는 정상적으로 커밋하는 방법에 대해 알아보겠습니다. 트랜잭션의 기본 원칙과 실무에서 필요한 부분 롤백 전략을 단계별로 설명드리겠습니다.
도입 & 배경
대용량 데이터를 일괄 처리하는 배치 작업으로 API 서버에서 리스트 형태의 데이터를 처리할 때, 모든 요소를 하나의 트랜잭션으로 묶으면 하나의 요소에서 예외가 발생했을 때 전체 작업이 롤백됩니다. 이는 데이터의 무결성을 보장하는 측면에서는 바람직하지만, 실무에서는 다른 접근이 필요할 수 있습니다.
예를 들어, 1,000건의 주문 데이터를 처리하는 과정에서 5건의 데이터에만 문제가 있다고 가정해봅시다. 전통적인 트랜잭션 방식으로는 이 5건 때문에 나머지 995건도 모두 롤백되어야 합니다. 그러나 비즈니스 요구사항에 따라 유효한 995건은 정상적으로 처리하고, 문제가 있는 5건만 별도로 관리해야 하는 경우가 있습니다.
이러한 요구사항을 충족하기 위해서는 각 요소를 개별 트랜잭션으로 처리하는 부분 롤백(Partial Rollback) 전략이 필요합니다. 이 글에서는 Spring Boot에서 이러한 전략을 구현하는 방법을 살펴보겠습니다.
핵심 개념 설명
트랜잭션의 ACID 원칙
트랜잭션은 데이터베이스에서 하나의 논리적 작업 단위를 의미하며, ACID 원칙을 통해 데이터의 무결성을 보장합니다.
- 원자성(Atomicity): 트랜잭션 내의 모든 작업이 성공적으로 완료되거나, 하나라도 실패하면 전체가 롤백되어야 합니다.
- 일관성(Consistency): 트랜잭션이 완료되면 데이터베이스는 일관된 상태를 유지해야 합니다.
- 격리성(Isolation): 동시에 실행되는 트랜잭션은 서로 간섭하지 않아야 합니다.
- 지속성(Durability): 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 반영되어야 합니다.
선언적 트랜잭션 vs 프로그래매틱 트랜잭션
Spring에서는 트랜잭션을 관리하는 두 가지 방식을 제공합니다.
선언적 트랜잭션(Declarative Transaction): @Transactional 어노테이션을 사용하여 트랜잭션 범위를 선언하는 방식입니다. 코드가 간결하고 AOP를 통해 트랜잭션 관리를 자동화할 수 있습니다. 그러나 트랜잭션의 세부 제어가 어렵고, 부분 롤백을 구현하기에는 한계가 있습니다.
프로그래매틱 트랜잭션(Programmatic Transaction): 코드 내에서 명시적으로 트랜잭션의 시작, 커밋, 롤백을 제어하는 방식입니다. 각 요소를 개별 트랜잭션으로 처리할 수 있어 부분 롤백을 구현하기에 적합합니다. 제어는 자유롭지만 코드 복잡도가 증가합니다.
문제 정의 및 분석
데이터 무결성을 보장하는 트랜잭션 처리 방식의 한계
@Transactional 어노테이션을 사용하여 리스트를 처리하는 경우, 다음과 같은 문제가 발생합니다:
@Service
public class OrderService {
@Transactional
public void processOrders(List<Order> orders) {
for (Order order : orders) {
// 주문 처리 로직
orderRepository.save(order);
}
}
}
위 코드에서 processOrders 메서드는 하나의 트랜잭션으로 실행됩니다. 따라서 리스트의 어느 요소에서든 예외가 발생하면 전체 트랜잭션이 롤백되어, 이미 처리된 요소들도 모두 취소됩니다.
부분 롤백이 필요한 시나리오
부분 롤백이 필요한 대표적인 시나리오는 다음과 같습니다:
대량 데이터 일괄 처리: 수천 건의 데이터를 처리할 때 일부 데이터에 문제가 있어도 나머지는 정상적으로 처리해야 하는 경우입니다. 예를 들어, 고객 정보 일괄 업데이트 작업에서 일부 고객 정보가 유효하지 않더라도 유효한 고객 정보는 업데이트되어야 합니다.
외부 시스템 연동: 외부 API나 시스템과 연동하여 데이터를 처리할 때, 일부 요청이 실패하더라도 성공한 요청은 반영되어야 하는 경우입니다.
데이터 검증 및 정제: 데이터 마이그레이션이나 ETL 작업에서 일부 데이터가 검증 규칙을 통과하지 못하더라도, 유효한 데이터는 정상적으로 처리되어야 합니다.
이러한 시나리오에서 비즈니스 요구사항을 충족시키기 위해 부분 롤백 전략이 적합할 수 있습니다.
해결 전략 / 구현 방향
부분 롤백을 구현하기 위해서는 각 요소를 개별 트랜잭션으로 처리해야 합니다. Spring에서는 이를 위해 프로그래매틱 트랜잭션 관리를 제공합니다.
PlatformTransactionManager 활용
PlatformTransactionManager 인터페이스를 사용하면 코드 내에서 트랜잭션을 명시적으로 제어할 수 있습니다. 각 요소를 처리하기 전에 새로운 트랜잭션을 시작하고, 성공 시 커밋, 실패 시 롤백하도록 구현합니다.
이 방식의 장점은 각 요소를 완전히 독립적인 트랜잭션으로 처리할 수 있어, 한 요소의 실패가 다른 요소에 영향을 주지 않는다는 점입니다. 단점은 트랜잭션 오버헤드가 발생할 수 있고, 코드가 다소 복잡해질 수 있다는 점입니다.
TransactionTemplate 활용
TransactionTemplate은 PlatformTransactionManager를 래핑하여 트랜잭션 관리를 더욱 간결하게 만든 클래스입니다. 템플릿 메서드 패턴을 적용하여 트랜잭션의 시작, 커밋, 롤백을 자동으로 처리합니다.
이 방식의 장점은 코드의 가독성과 유지보수성이 향상된다는 점입니다. 또한 예외 처리와 트랜잭션 관리가 분리되어 있어 코드 구조가 명확해집니다.
성능 고려사항
각 요소를 개별 트랜잭션으로 처리하면 트랜잭션 오버헤드가 발생합니다. 대량의 데이터를 처리할 때는 성능에 영향을 줄 수 있으므로, 다음과 같은 최적화 전략을 고려할 수 있습니다:
- 배치 크기 조정: 여러 요소를 묶어서 하나의 트랜잭션으로 처리하되, 실패한 배치만 롤백하는 하이브리드 방식
- 비동기 처리: 각 요소를 비동기로 처리하여 전체 처리 시간 단축
- 커넥션 풀 최적화: 트랜잭션 개수가 많아질 경우 커넥션 풀 크기를 적절히 조정
코드 & 예제
PlatformTransactionManager를 사용한 구현
PlatformTransactionManager를 직접 사용하여 부분 롤백을 구현하는 예시입니다:
@Service
@RequiredArgsConstructor
public class OrderService {
private final PlatformTransactionManager transactionManager;
private final OrderRepository orderRepository;
public ProcessResult processOrders(List<Order> orders) {
List<Order> successOrders = new ArrayList<>();
List<FailedOrder> failedOrders = new ArrayList<>();
for (Order order : orders) {
// 새로운 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try {
// 주문 검증 및 처리
validateOrder(order);
orderRepository.save(order);
// 트랜잭션 커밋
transactionManager.commit(status);
successOrders.add(order);
} catch (Exception e) {
// 트랜잭션 롤백
transactionManager.rollback(status);
failedOrders.add(new FailedOrder(order, e.getMessage()));
// 로깅 및 알림 처리
log.error("Order processing failed: orderId={}, error={}",
order.getId(), e.getMessage());
}
}
return new ProcessResult(successOrders, failedOrders);
}
private void validateOrder(Order order) {
if (order.getAmount() <= 0) {
throw new IllegalArgumentException("Invalid order amount");
}
// 추가 검증 로직...
}
}
코드 설명:
- getTransaction(): 새로운 트랜잭션을 시작하고 TransactionStatus를 반환합니다. 이 상태 객체를 통해 해당 트랜잭션을 제어할 수 있습니다.
- commit(status): 트랜잭션을 성공적으로 커밋합니다. 이 시점에서 데이터베이스에 변경사항이 영구적으로 반영됩니다.
- rollback(status): 트랜잭션을 롤백합니다. 예외 발생 시 이 메서드를 호출하여 해당 요소의 변경사항만 취소합니다.
- 각 요소는 독립적인 트랜잭션으로 처리되므로, 한 요소의 실패가 다른 요소에 영향을 주지 않습니다.
TransactionTemplate을 사용한 구현
TransactionTemplate을 사용하면 코드를 더욱 간결하게 작성할 수 있습니다:
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
public ProcessResult processOrders(List<Order> orders) {
List<Order> successOrders = new ArrayList<>();
List<FailedOrder> failedOrders = new ArrayList<>();
for (Order order : orders) {
try {
// TransactionTemplate을 사용한 트랜잭션 처리
Order savedOrder = transactionTemplate.execute(status -> {
validateOrder(order);
return orderRepository.save(order);
});
successOrders.add(savedOrder);
} catch (Exception e) {
// TransactionTemplate은 예외 발생 시 자동으로 롤백
failedOrders.add(new FailedOrder(order, e.getMessage()));
log.error("Order processing failed: orderId={}, error={}",
order.getId(), e.getMessage());
}
}
return new ProcessResult(successOrders, failedOrders);
}
}
코드 설명:
- transactionTemplate.execute(): 람다 표현식 내부의 코드를 트랜잭션 내에서 실행합니다. 정상 완료 시 자동으로 커밋되고, 예외 발생 시 자동으로 롤백됩니다.
- PlatformTransactionManager를 직접 사용하는 방식보다 코드가 간결하고, 예외 처리와 트랜잭션 관리가 명확하게 분리됩니다.
- 람다 표현식의 반환값을 활용하여 처리 결과를 받을 수 있어 코드의 유연성이 향상됩니다.
Configuration 설정
TransactionTemplate을 빈으로 등록하는 설정 예시입니다:
@Configuration
public class TransactionConfig {
@Bean
public TransactionTemplate transactionTemplate(
PlatformTransactionManager transactionManager) {
TransactionTemplate template = new TransactionTemplate(transactionManager);
// 트랜잭션 전파 방식 설정 (필요 시)
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
// 트랜잭션 타임아웃 설정 (필요 시)
template.setTimeout(30);
// 읽기 전용 트랜잭션 설정 (필요 시)
template.setReadOnly(false);
return template;
}
}
설정 설명:
- PROPAGATION_REQUIRES_NEW: 항상 새로운 트랜잭션을 시작합니다. 기존 트랜잭션이 있어도 독립적으로 실행되므로 부분 롤백 구현에 적합합니다.
- setTimeout(): 트랜잭션의 최대 실행 시간을 설정합니다. 장시간 실행되는 작업에 대한 타임아웃을 설정하여 데드락을 방지할 수 있습니다.
- 이러한 설정을 통해 트랜잭션의 동작을 세밀하게 제어할 수 있습니다.
선언적 트랜잭션 방식의 부분 롤백 구현
대부분의 Spring 애플리케이션은 선언적 트랜잭션을 기본으로 사용합니다. 문제는, 이 선언적 방식이 부분 롤백 요구사항과 충돌하는 지점에 있습니다.
선언적 트랜잭션 방식의 구조적 한계
@Transactional의 기본 동작 방식
@Transactional은 메서드 단위로 하나의 트랜잭션 범위를 생성합니다.
@Transactional
public void process(List<Item> items) {
for (Item item : items) {
save(item);
}
}
위와 같은 구조에서는 다음과 같은 특징이 있습니다.
- 메서드 전체가 하나의 트랜잭션
- for문 중 하나라도 예외가 발생하면
- 트랜잭션 전체가 롤백
즉, 부분 성공 / 부분 실패라는 요구사항을 기본 구조로는 만족하기 어렵습니다.
AOP 프록시 기반 동작의 제약
선언적 트랜잭션은 Spring AOP 프록시를 통해 동작합니다. 이로 인해 중요한 제약이 하나 존재합니다.
- 같은 클래스 내부의 메서드 호출에는 트랜잭션이 적용되지 않습니다.
@Transactional
public void processAll(List<Item> items) {
for (Item item : items) {
processOne(item); // 트랜잭션 분리 불가
}
}
@Transactional
public void processOne(Item item) {
// 기대와 달리 새로운 트랜잭션이 생성되지 않음
}
이 구조에서는 processOne에 @Transactional이 선언되어 있더라도 프록시를 거치지 않기 때문에 새로운 트랜잭션 경계가 생성되지 않습니다. 이 점이 선언적 방식에서 부분 롤백이 어려운 가장 큰 이유입니다.
선언적 트랜잭션으로 부분 롤백을 구현하는 구조
그렇다면 선언적 트랜잭션 환경에서는 부분 롤백이 불가능할까요? 그렇지는 않습니다. 핵심은 트랜잭션 프록시 경계를 분리하는 구조를 만드는 것입니다.
Facade 레이어를 통한 트랜잭션 분리
실무에서 가장 많이 사용하는 패턴은 Facade 레이어 분리입니다.
구조 개요
- Facade: 반복 처리 및 예외 제어 담당
- Service: 단일 작업 + 트랜잭션 담당
@Service
public class ItemFacade {
private final ItemService itemService;
public void processItems(List<Item> items) {
for (Item item : items) {
try {
itemService.process(item);
} catch (Exception e) {
// 실패한 항목만 로깅하고 계속 진행
}
}
}
}
@Service
public class ItemService {
@Transactional
public void process(Item item) {
// 단일 아이템 처리
}
}
이 구조가 부분 롤백을 가능하게 하는 이유
이 구조에서는 다음이 보장됩니다.
- ItemService.process()는 프록시를 통해 호출
- 각 호출마다 독립적인 트랜잭션 생성
- 특정 아이템에서 예외 발생 시
- 해당 트랜잭션만 롤백
- Facade 레벨에서는 전체 흐름 유지
즉, 선언적 트랜잭션을 유지하면서도 트랜잭션 경계를 구조적으로 분리함으로써 부분 롤백을 달성합니다
프로그래매틱 트랜잭션과의 비교
프로그래매틱 방식은 트랜잭션 상태를 코드로 명확히 제어할 수 있고 복잡한 조건 분기 처리에 유리합니다. 그러나 코드 가독성이 상대적으로 안좋을 수 있고 실무 상황에 따라서 적용하기 어려울 수 있습니다.
선언적 방식 + Facade 구조는 제어도는 상대적으로 낮지만 가독성이 좋고 실무에 바로 적용하기에 용이합니다.
| 선언적 + Facade | 프로그래매틱 | |
| 코드 가독성 | 높음 | 상대적으로 낮음 |
| 제어 자유도 | 중간 | 높음 |
| 구조 이해 필요 | 높음 | 낮음 |
| 실무 적용성 | 매우 높음 | 상황 의존 |
대부분의 실무에서는 선언적 방식 + 구조 설계가 더 선호됩니다. 다음은 제가 실무에서 사용한 트랜잭션 부분 롤백 코드입니다.
/** 수기 송장 목록 등록 */
public void createOutboundInvoiceList(List<LogisticsFront.CreateOutboundInvoiceRequest> request
, AccessUserInfo accessUserInfo) {
for (LogisticsFront.CreateOutboundInvoiceRequest req : request) {
try {
logisticsService.createOutboundInvoice(req, accessUserInfo);
} catch (Exception e) {
log.warn("송장 등록 API 호출 결과 예외가 발생했습니다. :: {}", e.getMessage());
}
}
}
@Override
@Transactional
public void createOutboundInvoice(CreateOutboundInvoiceRequest request
, AccessUserInfo accessUserInfo) {
LogisticsDomain.CreateOutboundInvoiceCommand cmd = logisticsMapper.of(request);
...
if (Objects.nonNull(outbound)) {
...
if (Objects.isNull(foundedOutboundInvoice)) {
...
} else {
throw new OrderException("이미 등록된 송장 번호입니다. 송장번호 : " + cmd.invoiceNumber());
}
} else {
throw new NotFoundException("출고 정보를 찾을 수 없습니다. 출고번호 : " + cmd.outboundNumber());
}
}

선언적 트랜잭션은 단순하고 강력하지만, 그만큼 구조적 제약도 명확합니다. 부분 롤백이 필요한 경우에는 @Transactional 설정만으로 해결하려 하지 않고 트랜잭션 경계를 기준으로 구조를 설계해야 합니다. Facade 레이어를 통한 선언적 트랜잭션 분리는 코드 복잡도를 크게 늘리지 않으면서도 실무 요구사항을 충족할 수 있는 현실적인 해결책입니다.
실무적 효과 & 고려사항
실무적 효과
부분 롤백 전략을 적용하면 다음과 같은 효과를 얻을 수 있습니다:
데이터 처리율 향상: 유효한 데이터는 정상적으로 처리되므로 전체 데이터 처리율이 향상됩니다. 예를 들어, 1,000건 중 5건만 실패하는 경우 995건은 정상 처리되어 99.5%의 처리율을 달성할 수 있습니다.
운영 효율성 개선: 실패한 요소만 별도로 관리하고 재처리할 수 있어 운영 효율성이 향상됩니다. 전체를 롤백하여 재처리하는 것보다 실패한 요소만 선별적으로 처리하는 것이 더 효율적입니다.
사용자 경험 향상: 대량의 데이터를 처리하는 API에서 일부 데이터에 문제가 있어도 나머지는 정상적으로 처리되므로, 사용자는 부분적인 성공 결과를 즉시 확인할 수 있습니다.
고려사항
부분 롤백을 적용할 때는 다음과 같은 사항을 고려해야 합니다:
데이터 일관성: 각 요소를 독립적인 트랜잭션으로 처리하면 요소 간의 일관성이 보장되지 않을 수 있습니다. 예를 들어, 주문과 주문 상세를 별도 트랜잭션으로 처리하면 주문은 성공했지만 주문 상세는 실패하는 상황이 발생할 수 있습니다. 이러한 경우 비즈니스 로직에서 보상 트랜잭션(Compensating Transaction)을 고려해야 합니다.
성능 최적화: 대량의 데이터를 처리할 때 각 요소를 개별 트랜잭션으로 처리하면 트랜잭션 오버헤드가 누적됩니다. 성능이 중요한 경우 배치 크기를 조정하거나 비동기 처리 방식을 고려해야 합니다.
예외 처리 및 모니터링: 실패한 요소에 대한 적절한 로깅과 알림이 필요합니다. 실패한 요소를 추적하고 재처리할 수 있는 메커니즘을 구축해야 합니다.
트랜잭션 격리 수준: 각 트랜잭션이 독립적으로 실행되므로, 동시성 문제가 발생할 수 있습니다. 필요에 따라 트랜잭션 격리 수준을 조정하거나 낙관적/비관적 락을 활용해야 합니다.
맺음말 및 정리
이번 글에서는 Spring Boot 3에서 리스트 형태의 데이터를 처리할 때 실패한 요소만 롤백하고 나머지는 정상적으로 커밋하는 방법을 살펴보았습니다. PlatformTransactionManager와 TransactionTemplate을 활용하여 프로그래매틱 트랜잭션 관리를 통해 부분 롤백을 구현할 수 있습니다. 한편, 실무에서는 일반적으로 선언적 방식 + Facade 구조로 부분 롤백을 구현하는 방식을 선호합니다.
부분 롤백은 모든 상황에 적합한 것은 아닙니다. 데이터의 일관성이 중요한 경우에는 전통적인 트랜잭션 방식이 더 적합할 수 있습니다. 따라서 비즈니스 요구사항과 데이터 특성을 종합적으로 고려하여 적절한 트랜잭션 전략을 선택하는 것이 중요합니다.
실무에서 부분 롤백을 적용할 때는 성능, 데이터 일관성, 예외 처리, 모니터링 등을 종합적으로 고려하여 설계해야 합니다. 특히 대량의 데이터를 처리하는 경우 성능 최적화와 실패한 요소에 대한 추적 메커니즘을 함께 구축하는 것을 권장합니다.
트랜잭션 관리는 애플리케이션의 안정성과 데이터 무결성을 보장하는 핵심 요소입니다. 상황에 맞는 적절한 전략을 선택하여 안정적이고 효율적인 시스템을 구축할 수 있으리라 생각합니다.
'WORK-RELATED' 카테고리의 다른 글
| [Spring Batch] 스프링배치 멀티스레딩 환경에서 스레드 안정성 (1) | 2025.12.09 |
|---|---|
| [Spring Batch] 병렬처리 구현과 실무 사례 (1) | 2025.08.29 |
| [Spring Batch] 배치 잡의 Step 사이에 변수 공유 (0) | 2025.04.01 |
| [Spring Batch 5] API 호출 Item Reader 커스텀과 csv 데이터 생성 (0) | 2025.03.28 |
| [Spring Batch 5] Meta Data 관리와 reader-writer datasource 분리 (0) | 2025.03.28 |
- Total
- Today
- Yesterday
- Cache Penetration
- 트래픽 처리
- 동시성처리
- 백엔드 아키텍처
- InterruptedException
- 스레드 생명주기
- Double-Checked Locking
- mybatis
- Redis 캐시 전략
- Initialization-on-Demand Holder Idiom
- Hot Key 문제
- Java Performance
- spring batch 5
- 백엔드 성능
- Enum 기반 싱글톤
- Cache Avalanche
- Eager Initialization
- DB 트랜잭션
- Redis vs DB
- Redis 성능 개선
- Spring Batch
- 캐시 장애
- 백엔드 성능 튜닝
- 캐시 성능 비교
- TTL 설계
- 백엔드 성능 설계
- 캐시와 인덱스
- Cache Aside
- 트랜잭션 관리
- DB 인덱스 성능
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

