본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 30. · 15 Views
Spring 트랜잭션 관리 마스터 가이드
Spring의 트랜잭션 관리를 처음부터 끝까지 완벽하게 이해하고 실무에 적용하는 방법을 배웁니다. 선언적 트랜잭션부터 격리 수준, 전파 속성까지 실전 예제와 함께 살펴봅니다.
목차
- @Transactional 기본 - 선언적 트랜잭션의 시작
- 트랜잭션 격리 수준 - 동시성 문제 해결하기
- 트랜잭션 전파 속성 - 트랜잭션 경계 제어하기
- 트랜잭션 롤백 규칙 - 어떤 예외에 롤백할까
- 읽기 전용 트랜잭션 - 조회 성능 최적화
- 트랜잭션 타임아웃 - 무한 대기 방지하기
- 프로그래밍 방식 트랜잭션 - 세밀한 제어가 필요할 때
- @Transactional 테스트 - 롤백으로 격리된 테스트 만들기
1. @Transactional 기본 - 선언적 트랜잭션의 시작
시작하며
여러분이 쇼핑몰에서 주문을 처리할 때 이런 상황을 겪어본 적 있나요? 결제는 완료되었는데 재고는 차감되지 않거나, 포인트는 차감되었는데 주문은 생성되지 않는 상황 말이죠.
이런 문제는 실제 개발 현장에서 데이터 일관성을 지키지 못해 발생합니다. 여러 데이터베이스 작업이 하나의 논리적 단위로 묶이지 않으면, 중간에 에러가 발생했을 때 일부만 완료되어 데이터가 꼬이게 됩니다.
바로 이럴 때 필요한 것이 @Transactional입니다. 이 어노테이션 하나로 여러 데이터베이스 작업을 하나의 원자적 단위로 묶어, 모두 성공하거나 모두 실패하도록 보장할 수 있습니다.
개요
간단히 말해서, @Transactional은 메서드나 클래스에 붙여 해당 범위 내의 모든 데이터베이스 작업을 하나의 트랜잭션으로 관리하는 선언적 트랜잭션 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 생각해봅시다.
은행 이체 작업에서 A계좌에서 돈을 빼고 B계좌에 돈을 넣는 두 작업은 반드시 함께 성공하거나 함께 실패해야 합니다. 둘 중 하나만 성공하면 돈이 사라지거나 복제되는 심각한 문제가 발생하죠.
기존에는 JDBC의 connection.commit()과 connection.rollback()을 직접 호출하며 복잡한 트랜잭션 코드를 작성했다면, 이제는 @Transactional 어노테이션만 붙이면 Spring이 자동으로 트랜잭션을 관리해줍니다. 핵심 특징은 첫째, AOP 기반으로 동작하여 비즈니스 로직과 트랜잭션 관리가 분리되고, 둘째, 런타임 예외 발생 시 자동으로 롤백되며, 셋째, 다양한 옵션으로 세밀한 제어가 가능합니다.
이러한 특징들이 코드의 가독성과 유지보수성을 크게 향상시킵니다.
코드 예제
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private InventoryService inventoryService;
@Transactional
public Order createOrder(OrderRequest request) {
// 주문 생성
Order order = new Order(request.getUserId(), request.getProductId());
orderRepository.save(order);
// 재고 차감 - 이 작업도 같은 트랜잭션 내에서 실행
inventoryService.decreaseStock(request.getProductId(), request.getQuantity());
// 예외 발생 시 위의 모든 작업이 자동으로 롤백됨
return order;
}
}
설명
이것이 하는 일: @Transactional이 붙은 메서드가 호출되면, Spring은 AOP 프록시를 통해 메서드 실행 전에 트랜잭션을 시작하고, 메서드가 정상 종료되면 커밋하며, 예외가 발생하면 롤백합니다. 첫 번째로, createOrder() 메서드가 호출되면 Spring은 자동으로 데이터베이스 트랜잭션을 시작합니다.
이때 새로운 EntityManager(JPA 사용 시)나 Connection이 생성되고 트랜잭션 컨텍스트에 바인딩됩니다. 왜 이렇게 하는지 이해하려면, 트랜잭션 범위 내의 모든 작업이 동일한 데이터베이스 연결을 사용해야 한다는 점을 기억하세요.
두 번째로, orderRepository.save()와 inventoryService.decreaseStock()이 차례로 실행됩니다. 이 두 작업은 같은 트랜잭션 내에서 실행되므로, 아직 데이터베이스에 실제로 반영되지 않은 상태입니다.
트랜잭션이 커밋되기 전까지는 다른 트랜잭션에서 이 변경사항을 볼 수 없죠. 세 번째로, 메서드가 정상적으로 종료되면 Spring은 자동으로 commit()을 호출하여 모든 변경사항을 데이터베이스에 영구 반영합니다.
만약 중간에 RuntimeException이나 Error가 발생하면 rollback()이 호출되어 모든 변경사항이 취소됩니다. 체크 예외(Checked Exception)는 기본적으로 롤백하지 않지만, rollbackFor 속성으로 제어할 수 있습니다.
여러분이 이 코드를 사용하면 복잡한 트랜잭션 관리 코드 없이도 데이터 일관성을 보장받을 수 있습니다. 주문과 재고 차감이 항상 함께 성공하거나 함께 실패하므로 데이터 불일치 문제가 발생하지 않고, 코드도 비즈니스 로직에만 집중할 수 있어 훨씬 깔끔해집니다.
실전 팁
💡 @Transactional은 반드시 public 메서드에만 적용하세요. private이나 protected 메서드에는 Spring AOP가 동작하지 않아 트랜잭션이 적용되지 않습니다.
💡 같은 클래스 내부에서 @Transactional 메서드를 직접 호출하면 트랜잭션이 적용되지 않습니다. 이는 self-invocation 문제로, 반드시 외부 빈을 통해 호출해야 프록시가 개입합니다.
💡 읽기 전용 작업에는 @Transactional(readOnly = true)를 사용하세요. 성능 최적화와 함께 실수로 데이터를 변경하는 것을 방지할 수 있습니다.
💡 체크 예외에서도 롤백이 필요하다면 @Transactional(rollbackFor = Exception.class)를 명시하세요. 기본적으로는 런타임 예외만 롤백됩니다.
💡 트랜잭션이 정말 적용되었는지 확인하려면 로그 레벨을 DEBUG로 설정하고 'org.springframework.transaction' 패키지의 로그를 확인하세요.
2. 트랜잭션 격리 수준 - 동시성 문제 해결하기
시작하며
여러분이 콘서트 예매 시스템을 개발하는데, 두 명의 사용자가 동시에 마지막 남은 한 자리를 예매하려고 시도하는 상황을 떠올려보세요. 제대로 처리하지 않으면 두 명 모두 예매에 성공했다고 표시되는 오버부킹 문제가 발생할 수 있습니다.
이런 문제는 실제 개발 현장에서 동시성 제어를 제대로 하지 못해 발생합니다. 여러 트랜잭션이 동시에 같은 데이터를 읽고 쓸 때, 서로의 작업이 간섭하면서 Dirty Read, Non-Repeatable Read, Phantom Read 같은 문제가 생기죠.
바로 이럴 때 필요한 것이 트랜잭션 격리 수준(Isolation Level)입니다. 격리 수준을 적절히 설정하면 동시성 문제를 방지하면서도 성능을 최적화할 수 있습니다.
개요
간단히 말해서, 트랜잭션 격리 수준은 동시에 실행되는 여러 트랜잭션이 서로의 작업에 얼마나 영향을 받는지를 제어하는 설정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 격리 수준이 너무 낮으면 데이터 일관성 문제가 발생하고, 너무 높으면 성능이 크게 저하됩니다.
예를 들어, 재고 관리 시스템에서는 높은 격리 수준이 필요하지만, 단순 조회 기능에서는 낮은 격리 수준으로도 충분합니다. 기존에는 데이터베이스의 기본 격리 수준을 그대로 사용했다면, 이제는 메서드별로 필요한 격리 수준을 세밀하게 지정할 수 있습니다.
격리 수준의 핵심 특징은 첫째, READ_UNCOMMITTED부터 SERIALIZABLE까지 4단계로 나뉘며, 둘째, 격리 수준이 높을수록 데이터 일관성은 높아지지만 동시성은 낮아지고, 셋째, 트랜잭션마다 다른 격리 수준을 적용할 수 있다는 점입니다. 이러한 특징들이 비즈니스 요구사항에 맞는 최적의 설정을 가능하게 합니다.
코드 예제
@Service
public class TicketService {
@Autowired
private TicketRepository ticketRepository;
// REPEATABLE_READ: 같은 쿼리를 여러 번 실행해도 같은 결과 보장
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void bookTicket(Long ticketId, Long userId) {
// 티켓 조회
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new TicketNotFoundException());
// 재고 확인 - 다른 트랜잭션이 동시에 수정해도 일관된 데이터 보장
if (ticket.getAvailableSeats() <= 0) {
throw new SoldOutException();
}
// 좌석 차감
ticket.decreaseSeats();
ticketRepository.save(ticket);
}
}
설명
이것이 하는 일: 트랜잭션 격리 수준은 한 트랜잭션이 다른 트랜잭션의 변경 사항을 언제 볼 수 있는지를 결정하여, 동시성 문제를 방지하면서도 필요한 수준의 성능을 유지합니다. 첫 번째로, bookTicket() 메서드에 REPEATABLE_READ 격리 수준을 지정하면, 트랜잭션 시작 시점의 데이터 스냅샷을 기준으로 작업이 진행됩니다.
이는 MySQL의 MVCC(Multi-Version Concurrency Control) 메커니즘을 활용하여, 다른 트랜잭션이 데이터를 변경하더라도 현재 트랜잭션은 일관된 데이터를 보게 됩니다. 두 번째로, ticket.getAvailableSeats()를 호출할 때 Non-Repeatable Read 문제를 방지합니다.
만약 READ_COMMITTED를 사용했다면, 첫 번째 조회와 두 번째 조회 사이에 다른 트랜잭션이 데이터를 변경하면 다른 결과가 나올 수 있습니다. 하지만 REPEATABLE_READ에서는 트랜잭션 내내 동일한 값을 읽게 됩니다.
세 번째로, 실제로 좌석을 차감하고 저장할 때는 데이터베이스 레벨의 락이 적용됩니다. REPEATABLE_READ는 읽은 행에 대해서는 일관성을 보장하지만, 완벽한 동시성 제어를 위해서는 비관적 락(@Lock(LockModeType.PESSIMISTIC_WRITE))이나 낙관적 락(@Version)을 추가로 사용하는 것이 좋습니다.
여러분이 이 격리 수준을 사용하면 티켓 예매 같은 중요한 비즈니스 로직에서 데이터 일관성을 보장받을 수 있습니다. 동시에 여러 사용자가 접속해도 오버부킹이 발생하지 않고, SERIALIZABLE보다 낮은 수준이므로 성능도 적절히 유지됩니다.
실전 팁
💡 대부분의 경우 READ_COMMITTED나 REPEATABLE_READ면 충분합니다. SERIALIZABLE은 성능 저하가 심하므로 정말 필요한 경우에만 사용하세요.
💡 MySQL은 기본적으로 REPEATABLE_READ를, PostgreSQL과 Oracle은 READ_COMMITTED를 사용합니다. 데이터베이스마다 동작이 다를 수 있으니 테스트가 필수입니다.
💡 격리 수준만으로 모든 동시성 문제가 해결되지 않습니다. 재고 차감 같은 중요한 로직에는 비관적 락이나 낙관적 락을 함께 사용하세요.
💡 조회 전용 메서드에는 READ_COMMITTED로 낮춰서 성능을 개선할 수 있습니다. @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)처럼 사용하세요.
💡 격리 수준에 따른 동시성 문제를 이해하려면 Dirty Read(커밋 안 된 데이터 읽기), Non-Repeatable Read(반복 읽기 불가), Phantom Read(유령 레코드) 개념을 꼭 학습하세요.
3. 트랜잭션 전파 속성 - 트랜잭션 경계 제어하기
시작하며
여러분이 주문 처리 메서드를 개발하는데, 그 안에서 포인트 적립 메서드를 호출한다고 가정해봅시다. 포인트 적립이 실패했을 때 주문 전체를 취소해야 할까요, 아니면 포인트만 실패하고 주문은 성공시켜야 할까요?
이런 문제는 실제 개발 현장에서 트랜잭션 경계를 어떻게 설정하느냐에 따라 완전히 다른 결과를 만듭니다. 잘못 설정하면 의도하지 않게 전체가 롤백되거나, 반대로 일부만 성공하여 데이터 불일치가 발생할 수 있습니다.
바로 이럴 때 필요한 것이 트랜잭션 전파 속성(Propagation)입니다. 전파 속성을 이해하면 여러 메서드가 중첩 호출될 때 트랜잭션을 어떻게 관리할지 정확히 제어할 수 있습니다.
개요
간단히 말해서, 트랜잭션 전파 속성은 트랜잭션 메서드가 다른 트랜잭션 메서드를 호출할 때, 기존 트랜잭션을 사용할지 새로운 트랜잭션을 만들지 등을 결정하는 설정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 비즈니스 로직의 복잡도가 높아질수록 여러 서비스 메서드가 서로를 호출하는 경우가 많아집니다.
예를 들어, 주문 서비스가 결제 서비스, 재고 서비스, 알림 서비스를 순차적으로 호출할 때, 각 서비스의 실패가 전체에 어떤 영향을 미칠지 명확히 정의해야 합니다. 기존에는 트랜잭션 경계를 수동으로 관리하며 복잡한 try-catch 블록으로 처리했다면, 이제는 전파 속성만 지정하면 Spring이 자동으로 트랜잭션을 적절히 관리해줍니다.
전파 속성의 핵심 특징은 첫째, REQUIRED(기본값)는 기존 트랜잭션을 재사용하고, 둘째, REQUIRES_NEW는 항상 새 트랜잭션을 시작하며, 셋째, NESTED는 중첩 트랜잭션을 만들어 부분 롤백이 가능하다는 점입니다. 이러한 특징들이 복잡한 비즈니스 로직에서도 정확한 트랜잭션 제어를 가능하게 합니다.
코드 예제
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Autowired
private PointService pointService;
@Transactional
public void processOrder(OrderRequest request) {
// 주문 생성 - 이 트랜잭션의 일부
createOrder(request);
// 결제 처리 - REQUIRED(기본값)이므로 같은 트랜잭션 사용
paymentService.processPayment(request);
// 포인트 적립 - REQUIRES_NEW로 별도 트랜잭션 시작
try {
pointService.earnPoints(request.getUserId(), request.getAmount());
} catch (Exception e) {
// 포인트 실패해도 주문/결제는 성공
log.warn("포인트 적립 실패", e);
}
}
}
@Service
public class PointService {
// 새로운 트랜잭션 시작 - 실패해도 부모 트랜잭션에 영향 없음
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void earnPoints(Long userId, BigDecimal amount) {
// 포인트 계산 및 저장
Point point = calculatePoints(amount);
pointRepository.save(point);
}
}
설명
이것이 하는 일: 트랜잭션 전파 속성은 메서드 호출 체인에서 각 트랜잭션의 범위와 독립성을 정의하여, 비즈니스 요구사항에 맞는 트랜잭션 경계를 만들어줍니다. 첫 번째로, processOrder() 메서드가 시작되면 Spring은 새로운 트랜잭션을 시작합니다.
이것이 부모 트랜잭션이 됩니다. createOrder()와 paymentService.processPayment()는 별도의 전파 속성이 없으므로 기본값인 REQUIRED를 사용하여 이 부모 트랜잭션에 참여합니다.
즉, 같은 트랜잭션 컨텍스트를 공유하므로 하나라도 실패하면 모두 롤백됩니다. 두 번째로, pointService.earnPoints()는 REQUIRES_NEW 전파 속성을 가지고 있어서 완전히 독립적인 새 트랜잭션을 시작합니다.
이때 부모 트랜잭션은 일시 중단(suspend)되고, 포인트 적립이 완료되면 즉시 커밋됩니다. 부모 트랜잭션과 물리적으로 분리되어 있어서, 포인트 적립이 실패해도 주문과 결제에는 영향을 주지 않습니다.
세 번째로, try-catch 블록으로 포인트 적립 예외를 잡아 처리함으로써, 포인트 적립 실패가 부모 트랜잭션에 전파되지 않도록 합니다. 만약 REQUIRES_NEW가 아니라 REQUIRED를 사용했다면, 예외를 잡더라도 이미 같은 트랜잭션이 rollback-only로 마킹되어 전체가 롤백될 것입니다.
여러분이 이 전파 속성을 사용하면 비즈니스 요구사항에 맞는 정교한 트랜잭션 설계가 가능합니다. 주문과 결제는 반드시 함께 성공해야 하지만, 포인트 적립은 선택적으로 처리할 수 있어 사용자 경험이 향상되고, 부분 실패를 허용하여 시스템의 가용성도 높아집니다.
실전 팁
💡 REQUIRES_NEW는 새로운 물리적 트랜잭션을 만들므로 데이터베이스 연결을 추가로 사용합니다. 동시 접속이 많은 환경에서는 커넥션 풀 고갈에 주의하세요.
💡 NESTED 전파 속성은 JDBC Savepoint를 사용하여 부분 롤백이 가능하지만, 모든 데이터베이스가 지원하는 것은 아닙니다. PostgreSQL과 Oracle은 지원하지만 MySQL InnoDB는 제한적입니다.
💡 REQUIRED가 기본값이므로 대부분의 경우 명시하지 않아도 됩니다. 하지만 코드 가독성을 위해 중요한 부분에는 명시적으로 작성하는 것이 좋습니다.
💡 MANDATORY는 반드시 기존 트랜잭션이 있어야 하고, NEVER는 트랜잭션이 없어야 합니다. 트랜잭션 정책을 강제할 때 유용합니다.
💡 전파 속성을 테스트할 때는 실제 데이터베이스를 사용해야 정확합니다. 인메모리 DB나 모킹으로는 트랜잭션 동작을 제대로 확인할 수 없습니다.
4. 트랜잭션 롤백 규칙 - 어떤 예외에 롤백할까
시작하며
여러분이 파일 업로드 기능을 개발하는데, 파일은 성공적으로 저장했지만 데이터베이스 저장 중 체크 예외(IOException)가 발생했다고 가정해봅시다. 이때 트랜잭션이 롤백되지 않아 파일은 있는데 DB 레코드는 없는 불일치 상황이 발생할 수 있습니다.
이런 문제는 실제 개발 현장에서 Spring의 기본 롤백 정책을 제대로 이해하지 못해 발생합니다. Spring은 기본적으로 런타임 예외와 Error만 롤백하고, 체크 예외는 롤백하지 않습니다.
이를 모르면 데이터 일관성 문제가 생길 수 있죠. 바로 이럴 때 필요한 것이 트랜잭션 롤백 규칙입니다.
rollbackFor와 noRollbackFor 속성으로 어떤 예외에 롤백할지 명확히 지정할 수 있습니다.
개요
간단히 말해서, 트랜잭션 롤백 규칙은 특정 예외가 발생했을 때 트랜잭션을 롤백할지 커밋할지를 결정하는 설정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 모든 예외 상황이 트랜잭션 롤백을 필요로 하는 것은 아니기 때문입니다.
예를 들어, 외부 API 호출 실패나 이메일 발송 실패는 트랜잭션과 무관하게 처리하고 싶을 수 있습니다. 반대로 체크 예외라도 데이터 일관성을 깨뜨리는 경우에는 반드시 롤백해야 합니다.
기존에는 모든 예외를 직접 catch하여 수동으로 rollback()을 호출했다면, 이제는 롤백 규칙만 선언하면 Spring이 자동으로 판단하여 처리해줍니다. 롤백 규칙의 핵심 특징은 첫째, 기본적으로 RuntimeException과 Error는 롤백되고, 둘째, 체크 예외는 기본적으로 롤백되지 않으며, 셋째, rollbackFor와 noRollbackFor로 세밀하게 제어할 수 있다는 점입니다.
이러한 특징들이 비즈니스 로직에 맞는 정확한 예외 처리를 가능하게 합니다.
코드 예제
@Service
public class FileUploadService {
@Autowired
private FileRepository fileRepository;
@Autowired
private StorageService storageService;
// IOException도 롤백 대상에 포함
@Transactional(rollbackFor = Exception.class)
public FileMetadata uploadFile(MultipartFile file) throws IOException {
// 물리적 파일 저장
String fileUrl = storageService.save(file);
// DB에 메타데이터 저장 - IOException 발생 시 위의 파일도 삭제되어야 함
FileMetadata metadata = new FileMetadata(file.getOriginalFilename(), fileUrl);
fileRepository.save(metadata);
return metadata;
}
// BusinessException은 롤백하되, ValidationException은 롤백하지 않음
@Transactional(
rollbackFor = BusinessException.class,
noRollbackFor = ValidationException.class
)
public void processData(DataRequest request) {
// 검증 실패는 롤백 불필요 (클라이언트 에러)
validateRequest(request); // ValidationException 발생 가능
// 비즈니스 로직 실패는 롤백 필요
processBusinessLogic(request); // BusinessException 발생 가능
}
}
설명
이것이 하는 일: 트랜잭션 롤백 규칙은 예외 발생 시 Spring이 트랜잭션을 커밋할지 롤백할지 판단하는 기준을 제공하여, 비즈니스 요구사항에 맞는 데이터 일관성 제어를 가능하게 합니다. 첫 번째로, uploadFile() 메서드에서 rollbackFor = Exception.class를 지정하면, 체크 예외인 IOException을 포함한 모든 예외가 롤백 대상이 됩니다.
파일 저장 후 DB 저장 중 IOException이 발생하면, 트랜잭션이 롤백되면서 fileRepository.save()가 취소됩니다. 하지만 주의할 점은 이미 저장된 물리적 파일은 트랜잭션 범위 밖이므로 별도로 삭제 로직이 필요합니다.
두 번째로, processData() 메서드는 더 세밀한 제어를 보여줍니다. ValidationException은 클라이언트의 잘못된 입력으로 발생하는 예외이므로, 트랜잭션을 롤백할 필요가 없습니다.
이미 저장된 데이터가 있다면 그대로 유지하고, 단순히 400 에러를 반환하면 됩니다. 반면 BusinessException은 비즈니스 규칙 위반으로 데이터 일관성을 해칠 수 있으므로 롤백합니다.
세 번째로, Spring은 메서드가 예외를 던질 때 롤백 규칙을 확인하여 해당 예외가 rollbackFor 목록에 있거나 RuntimeException/Error의 하위 타입이면 rollback()을 호출합니다. noRollbackFor에 명시된 예외는 롤백하지 않고 commit()을 호출하죠.
이 과정은 AOP 프록시가 자동으로 처리하므로 개발자는 비즈니스 로직에만 집중할 수 있습니다. 여러분이 이 롤백 규칙을 사용하면 예외 상황에서도 정확한 데이터 관리가 가능합니다.
체크 예외로 인한 부분 커밋 문제를 방지하고, 불필요한 롤백으로 인한 성능 저하도 막을 수 있으며, 예외 종류에 따라 다른 처리 전략을 명확히 구현할 수 있습니다.
실전 팁
💡 가장 안전한 방법은 rollbackFor = Exception.class를 사용하여 모든 예외에 롤백하는 것입니다. 성능보다 데이터 일관성이 중요한 경우 추천합니다.
💡 롤백되지 않은 트랜잭션에서 발생한 체크 예외는 정말 커밋되어야 하는지 비즈니스 로직을 다시 검토하세요. 대부분의 경우 롤백이 맞습니다.
💡 noRollbackFor는 신중하게 사용하세요. 런타임 예외인데도 롤백하지 않으면 데이터 불일치가 발생할 수 있습니다.
💡 롤백 규칙을 테스트할 때는 실제로 예외를 발생시키고 데이터베이스를 확인하여 롤백 여부를 검증하세요.
💡 외부 리소스(파일, 메시지 큐 등)는 트랜잭션 범위에 포함되지 않으므로, 롤백 시 별도의 보상 트랜잭션(Compensating Transaction)이 필요할 수 있습니다.
5. 읽기 전용 트랜잭션 - 조회 성능 최적화
시작하며
여러분이 대시보드 화면에서 통계 데이터를 보여주는 API를 개발한다고 가정해봅시다. 이 API는 데이터를 조회만 하고 절대 수정하지 않는데, 일반 트랜잭션과 똑같이 처리하면 불필요한 오버헤드가 발생합니다.
이런 문제는 실제 개발 현장에서 읽기와 쓰기 작업을 구분하지 않아 발생합니다. 조회 작업에도 쓰기를 위한 락이나 플러시 작업이 수행되면서 성능이 저하되고, 데이터베이스 리소스도 낭비되죠.
바로 이럴 때 필요한 것이 읽기 전용 트랜잭션입니다. readOnly = true로 설정하면 Spring과 JPA가 다양한 최적화를 수행하여 조회 성능을 크게 향상시킵니다.
개요
간단히 말해서, 읽기 전용 트랜잭션은 데이터를 조회만 하고 변경하지 않는 트랜잭션으로, 이를 명시하면 프레임워크와 데이터베이스가 다양한 성능 최적화를 적용합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 대부분의 애플리케이션에서 읽기 작업이 쓰기 작업보다 훨씬 많습니다.
예를 들어, 전자상거래 사이트에서 상품 목록 조회는 초당 수천 건이 발생하지만, 주문은 그보다 훨씬 적죠. 읽기 작업을 최적화하면 전체 시스템 성능이 크게 개선됩니다.
기존에는 읽기와 쓰기를 구분하지 않고 모두 같은 트랜잭션으로 처리했다면, 이제는 readOnly 플래그로 명확히 구분하여 각각에 최적화된 처리가 가능합니다. 읽기 전용 트랜잭션의 핵심 특징은 첫째, JPA의 변경 감지(dirty checking)가 비활성화되어 메모리와 CPU를 절약하고, 둘째, 데이터베이스가 읽기 전용으로 인식하여 락 전략을 최적화하며, 셋째, 실수로 데이터를 변경하는 것을 방지한다는 점입니다.
이러한 특징들이 안전하면서도 빠른 조회 작업을 가능하게 합니다.
코드 예제
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Autowired
private EntityManager entityManager;
// 읽기 전용 트랜잭션으로 성능 최적화
@Transactional(readOnly = true)
public List<ProductDto> getProductList(ProductSearchCondition condition) {
// 조회만 수행
List<Product> products = productRepository.findByCondition(condition);
// DTO로 변환하여 반환
return products.stream()
.map(ProductDto::from)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public ProductDetailDto getProductDetail(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException());
// 엔티티 변경해도 DB에 반영되지 않음 (변경 감지 비활성화)
product.increaseViewCount(); // 이 변경사항은 무시됨
return ProductDetailDto.from(product);
}
}
설명
이것이 하는 일: 읽기 전용 트랜잭션은 데이터 변경이 없음을 프레임워크와 데이터베이스에 알려, 불필요한 쓰기 준비 작업을 생략하고 조회에 최적화된 전략을 사용하도록 합니다. 첫 번째로, getProductList() 메서드에 readOnly = true를 지정하면, JPA(Hibernate)는 영속성 컨텍스트의 스냅샷 저장을 생략합니다.
일반 트랜잭션에서는 엔티티를 조회할 때 변경 감지를 위해 초기 상태의 스냅샷을 메모리에 보관하는데, 읽기 전용에서는 이 작업이 불필요하므로 메모리 사용량이 크게 줄어듭니다. 대량의 데이터를 조회할 때 특히 효과적입니다.
두 번째로, 트랜잭션 종료 시점에 flush()가 호출되지 않습니다. 일반 트랜잭션에서는 커밋 전에 영속성 컨텍스트의 변경사항을 데이터베이스에 동기화하는 flush 작업이 수행되는데, 이 과정에서 모든 엔티티를 검사하므로 비용이 큽니다.
읽기 전용에서는 이 검사를 완전히 생략하여 CPU 사용량을 줄입니다. 세 번째로, getProductDetail() 메서드에서 product.increaseViewCount()를 호출해도 실제로 데이터베이스에 반영되지 않습니다.
이는 실수로 데이터를 변경하는 버그를 방지하는 안전장치 역할을 합니다. 만약 정말 조회수를 증가시켜야 한다면, 별도의 쓰기 전용 메서드로 분리해야 합니다.
여러분이 이 읽기 전용 트랜잭션을 사용하면 조회 API의 응답 속도가 빨라지고, 데이터베이스 부하도 줄어듭니다. 특히 목록 조회나 통계 집계처럼 대량의 데이터를 다루는 경우 메모리 사용량이 크게 감소하고, Read Replica를 사용하는 환경에서는 자동으로 읽기 전용 DB로 라우팅되어 Master DB의 부하를 분산시킬 수 있습니다.
실전 팁
💡 모든 조회 메서드에는 습관적으로 @Transactional(readOnly = true)를 붙이세요. 성능 향상뿐만 아니라 코드의 의도도 명확해집니다.
💡 Service 클래스 레벨에 @Transactional(readOnly = true)를 붙이고, 쓰기 메서드에만 @Transactional을 붙여 오버라이드하는 패턴도 유용합니다.
💡 읽기 전용 트랜잭션 내에서 실수로 데이터를 변경하려고 하면 조용히 무시되므로, 테스트로 검증하는 것이 중요합니다.
💡 Master-Slave 구조에서 읽기 전용 트랜잭션은 자동으로 Slave로 라우팅되도록 설정할 수 있습니다. Spring의 AbstractRoutingDataSource를 활용하세요.
💡 JPQL이나 QueryDSL로 대량 조회 시 setHint("org.hibernate.readOnly", true)를 추가하면 더욱 최적화됩니다.
6. 트랜잭션 타임아웃 - 무한 대기 방지하기
시작하며
여러분이 외부 결제 API를 호출하는 트랜잭션을 작성했는데, 네트워크 문제로 응답이 오지 않아 트랜잭션이 무한정 대기하는 상황을 상상해보세요. 이 트랜잭션이 데이터베이스 연결을 계속 점유하면서 다른 요청들도 처리되지 못하는 장애가 발생할 수 있습니다.
이런 문제는 실제 개발 현장에서 타임아웃 설정을 하지 않아 발생합니다. 느린 쿼리나 외부 시스템 장애로 인해 트랜잭션이 오래 유지되면, 데이터베이스 커넥션 풀이 고갈되고 전체 시스템이 응답 불능 상태에 빠지게 됩니다.
바로 이럴 때 필요한 것이 트랜잭션 타임아웃입니다. 적절한 타임아웃을 설정하면 문제 있는 트랜잭션을 조기에 종료하여 시스템 전체의 안정성을 보호할 수 있습니다.
개요
간단히 말해서, 트랜잭션 타임아웃은 트랜잭션이 시작된 후 지정된 시간 내에 완료되지 않으면 자동으로 롤백시키는 안전장치입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 모든 시스템에는 예상치 못한 지연이 발생할 수 있습니다.
예를 들어, 대용량 배치 작업이 잘못 작성되어 수백만 건을 처리하거나, 네트워크 파티션으로 외부 시스템이 응답하지 않을 수 있습니다. 이런 상황에서 타임아웃이 없으면 시스템이 무한정 대기하게 됩니다.
기존에는 애플리케이션 레벨에서 별도의 타이머를 구현하거나 데이터베이스 설정에만 의존했다면, 이제는 트랜잭션 레벨에서 간단히 타임아웃을 지정할 수 있습니다. 트랜잭션 타임아웃의 핵심 특징은 첫째, 초 단위로 지정하며 기본값은 -1(무제한)이고, 둘째, 타임아웃 시 TransactionTimedOutException을 발생시키며 자동 롤백하고, 셋째, 데이터베이스 커넥션과 락을 빠르게 해제하여 리소스 고갈을 방지한다는 점입니다.
이러한 특징들이 시스템의 회복탄력성을 크게 향상시킵니다.
코드 예제
@Service
public class PaymentService {
@Autowired
private PaymentGateway paymentGateway;
@Autowired
private OrderRepository orderRepository;
// 결제 처리는 최대 10초까지만 허용
@Transactional(timeout = 10)
public PaymentResult processPayment(PaymentRequest request) {
// 주문 조회
Order order = orderRepository.findById(request.getOrderId())
.orElseThrow(() -> new OrderNotFoundException());
// 외부 결제 API 호출 - 이것이 오래 걸리면 타임아웃 발생
PaymentResult result = paymentGateway.charge(request);
// 주문 상태 업데이트
order.updatePaymentStatus(result);
orderRepository.save(order);
return result;
// 10초 초과 시 TransactionTimedOutException 발생 및 롤백
}
// 배치 작업은 더 긴 타임아웃 허용
@Transactional(timeout = 300) // 5분
public void processBatchOrders(List<Long> orderIds) {
for (Long orderId : orderIds) {
Order order = orderRepository.findById(orderId).orElse(null);
if (order != null) {
processSingleOrder(order);
}
}
}
}
설명
이것이 하는 일: 트랜잭션 타임아웃은 트랜잭션 시작 시점부터 경과 시간을 추적하여, 설정된 제한 시간을 초과하면 강제로 트랜잭션을 종료하고 예외를 발생시켜 리소스 누수를 방지합니다. 첫 번째로, processPayment() 메서드가 시작되면 Spring은 트랜잭션과 함께 타이머를 시작합니다.
timeout = 10은 10초를 의미하며, 트랜잭션 시작부터 10초 이내에 메서드가 완료되지 않으면 TransactionTimedOutException이 발생합니다. 이는 데이터베이스 쿼리 시간, 외부 API 호출 시간, 비즈니스 로직 처리 시간을 모두 포함한 전체 실행 시간입니다.
두 번째로, paymentGateway.charge() 호출이 네트워크 지연이나 외부 시스템 장애로 8초가 걸렸다고 가정해봅시다. 그 후 orderRepository.save()를 실행하려고 할 때 이미 10초를 초과했다면, Spring은 즉시 예외를 발생시키고 트랜잭션을 롤백합니다.
이미 조회한 Order 엔티티의 변경사항도 데이터베이스에 반영되지 않으며, 외부 결제는 성공했지만 DB 저장은 실패한 상황이므로 별도의 보상 처리가 필요합니다. 세 번째로, processBatchOrders() 메서드는 더 긴 타임아웃을 사용합니다.
배치 작업은 대량의 데이터를 처리하므로 일반 트랜잭션보다 시간이 더 필요합니다. 하지만 무제한으로 두지 않고 5분으로 제한하여, 잘못 작성된 배치 작업이 시스템을 마비시키는 것을 방지합니다.
타임아웃 발생 시 지금까지 처리한 내용이 모두 롤백되므로, 실무에서는 배치를 더 작은 단위로 쪼개고 각각 별도의 트랜잭션으로 처리하는 것이 좋습니다. 여러분이 이 타임아웃 설정을 사용하면 시스템이 훨씬 안정적이 됩니다.
느린 쿼리나 외부 장애로 인한 트랜잭션 적체를 방지하고, 데이터베이스 커넥션 풀을 효율적으로 사용하며, 장애 상황에서도 빠르게 실패(fail-fast)하여 사용자에게 적절한 에러 메시지를 제공할 수 있습니다.
실전 팁
💡 타임아웃 값은 실제 운영 환경에서 측정한 평균 응답 시간의 2-3배로 설정하는 것이 좋습니다. 너무 짧으면 정상 요청도 실패하고, 너무 길면 효과가 없습니다.
💡 외부 API를 호출하는 트랜잭션은 외부 시스템의 타임아웃보다 약간 길게 설정하세요. 외부 API가 5초 타임아웃이면 트랜잭션은 7-8초 정도로 설정합니다.
💡 타임아웃 예외는 런타임 예외이므로 자동으로 롤백됩니다. 별도의 rollbackFor 설정이 필요 없습니다.
💡 배치 작업은 한 트랜잭션에 모두 담지 말고, 청크(chunk) 단위로 나누어 각각 별도 트랜잭션으로 처리하세요. 일부 실패해도 나머지는 성공하도록 만들 수 있습니다.
💡 데이터베이스 자체의 타임아웃(query timeout, lock timeout)과 혼동하지 마세요. 트랜잭션 타임아웃은 더 상위 레벨의 제한입니다.
7. 프로그래밍 방식 트랜잭션 - 세밀한 제어가 필요할 때
시작하며
여러분이 복잡한 배치 작업을 개발하는데, 조건에 따라 트랜잭션을 부분적으로 커밋하거나 특정 시점에서만 롤백해야 하는 상황을 떠올려보세요. 선언적 트랜잭션(@Transactional)만으로는 이런 세밀한 제어가 불가능합니다.
이런 문제는 실제 개발 현장에서 복잡한 비즈니스 로직에 유연성이 필요할 때 발생합니다. 예를 들어, 천 건의 데이터를 처리하는데 100건마다 중간 커밋이 필요하거나, 특정 조건에서만 트랜잭션을 시작해야 할 수 있습니다.
바로 이럴 때 필요한 것이 프로그래밍 방식 트랜잭션입니다. TransactionTemplate이나 PlatformTransactionManager를 직접 사용하면 트랜잭션을 코드로 완전히 제어할 수 있습니다.
개요
간단히 말해서, 프로그래밍 방식 트랜잭션은 어노테이션 대신 코드로 직접 트랜잭션을 시작하고, 커밋하고, 롤백하는 방식으로, 세밀한 트랜잭션 제어가 가능합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 선언적 트랜잭션은 메서드 단위로만 적용되고 중간에 부분 커밋이 불가능합니다.
예를 들어, CSV 파일에서 백만 건의 데이터를 임포트할 때, 전체를 하나의 트랜잭션으로 처리하면 메모리 부족이나 락 타임아웃이 발생할 수 있습니다. 청크 단위로 나누어 각각 커밋해야 하는데, 이는 프로그래밍 방식으로만 가능합니다.
기존에는 모든 트랜잭션을 선언적으로만 처리하거나, JDBC를 직접 사용하며 복잡한 코드를 작성했다면, 이제는 TransactionTemplate으로 Spring의 트랜잭션 추상화를 활용하면서도 세밀한 제어가 가능합니다. 프로그래밍 방식 트랜잭션의 핵심 특징은 첫째, TransactionTemplate이 콜백 패턴으로 간편한 사용을 제공하고, 둘째, 메서드 중간에서도 트랜잭션을 커밋하거나 새로 시작할 수 있으며, 셋째, 조건부 트랜잭션 로직을 자유롭게 구현할 수 있다는 점입니다.
이러한 특징들이 복잡한 비즈니스 요구사항에 대응할 수 있게 합니다.
코드 예제
@Service
public class DataImportService {
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private DataRepository dataRepository;
public ImportResult importLargeDataset(List<DataRecord> records) {
int chunkSize = 100;
int totalProcessed = 0;
int totalFailed = 0;
// 청크 단위로 분할 처리
for (int i = 0; i < records.size(); i += chunkSize) {
int endIndex = Math.min(i + chunkSize, records.size());
List<DataRecord> chunk = records.subList(i, endIndex);
// 각 청크를 별도 트랜잭션으로 처리
Boolean success = transactionTemplate.execute(status -> {
try {
for (DataRecord record : chunk) {
Data data = convertToEntity(record);
dataRepository.save(data);
}
return true; // 커밋됨
} catch (Exception e) {
status.setRollbackOnly(); // 이 청크만 롤백
return false;
}
});
if (Boolean.TRUE.equals(success)) {
totalProcessed += chunk.size();
} else {
totalFailed += chunk.size();
}
}
return new ImportResult(totalProcessed, totalFailed);
}
// 조건부 트랜잭션 실행
public void conditionalTransaction(boolean needsTransaction) {
if (needsTransaction) {
transactionTemplate.execute(status -> {
performDatabaseOperations();
return null;
});
} else {
performDatabaseOperations(); // 트랜잭션 없이 실행
}
}
}
설명
이것이 하는 일: 프로그래밍 방식 트랜잭션은 개발자가 코드로 직접 트랜잭션의 시작, 커밋, 롤백 시점을 제어하여, 메서드 경계에 얽매이지 않는 유연한 트랜잭션 관리를 가능하게 합니다. 첫 번째로, importLargeDataset() 메서드는 대용량 데이터를 100건씩 청크로 나누어 처리합니다.
각 청크마다 transactionTemplate.execute()를 호출하여 독립적인 트랜잭션을 시작합니다. 선언적 트랜잭션을 사용했다면 메서드 전체가 하나의 트랜잭션이 되어, 중간에 실패하면 이미 처리한 수천 건도 모두 롤백되었을 것입니다.
하지만 프로그래밍 방식을 사용하면 각 청크가 독립적으로 커밋되므로, 일부 실패해도 나머지는 성공적으로 저장됩니다. 두 번째로, execute() 메서드는 TransactionCallback을 인자로 받아 그 안에서 데이터베이스 작업을 수행합니다.
콜백이 정상적으로 완료되면 자동으로 커밋되고, 예외가 발생하면 롤백됩니다. status.setRollbackOnly()를 호출하면 예외 없이도 명시적으로 롤백할 수 있습니다.
이는 비즈니스 로직 상 롤백이 필요하지만 예외를 던지고 싶지 않을 때 유용합니다. 세 번째로, conditionalTransaction() 메서드는 조건에 따라 트랜잭션을 선택적으로 적용합니다.
선언적 트랜잭션에서는 메서드에 항상 트랜잭션이 적용되거나 적용되지 않는 이분법적 선택만 가능하지만, 프로그래밍 방식에서는 런타임 조건에 따라 동적으로 결정할 수 있습니다. 이는 설정이나 사용자 권한에 따라 다른 트랜잭션 전략이 필요할 때 매우 유용합니다.
여러분이 이 프로그래밍 방식을 사용하면 복잡한 배치 처리나 데이터 마이그레이션 작업에서 안정성과 성능을 동시에 확보할 수 있습니다. 대용량 데이터를 처리할 때 메모리 부족을 방지하고, 부분 실패를 허용하여 전체 작업의 성공률을 높이며, 비즈니스 로직에 딱 맞는 트랜잭션 경계를 설정할 수 있습니다.
실전 팁
💡 대부분의 경우 선언적 트랜잭션(@Transactional)으로 충분하므로, 프로그래밍 방식은 정말 필요할 때만 사용하세요. 코드 복잡도가 높아집니다.
💡 TransactionTemplate은 스레드 안전하므로 멤버 변수로 선언하고 재사용하세요. 매번 새로 만들 필요가 없습니다.
💡 executeWithoutResult()를 사용하면 반환값이 없는 작업을 더 간단히 작성할 수 있습니다. execute()는 반환값이 필요할 때만 사용하세요.
💡 중첩된 프로그래밍 방식 트랜잭션은 전파 속성을 TransactionDefinition으로 제어할 수 있습니다. new DefaultTransactionDefinition()으로 설정을 커스터마이징하세요.
💡 Spring Batch를 사용하면 청크 기반 처리가 프레임워크 차원에서 지원되므로, 대용량 배치 작업에는 Spring Batch 도입을 검토하세요.
8. @Transactional 테스트 - 롤백으로 격리된 테스트 만들기
시작하며
여러분이 통합 테스트를 작성하는데, 각 테스트가 데이터베이스에 데이터를 남겨서 다음 테스트에 영향을 주는 상황을 경험해본 적 있나요? 테스트 순서에 따라 성공하기도 하고 실패하기도 하는 불안정한 테스트가 만들어집니다.
이런 문제는 실제 개발 현장에서 테스트 간 격리가 되지 않아 발생합니다. 한 테스트에서 생성한 데이터가 다른 테스트의 전제 조건을 깨뜨리거나, 매번 테스트 데이터를 직접 정리하는 번거로운 코드를 작성해야 하죠.
바로 이럴 때 필요한 것이 테스트용 @Transactional입니다. 테스트 메서드에 이 어노테이션을 붙이면 테스트가 끝날 때 자동으로 롤백되어, 데이터베이스를 깨끗한 상태로 유지할 수 있습니다.
개요
간단히 말해서, 테스트에서 사용하는 @Transactional은 테스트 메서드 종료 시 자동으로 롤백하여 데이터베이스 변경사항을 취소함으로써, 테스트 간 격리를 보장하고 반복 가능한 테스트를 만듭니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 좋은 테스트는 독립적이고 반복 가능해야 합니다.
예를 들어, 사용자 등록 테스트를 실행하면 DB에 사용자가 추가되는데, 같은 이메일로 두 번째 테스트를 실행하면 중복 에러가 발생합니다. 매번 데이터를 삭제하는 @AfterEach 메서드를 작성하는 것은 번거롭고 실수하기 쉽습니다.
기존에는 테스트마다 setUp/tearDown 메서드로 데이터를 직접 정리했다면, 이제는 @Transactional 하나로 자동 롤백이 가능합니다. 테스트 트랜잭션의 핵심 특징은 첫째, 테스트 종료 시 기본적으로 롤백되어 데이터베이스를 원상복구하고, 둘째, @Commit을 붙이면 예외적으로 커밋할 수도 있으며, 셋째, 실제 운영 코드의 트랜잭션 동작을 정확히 테스트할 수 있다는 점입니다.
이러한 특징들이 신뢰할 수 있는 통합 테스트를 가능하게 합니다.
코드 예제
@SpringBootTest
@Transactional // 테스트 메서드마다 자동 롤백
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void createUser_shouldSaveToDatabase() {
// Given
UserCreateRequest request = new UserCreateRequest("test@example.com", "password");
// When
User user = userService.createUser(request);
// Then
assertThat(user.getId()).isNotNull();
User found = userRepository.findById(user.getId()).orElseThrow();
assertThat(found.getEmail()).isEqualTo("test@example.com");
// 테스트 종료 시 자동으로 롤백되어 DB에서 삭제됨
}
@Test
void createDuplicateUser_shouldThrowException() {
// Given - 첫 번째 사용자 생성
userService.createUser(new UserCreateRequest("duplicate@example.com", "password"));
// When & Then - 같은 이메일로 다시 생성 시도
assertThatThrownBy(() ->
userService.createUser(new UserCreateRequest("duplicate@example.com", "password"))
).isInstanceOf(DuplicateEmailException.class);
// 이 테스트도 롤백되므로 다음 테스트에 영향 없음
}
@Test
@Commit // 예외적으로 커밋 (테스트 데이터를 실제로 남길 때)
void createAdminUser_shouldBeCommitted() {
userService.createUser(new UserCreateRequest("admin@example.com", "admin123"));
// 이 데이터는 실제로 DB에 남음
}
}
설명
이것이 하는 일: 테스트용 트랜잭션은 Spring TestContext Framework가 각 테스트 메서드 실행 전에 트랜잭션을 시작하고, 테스트 종료 후 자동으로 롤백하여 데이터베이스를 깨끗한 상태로 되돌립니다. 첫 번째로, createUser_shouldSaveToDatabase() 테스트가 시작되면 Spring은 트랜잭션을 시작합니다.
테스트 내부에서 userService.createUser()를 호출하면 실제로 데이터베이스에 INSERT 쿼리가 실행되고, 같은 트랜잭션 내에서 userRepository.findById()로 조회도 가능합니다. 하지만 테스트 메서드가 종료되면 Spring TestContext Framework는 이 트랜잭션을 커밋하지 않고 롤백하여, 생성된 사용자 데이터가 데이터베이스에서 완전히 사라집니다.
두 번째로, createDuplicateUser_shouldThrowException() 테스트는 같은 이메일로 두 명의 사용자를 생성하려고 시도합니다. 첫 번째 테스트에서 이미 "duplicate@example.com"을 사용했더라도, 그 테스트는 롤백되어 데이터가 남아있지 않으므로 중복 에러가 발생하지 않습니다.
이 테스트에서 생성한 두 사용자도 테스트 종료 시 롤백되어, 다음 테스트는 완전히 깨끗한 데이터베이스 상태에서 시작할 수 있습니다. 세 번째로, createAdminUser_shouldBeCommitted() 테스트는 @Commit 어노테이션을 사용하여 예외적으로 커밋합니다.
이는 테스트 데이터를 수동으로 세팅하거나, 데이터베이스 마이그레이션 테스트처럼 실제로 데이터를 남겨야 할 때 사용합니다. 하지만 대부분의 테스트는 롤백하는 것이 원칙이며, @Commit은 신중하게 사용해야 합니다.
여러분이 이 테스트 트랜잭션을 사용하면 통합 테스트 작성이 훨씬 쉬워집니다. 매번 데이터 정리 코드를 작성할 필요가 없고, 테스트 순서에 관계없이 항상 같은 결과를 보장하며, 실제 데이터베이스를 사용하면서도 빠른 테스트 실행 속도를 유지할 수 있습니다.
실전 팁
💡 클래스 레벨에 @Transactional을 붙이면 모든 테스트 메서드에 적용됩니다. 대부분의 통합 테스트에 권장되는 패턴입니다.
💡 테스트에서 비동기 작업이나 별도 스레드를 사용하면 트랜잭션이 공유되지 않습니다. @Async 메서드 테스트 시 주의하세요.
💡 @Sql 어노테이션과 함께 사용하면 테스트 데이터 세팅과 자동 롤백을 조합할 수 있습니다. @Sql로 초기 데이터를 넣고, 테스트 후 모두 롤백됩니다.
💡 JPA를 사용할 때 flush()를 명시적으로 호출해야 실제 쿼리가 실행됩니다. assertThat 전에 entityManager.flush()를 호출하여 쿼리 에러를 확인하세요.
💡 테스트 트랜잭션과 운영 코드의 트랜잭션 전파 속성이 상호작용하는 방식을 이해해야 합니다. 예를 들어, REQUIRES_NEW는 별도 트랜잭션을 만들어 테스트 롤백과 무관하게 커밋될 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.
스프링 관찰 가능성 완벽 가이드
Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.
Micrometer Tracing 완벽 가이드
분산 시스템에서 요청 흐름을 추적하는 Micrometer Tracing의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 실무 스토리와 비유로 풀어낸 완벽 가이드입니다.