[ T I L ] 2024.03.12

오세창·2024년 3월 12일

TIL

목록 보기
9/18

문제

장바구니 취소기능을 구현하는 와중에 문제가 발생하였다.

나는 프로젝트 전체적으로 soft-delete 정책을 이용하여 삭제 기능을 구현하려 했기에, 장바구니 취소도 해당 내용을 토대로 구현하고자 하였다.

CartManagementService.java

    public void deleteCart(User user, Long cartMenuId) {
        
        CartMenu findCartMenu = cartMenuQueryService.findOneCartMenu(cartMenuId);

        if(!findCartMenu.getCart().getUser().getId().equals(user.getId())){
            throw new IllegalArgumentException("본인의 장바구니만 접근할 수 있습니다.");
        }

        System.out.println("// ====== 장바구니 삭제 ======= //");
        findCartMenu.deleteCartMenu();
    }

CartMenu.java

    public void deleteCartMenu() {
        this.isDeleted = true;
    }

이처럼 코드를 구현하였고, 해당 API 를 호출하면 지정한 장바구니 목록이 논리 삭제가 되는 방식이다.

Query

// ====== 장바구니 목록 조회 ======= //
Hibernate: 
    select
        cm1_0.id,
        cm1_0.cart_id,
        cm1_0.count,
        cm1_0.is_deleted,
        cm1_0.menu_id 
    from
        cart_menu cm1_0 
    where
        cm1_0.id=?
// ====== 장바구니 목록 검증 ======= //
Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.deleted_at,
        c1_0.modified_at,
        u1_0.id,
        u1_0.created_at,
        u1_0.deleted_at,
        u1_0.email,
        u1_0.is_deleted,
        u1_0.login_id,
        u1_0.modified_at,
        u1_0.password,
        u1_0.role 
    from
        cart c1_0 
    left join
        users u1_0 
            on c1_0.id=u1_0.cart_id 
    where
        c1_0.id=?
// ====== 장바구니 삭제 ======= //
Hibernate: 
    update
        cart_menu 
    set
        cart_id=?,
        count=?,
        is_deleted=?,
        menu_id=? 
    where
        id=?

쿼리를 확인하니 update 문이 확실하게 나간 것을 보고, DB 도 확인해보니 문제 없이 isDeleted 의 값이 true 변환된 것을 확인할 수 있었다.

그런데 왜 문제냐면,,,

제대로 논리 삭제에 의해 제거가 되었는지 확인을 위해 장바구니 조회 기능을 실행해보니, 예상과는 다르게 삭제되었다고 생각한 목록이 출력되는 것이었다.

CartMenuRepository.java

    @Query("select cm from CartMenu cm where cm.cart.id = :cartId and cm.menu.id = :menuId and cm.isDeleted = false")
    List<CartMenu> findByCartIdAndMenuId(@Param("cartId") Long cartId, @Param("menuId") Long menuId);

분명 장바구니 목록 조회 쿼리 조건문에 isDeletd 가 false 인 것만 조회하라고 명시해뒀는데 이상했다.

readAllCart()

    private List<CartMenuResponseDto> getCartMenuResponseDtos(Cart oneCart, Map<Long, List<CartMenuOption>> cartOptionMap) {
        List<CartMenuResponseDto> cartMenuResponseDtos = oneCart.getCartMenus().stream()
                .map(cartMenu -> {
                    List<CartMenuOptionResponseDto> optionDtos = cartOptionMap.get(cartMenu.getId())
                            .stream()
                            .map(CartMenuOptionResponseDto::new)
                            .collect(Collectors.toList());
                    CartMenuResponseDto cartMenuResponseDto = new CartMenuResponseDto(cartMenu, optionDtos);

                    return cartMenuResponseDto;
                })
                .collect(Collectors.toList());
        return cartMenuResponseDtos;
    }

조회 메서드 로직을 확인해보니, 응답 Dto 에 매핑할 때 JPA 메서드를 이용하여 매핑하는 것이 아니라, 조회한 장바구니 내에서 stream 을 통해 모든 목록들을 매핑했던 것이다.

이러면 isDeleted 가 있든 없든 상관없겠구나 싶었다.

시도 ( 1 )

그렇다면 삭제할 때 Cart 의 CartMenuList 에서 해당 CartMenu 를 제거해주면 되지 않을까 싶었다.

CartManagementServcie.java

    public void deleteCart(User user, Long cartMenuId) {

        Cart findCart = findOneCart(user);

        CartMenu findCartMenu = cartMenuQueryService.findOneCartMenu(cartMenuId);

        if(!findCart.getUser().getId().equals(user.getId())){
            throw new IllegalArgumentException("본인의 장바구니만 접근할 수 있습니다.");
        }

        System.out.println("// ====== 장바구니 삭제 ======= //");
        findCartMenu.deleteCartMenu(findCart);
    }

우선 장바구니를 조회하고 이를 delete 메서드 파라미터에 전달한다.

CartMemnu.java

    public void deleteCartMenu(Cart cart) {
        cart.getCartMenus().remove(this);
        this.isDeleted = true;
    }

그렇다면 해당 엔티티 내에 존재하는 delete 메서드에서 부모는 자식을 리스트에서 제거하고, 자식의 상태값을 true 가 됨으로써 DB 상에는 자식 객체가 isDeleted 가 true 인 즉, 논리적 삭제가 된 상태로 유지한 것을 의도했다.

그러나, 이후 조회 메서드를 수행하니, 여전히 제거가 되었다고 생각한 자식 객체가 조회되는 것이었다.

시도 ( 2 )

어떤 점을 놓친 것일까 우선 테스트를 수행했다.

JpaMain.java

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("member");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {

            Parent parent = new Parent();
            parent.setName("부모 1");

            Child child1 = new Child();
            child1.setName("자식 1");
            child1.setParent(parent);

            Child child2 = new Child();
            child2.setName("자식 2");
            child2.setParent(parent);

            Child child3 = new Child();
            child3.setName("자식 3");
            child3.setParent(parent);

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }
}

우선 JPA 작업 흐름을 검증하기 위해 환경을 바꿨다.

우선 위와 같이 부모와 여러 자식들 간의 관계를 설정하고,

Parent parent = em.find(Parent.class, 1L);

Child child = em.find(Child.class, 1L);
            
parent.getChildList().remove(child);

tx.commit();

이전에 장바구니에서 수행했던 거 처럼 먼저 부모를 조회 후 부모의 자식 리스트에서 특정 자식을 제거한다.

Parent parent = em.find(Parent.class, 1L);

List<Child> childList = parent.getChildList();

 for (Child child : childList) {
     System.out.println("child.getName() = " + child.getName());
}

이후 출력을 시도해보니

child.getName() = 자식 1
child.getName() = 자식 2
child.getName() = 자식 3

여전히 이전과 같은 결과가 발생했다.

시도 ( 3 )

혹시 em.persist 를 하지 않아서 그럴 걸까 싶었지만, 이는 상관이 없다고 판단했다.

변경감지 기능으로 인하여 혹시 변경이 일어났다면 persist 호출 없이 업데이트 되었을테니 말이다.

그래서 이번에는

Parent parent = em.find(Parent.class, 1L);

Child child = em.find(Child.class, 1L);

parent.getChildList().remove(child);

List<Child> childList = parent.getChildList();

for (Child child1 : childList) {
    System.out.println("child1.getName() = " + child1.getName());
            }

tx.commit();

이렇게 한 번에 수행해봤다.

child1.getName() = 자식 2
child1.getName() = 자식 3

이번에는 자식이 제거가 된채로 출력이 되었고,

Parent parent = em.find(Parent.class, 1L);

List<Child> childList = parent.getChildList();

for (Child child1 : childList) {
    System.out.println("child1.getName() = " + child1.getName());
            }

tx.commit();

곧바로 해당 로직을 다시 수행해봤다.

child1.getName() = 자식 1
child1.getName() = 자식 2
child1.getName() = 자식 3

자식 1 데이터가 되살아났다.

시도 ( 4 )

앞선 시도에서 중요한 차이점을 알게되었다.

부모가 자식 리스트를 호출하고, 여기서 특정 자식과의 관계를 끊어내는 것은 그저 메모리상에서 수행되는 작업일 뿐이었던 것이다.

즉, 자식과 관계를 끊어내고, 자식쪽에서 특별한 작업 없이 트랜잭션이 끝나버리면, 메모리는 초기화되고 당연히 DB 에는 아무런 영향이 가지 않는 것이다.

단순히 컬렉션에서 엔티티를 제거하는 것은 연관된 엔티티의 외래키 관계를 직접적으로 수정하지 않는 것이다.

물론 이를 위해서 orphanRemoval = true 를 설정하면 컬렉션에서 제거하는 것만으로 자식도 제거가 될 것이다.

하지만 나는 자식은 여전히 유지하되, 오직 isDeleted = true 인 상태로 만들고 싶은 것이니까 해당 설정을 걸지 않을 것이다.

그렇다면 어떻게 해야할까 알아보니, 자식쪽에서 부모의 엔티티를 null 설정하면 된다는 것이다.

Child child = em.find(Child.class, 1L);

child.setParent(null);

이런식으로 말이다.

그러나 외래키에 null 값을 허용하면, 혹여나 실수로 외래키가 설정되지 않았을 때를 방어할 수단이 없어진다고 판단하여, 이 방법은 수용하지 않기로 하였다.

시도 ( 5 )

그렇다면 자식은 논리 삭제된 상태에서 DB 에는 존재하고, 이를 부모에서 자식을 조회할 때 해당 자식은 제외하고 조회는 어떻게 해야할까.

얼마전에 stream 기능 중 filter 를 사용했던 것이 떠올랐다.

Parent parent = em.find(Parent.class, 1L);

List<Child> resulut = parent.getChildList().stream()
            .filter(child -> !child.isDeleted())
            .toList();

for (Child child : resulut) {
    System.out.println("child.getName() = " + child.getName());
}

이와 같이 코드를 작성하고 수행하니

child.getName() = 자식 2
child.getName() = 자식 3

됐다 !

해결

    private List<CartMenuResponseDto> getCartMenuResponseDtos(Cart oneCart, Map<Long, List<CartMenuOption>> cartOptionMap) {
        List<CartMenuResponseDto> cartMenuResponseDtos = oneCart.getCartMenus().stream()
                .filter(cartMenu -> !cartMenu.isDeleted)
                .map(cartMenu -> {
                    List<CartMenuOptionResponseDto> optionDtos = cartOptionMap.get(cartMenu.getId())
                            .stream()
                            .map(CartMenuOptionResponseDto::new)
                            .collect(Collectors.toList());
                    CartMenuResponseDto cartMenuResponseDto = new CartMenuResponseDto(cartMenu, optionDtos);

                    return cartMenuResponseDto;
                })
                .collect(Collectors.toList());
        return cartMenuResponseDtos;
    }
.filter(cartMenu -> !cartMenu.isDeleted)

그저 이렇게 filter 로 걸러주니 빈 리스만 출력된다.

혹시나 장바구니가 비어있을 때 exception 을 터뜨리는 것을 염두해봤지만, 그래도 비어있는 거 자체를 클라이언트에게 전달하는 게 좋겠다 싶어서, 우선 현 상태를 유지할 생각이다.

추후 프런트 개발할 때 개선해봐야지 싶다.

알게된 점

JPA에서 부모 엔티티의 컬렉션에서 자식 엔티티를 제거하는 행위는 메모리 상에서만 일어나며, 이러한 변경이 자동으로 데이터베이스에 반영되지 않는다.

이는 영속성 컨텍스트 내에서의 변경사항이 데이터베이스에 반영되기 위해서는 추가적인 조치가 필요함을 의미한다.

혹시나 부모와 자식 관계를 끊고 싶다면, orphanRemover = true 를 설정하거나, 자식 엔티티를 유지하고 싶다면, 자식 쪽에서 부모관계를 null 로 처리하면 될 것이다.

0개의 댓글