일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 주린이
- mco
- Java
- 알고리즘
- 금리인상
- 인플레이션
- 현금흐름표
- 오버라이딩
- 접근제어자
- 기업분석
- javascript
- 미국주식
- etf
- object
- 백준
- 다형성
- 자바
- 주식
- 객체지향
- 잉여현금흐름
- XLF
- 그리디 알고리즘
- 프로그래머스
- S&P500
- 금리인하
- 제태크
- 배당성장
- 무디스
- FCF
- StringBuffer
- Today
- Total
오늘의하루
[JPA] 벌크 연산(Bulk Operation) 알고 계신가요? 본문
1. 벌크 연산 (Bulk Operation)이란?
벌크 연산이란 N개의 데이터를 한 번에 UPDATE 또는 DELETE 하는 작업을 의미합니다.
Spring Data JPA에서 벌크 연산을 수행하기 위해서는 @Modifying이 필요합니다.
2. 왜 필요할까?
Spring Data JPA에서는 기본적으로 Query 메서드의 반환 값을 getResultList() 또는 getSingleResult()를 통해 처리하는데 이는 SELECT에서만 사용 가능합니다.
이러한 이유로 UPDATE와 DELETE는 수정 또는 삭제된 행의 개수를 반환하게 되기 때문에 반환 값을 맞추기 위해서 @Modifying이 필요한데 만약 사용하지 않는다면 아래와 같은 예외를 맞이할 수 있습니다.
예외 클래스 : InvalidDataAccessApiUsageException
예외 내용 : Query executed via 'getResultList()' or 'getSingleResult()' must be a 'select' query [update 또는 delete]
사용해야 할 반환 타입 : executeUpdate()
3. 영속성 컨텍스트와의 관계
벌크 연산은 영속성 컨텍스트를 무시하고 바로! 직접 DB에 쿼리를 실행하여 데이터를 반영하게 됩니다.
문제 ) 1차 캐시와의 불일치
같은 트랜잭션에서 벌크 연산으로 변경된 데이터는 1차 캐시에 적용되지 않기 때문에 1차 캐시를 사용해서 데이터를 작업한다면 의도하지 않은 문제가 발생할 수 있습니다.
(1) 1차 캐시와의 불일치 예시
// memberJpaRepository는 순수 JPA로 작성되어있습니다.
@Test
void bulkUpdate() {
// given
memberJpaRepository.save(new Member("member2", 10, null));
memberJpaRepository.save(new Member("member1", 15, null));
memberJpaRepository.save(new Member("member4", 20, null));
memberJpaRepository.save(new Member("member3", 25, null));
memberJpaRepository.save(new Member("member5", 30, null));
// when ( 1차 캐시 clear X ) - log 확인
Object resultCount = memberJpaRepository.bulkAgePlus(20);
// then
assertThat(resultCount).isEqualTo(3);
List<Member> members = memberJpaRepository.findAll();
assertThat(members).hasSize(5)
.extracting("age")
.containsExactlyInAnyOrder(10,15,20,25,30);
}
/*
[bulkAgePlus - log]
update
member m1_0
set
age=(m1_0.age+1)
where
m1_0.age>=?
>> binding parameter (1:INTEGER) <- [20]
*/
위 log를 보면 벌크 연산이 실행되었음에도 불구하고 실제 1차 캐시에 저장되어 있는 데이터는 벌크 연산 실행 전 데이터가 저장되어 있는 것을 확인할 수 있습니다.
해결) 영속성 컨텍스트 초기화
1차 캐시와의 불일치 문제를 해결하기 위해서는 영속성 컨텍스트를 초기화(clear) 하거나 최신 데이터로 덮어쓰기(refresh) 하는 두 가지 방법이 있습니다.
하지만 최신 데이터로 덮어쓰는 경우 연관 관계로 맺어져 있는 엔티티까지 신경 써야 하기 때문에 조금은 머리가 아플 수 있기 때문에 초기화로 간단하게 처리하는 게 좋습니다.
(1) Refresh로 해결 - 권장 X
// memberJpaRepository는 순수 JPA로 작성되어있습니다.
@Test
void bulkUpdate() {
// given
Member member2 = memberJpaRepository.save(new Member("member2", 10, null));
Member member1 = memberJpaRepository.save(new Member("member1", 15, null));
Member member4 = memberJpaRepository.save(new Member("member4", 20, null));
Member member3 = memberJpaRepository.save(new Member("member3", 25, null));
Member member5 = memberJpaRepository.save(new Member("member5", 30, null));
// when ( 1차 캐시 clear X ) - log 확인
int resultCount = memberJpaRepository.bulkAgePlus(20);
// refresh로 1차 캐시와 불일치 문제 해결
entityManager.refresh(member2);
entityManager.refresh(member1);
entityManager.refresh(member4);
entityManager.refresh(member3);
entityManager.refresh(member5);
// then
assertThat(resultCount).isEqualTo(3);
List<Member> members = memberJpaRepository.findAll();
assertThat(members).hasSize(5)
.extracting("age")
.containsExactlyInAnyOrder(10,15,21,26,31);
}
(2) Clear로 해결 - 권장 O
// memberJpaRepository는 순수 JPA로 작성되어있습니다.
@Test
void bulkUpdate() {
// given
Member member2 = memberJpaRepository.save(new Member("member2", 10, null));
Member member1 = memberJpaRepository.save(new Member("member1", 15, null));
Member member4 = memberJpaRepository.save(new Member("member4", 20, null));
Member member3 = memberJpaRepository.save(new Member("member3", 25, null));
Member member5 = memberJpaRepository.save(new Member("member5", 30, null));
// when ( 1차 캐시 clear X ) - log 확인
int resultCount = memberJpaRepository.bulkAgePlus(20);
entityManager.clear();
// then
assertThat(resultCount).isEqualTo(3);
// 영속성 컨텍스트가 비어있기 때문에 DB에서 조회 후 1차 캐시에 저장
List<Member> members = memberJpaRepository.findAll();
assertThat(members).hasSize(5)
.extracting("age")
.containsExactlyInAnyOrder(10,15,21,26,31);
}
4. Spring Data JPA : 내가 해결해 줄게!
Spring Data JPA가 제공하는 @Modifying의 clearAutomatically, flushAutomatically라는 옵션이 존재합니다.
아래에서 각 옵션들에 대해 알아보겠습니다.
clearAutomatically 란?
해당 옵션의 기본값은 false이며, 해당 쿼리 실행 후 영속성 컨텍스트를 초기화할지 말지 결정하는 기능입니다.
// MemberRepository.java
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
(1) clearAutomatically 예시
@Test
void bulkUpdate() {
// given
Member member2 = memberRepository.save(new Member("member2", 10, null));
Member member1 = memberRepository.save(new Member("member1", 15, null));
Member member4 = memberRepository.save(new Member("member4", 20, null));
Member member3 = memberRepository.save(new Member("member3", 25, null));
Member member5 = memberRepository.save(new Member("member5", 30, null));
// when
int resultCount = memberRepository.bulkAgePlus(20);
// 영속성 컨테이너가 확실히 초기화 되었는지 검증
assertThat(entityManager.contains(member2)).isFalse();
assertThat(entityManager.contains(member1)).isFalse();
assertThat(entityManager.contains(member4)).isFalse();
assertThat(entityManager.contains(member3)).isFalse();
assertThat(entityManager.contains(member5)).isFalse();
// then
assertThat(resultCount).isEqualTo(3);
// 새롭게 DB에서 조회 후 1차 캐시에 저장
List<Member> members = memberRepository.findAll();
assertThat(members).hasSize(5)
.extracting("age")
.containsExactlyInAnyOrder(10,15,21,26,31);
}
flushAutomatically 란?
해당 옵션의 기본값은 false이며, 해당 쿼리 실행 전 쓰기 지연 SQL 저장소에 담겨있는 쿼리를 실행할지 말지 결정하는 기능이지만 아직까지는 이 옵션의 필요 여부를 모르겠습니다.
// MemberRepository.java
@Modifying(flushAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
flushAutomatically 옵션에 대한 예시는 만들지 않았습니다.
5. JPQL로 알아보는 동작 순서 (기초)
JPQL로 직접 Query를 작성하는 경우 어떤 순서로 동작하는지 알아보겠습니다.
// 예시에 SELECT 쿼리는 존재하지 않습니다.
@Test
void jpqlAction () {
1. JPQL 직접 쿼리
2. Spring Data JPA에서 제공하는 쿼리
3. Spring Data JPA에서 제공하는 쿼리
4. JPQL 직접 쿼리
5. JPQL 직접 쿼리
}
이 상태에서 어떤 식으로 flush()가 발생하는지 순서를 알아야 조금 더 정확한 코드를 작성할 수 있기 때문입니다.
- 1번 쿼리 쓰기 지연 SQL 저장소(저장 X) & 바로 쿼리 실행 후 DB 반영은 커밋 시점
- 2번 쿼리 쓰기 지연 SQL 저장소(저장 O)
- 3번 쿼리 쓰기 지연 SQL 저장소(저장 O)
- 4번 쿼리 실행 & 쓰기 지연 저장소 (저장 X) & 바로 쿼리 실행 후 DB 반영은 커밋 시점
- 5번 쿼리 쓰기 지연 SQL 저장소(저장 X) & 바로 쿼리 실행 후 DB 반영은 커밋 시점