장바구니 삭제 API 리팩토링 (Batch Processing)

허석진·2023년 3월 14일
0
post-thumbnail

기능 설명

간단하고 간단한 장바구니 삭제 기능!
장바구니를 삭제하면서 같이 삭제할 연관데이터도 없어 선택한 장바구니장바구니 테이블에서만 삭제하면된다.

이전 코드

// CartServiceImpl.java
@Override
public void deleteCarts(CartDelete delete, CustomPrincipal principal) {
    try {
        cartRepository.deleteAllByIdsAndProfile_Id(delete.cartIds(), principal.profileId());
    } catch (Exception e) {
        throw new BusinessLogicException(ExceptionCode.CART_NOT_FOUND);
    }
}
// CartRepositoryCustomImpl.java
@Override
public void deleteAllByIdsAndProfile_Id(List<Long> cartIds, Long profileId) {
    QCart cart = QCart.cart;

    BooleanExpression condition = cart.id.in(cartIds)
            .and(cart.profile.id.eq(profileId));
    JPQLQuery<Cart> count = from(cart).where(condition);

    if (count.fetchCount() != cartIds.size()) {
        throw new BusinessLogicException(ExceptionCode.CART_NOT_FOUND);
    }

    delete(cart)
            .where(condition)
            .execute();
}

왜? (리팩토링하려는 이유)

이번에 장바구니 삭제 코드를 리팩토링하려는 이유는 위에 코드만 봐도 한눈에 들어온다.
바로 다음과 같다!

  1. 어디서 예외를 발생시키는지, 왜 굳이 저런식으로 코드를 작성했는지 의도를 파악하기가 어렵다!
  2. Repository 까지 퍼져버린 비지니스 로직!

사실 위에 1, 2번 모두 한 가지 기준점에서 벗어나서 문제라고 생각한다.

비즈니스 로직은 Service 단에서 처리하자

이전에 저 코드를 작성할 때는 이런 역할의 분담과 상관없이 좀 더 완성도 있어보이는 코드를 작성하는데 빠져있었고 그 결과 너무 단일 케이스에만 걸맞는, 유지-보수하기 어려운 코드가 탄생했다.

어떻게?

위에서 설명했던 것처럼 Service와 Repository에 걸쳐있는 삭제할 수 있는 "올바른" Cart Id들만 넘어왔는지 확인하고, 그렇지 않다면 오류를 던지는 코드를 Service로 옮기는게 이번 리팩토링의 목표이다.

첫 번째 시도

// CartServiceImpl.java
@Override
public void deleteCarts(CartDelete delete, CustomPrincipal principal) {
    if (!cartRepository.existAllByIdsAndProfile_Id(delete.cartIds(), principal.profileId()))
        throw new BusinessLogicException(ExceptionCode.CART_NOT_FOUND);
    cartRepository.deleteAllById(delete.cartIds());
}
// CartRepositoryCustomImpl.java
@Override
public boolean existAllByIdsAndProfile_Id(List<Long> cartIds, Long profileId) {
    QCart cart = QCart.cart;

    return from(cart)
            .where(cart.id.in(cartIds)
            .and(cart.profile.id.eq(profileId)))
            .fetchCount() == cartIds.size();
}

Service, Repository 둘다 누가봐도 한눈에 의도를 파악할 수 있을 만큼 깔끔해졌고 각자의 역할 또한 잘 나눠 가지게 되었다고 생각한다.
거기에더해 QueryDSL로 작성한 코드 역시 간단한 코드지만 사랑스러울 정도로 명료하다.
그런데 여기에는 문제가 한 가지 존재했는데...

Hibernate: 
    select
        count(c1_0.id) 
    from
        cart c1_0 
    where
        c1_0.id in(?,?) 
        and c1_0.profile_id=?
2023-03-14T11:41:25.564+09:00 DEBUG 4296 --- [nio-8080-exec-9] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1101172432<open>)] for JPA transaction
2023-03-14T11:41:25.564+09:00 DEBUG 4296 --- [nio-8080-exec-9] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.created_by,
        c1_0.modified_at,
        c1_0.modified_by,
        c1_0.product_id,
        c1_0.profile_id,
        c1_0.quantity 
    from
        cart c1_0 
    where
        c1_0.id=?
Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.created_by,
        c1_0.modified_at,
        c1_0.modified_by,
        c1_0.product_id,
        c1_0.profile_id,
        c1_0.quantity 
    from
        cart c1_0 
    where
        c1_0.id=?
2023-03-14T11:41:25.576+09:00 DEBUG 4296 --- [nio-8080-exec-9] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
2023-03-14T11:41:25.576+09:00 DEBUG 4296 --- [nio-8080-exec-9] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1101172432<open>)]
Hibernate: 
    delete 
    from
        cart 
    where
        id=?
Hibernate: 
    delete 
    from
        cart 
    where
        id=?

또 JPA, 또 N+1이다.
이번엔 2N+1이라는 비효율의 극치를 보여주는 현상이 발생했다.
사실 delete 문이 여러개 발생한 것까지는 deleteAll이라는 QueryMethod의 사양이라고 이해할 수 있다. 근데 select 문은 대체 왜 발생한거란 말인가??

놀랍게도 그것 또한 JPA 기본 사양이다 ㅋㅋ..
Spring Data JPA의 구현 GitHub을 보면 deleteAllById의 코드는 아래와 같다
Spring Data JPA GitHub 링크

@Override
@Transactional
public void deleteAllById(Iterable<? extends ID> ids) {

    Assert.notNull(ids, "Ids must not be null");

    for (ID id : ids) {
        deleteById(id);
    }
}

@Transactional
@Override
public void deleteById(ID id) {

    Assert.notNull(id, ID_MUST_NOT_BE_NULL);

    findById(id).ifPresent(this::delete);
}

넘겨준 Iterable을 순회하며 하나하나 삭제하는 것도 모자라, findById를 통해 select 문도 발생 시킨다.

그럼 왜 이렇게 했을까? 한 번에 삭제해주는게 기본 값이고 그것만 있으면 되는거 아닌가? 라고 생각했기 때문에 다음 포스팅에 검색하며 알아본 내용을 작성하고 링크를 여기에 달아두겠다 (왜 deleteAll과 deleteAllInBatch를 구분했을까?)

아무튼 현 시점에서 알아본 바로는 복합키와 정합성 문제 때문에 고려해봐야한다는 deleteAllInBatch를 써서 해결해 볼 것이다.
현재 지식선에서는 작성한 코드가 복합키를 사용하지도 않을 분더러 앞선 existAllByIdsAndProfile_Id 메서드가 클라이언트로부터 받은 Cart Id들이 실제로 현재 로그인한 사용자(Profile Id)가 소유한 것인지 확인하기 때문에 사용해도 괜찮을 것이라고 생각된다.

최종

@Override
public void deleteCarts(CartDelete delete, CustomPrincipal principal) {
    if (!cartRepository.existAllByIdsAndProfile_Id(delete.cartIds(), principal.profileId()))
        throw new BusinessLogicException(ExceptionCode.CART_NOT_FOUND);
    cartRepository.deleteAllByIdInBatch(delete.cartIds());
}

deleteAllByIdInBatch를 이용해 batch processing (일괄 처리) 함으로써 한 번의 Query로 처리 할 수 있게 된다. 이 메서드는 복합 key를 가진 경우에는 getReferenceById 메서드를 사용하고 그렇지 않는 경우는 추가적인 select 문을 발생시키는 find 메서드를 포함하고 있지 않기 때문에 아래와 같이 in을 사용한 1개의 delete 문으로 끝난다.
public static final String DELETE_ALL_QUERY_BY_ID_STRING = "delete from %s x where %s in :ids";

@Override
@Transactional
public void deleteAllByIdInBatch(Iterable<ID> ids) {

    Assert.notNull(ids, "Ids must not be null");

    if (!ids.iterator().hasNext()) {
        return;
    }

    if (entityInformation.hasCompositeId()) {

        List<T> entities = new ArrayList<>();
        // generate entity (proxies) without accessing the database.
        ids.forEach(id -> entities.add(getReferenceById(id)));
        deleteAllInBatch(entities);
    } else {

        String queryString = String.format(DELETE_ALL_QUERY_BY_ID_STRING, entityInformation.getEntityName(),
                entityInformation.getIdAttribute().getName());

        Query query = em.createQuery(queryString);
        /**
            * Some JPA providers require {@code ids} to be a {@link Collection} so we must convert if it's not already.
            */
        if (Collection.class.isInstance(ids)) {
            query.setParameter("ids", ids);
        } else {
            Collection<ID> idsCollection = StreamSupport.stream(ids.spliterator(), false)
                    .collect(Collectors.toCollection(ArrayList::new));
            query.setParameter("ids", idsCollection);
        }

        applyQueryHints(query);

        query.executeUpdate();
    }
}

따라서 리팩토링 이후 deleteCarts를 실행하면 발생하는 Query는 아래와 같이 단 2개 이다.

Hibernate: 
    select
        count(c1_0.id) 
    from
        cart c1_0 
    where
        c1_0.id in(?,?) 
        and c1_0.profile_id=?
Hibernate: 
    delete 
    from
        cart 
    where
        id in(?,?) 
        and profile_id=?

마치며

Spring Data JPA가 참 무심하면서도 세심하게 구현되어 있다는 것을 알게된 작업이었다.
오픈소스로 모든 코드가 공개되어 있었을 줄이야...
지금까지 이 공식 문서만 보면서 좀 답답했는데 이제야 어떤 식으로 작동하는지 이해가 간다 ㅋㅋ

추가로 느낀점이 있는데 그건 이번 포스팅을 위해 여러가지 방식으로 검색을 시도하다가 검색 키워드의 부족함과 검색 결과에 대한 불만족을 느끼고 Chat GPT와 Bing AI를 사용해 본 후 느낀 AI 기술의 고마움과 무서움이다.
머리속에서 떠오르는 애매모호한 키워드의 느낌조차 물어보면 구체화해주고 그걸가지고 Bing AI에 물어보면 레퍼런스와 같이 설명을 해주는 세상이 왔다.
이런 것들을 보면서 정말 사수, 선생이 필요 없다고 느끼면서도 결국 AI가 제시하는 결과와 레퍼런스를 비판적인 시선에서 보고 이해하고 내 것에 적용하는 것은 여전히 AI가 아닌 내가해야하는 일이라 생각하며 역시 AI 기술에 감사하며 포스팅을 마친다 ㅎ

0개의 댓글