minwoorich / 2024-spring-jpa-study

4 stars 5 forks source link

JPA 벌크 연산 주의점 #50

Open minwoorich opened 4 months ago

minwoorich commented 4 months ago

1. 소개

프로젝트를 하면서 프론트분께서 요구사항을 전달해주셨는데 장바구니를 통해서 주문을 완료했는데도 계속 장바구니에 구매한 상품목록들이 남아있다는 것이였다. 그래서 허겁지겁 코드를 수정 하러 컴퓨터를 켰다.

로직이야 뭐 간단하다. 클라이언트로부터 전달 받은 요청 DTO에서 상품 아이디들 그리고 토큰 값으로부터 회원아이디를 추출한 다음 장바구니를 삭제하는 쿼리메서드를 호출해서 인수로 전달해주면 그만이다. (물론 쿼리 메서드는 직접 작성을 해놔야한다.)

코드에서 볼 수 있다시피 deleteAllByProductIdsAndMemberIdInBatch() 를 호출 하고 있다.

스프링 DATA JPA 에서 작명 규칙을 맞추면 굳이 JPQL이나 SQL 을 작성하지않아도 바로 쿼리를 자동 생성해주는 기능을 제공해주지만 본인은 그 작명 규칙 외우는게 JPQL 짜는것보다 어려워서 그냥 JPQL을 직접 작성하였다. 지능 이슈

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Cart c WHERE c.product.id IN :productIds AND c.member.id = :memberId")
void deleteAllByProductIdsAndMemberIdInBatch(@Param("productIds") List<Long> productIds, @Param("memberId") Long memberId);

그런데 사실 JPA에서 JPQL로 DELETE 쿼리와 같은 벌크 연산을 사용할 경우 굉장히 조심하고 주의해야할 것들이 있는데 이를 한번 알아보자.

2. 문제점

벌크 연산 이란?

JPA(Java Persistence API)에서 "벌크 연산"은 대량의 엔티티를 한 번에 변경 또는 삭제하는 작업으로 한 번의 쿼리로 수 많은 row 들의 값들을 변경하는 것을 의미한다.

사실 JPA의 엔티티 매니저는 persist(), find(), merge() 등과 같이 저장하고 조회하는 기능은 있으나 delete() , update() 와 같이 데이터를 수정하는 메서드는 따로 존재하지 않는다.

remove() 가 있지만 이것은 데이터를 DB에서 삭제하는것이 아니라 영속성 컨텍스트에서 해당 엔티티를 제거 하겠다는 뜻으로 본인이 말하고자하는 delete 와는 개념이 좀(많이) 다르다.

그래서 JPA를 활용해서 데이터를 수정하려면 2가지 방식이 있는데,

1) JPA의 "더티 체킹" 방식을 사용해서 값을 수정한다. 2) JPQL 혹은 native 쿼리를 DB에 직접 날린다.

이 있는데 1) '더티 체킹' 방식의 경우 다량의 데이터를 수정하기에는 매우 비효율적이다. 예를 들어, 100만명의 사원의 월급을 5% 증가 시켜야하는 경우 더티 체킹을 사용한다면 속도는 물론이거니와 메모리에 그 수 많은 데이터가 먼저 올라와야하기 때문에 굉장히 비효율적이다.

그래서 주로 2) 번 방식을 사용하며 본인 또한 위에서 살펴보았듯이 JPQL 을 직접 작성하였다.

데이터 정합성 이슈

JPA 를 공부해보신분들이라면 아시겠지만, JPA 는 한 트랜잭션 단위 동안 영속성 컨텍스트에 엔티티에 대한 모든 상태 정보와 변경 사항 들을 차곡 차곡 캐싱 해놓았다가 마지막 트랜잭션이 commit 되는 순간에 모든 정보들을 추합해서 적절한 쿼리를 생성하여 DB로 전송 한다.

하지만 JPQL 이나 네이티브 쿼리를 사용할 경우 JPA는 영속성 컨텍스트를 사용하지 않고 그냥 바로 DB로 쿼리를 날려보낸다. 그렇기 때문에 자칫 잘못하면 DB의 데이터와 영속성 컨텍스트의 데이터가 서로 동기화 되지 않아 데이터의 정합성에 문제가 발생할 수 있는 것이다.

그래서 JPA는 JPQL 을 사용할 경우 무조건 JPQL을 수행하기 전에 먼저 영속성 컨텍스트에 쌓여있는 쿼리들을 flush 해버린다. 그렇게 함으로써 DB와 영속성 컨텍스트의 동기화를 맞추는 것이다.

벌크 연산시 주의 해야할 점 : clear

그러면 JPA에서 JPQL 을 사용하면 알아서 전에 flush 까지 수행해서 동기화를 수행해준거니깐 문제없는거 아닌가? 라는 생각이 들 수도 있는데 이러면 큰일 난다. 다음 예시를 살펴 보자.

memberRepository.save(new Member(10)); // id = 1

memberRepository.bulkPlus(5); // 모든 멤버들의 나이에 +5 를 해주는 벌크 연산
Member member = memberRepository.findById(1);

System.out.println(member.getAge())// 기대 : 15 , 실제 : 10

위 코드를 보면 멤버들의 나이를 +5 씩 하는 벌크연산을 해줬음에도 불구하고 실제 출력 값은 그대로 10으로 출력이 된다.

왜냐하면 아직 영속성 컨텍스트에는 새로 변경된 값이 반영이 안 되어있기 때문이다. 아마 실제 DB로 접속해서 값을 확인해보면 DB에는 값이 15로 수정되어있는것을 확인 할 수 있을것이다. 하지만, 현재 영속성 컨텍스트에는 그렇지 않다.

아니 findById(1) 호출 했는데 왜 계속 10 이 저장되어있는거죠?

왜냐하면 findById() 메서드는 DB를 조회하기전에 영속성 컨텍스트를 먼저 조회하기 때문이다. 현재 영속성 컨텍스트에는 아직까지 10살의 멤버 엔티티가 저장이 되어있기 때문에 JPA 가 findById() 를 호출했을때 영속성 컨텍스트를 먼저 살펴보고 "어? 값이 있네~" 하고는 컨텍스트에 있는 값을 그대로 건내주게 된다. 그래서 아무리 벌크 연산을 통해 데이터를 수정 했을지라도 영속성 컨텍스트에 반영이 되어있지 않은것이다.

이를 해결 하기 위해서는 벌크 연산 이후에는 웬만하면 영속성 컨텍스트를 비워줘야한다.

JPA 에서는 clear() 호출 하게 되면 영속성 컨텍스트에 캐싱 하고 있던 모든 정보들을 싹 비워 준다. 영속성 컨텍스트가 싹 비워지게 되면 JPA는 findById() 를 사용했을 때 컨텍스트를 들여다 보곤 "음, 값이 텅~비어있네. DB에서 가져와야겠다!" 라고 생각을 하고 DB로 부터 가장 최근의 싱싱한 데이터를 조회하게 되는것이다.

스프링 데이터 JPA에서 벌크 연산 이슈 해결하는법

순수 JPA를 사용한다면 직접 EntityManager 를 선언해서 clear() 를 벌크연산 직후 호출을 해줘야하지만 스프링 데이터 JPA에서는 이 기능을 어노테이션으로 간단하게 사용할 수 있다.

@Modifying 어노테이션의 옵션값으로 flushAutomaticallyclearAutomatically 가 있는데 둘 다 기본값은 false이나 이를 true로 바꿔주면 자동으로 flush 와 clear를 수행해준다.

📚 레퍼런스

https://www.inflearn.com/course/lecture?courseSlug=%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84&unitId=28018&tab=curriculum