[트러블 슈팅] 기존 엔티티의 부속 엔티티를 재활용하지 못하는 문제.

이규정·2025년 3월 7일
0

1. 문제 상황


Order 생성 시, Cart 엔티티의 cartItems를 Order의 orderMenus에 그대로 할당하여 저장하려고 할 때 detached entity passed to persist 와 같은 JPA 시스템 예외가 발생함.

- 예외 메시지

javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist

- 구조 상세

주문 전에는 장바구니(Cart)에 아이템(CartItem) 을 넣어 관리하고, 주문(Order)이 시작되면 장바구니를 호출하여 장바구니에 들어있는 아이템을 오더에 그대로 넣어주며, 아이템에서 장바구니와의 연관관계를 끊고 주문과의 관계를 시작하는 구조.

주문이 시작 되기 전 장바구니는 소프트 딜리트를 적용하여, 향후 사용자의 관심메뉴를 제공하는 기능에 활용될 예정.

- 서비스 로직

@Transactional
public OrderResponseDto createOrder(AuthUser authUser, Pageable pageable) {
    // ... (유저, 카트 검증 로직)
    Cart cart = cartRepository.findCartByUserAndDeletedAtIsNull(user);
    // Order 생성 시 detach된 CartItem 문제 발생
    Order newOrder = new Order(OrderState.CLIENT_ACCEPT, user, cart);
    Order order = orderRepository.save(newOrder);
    cart.getCartItems().forEach(cartItem -> cartItem.updateOrder(order));
    cart.setDeletedAt();
    return new OrderResponseDto(order, pageable);
}

- CartRepository

public interface CartRepository extends JpaRepository<Cart, Long> {

    Cart findCartByUserAndDeletedAtIsNull(User user);
}

- Order 엔티티

@Getter
@Entity
@NoArgsConstructor
@Table(name = "orders")
public class Order extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderState state;

    @Enumerated(EnumType.STRING)
    private CancelReason reason;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "shop_id", nullable = false)
    private Shop shop;

    // 아이템 (CartItem)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<CartItem> orderMenus;

    private Integer totalPrice;


    public Order(OrderState state, User user, Cart cart) {
        this.state = state;
        this.user = user;
        this.shop = cart.getShop();
        this.totalPrice = cart.getTotalPrice();
    }
}

- Cart 엔티티

@Getter
@Entity
@NoArgsConstructor
@Table(name = "carts")
public class Cart extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "shop_id", nullable = false)
    private Shop shop;

    // 아이템 (CartItem)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<CartItem> cartItems;

    private Integer totalPrice;


    public Cart(User user, Shop shop, List<CartItem> cartItems){
        this.user = user;
        this.shop = shop;
        this.cartItems = cartItems;
    }
}

- CartItem 엔티티

@Getter
@Entity
@NoArgsConstructor
@Table(name = "cart_items")
public class CartItem extends BaseTimeEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne
    @JoinColumn(name = "cart_id")
    private Cart cart;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "menu_id", nullable = false)
    private Menu menu;

    private String name;

    private Integer price;

    private Integer quantity;


    public CartItem(Menu menu, Integer quantity){
        this.menu = menu;
        this.name = menu.getName();
        this.price = menu.getPrice();
        this.quantity = quantity;
    }

    public void updateCart(Cart cart) {
        this.cart = cart;
    }

    public void updateOrder(Order order){
        this.cart = null;
        this.order = order;
    }
}

2. 사실 수집


현상

Order를 저장할 때 JPA는 CartItem들이 이미 DB에 존재하는 detached 상태임에도 불구하고 cascade persist를 시도하여 예외 발생함.

예외 발생 추측

  1. Lazy 로딩 문제
    fetch = FetchType.LAZY 설정이 되어 있을 경우, Cart 조회 시 CartItem이 즉시 로딩되지 않음. 따라서 Order에 추가할 때 detach된 객체가 될 가능성이 높음.

  2. 영속성 컨텍스트의 범위
    Cart를 조회한 시점에는 CartItem이 영속 상태가 아닐 수 있으며, 트랜잭션 범위를 벗어나면 detach됨.

  3. CascadeType.PERSIST 설정
    Order의 orderMenus에 CascadeType.PERSIST가 적용된 경우, Order를 저장하면서 관련 CartItem도 persist하려고 시도함. 하지만 CartItem은 이미 DB에 존재하는 엔티티이므로 새로 persist할 수 없음.

  4. Detached 엔티티 문제
    detach된 CartItem을 Order에 추가한 후 저장하면 JPA가 이를 새로운 엔티티로 간주하여 persist하려고 함. 그러나 기존 CartItem은 식별자를 가지고 있으므로 새로 persist하는 것이 불가능하여 예외가 발생함.

  5. EntityManager.merge() 사용 가능
    detached 상태의 엔티티를 영속 상태로 변경하려면 persist가 아닌 merge()를 사용해야 함.

  6. 패치 조인(fetch join) 활용 가능
    JPQL에서 join fetch를 사용하면, Cart와 연관된 CartItem을 함께 로딩할 수 있어 detach 문제를 방지할 수 있음.

  7. 매핑 테이블을 활용한 해결 가능
    CartItem을 Order에 직접 추가하는 대신, 매핑 테이블을 활용하여 관계를 관리하면 detach 문제를 피할 수 있음.

  8. ID를 기반으로 CartItem을 새로 조회하면 문제 해결 가능
    detach된 CartItem을 ID로 다시 조회하면 영속 상태로 변경되어 문제 해결됨.

3. 원인 분석


핵심 원인

Order 생성 시, Cart에서 가져온 CartItem들이 detach 상태이기 때문에, Order를 persist할 때 JPA가 이들 객체를 새 엔티티로 인식하여 예외 발생.

상세 분석

  1. 영속성 컨텍스트에서 관리되지 않는 CartItem

    • JPA는 조회된 엔티티를 영속성 컨텍스트에서 관리하지만, Cart의 cartItems가 Lazy 로딩된 상태라면, 해당 CartItem들이 실제 사용되기 전까지는 영속성 컨텍스트에 포함되지 않음.
    • CartItem이 영속성 컨텍스트에서 분리된(detached) 상태이면, Order에 추가할 때 JPA가 이를 새로운 엔티티로 인식하여 persist하려고 함.
  2. Cascade persist 문제

    • CascadeType.PERSIST가 설정된 상태에서, Order에 CartItem을 추가하면 JPA는 Order를 persist할 때 CartItem도 persist하려고 시도함.
    • 하지만 CartItem은 이미 DB에 존재하는 엔티티로, persist 대상이 될 수 없음.
    • 따라서 detached entity passed to persist 예외가 발생하게 됨.
  3. Lazy 로딩과 detach 문제

    • CartItem이 Order에 추가되기 전에 detach 상태일 경우, JPA는 이를 관리할 수 없음.
    • CartItem을 새로 조회하지 않으면, Order에 추가할 때 JPA는 해당 CartItem을 새 엔티티로 간주하고 persist를 시도함.
    • CartItem을 별도로 조회하여 영속 상태로 만들어야 문제를 해결할 수 있음.
  4. Order에 할당하는 방식의 문제

    • CartItem을 Order의 orderMenus에 그대로 할당하면, detached 객체가 추가됨.
    • 해결 방법으로는
      • ID 기반으로 CartItem을 새로 조회
      • EntityManager.merge() 활용
      • 패치 조인(fetch join) 사용
      • 매핑 테이블을 활용한 연관관계 설정 등이 있음.
  5. 트랜잭션 범위 문제

    • CartItem이 처음 조회된 이후, 영속성 컨텍스트가 종료되거나 다른 트랜잭션에서 사용될 경우 detach됨.
    • 트랜잭션이 유지된 상태에서 처리하지 않으면 JPA는 CartItem을 관리할 수 없음.

4. 해결 방법


(A) CartItem을 새로 조회해서 해결 (가장 안전하며, 실제 프로젝트에 적용된 방식)

방법 설명
detach 상태의 CartItem을 다시 영속 상태로 만들기 위해 ID로 새로 조회해서 해결함.

수정 코드 예시 (서비스 로직)

@Transactional
public OrderResponseDto createOrder(AuthUser authUser, Pageable pageable){
    Cart cart = cartRepository.findCartByUserAndDeletedAtIsNull(user);

        // 새로운 Order 생성
        Order newOrder = new Order(OrderState.CLIENT_ACCEPT, user, cart);
        newOrder.setTotalPrice(cart.getTotalPrice()); // totalPrice 설정

        // CartItem 복사 및 Order에 연결
        List<CartItem> orderMenus = new ArrayList<>();

        // 새로운 CartItem (복사본) 생성하여 Order과 연결
        for (CartItem cartItem : cart.getCartItems()) {
            CartItem newOrderItem = new CartItem(cartItem.getMenu(), cartItem.getQuantity()); 
            newOrderItem.updateOrder(newOrder); 
            orderMenus.add(newOrderItem);
        }

        // Order에 새로운 CartItem 목록 설정
        newOrder.setOrderMenus(orderMenus); 
         // Order 저장 (CascadeType.ALL 로 인해 CartItem들도 함께 저장됨)
        Order order = orderRepository.save(newOrder);
        cart.setDeletedAt();
        // Cart soft delete (deletedAt 업데이트)
        cartRepository.save(cart); 

        return new OrderResponseDto(order, pageable);
    }

장점

  • detach된 CartItem 문제를 완전히 해결 가능
  • 데이터 일관성이 보장됨

단점

  • CartItem을 추가적으로 조회하는 쿼리 비용 발생
  • 코드가 약간 길어질 수 있음

(B) EAGER 설정 또는 엔티티 그래프 사용

방법 설명
Cart 엔티티의 cartItems 필드를 EAGER로 설정(혹은 특정 쿼리에서 엔티티그래프를 사용)하면, Cart 조회 시에 연관 CartItem들이 즉시 로딩되어 영속성 컨텍스트에 포함됨.

수정 코드 예시 (Order 엔티티)

// Cart 엔티티 - cartItems 필드에 EAGER 설정
@OneToMany(mappedBy = "cart", fetch = FetchType.EAGER)
private List<CartItem> cartItems;

장점

  • 별도의 추가 코드 없이 Cart 조회 시 항상 CartItem을 로딩
  • 이후 Order 생성 시 detach 문제 발생 없이 연관관계 설정 가능

단점

  • 항상 CartItem이 로딩되어 성능에 부정적 영향을 줄 수 있음
  • 모든 상황에서 CartItem이 필요한 것이 아니라면 불필요한 데이터 로딩 발생

(C) merge()를 통한 재부착

방법 설명
detach된 CartItem들을 EntityManager.merge()를 호출해 재부착한 후 Order에 연결하는 방식.

수정 코드 예시 (서비스 로직)

@Service
@RequiredArgsConstructor
public class OrderService {
    // 필요한 Repository와 함께 EntityManager 주입
    private final OrderRepository orderRepository;
    private final CartRepository cartRepository;
    private final UserRepository userRepository;

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public OrderResponseDto createOrder(AuthUser authUser, Pageable pageable) {
        // ... (유저, 카트 검증 로직)
        Cart cart = cartRepository.findCartByUserAndDeletedAtIsNull(user);
        
        Order order = new Order(OrderState.CLIENT_ACCEPT, user, cart);

        if (cart.getCartItems() != null) {
            for (CartItem cartItem : cart.getCartItems()) {
                // merge()를 통해 detach된 CartItem 재부착
                CartItem managedCartItem = entityManager.merge(cartItem);
                order.addCartItem(managedCartItem);
            }
        }

        Order savedOrder = orderRepository.save(order);
        cart.setDeletedAt();
        return new OrderResponseDto(savedOrder, pageable);
    }
}

장점

  • 명시적으로 detach된 CartItem을 재부착하여 문제 해결
  • 기존 데이터의 연관관계를 그대로 사용 가능

단점

  • 코드가 복잡해지며, merge() 호출로 인해 불필요한 DB 업데이트가 발생할 가능성
  • 재부착 시점을 개발자가 명시적으로 관리해야 함

(D) 패치 조인(Fetch Join) 방식

방법 설명
JPQL 쿼리에서 패치 조인(join fetch)을 사용해 Cart와 연관된 CartItem을 한 번의 쿼리로 로딩하여, CartItem들이 영속 상태에 포함되도록 함.

수정 코드 예시 (CartRepository, 서비스 로직)

public interface CartRepository extends JpaRepository<Cart, Long> {
    @Query("select c from Cart c join fetch c.cartItems where c.user = :user and c.deletedAt is null")
    Cart findCartByUserAndDeletedAtIsNull(@Param("user") User user);
}
@Transactional
public OrderResponseDto createOrder(AuthUser authUser, Pageable pageable) {
    // ... (유저, 카트 검증 로직)
    // 패치 조인을 통해 CartItem들이 함께 로딩됨
    Cart cart = cartRepository.findCartByUserAndDeletedAtIsNull(user);
    
    Order order = new Order(OrderState.CLIENT_ACCEPT, user, cart);

    if (cart.getCartItems() != null) {
        for (CartItem cartItem : cart.getCartItems()) {
            order.addCartItem(cartItem);
        }
    }
    
    Order savedOrder = orderRepository.save(order);
    cart.setDeletedAt();
    return new OrderResponseDto(savedOrder, pageable);
}

장점

  • 필요한 경우에만 연관 데이터를 함께 로딩할 수 있어 효율적
  • 별도의 merge() 호출 없이 영속성 컨텍스트에 포함되어 문제 해결

단점

  • JPQL 쿼리 작성이 복잡해질 수 있음
  • 패치 조인 대상이 컬렉션인 경우 결과 집합 중복에 주의해야 함

(E) Cascade 설정 조정

방법 설명
Order 엔티티의 연관관계에 대해 CascadeType.PERSIST 대신 CascadeType.MERGE 등을 사용하면, Order 저장 시 detach된 CartItem을 자동으로 merge하여 재부착할 수 있음.

수정 코드 예시 (Order 엔티티)

@Getter
@Entity
@NoArgsConstructor
@Table(name = "orders")
public class Order extends BaseTimeEntity {

    // ... (기존 필드 생략)

    // CascadeType.PERSIST를 제거하여 persist 시 detach된 CartItem이 재부착되지 않도록 함
    @OneToMany(mappedBy = "order", cascade = {CascadeType.MERGE, CascadeType.REMOVE}, orphanRemoval = true)
    private List<CartItem> orderMenus = new ArrayList<>();

    // 생성자 및 헬퍼 메서드
    public Order(OrderState state, User user, Cart cart) {
        this.state = state;
        this.user = user;
        this.shop = cart.getShop();
        this.totalPrice = cart.getTotalPrice();
    }
    
    public void addCartItem(CartItem cartItem) {
        orderMenus.add(cartItem);
        cartItem.updateOrder(this);
    }
}

장점

  • Cascade 옵션에 따라 자동으로 merge가 이루어지므로 별도 merge() 호출이 필요 없음
  • 연관관계 관리가 자동화되어 코드 간결

단점

  • Cascade 설정이 잘못되면 의도치 않은 업데이트나 삭제가 발생할 수 있음
  • 이미 존재하는 엔티티에 대해 persist가 호출되지 않도록 세심한 설정 필요

(F) 매핑 테이블(Join Table) 활용

방법 설명
Order와 CartItem 사이의 연관관계를 직접 외래키로 관리하지 않고, 별도의 매핑 테이블을 사용하여 관계 정보를 저장하는 방식임. 이 방식은 Order 저장 시, CartItem 자체는 변경하지 않고 매핑 테이블에 관계만 기록하므로 detach 문제를 피할 수 있음.

수정 코드 예시 (Order 엔티티)

@Getter
@Entity
@NoArgsConstructor
@Table(name = "orders")
public class Order extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderState state;

    @Enumerated(EnumType.STRING)
    private CancelReason reason;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "shop_id", nullable = false)
    private Shop shop;

    // 매핑 테이블을 사용한 단방향 OneToMany 관계
    @OneToMany(cascade = {CascadeType.MERGE, CascadeType.REMOVE})
    @JoinTable(
        name = "order_cart_items",
        joinColumns = @JoinColumn(name = "order_id"),
        inverseJoinColumns = @JoinColumn(name = "cart_item_id")
    )
    private List<CartItem> orderCartItems = new ArrayList<>();

    private Integer totalPrice;

    public Order(OrderState state, User user, Cart cart) {
        this.state = state;
        this.user = user;
        this.shop = cart.getShop();
        this.totalPrice = cart.getTotalPrice();
    }

    public void addCartItem(CartItem cartItem) {
        orderCartItems.add(cartItem);
    }
}
@Transactional
public OrderResponseDto createOrder(AuthUser authUser, Pageable pageable) {
    // ... (유저, 카트 검증 로직)
    Cart cart = cartRepository.findCartByUserAndDeletedAtIsNull(user);
    
    Order order = new Order(OrderState.CLIENT_ACCEPT, user, cart);

    if (cart.getCartItems() != null) {
        for (CartItem cartItem : cart.getCartItems()) {
            order.addCartItem(cartItem);
        }
    }
    
    Order savedOrder = orderRepository.save(order);
    cart.setDeletedAt();
    return new OrderResponseDto(savedOrder, pageable);
}

장점

  • Order와 CartItem의 관계가 매핑 테이블에 기록되므로, detach된 CartItem 문제를 우회할 수 있음
  • 기존 CartItem 엔티티를 수정하지 않고 관계만 관리

단점

  • 매핑 테이블을 별도로 관리해야 하므로 설계가 복잡해질 수 있음
  • 단방향 관계로 관리되므로, 양방향 연관관계가 필요한 경우 추가 작업 필요

5. 참고자료

profile
반갑습니다. 백엔드 개발자가 되기 위해 노력중입니다.

0개의 댓글

관련 채용 정보