본문 바로가기

Spring/이론

[트러블슈팅] JPA Cascade와 트랜잭션 전파 - 편의성 vs 안전성

"JPA 고급 기능, 정말 써야 할까?" 시리즈 4편 (완결편)

들어가며

시리즈의 마지막 편입니다. 1편에서 연관관계 매핑의 복잡성을, 2편에서 상속관계 매핑의 실무 한계를, 3편에서 지연로딩의 함정과 해결책을 다뤘다면, 이번에는 영속성 전이(Cascade)와 트랜잭션 전파에 대해 이야기해보겠습니다. 이 두 기능의 공통점은 "편의성"입니다. 코드를 간단하게 만들어주는 매력적인 기능들이죠. 하지만 편의성 뒤에 숨겨진 "위험성"도 함께 알아야 합니다.

"편리하다고 해서 항상 좋은 것은 아니다" - 이것이 이번 편의 핵심 메시지입니다.


영속성 전이(Cascade): 편리하지만 위험한 마법

게시판으로 이해하는 Cascade

블로그 게시판을 예로 들어보겠습니다:

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private String content;
    
    @OneToMany(mappedBy = "post")
    private List<Comment> comments = new ArrayList<>();
}

@Entity  
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;
    
    @ManyToOne
    private Post post;
}

 

게시글을 저장할 때 댓글들도 함께 저장하고 싶습니다. 어떻게 해야 할까요?

 

Cascade 없을 때의 번거로움

@Service
@Transactional
public class PostService {
    
    public void createPost(PostCreateRequest request) {
        // 하나하나 다 저장해야 함 😫
        Post post = new Post(request.getTitle(), request.getContent());
        Post savedPost = postRepository.save(post);
        
        List<Comment> comments = request.getComments().stream()
                .map(commentDto -> new Comment(commentDto.getContent(), savedPost))
                .collect(toList());
        
        commentRepository.saveAll(comments);  // 별도로 저장
        
        // 연관관계 설정도 직접
        comments.forEach(comment -> post.addComment(comment));
    }
}

번거롭긴 하지만 명확하고 예측 가능합니다.

 

Cascade 적용: 마법같은 편리함

@Entity
public class Post {
    @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST)  // 🔥 마법!
    private List<Comment> comments = new ArrayList<>();
    
    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }
}

@Service
@Transactional
public class PostService {
    
    public void createPost(PostCreateRequest request) {
        Post post = new Post(request.getTitle(), request.getContent());
        
        request.getComments().forEach(commentDto -> {
            Comment comment = new Comment(commentDto.getContent());
            post.addComment(comment);
        });
        
        postRepository.save(post);  // 👍 이것만 하면 댓글들도 자동 저장!
    }
}

정말 편리해보이죠? 하지만 여기서 함정이 시작됩니다.


Cascade의 숨겨진 위험들

위험 1: 의도치 않은 삭제

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)  // 모든 상태 전이
private List<Comment> comments;

// 실수로 이렇게 하면...
@Transactional
public void updatePost(Long postId, PostUpdateRequest request) {
    Post post = postRepository.findById(postId).orElseThrow();
    
    post.getComments().clear();  // 댓글 리스트 비우기
    // 새로운 댓글들 추가...
    
    postRepository.save(post);   // 💥 기존 댓글들이 DB에서 삭제됨!
}

 

문제점:

  • clear() 호출로 모든 기존 댓글이 삭제
  • 개발자는 단순히 리스트를 비운 것뿐인데 실제 DB 데이터가 삭제
  • 데이터 유실 위험

 

위험 2: 예측하기 어려운 대량 삭제

// 게시글 하나 삭제
postRepository.delete(post);

// 실제로는 연관된 수백 개의 댓글도 함께 삭제될 수 있음
// 개발자는 게시글 하나만 삭제한다고 생각했는데...

 

문제점:

  • 성능 문제 (대량 DELETE 쿼리)
  • 예상치 못한 데이터 손실
  • 롤백 시 복구 어려움

 

위험 3: 복잡한 객체 그래프에서의 연쇄 반응

User → Post → Comment → Reply
  ↓      ↓       ↓       ↓
 ALL    ALL     ALL     ALL

// 사용자 삭제 시
userRepository.delete(user);
// 연쇄적으로 Post, Comment, Reply까지 모두 삭제!

 

실무에서 실제 일어난 사고:

  • 테스트 데이터 정리하려고 사용자 하나 삭제
  • 연관된 모든 게시글, 댓글, 답글까지 삭제됨
  • 복구 불가능한 데이터 손실

안전한 대안: 명시적 관리

방법 1: Cascade 없이 직접 관리

@Service
@Transactional
public class PostService {
    
    public void deletePost(Long postId) {
        // 명시적으로 순서 정해서 삭제
        commentRepository.deleteByPostId(postId);  // 댓글 먼저 삭제
        postRepository.deleteById(postId);         // 게시글 삭제
    }
    
    public void createPost(PostCreateRequest request) {
        // 명시적으로 저장
        Post post = postRepository.save(new Post(request));
        
        List<Comment> comments = request.getComments().stream()
                .map(dto -> new Comment(dto.getContent(), post))
                .collect(toList());
        
        commentRepository.saveAll(comments);  // 명시적 저장
    }
}

 

장점:

  • 예측 가능한 동작
  • 명확한 의도
  • 디버깅 용이
  • 실수 방지

 

방법 2: PERSIST만 제한적 사용

@OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST)  // 저장만 전이
private List<Comment> comments;

// 저장은 자동으로, 삭제는 명시적으로
@Transactional
public void deletePost(Long postId) {
    commentRepository.deleteByPostId(postId);  // 직접 삭제
    postRepository.deleteById(postId);
}

절충안: 편의성은 살리되 위험한 삭제는 명시적으로

 

방법 3: 소프트 삭제 적용

@Entity
public class Post {
    private boolean deleted = false;
    private LocalDateTime deletedAt;
    
    public void delete() {
        this.deleted = true;
        this.deletedAt = LocalDateTime.now();
    }
}

// Repository에서 삭제되지 않은 것만 조회
@Query("SELECT p FROM Post p WHERE p.deleted = false")
List<Post> findAllActive();

실제 삭제 대신 논리적 삭제로 데이터 보호


트랜잭션 전파: 복잡성의 늪

문제 상황: 트랜잭션 안에서 다른 트랜잭션 호출

@Service
public class OrderService {
    
    @Transactional
    public void createOrder(OrderRequest request) {
        // 1. 주문 생성
        Order order = orderRepository.save(new Order(request));
        
        // 2. 포인트 차감 (다른 서비스 호출)
        pointService.deductPoints(request.getUserId(), request.getAmount());
        
        // 3. 재고 감소 (다른 서비스 호출)  
        inventoryService.decreaseStock(request.getProductId(), request.getQuantity());
        
        // 4. 알림 발송 (다른 서비스 호출)
        notificationService.sendOrderNotification(order);
    }
}

@Service 
public class PointService {
    @Transactional  // 🤔 이 트랜잭션은 어떻게 될까?
    public void deductPoints(Long userId, int amount) {
        // 포인트 차감 로직
    }
}

핵심 질문: createOrder() 트랜잭션 안에서 deductPoints()를 호출할 때, 트랜잭션은 어떻게 처리될까요?

 

REQUIRED: 기본값이지만 함정 있음

@Transactional(propagation = Propagation.REQUIRED)  // 기본값
public void deductPoints(Long userId, int amount) {
    // 기존 트랜잭션에 참여
}

 

동작:

createOrder() 트랜잭션 시작
    ↓
deductPoints() 호출 → 같은 트랜잭션에 참여
    ↓
inventoryService() 호출 → 같은 트랜잭션에 참여
    ↓
하나라도 실패하면 전체 롤백

 

문제점:

  • 알림 발송 실패주문 전체가 롤백될 수 있음
  • 비즈니스 로직의 의존성 증가
  • 디버깅 어려움 (어디서 롤백됐는지 추적 어려움)

 

REQUIRES_NEW: 독립적이지만 복잡함

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(Order order) {
    // 새로운 트랜잭션으로 독립 실행
}

 

동작:

createOrder() 트랜잭션 시작
    ↓
sendNotification() 호출 → 새 트랜잭션 생성 (독립적)
    ↓
알림 실패해도 주문은 성공

 

문제점:

  • 데이터 정합성 문제 (주문은 성공했는데 포인트 차감 실패 등)
  • 복잡한 예외 처리 필요
  • 분산 트랜잭션과 유사한 복잡성

실무에서의 Best Practice

원칙 1: 단순함을 선택하라

// ✅ 좋은 예: 명확하고 단순
@Transactional
public void createOrder(OrderRequest request) {
    // 핵심 비즈니스 로직만 하나의 트랜잭션으로
    Order order = orderRepository.save(new Order(request));
    pointRepository.save(new Point(userId, -amount));
    inventoryRepository.save(new Inventory(productId, -quantity));
}

// 부가 기능들은 별도 처리
public void createOrderWithNotification(OrderRequest request) {
    Long orderId = createOrder(request);  // 트랜잭션 완료
    
    // 트랜잭션 밖에서 알림 처리
    try {
        notificationService.sendOrderNotification(orderId);
    } catch (Exception e) {
        log.error("알림 발송 실패, 주문 ID: {}", orderId, e);
        // 알림 실패가 주문에 영향 없음
    }
}

 

원칙 2: 이벤트 기반 아키텍처 고려

@Service
@Transactional
public class OrderService {
    
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        
        // 이벤트 발행으로 느슨한 결합
        applicationEventPublisher.publishEvent(
            new OrderCreatedEvent(order.getId())
        );
    }
}

@EventListener
@Async  // 비동기 처리
public void handleOrderCreated(OrderCreatedEvent event) {
    // 별도 트랜잭션에서 처리
    notificationService.sendOrderNotification(event.getOrderId());
}

 

장점:

  • 트랜잭션 경계 명확
  • 시스템 간 결합도 낮음
  • 확장성 좋음

 

원칙 3: 보상 트랜잭션 패턴

@Service
public class OrderService {
    
    public void createOrder(OrderRequest request) {
        try {
            // 1. 주문 생성
            Long orderId = orderRepository.save(new Order(request)).getId();
            
            // 2. 포인트 차감
            pointService.deductPoints(request.getUserId(), request.getAmount());
            
            // 3. 재고 감소
            inventoryService.decreaseStock(request.getProductId(), request.getQuantity());
            
        } catch (Exception e) {
            // 실패 시 보상 처리
            compensateOrder(orderId, request);
            throw e;
        }
    }
    
    private void compensateOrder(Long orderId, OrderRequest request) {
        // 역순으로 보상 처리
        inventoryService.increaseStock(request.getProductId(), request.getQuantity());
        pointService.refundPoints(request.getUserId(), request.getAmount());
        orderRepository.deleteById(orderId);
    }
}

실제 분산 시스템에서 사용하는 패턴


최종 권장사항

Cascade 사용 가이드

// ✅ 안전한 사용
@OneToMany(cascade = CascadeType.PERSIST)  // 저장만 전이
private List<OrderItem> orderItems;

// ⚠️ 신중한 사용  
@OneToMany(cascade = CascadeType.ALL)      // 강한 소유 관계에서만
private List<OrderItem> orderItems;

// ❌ 위험한 사용
@ManyToOne(cascade = CascadeType.ALL)      // 독립적인 엔티티에는 절대 금지
private User user;

 

트랜잭션 전파 가이드

// ✅ 대부분의 경우
@Transactional  // 기본값(REQUIRED) 사용

// ⚠️ 특별한 경우만
@Transactional(propagation = Propagation.REQUIRES_NEW)  // 로깅, 감사 등

// ❌ 복잡한 전파보다는
// 메서드 분리나 이벤트 기반 아키텍처 고려

실무 체크리스트

Cascade 적용 전 질문:

  1. 정말 생명주기가 동일한가?
  2. 독립적으로 존재할 가능성은 없나?
  3. 실수로 삭제될 위험은 없나?
  4. 명시적 관리가 더 안전하지 않나?

트랜잭션 전파 적용 전 질문:

  1. 정말 같은 트랜잭션이어야 하나?
  2. 실패 시 모두 롤백되어야 하나?
  3. 이벤트 기반으로 분리 가능하지 않나?
  4. 단순한 방법은 없나?

시리즈를 마무리하며

4편에 걸쳐 JPA의 고급 기능들을 살펴봤습니다. 각 편의 핵심 메시지를 정리하면:

시리즈 전체 메시지

  1. 연관관계 매핑: Repository 패턴이 더 안전할 수 있다
  2. 상속관계 매핑: 컴포지션이 더 단순할 수 있다
  3. 지연로딩과 Proxy: 명시적 제어가 더 예측 가능하다
  4. Cascade와 트랜잭션 전파: 편의성보다 안전성이 우선이다

일관된 철학: "외우지 말고 이해하자"

  • 모든 기능을 써야 한다는 강박 버리기
  • "할 수 있다"와 "해야 한다" 구분하기
  • 복잡한 기능보다 단순한 해결책 우선 고려
  • 실무에서는 안전성과 유지보수성 중심으로 판단

마지막 당부

JPA는 정말 강력한 도구입니다. 하지만 강력한 도구일수록 신중하게 사용해야 합니다.

복잡한 기능보다는 단순하고 명확한 코드가 장기적으로 더 가치 있습니다.

"개발자도 다 찾아가면서 한다" : 모든 걸 외우려 하지 말고, 적절한 선택을 할 수 있는 판단력을 기르는 것이 더 중요하다고 생각합니다.


"JPA 고급 기능, 정말 써야 할까?" 시리즈 완결

  1. 연관관계 매핑 vs Repository 패턴
  2. 상속관계 매핑 3가지 전략
  3. 지연로딩과 Proxy - LazyInitializationException 해결하기
  4. Cascade와 트랜잭션 전파 - 편의성 vs 안전성 ← 현재 글

긴 시리즈를 읽어주셔서 감사합니다. 이 글들이 JPA를 학습하는 많은 개발자들에게 실질적인 도움이 되었으면 좋겠습니다. 다들 화이팅입니다!