Cascade·@Transactional 함정 (JPA 7편)

6편에서 @ManyToMany 로 사고 치는 얘기를 했다. 이번엔 cascade@Transactional 이다.

둘 다 편해서 습관적으로 붙이는데, 연차와 상관없이 사고가 난다.

JPA Cascade 전파 범위와 @Transactional self-invocation 문제 다이어그램

Cascade, 편하다고 ALL 박으면 큰일 남

CascadeType.ALL 은 부모를 저장하면 자식도 같이 저장돼서 편하다. 문제는 삭제도 같이 전이된다는 걸 까먹는 순간이다.

옵션은 여섯 가지다. PERSIST 는 부모 저장 시 자식도 저장하고 가장 안전해서 자주 쓴다. MERGE 는 병합을 전이하며 보통 PERSIST 와 같이 쓴다. REMOVE 는 부모 삭제 시 자식도 삭제하는데, 생명주기가 완전히 종속될 때만 쓴다. REFRESHDETACH 는 실무에서 거의 안 쓴다. ALL 은 위 다섯을 전부 켜는 것이라, 편하다고 습관적으로 쓰면 REMOVE 때문에 사고가 납니다.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();

이렇게 걸어두면 나중에 누가 “주문 하나만 잠깐 지워볼게요” 하고 orderRepository.delete(order) 를 부르는 순간 연결된 OrderItem 이 통째로 날아간다.

주문 하나에 아이템 2개를 넣어두고 주문만 지워본 로그다.

select o1_0.id, o1_0.status
from orders o1_0 where o1_0.id=?          -- 주문 조회

select oi1_0.order_id, oi1_0.id, oi1_0.item_name
from order_item oi1_0 where oi1_0.order_id=?   -- 자식을 먼저 읽어오고

delete from order_item where id=?         -- 아이템 1
delete from order_item where id=?         -- 아이템 2
delete from orders where id=?             -- 그다음에야 주문

delete 를 한 번 불렀는데 DELETE 가 3번 나갔다. 자식을 지우려고 SELECT 까지 한 번 더 나간다. 아이템이 수천 개면 그것부터 전부 읽어온다는 뜻이다.

orphanRemoval 까지 켜져 있으면 컬렉션에서 빼기만 해도 삭제 쿼리가 나간다. “지운 적 없는데 왜 없어졌지” 하는 상황이 생긴다.

판단 기준은 이렇다. OrderOrderItem 처럼 부모 없이는 자식이 존재할 수 없는 진짜 종속 관계일 때만 ALL 이나 REMOVE 를 쓴다. 그냥 연관만 있는 관계(MemberOrder)에는 PERSIST 정도만 걸거나 아예 안 쓴다.

동작 차이는 아래 참고 절의 글들에 사례와 함께 정리돼 있습니다.

@Transactional, 같은 클래스 안에서 부르면 안 먹힌다는 거 아셨어요?

Spring 의 @Transactional 은 AOP 프록시로 동작한다.

같은 클래스 안에서 this.method() 로 내부 메서드를 직접 호출하면 프록시를 거치지 않고 원본이 실행된다. 트랜잭션이 아예 안 걸린다.

@Service
public class OrderService {

    public void cancelOrder(Long orderId) {
        // 트랜잭션 없이 그냥 내부 호출됨
        updateOrderStatus(orderId);
    }

    @Transactional
    public void updateOrderStatus(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.cancel();
    }
}

cancelOrder 에서 updateOrderStatus 를 불렀는데 @Transactional 이 안 먹어 더티체킹도 안 되고 예외가 터져도 롤백이 안 된다.

컴파일도 되고 테스트도 넘어가다가 운영에서 정합성이 깨지고 나서야 발견된다.

해결은 간단하다. 트랜잭션이 필요한 로직을 다른 빈으로 분리해 실제로 프록시를 타게 만들면 됩니다.

실무에서 같이 챙기면 좋은 @Transactional 옵션 두 가지

  • readOnly = true: 단순 조회 메서드에 붙이면 하이버네이트가 굳이 스냅샷 비교(더티체킹)를 안 하고, 플러시도 생략해서 성능이 조금 더 좋아져요. 조회 전용 서비스 메서드에는 습관적으로 붙이는 게 실무 관례예요.
  • propagation: 기본값인 REQUIRED는 기존 트랜잭션이 있으면 합류하는 방식인데, 로그 남기기처럼 부모 트랜잭션이 롤백돼도 독립적으로 커밋되고 싶은 로직은 REQUIRES_NEW로 새 트랜잭션을 강제로 열어야 해요. 이거 모르고 REQUIRED로 두면, 정작 실패 이력을 남기려던 로그 저장까지 부모 트랜잭션 롤백에 같이 휩쓸려서 사라지는 경우가 생겨요.

정리

  • cascade는 진짜 생명주기를 같이 가야 하는 관계에만, 습관적으로 ALL 박지 말기
  • @Transactional은 프록시 기반이라 같은 클래스 내부 호출에서는 무효, 트랜잭션이 필요한 로직은 별도 빈으로 분리하기
  • 단순 조회엔 readOnly = true, 부모 트랜잭션과 독립적으로 커밋돼야 하는 로직엔 propagation = REQUIRES_NEW

둘 다 “붙여만 놓으면 알아서 되겠지” 하다가 사고 나는 옵션이다. 언제 발동하는지는 한 번씩 짚고 넘어가야 한다.

참고

JPA 시리즈 전체 보기

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤