목차
[JPA] 엔티티의 생명주기와 Spring Data JPA- (1)
0. 들어가기 전에
- [10분 테코톡] 잉, 페퍼의Spring Data JPA 삽질일지 을 보다가 흥미로운 점이 생겨서 JPA의 영속성 관리 및 기본 메서드에 대해서 알아보려고 한다.
1. 영속성 컨텍스트(PersistenceContext)
- 엔티티를 영구 저장하는 환경으로 논리적 개념
- 엔티티 매니저(EntityManger)를 통해 영속성 컨텍스트에 접근
- 장점
- 1차 캐시
- 한 트랜잭션 내에서 공유
- JPA가 객체를 찾을 때 1차 캐시를 먼저 찾은 후에 없으면 DB에서 확인
- DB에서 조회를 하고 1차 캐시에 저장 후 반환
- 동일성 보장
- 트랜잭션을 지원하는 쓰기 지연
- 변경 감지(Dirty Checking)
- commit이 발생하면 1차 캐시와 스냅샷을 비교한다
- 변경 사항이 있으면 쓰기 지연 sql 저장소에 update 쿼리를 생성하고, flush를 통해 db에 반영한다.
- flush : 쓰기 지연 sql 저장소에 있는 sql을 실행하여 db에 쿼리를 날리는 행위, 1차캐시 삭제x
- commit이 발생하면 1차 캐시와 스냅샷을 비교한다
- 1차 캐시
2. 엔티티의 생명주기
- 비영속(new/transient)
- 객체를 생성만 한 상태
- 영속(managed)
- 객체를 생성한 상태 + 객체를 저장(persist)한 상태
- DB에 쿼리를 날리지 않음 -> 트랜잭션이 commit을 해야 쿼리가 나감
- 준영속(detached)
- 영속성 컨텍스트가 관리하지 않음
- 변경감지x
- detach()
- 1차 캐시에 해당 객체 지우기 -> 쓰기지연 sql 저장소 지우기
- 삭제(removed)
3. 테스트
3.1 save() 메소드 관련
@SpringBootTest
class MemberRepositoryTest {
//...
private static final Member 홍길동 = new Member("홍길동");
@DisplayName("저장_후_비교_한다")
@Test
void 저장_후_비교_한다() {
log.info("홍길동이 비영속 상태 인가요? {}", entityInformation.isNew(홍길동));
log.info("홍길동 ID = {}", 홍길동.getId());
Member 결과 = memberRepository.save(홍길동);
assertThat(결과).isEqualTo(홍길동);
}
@DisplayName("회원_찾기")
@Test
void 찾기() {
log.info("홍길동이 비영속 상태 인가요? {}", entityInformation.isNew(홍길동));
log.info("홍길동 ID = {}", 홍길동.getId());
Member 저장됨 = memberRepository.save(홍길동);
Optional<Member> 결과 = memberRepository.findById(저장됨.getId());
assertThat(결과).hasValue(홍길동);
}
//...
}
- 테스트 두 개를 같이 돌리면 실패하게 된다.
- 테스트 격리가 안되서 발생한 문제도 있지만, 추가로 영속성과 관련된 문제도 존재한다.
- static으로 선언된 홍길동이 먼저 영속 상태가 되고, 그 객체를 다른 테스트에서도 사용해서 문제가 발생한다.
- 이 문제를 해결할 방법을 찾으려면 save() 메서드에 대한 이해가 필요하다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
//...
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
//...
}
- save() 메서드를 보면 isNew()메서드로 영속상태인지 확인하고, 비영속상태이면 persist()를 실행하고, 아니면 merge()를 실행한다.
- persist()는 null인 Id를 새로 발급하는 과정이고, merge()는 기존 아이디를 가지고 있어도 복사본을 만들어 새로운 Id로 발급하는 과정이다. 이후에는 모두 영속상태가 된다.
- 주의할 점은 merge()는 객체를 교환하는 형식이기 때문에 일부분만 변경을 시도하면, 변경이 안된 부분이 null 값으로 들어갈 위험이 있다.
- 따라서 테스트를 실행하면 persist와 merge가 둘 다 발생해서 동일성이 보장이 안된다.
- 그러므로 동일성을 보장하기 위해서, 객체를 일일이 선언해서 쓰는게 더 낫다.
3.2 deleteAll() 메소드 관련
- name을 unique로 설정하고 테스트를 진행한다.
- deleteAndSave() 메서드는 먼저 deleteAll()을 실행하고, saveAll(List members)을 실행한다.
@DisplayName("삭제 후 저장 테스트")
@Test
void 삭제_후_저장_테스트() {
//given
memberRepository.save(new Member("홍길동"));
memberRepository.save(new Member("김철수"));
//when
//then
assertThatThrownBy(() -> memberService.deleteAndSave(List.of(
new Member("홍길동"),
new Member("김철수"),
new Member("이영희")
))).doesNotThrowAnyException();
}
}
- 위 테스트를 실행하면 log는 다음과 같이 나오며, UniqueViolation이 발생하며 실패한다.
- 왜냐하면 JPA에서는, 우리의 생각처럼 DB에 있는 내용을 먼저 지우고, 바로 저장하지 않는다.
- deleteAll() 먼저 db에서 값들을 모두 찾아 1차 캐시에 저장하고, 쓰기지연 sql 저장소에 delete 쿼리를 저장하고 flush()가 발생하면 그 때 실행된다.
- 여기에서 위 문제가 발생하는데, 쓰기지연 저장소에 쿼리들이 존재하지만 아직 실행이 되지 않은 채로 save 메서드를 호출했기 때문에 문제가 발생했다.
- deleteAll() 먼저 db에서 값들을 모두 찾아 1차 캐시에 저장하고, 쓰기지연 sql 저장소에 delete 쿼리를 저장하고 flush()가 발생하면 그 때 실행된다.
- 왜냐하면 JPA에서는, 우리의 생각처럼 DB에 있는 내용을 먼저 지우고, 바로 저장하지 않는다.
- 그렇다면 이 문제는 어떻게 해결 할 수 있을까?
- 유튜브 영상에서는 아래와 같은 방법을 사용한다.
- save()를 하기전에 flush()를 사용한다.
- JPQL을 사용할 때 flush가 사용된다.
- 직접 flush()를 사용한다.
- commit을 활용한다.
- 하지만 위 방법보다 좋은 방법이 존재한다.
- deleteAllInBatch() 메서드를 사용하는 것이다.
< deleteAllInBatch() >
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.JpaRepository#deleteInBatch(java.lang.Iterable)
*/
@Override
@Transactional
public void deleteAllInBatch(Iterable<T> entities) {
Assert.notNull(entities, "Entities must not be null!");
if (!entities.iterator().hasNext()) {
return;
}
applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities, em)
.executeUpdate();
}
- deleteAll()은 쓰기지연 때문에 문제가 발생하며, 또한 iterator로 delete() 메서드를 호출하기 때문에 삭제할 객체 수대로 쿼리가 만들어서 보내진다.
- 하지만 deleteAllInBatch() 위의 내용과 같이 applyAndBind를 통해 쿼리를 만들고 executeUpdate() 형식으로 쿼리를 직접 실행하므로, 쓰기 지연 문제도 해결되며, 다수의 객체를 삭제해도 쿼리를 한 개만 삭제하므로 언급했던 문제는 물론, 성능 문제도 해결된다.
※ 참조
'ORM > JPA' 카테고리의 다른 글
[JPA] 영속성 전이와 고아 객체 (0) | 2022.07.18 |
---|---|
자바 ORM 표준 JPA 프로그래밍 - 객체지향 쿼리 언어 (0) | 2021.08.22 |
자바 ORM 표준 JPA 프로그래밍 - 값 타입 (0) | 2021.08.21 |
자바 ORM 표준 JPA 프로그래밍 - 프록시와 연관관계 관리 (0) | 2021.08.19 |
자바 ORM 표준 JPA 프로그래밍 - 고급 매핑 (0) | 2021.08.16 |
[JPA] 엔티티의 생명주기와 Spring Data JPA- (1)
0. 들어가기 전에
- [10분 테코톡] 잉, 페퍼의Spring Data JPA 삽질일지 을 보다가 흥미로운 점이 생겨서 JPA의 영속성 관리 및 기본 메서드에 대해서 알아보려고 한다.
1. 영속성 컨텍스트(PersistenceContext)
- 엔티티를 영구 저장하는 환경으로 논리적 개념
- 엔티티 매니저(EntityManger)를 통해 영속성 컨텍스트에 접근
- 장점
- 1차 캐시
- 한 트랜잭션 내에서 공유
- JPA가 객체를 찾을 때 1차 캐시를 먼저 찾은 후에 없으면 DB에서 확인
- DB에서 조회를 하고 1차 캐시에 저장 후 반환
- 동일성 보장
- 트랜잭션을 지원하는 쓰기 지연
- 변경 감지(Dirty Checking)
- commit이 발생하면 1차 캐시와 스냅샷을 비교한다
- 변경 사항이 있으면 쓰기 지연 sql 저장소에 update 쿼리를 생성하고, flush를 통해 db에 반영한다.
- flush : 쓰기 지연 sql 저장소에 있는 sql을 실행하여 db에 쿼리를 날리는 행위, 1차캐시 삭제x
- commit이 발생하면 1차 캐시와 스냅샷을 비교한다
- 1차 캐시
2. 엔티티의 생명주기
- 비영속(new/transient)
- 객체를 생성만 한 상태
- 영속(managed)
- 객체를 생성한 상태 + 객체를 저장(persist)한 상태
- DB에 쿼리를 날리지 않음 -> 트랜잭션이 commit을 해야 쿼리가 나감
- 준영속(detached)
- 영속성 컨텍스트가 관리하지 않음
- 변경감지x
- detach()
- 1차 캐시에 해당 객체 지우기 -> 쓰기지연 sql 저장소 지우기
- 삭제(removed)
3. 테스트
3.1 save() 메소드 관련
@SpringBootTest
class MemberRepositoryTest {
//...
private static final Member 홍길동 = new Member("홍길동");
@DisplayName("저장_후_비교_한다")
@Test
void 저장_후_비교_한다() {
log.info("홍길동이 비영속 상태 인가요? {}", entityInformation.isNew(홍길동));
log.info("홍길동 ID = {}", 홍길동.getId());
Member 결과 = memberRepository.save(홍길동);
assertThat(결과).isEqualTo(홍길동);
}
@DisplayName("회원_찾기")
@Test
void 찾기() {
log.info("홍길동이 비영속 상태 인가요? {}", entityInformation.isNew(홍길동));
log.info("홍길동 ID = {}", 홍길동.getId());
Member 저장됨 = memberRepository.save(홍길동);
Optional<Member> 결과 = memberRepository.findById(저장됨.getId());
assertThat(결과).hasValue(홍길동);
}
//...
}
- 테스트 두 개를 같이 돌리면 실패하게 된다.
- 테스트 격리가 안되서 발생한 문제도 있지만, 추가로 영속성과 관련된 문제도 존재한다.
- static으로 선언된 홍길동이 먼저 영속 상태가 되고, 그 객체를 다른 테스트에서도 사용해서 문제가 발생한다.
- 이 문제를 해결할 방법을 찾으려면 save() 메서드에 대한 이해가 필요하다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
//...
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
//...
}
- save() 메서드를 보면 isNew()메서드로 영속상태인지 확인하고, 비영속상태이면 persist()를 실행하고, 아니면 merge()를 실행한다.
- persist()는 null인 Id를 새로 발급하는 과정이고, merge()는 기존 아이디를 가지고 있어도 복사본을 만들어 새로운 Id로 발급하는 과정이다. 이후에는 모두 영속상태가 된다.
- 주의할 점은 merge()는 객체를 교환하는 형식이기 때문에 일부분만 변경을 시도하면, 변경이 안된 부분이 null 값으로 들어갈 위험이 있다.
- 따라서 테스트를 실행하면 persist와 merge가 둘 다 발생해서 동일성이 보장이 안된다.
- 그러므로 동일성을 보장하기 위해서, 객체를 일일이 선언해서 쓰는게 더 낫다.
3.2 deleteAll() 메소드 관련
- name을 unique로 설정하고 테스트를 진행한다.
- deleteAndSave() 메서드는 먼저 deleteAll()을 실행하고, saveAll(List members)을 실행한다.
@DisplayName("삭제 후 저장 테스트")
@Test
void 삭제_후_저장_테스트() {
//given
memberRepository.save(new Member("홍길동"));
memberRepository.save(new Member("김철수"));
//when
//then
assertThatThrownBy(() -> memberService.deleteAndSave(List.of(
new Member("홍길동"),
new Member("김철수"),
new Member("이영희")
))).doesNotThrowAnyException();
}
}
- 위 테스트를 실행하면 log는 다음과 같이 나오며, UniqueViolation이 발생하며 실패한다.
- 왜냐하면 JPA에서는, 우리의 생각처럼 DB에 있는 내용을 먼저 지우고, 바로 저장하지 않는다.
- deleteAll() 먼저 db에서 값들을 모두 찾아 1차 캐시에 저장하고, 쓰기지연 sql 저장소에 delete 쿼리를 저장하고 flush()가 발생하면 그 때 실행된다.
- 여기에서 위 문제가 발생하는데, 쓰기지연 저장소에 쿼리들이 존재하지만 아직 실행이 되지 않은 채로 save 메서드를 호출했기 때문에 문제가 발생했다.
- deleteAll() 먼저 db에서 값들을 모두 찾아 1차 캐시에 저장하고, 쓰기지연 sql 저장소에 delete 쿼리를 저장하고 flush()가 발생하면 그 때 실행된다.
- 왜냐하면 JPA에서는, 우리의 생각처럼 DB에 있는 내용을 먼저 지우고, 바로 저장하지 않는다.
- 그렇다면 이 문제는 어떻게 해결 할 수 있을까?
- 유튜브 영상에서는 아래와 같은 방법을 사용한다.
- save()를 하기전에 flush()를 사용한다.
- JPQL을 사용할 때 flush가 사용된다.
- 직접 flush()를 사용한다.
- commit을 활용한다.
- 하지만 위 방법보다 좋은 방법이 존재한다.
- deleteAllInBatch() 메서드를 사용하는 것이다.
< deleteAllInBatch() >
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.JpaRepository#deleteInBatch(java.lang.Iterable)
*/
@Override
@Transactional
public void deleteAllInBatch(Iterable<T> entities) {
Assert.notNull(entities, "Entities must not be null!");
if (!entities.iterator().hasNext()) {
return;
}
applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities, em)
.executeUpdate();
}
- deleteAll()은 쓰기지연 때문에 문제가 발생하며, 또한 iterator로 delete() 메서드를 호출하기 때문에 삭제할 객체 수대로 쿼리가 만들어서 보내진다.
- 하지만 deleteAllInBatch() 위의 내용과 같이 applyAndBind를 통해 쿼리를 만들고 executeUpdate() 형식으로 쿼리를 직접 실행하므로, 쓰기 지연 문제도 해결되며, 다수의 객체를 삭제해도 쿼리를 한 개만 삭제하므로 언급했던 문제는 물론, 성능 문제도 해결된다.
※ 참조
'ORM > JPA' 카테고리의 다른 글
[JPA] 영속성 전이와 고아 객체 (0) | 2022.07.18 |
---|---|
자바 ORM 표준 JPA 프로그래밍 - 객체지향 쿼리 언어 (0) | 2021.08.22 |
자바 ORM 표준 JPA 프로그래밍 - 값 타입 (0) | 2021.08.21 |
자바 ORM 표준 JPA 프로그래밍 - 프록시와 연관관계 관리 (0) | 2021.08.19 |
자바 ORM 표준 JPA 프로그래밍 - 고급 매핑 (0) | 2021.08.16 |