@Transactional 상세 옵션, 전파 설정, 낙관적/비관적 락을 학습합니다.
| 속성 | 설명 | 기본값 |
|---|---|---|
| propagation | 트랜잭션 전파 옵션 | REQUIRED |
| isolation | 격리 수준 | DEFAULT (DB 설정) |
| readOnly | 읽기 전용 여부 | false |
| timeout | 타임아웃 (초) | -1 (무제한) |
| rollbackFor | 롤백할 예외 | RuntimeException |
REQUIRED (기본값)
기존 트랜잭션 있으면 참여, 없으면 새로 생성
REQUIRES_NEW
항상 새 트랜잭션 생성 (기존 트랜잭션 일시 중단)
NESTED
중첩 트랜잭션 (Savepoint 사용)
MANDATORY
기존 트랜잭션 필수, 없으면 예외 발생
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final NotificationService notificationService;
private final AuditLogService auditLogService;
@Transactional
public Long createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
// 결제 처리 (같은 트랜잭션)
paymentService.processPayment(order);
// 알림은 별도 트랜잭션 (실패해도 주문은 유지)
notificationService.sendOrderNotification(order);
// 감사 로그는 항상 기록 (주문 실패해도)
auditLogService.log("ORDER_CREATED", order.getId());
return order.getId();
}
}
@Service
public class NotificationService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendOrderNotification(Order order) {
// 별도 트랜잭션: 실패해도 주문 트랜잭션에 영향 없음
// 알림 실패 시 이 트랜잭션만 롤백
}
}
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String action, Long entityId) {
// 항상 새 트랜잭션: 메인 트랜잭션 롤백되어도 로그는 남음
}
}Self-Invocation 문제
@Service
public class OrderService {
@Transactional
public void processOrder(Long orderId) {
// 내부 메서드 호출 - @Transactional 무시됨!
this.updateStatus(orderId); // ❌ 프록시 우회
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateStatus(Long orderId) {
// REQUIRES_NEW가 적용되지 않음
}
}
// 해결 방법 1: 별도 서비스로 분리
@Service
public class OrderStatusService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateStatus(Long orderId) { ... }
}
// 해결 방법 2: Self-injection
@Service
public class OrderService {
@Lazy @Autowired
private OrderService self;
@Transactional
public void processOrder(Long orderId) {
self.updateStatus(orderId); // ✅ 프록시 통해 호출
}
}@Transactional 핵심 원칙
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private Integer stockQuantity;
@Version // 낙관적 락 - 수정 시 버전 자동 증가
private Long version;
public void decreaseStock(int quantity) {
if (stockQuantity < quantity) {
throw new IllegalStateException("재고 부족");
}
stockQuantity -= quantity;
}
}
// 사용
@Transactional
public void orderProduct(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(quantity);
// 커밋 시점에 version 체크
// 다른 트랜잭션이 먼저 수정했으면 OptimisticLockException 발생
}
// 실행되는 SQL
// UPDATE products
// SET stock_quantity = ?, version = version + 1
// WHERE id = ? AND version = ?// Spring Retry 사용
@Service
@RequiredArgsConstructor
public class ProductService {
@Transactional
@Retryable(
value = OptimisticLockException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.decreaseStock(quantity);
}
@Recover
public void recoverFromOptimisticLock(OptimisticLockException e,
Long productId, int quantity) {
log.error("재고 차감 실패 - 재시도 초과: productId={}", productId);
throw new BusinessException("일시적인 오류가 발생했습니다. 다시 시도해주세요.");
}
}
// 수동 재시도 구현
public void decreaseStockWithRetry(Long productId, int quantity) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
decreaseStock(productId, quantity);
return;
} catch (OptimisticLockException e) {
if (i == maxRetries - 1) throw e;
Thread.sleep(100 * (i + 1)); // 백오프
}
}
}public interface ProductRepository extends JpaRepository<Product, Long> {
// 비관적 쓰기 락 (SELECT ... FOR UPDATE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
// 비관적 읽기 락 (SELECT ... FOR SHARE)
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<Product> findWithReadLockById(Long id);
// 타임아웃 설정
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLockTimeout(@Param("id") Long id);
}
// 사용
@Transactional
public void orderProductWithLock(Long productId, int quantity) {
// 다른 트랜잭션은 이 행에 접근 불가 (대기)
Product product = productRepository.findByIdWithLock(productId)
.orElseThrow();
product.decreaseStock(quantity);
}
// 실행되는 SQL
// SELECT * FROM products WHERE id = ? FOR UPDATE락 전략 핵심 원칙
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
// 낙관적 락 + 재시도 (일반적인 주문)
@Transactional
@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
public Long createOrder(CreateOrderCommand command) {
Order order = new Order();
for (OrderItemRequest item : command.getItems()) {
Product product = productRepository.findById(item.getProductId())
.orElseThrow();
product.decreaseStock(item.getQuantity()); // @Version 체크
order.addOrderItem(OrderItem.create(product, item.getQuantity()));
}
orderRepository.save(order);
return order.getId();
}
// 비관적 락 (선착순 이벤트, 재고 정확성 필수)
@Transactional
public Long createOrderWithLock(CreateOrderCommand command) {
Order order = new Order();
// 상품 ID 정렬하여 데드락 방지
List<Long> sortedProductIds = command.getItems().stream()
.map(OrderItemRequest::getProductId)
.sorted()
.collect(Collectors.toList());
for (Long productId : sortedProductIds) {
Product product = productRepository.findByIdWithLock(productId)
.orElseThrow();
int quantity = command.getItems().stream()
.filter(i -> i.getProductId().equals(productId))
.mapToInt(OrderItemRequest::getQuantity)
.sum();
product.decreaseStock(quantity);
order.addOrderItem(OrderItem.create(product, quantity));
}
orderRepository.save(order);
return order.getId();
}
}@Entity
public class CouponEvent {
@Id @GeneratedValue
private Long id;
private String name;
private Integer totalQuantity;
private Integer issuedQuantity;
@Version
private Long version;
public void issue() {
if (issuedQuantity >= totalQuantity) {
throw new CouponExhaustedException("쿠폰이 모두 소진되었습니다.");
}
issuedQuantity++;
}
}
@Service
@RequiredArgsConstructor
public class CouponService {
// 비관적 락 사용 (선착순이므로 정확성 필수)
@Transactional
public Long issueCoupon(Long eventId, Long memberId) {
CouponEvent event = couponEventRepository.findByIdWithLock(eventId)
.orElseThrow();
// 중복 발급 체크
if (couponRepository.existsByEventIdAndMemberId(eventId, memberId)) {
throw new DuplicateCouponException("이미 발급받은 쿠폰입니다.");
}
event.issue(); // 수량 차감
Coupon coupon = Coupon.create(event, memberId);
couponRepository.save(coupon);
return coupon.getId();
}
}@Transactional 전파 옵션으로 트랜잭션 범위 제어
낙관적 락: @Version + 재시도로 충돌 처리
비관적 락: @Lock으로 행 잠금, 타임아웃 필수
데드락 방지: 락 순서 일관성 유지
마지막 세션에서는 Flyway를 이용한 DB 스키마 버전 관리를 학습합니다.
다중 서버 환경의 한계
JPA의 낙관적/비관적 락은 단일 DB 트랜잭션 내에서만 동작합니다. 다중 서버 환경에서는 분산 락이 필요합니다.
// build.gradle
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
}@Service
@RequiredArgsConstructor
public class StockService {
private final RedissonClient redissonClient;
private final ProductRepository productRepository;
public void decreaseStock(Long productId, int quantity) {
// 분산 락 키
String lockKey = "product:stock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 락 획득 시도 (대기 10초, 락 유지 5초)
boolean acquired = lock.tryLock(10, 5, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("락 획득 실패");
}
// 비즈니스 로직 실행
decreaseStockInternal(productId, quantity);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 획득 중 인터럽트", e);
} finally {
// 락 해제 (현재 스레드가 보유한 경우만)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
@Transactional
protected void decreaseStockInternal(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new EntityNotFoundException());
product.decreaseStock(quantity);
}
}// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
long waitTime() default 10L;
long leaseTime() default 5L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
// AOP 구현
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
String key = distributedLock.key();
RLock lock = redissonClient.getLock(key);
try {
boolean acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new RuntimeException("락 획득 실패: " + key);
}
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
// 사용 예시
@Service
public class StockService {
@DistributedLock(key = "'product:stock:' + #productId")
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(quantity);
}
}데드락 발생 조건
두 트랜잭션이 서로 다른 순서로 락을 획득하려 할 때 발생합니다.
// ❌ 데드락 발생 가능
@Transactional
public void transferA() {
Account from = accountRepository.findByIdWithLock(1L); // 락 1
Account to = accountRepository.findByIdWithLock(2L); // 락 2 대기
// ...
}
@Transactional
public void transferB() {
Account from = accountRepository.findByIdWithLock(2L); // 락 2
Account to = accountRepository.findByIdWithLock(1L); // 락 1 대기 → 데드락!
// ...
}
// ✅ 해결책 1: 락 순서 통일
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// ID 순서대로 락 획득
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
Account first = accountRepository.findByIdWithLock(firstId);
Account second = accountRepository.findByIdWithLock(secondId);
// 비즈니스 로직
if (fromId.equals(firstId)) {
first.withdraw(amount);
second.deposit(amount);
} else {
second.withdraw(amount);
first.deposit(amount);
}
}
// ✅ 해결책 2: 락 타임아웃 설정
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
Optional<Account> findByIdWithLock(Long id);// 의존성
// implementation 'org.springframework.retry:spring-retry'
@Configuration
@EnableRetry
public class RetryConfig {}
@Service
public class OrderService {
@Retryable(
value = {OptimisticLockException.class, ObjectOptimisticLockingFailureException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void updateOrder(Long orderId, OrderUpdateRequest request) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.update(request);
// 낙관적 락 충돌 시 자동 재시도
}
@Recover
public void recover(OptimisticLockException e, Long orderId, OrderUpdateRequest request) {
log.error("주문 업데이트 실패 (재시도 초과): orderId={}", orderId, e);
throw new RuntimeException("주문 업데이트에 실패했습니다. 잠시 후 다시 시도해주세요.");
}
}동시성 제어 선택 가이드