6편에서 @ManyToMany 로 사고 치는 얘기를 했다. 이번엔 cascade 와 @Transactional 이다.
둘 다 편해서 습관적으로 붙이는데, 연차와 상관없이 사고가 난다.

Cascade, 편하다고 ALL 박으면 큰일 남
CascadeType.ALL 은 부모를 저장하면 자식도 같이 저장돼서 편하다. 문제는 삭제도 같이 전이된다는 걸 까먹는 순간이다.
옵션은 여섯 가지다. PERSIST 는 부모 저장 시 자식도 저장하고 가장 안전해서 자주 쓴다. MERGE 는 병합을 전이하며 보통 PERSIST 와 같이 쓴다. REMOVE 는 부모 삭제 시 자식도 삭제하는데, 생명주기가 완전히 종속될 때만 쓴다. REFRESH 와 DETACH 는 실무에서 거의 안 쓴다. 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 까지 켜져 있으면 컬렉션에서 빼기만 해도 삭제 쿼리가 나간다. “지운 적 없는데 왜 없어졌지” 하는 상황이 생긴다.
판단 기준은 이렇다. Order 와 OrderItem 처럼 부모 없이는 자식이 존재할 수 없는 진짜 종속 관계일 때만 ALL 이나 REMOVE 를 쓴다. 그냥 연관만 있는 관계(Member 와 Order)에는 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
둘 다 “붙여만 놓으면 알아서 되겠지” 하다가 사고 나는 옵션이다. 언제 발동하는지는 한 번씩 짚고 넘어가야 한다.