Session 7JPA Workshop

트랜잭션과 동시성

@Transactional 상세 옵션, 전파 설정, 낙관적/비관적 락을 학습합니다.

학습 목표

  • @Transactional의 전파 옵션을 이해하고 활용할 수 있다
  • 낙관적 락(@Version)의 동작 원리를 이해한다
  • 비관적 락(@Lock)을 적절히 사용할 수 있다
  • 상황에 맞는 동시성 제어 전략을 선택할 수 있다
  • 분산 락(Redis)을 구현하고 데드락을 방지할 수 있다

1. @Transactional 상세

1.1 @Transactional 속성

속성설명기본값
propagation트랜잭션 전파 옵션REQUIRED
isolation격리 수준DEFAULT (DB 설정)
readOnly읽기 전용 여부false
timeout타임아웃 (초)-1 (무제한)
rollbackFor롤백할 예외RuntimeException

1.2 Propagation (전파 옵션)

REQUIRED (기본값)

기존 트랜잭션 있으면 참여, 없으면 새로 생성

REQUIRES_NEW

항상 새 트랜잭션 생성 (기존 트랜잭션 일시 중단)

NESTED

중첩 트랜잭션 (Savepoint 사용)

MANDATORY

기존 트랜잭션 필수, 없으면 예외 발생

1.3 실무 활용 예시

@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) {
        // 항상 새 트랜잭션: 메인 트랜잭션 롤백되어도 로그는 남음
    }
}

1.4 @Transactional 주의사항

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 핵심 원칙

  • • public 메서드에만 적용 (프록시 기반)
  • • 내부 호출 시 프록시 우회 주의
  • • readOnly=true로 읽기 성능 최적화
  • • Checked Exception은 기본 롤백 안됨

2. 동시성 제어와 락

2.1 낙관적 락 (Optimistic Lock)

@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 = ?
장점
  • • DB 락 없이 동시성 제어
  • • 성능 우수 (락 대기 없음)
단점
  • • 충돌 시 재시도 로직 필요
  • • 충돌 빈번하면 비효율

2.2 낙관적 락 + 재시도 패턴

// 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));  // 백오프
        }
    }
}

2.3 비관적 락 (Pessimistic Lock)

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

2.4 락 선택 가이드

낙관적 락 사용

  • • 충돌이 드문 경우 (읽기 위주)
  • • 성능이 중요한 경우
  • • 게시글 수정, 프로필 업데이트
  • • 재시도 가능한 작업

비관적 락 사용

  • • 충돌이 빈번한 경우
  • • 재고 차감 등 정확성 필수
  • • 짧은 트랜잭션
  • • 선착순 이벤트, 좌석 예약

락 전략 핵심 원칙

  • • 낙관적 락: @Version + 재시도 패턴
  • • 비관적 락: @Lock + 타임아웃 설정 필수
  • • 트랜잭션은 최대한 짧게 유지
  • • 데드락 방지를 위해 락 순서 일관성 유지

3. 실습 및 정리

3.1 이커머스 재고 관리 예제

@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();
    }
}

3.2 선착순 쿠폰 발급 예제

@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();
    }
}

3.3 정리

@Transactional 전파 옵션으로 트랜잭션 범위 제어

낙관적 락: @Version + 재시도로 충돌 처리

비관적 락: @Lock으로 행 잠금, 타임아웃 필수

데드락 방지: 락 순서 일관성 유지

핵심 키워드

@TransactionalPropagationREQUIREDREQUIRES_NEW@Version낙관적 락@Lock비관적 락PESSIMISTIC_WRITEFOR UPDATE@Retryable
Session 8: Flyway DB 마이그레이션

마지막 세션에서는 Flyway를 이용한 DB 스키마 버전 관리를 학습합니다.

4. 분산 락과 데드락 해결

4.1 분산 환경에서의 동시성 문제

다중 서버 환경의 한계

JPA의 낙관적/비관적 락은 단일 DB 트랜잭션 내에서만 동작합니다. 다중 서버 환경에서는 분산 락이 필요합니다.

락 전략 비교
낙관적 락
  • • 단일 서버/DB
  • • 충돌 적을 때
  • • 재시도 필요
비관적 락
  • • 단일 서버/DB
  • • 충돌 많을 때
  • • 데드락 주의
분산 락
  • • 다중 서버
  • • Redis/Zookeeper
  • • 확장성 좋음

4.2 Redis 분산 락 (Redisson)

의존성 추가
// 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);
    }
}
AOP 기반 분산 락
// 커스텀 어노테이션
@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);
    }
}

4.3 데드락 방지

데드락 발생 조건

두 트랜잭션이 서로 다른 순서로 락을 획득하려 할 때 발생합니다.

데드락 예방 전략
// ❌ 데드락 발생 가능
@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);

4.4 재시도 패턴

Spring Retry 활용
// 의존성
// 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("주문 업데이트에 실패했습니다. 잠시 후 다시 시도해주세요.");
    }
}

동시성 제어 선택 가이드

낙관적 락: 충돌 적음, 읽기 많음, 재시도 가능
비관적 락: 충돌 많음, 재고/좌석 예약, 단일 DB
분산 락: 다중 서버, 외부 리소스 동기화