JPA를 공부하다보면 자연스럽게 영속성 컨텍스트를 접하게된다. 내용이 방대하니까 한 번 정리하면 좋을 것 같다.
☁️ ORM의 영속성 컨텍스트
ORM은 객체지향과 관계형 DB 사이의 패러다임 불일치를 해소시키는 개념이다. 그리고 ORM은 객체의 영속성을 관리하기 위해 영속성 컨텍스트를 활용한다.
(ORM을 자바에 맞는 형태로 명세한 것이 JPA이다. JPA는 Entity Manager에서 영속성 컨텍스트를 관리하도록 명세한다. JPA 구현체인 Hibernate는 Session의 형태로 영속성 컨텍스트를 관리한다.)
☁️ @PersistenceContext
JPA를 이용할 때, @PersistentContext를 통해 현재 트랜잭션에서 사용중인 Entity Manager를 주입받을 수 있다.
@Repository
public class MemberRepository {
@PersistenceContext // EntityManager 주입
private EntityManager em;
실제로 @PersistenceContext를 통해 주입받는 것은 Entity Manager 프록시 객체다. @Transactional에 의해, 해당 트랜잭션에서 사용할 Entity Manager를 만들거나 가져오게 되고, Entity Manager 프록시 객체가 가져온 Entity Manager에 행위를 위임한다.
☁️ 영속성 컨텍스트의 역할
영속성 컨텍스트가 하는 일에 대해 알아보자.
✔️ 1차 캐시
db에 영속화된 데이터에 접근할 때마다 직접 db와 통신해야 한다면 많은 비용이 들 것이다.
그리고 한 레코드를 나타내는 객체가 여러 개라면 혼동이 생길 것이다.
이를 커버해주는 것이 1차 캐시이다.
db에 저장한 데이터, 혹은 db에서 읽어온 데이터는 우리가 관리하기 편한 형태인 객체(엔티티)로 변환되어 1차 캐시에 저장된다. 이후 CrudRepository의 findById()처럼 id를 기반으로 데이터에 접근할 때, 바로 db에 가지 않고 1차 캐시를 한 번 확인한다.
아래의 코드는 SELECT 쿼리가 나가지 않는 것을 확인할 수 있었다.
@Test
@Transactional
@DisplayName("findById() - 1차 캐시에 존재하면 SELECT 날리지 않는다")
void testQueryOrder7() {
// insert
Member newMember1 = new Member("Alice", "alice@example.com", "1234");
em.persist(newMember1);
// SELECT 날아가지 않는다
Member member = memberRepository.findById(newMember1.getId()).get();
}
findById() 전에 em.clear()를 해주면, 1차 캐시에 영속 객체로 남아있지 않기 때문에, SELECT 쿼리가 나간다.
한 트랜잭션 내에서 한 레코드를 나타내는 엔티티 인스턴스가 여러개인 경우 어떤 문제가 발생할 수 있을까? 만약 여러 인스턴스가 수정된다면, 더티 체킹의 대상은 누가 되어야 할지 판단할 수 없을 것이다. 그래서 1차 캐시는 데이터 동일성을 보장해준다.
✔️ 더티 체킹
JPA를 처음 도입했을 때 가장 놀라웠던 기능이다. JPA를 사용하지 않았을 때는 UPDATE 쿼리를 날리는 메서드도 만들어야 했는데, 이제 영속 객체를 수정하면 알아서 UPDATE 쿼리가 나간다.
Entity Manager에서 관리되는 영속 객체들은 스냅샷을 기반으로 변경을 감지한다. 엔티티가 처음 Entity Manager에 로드될 때 상태를 찍어서 스냅샷 형태로 저장한다. 이후 flush 시점에 모든 영속 객체의 현재 상태를 스냅샷과 비교하고, 변경이 있다면 UPDATE 쿼리를 액션 큐에 넣어준다.
기본적으로 모든 필드에 대해 update를 수행한다. 변경이 감지된 필드에 대해서만 update하고 싶으면, 엔티티에 @DynamicUpdate 애노테이션을 붙여주면 된다.
@DynamicUpdate를 붙이는 것에 대한 의문(필요한 필드에 대해서만 동적으로 update 쿼리를 만드는 것도 비용이 아닌가? 모든 필드를 업데이트한다 해서 쿼리가 여러개가 되는 게 아니라 덮어쓰는 비용만 생기는 것이 아닌가?) 이 들었는데, 트레이드오프 부분인 것 같아서 나중에 필요할 때 더 고민해보면 좋을 것 같다.
✔️ 쓰기 지연
INSERT, UPDATE, DELETE 쿼리는 액션 큐에 모아두고, flush될 때 혹은 배치 사이즈를 충족했을 때 한 번에 나간다.
여러 쿼리를 묶어서 db에 보내기 때문에 통신 비용을 아낄 수 있다. 그리고 롤백이 필요한 상황에도 db에는 변경이 없고 액션 큐에 쿼리들만 남아있기 때문에, 원자성을 보장하기 쉽다.
처음에 쓰기 지연에 대해 알게 됐을 때는 액션 큐에 저장된 순서로 실행될 줄 알았는데, INSERT -> UPDATE -> DELETE 순으로 실행된다고 한다. 그래서 내가 미션 중 예기치 못한 오류를 겪었었다.
@Test
@Transactional
@DisplayName("쓰기 지연에서 INSERT가 DELETE보다 먼저 일어날까?")
void testQueryOrder12() {
Member oldMember = new Member("메이1", "may@gmail.com", "1234");
em.persist(oldMember); // INSERT
em.remove(oldMember); // DELETE
Member newMember = new Member("메이2", "may@gmail.com", "1234");
em.persist(newMember); // INSERT
em.flush();
// 논리상으로는 INSERT - DELETE - INSERT
// 근데 오류 터질까?(email unique 제약조건)
}
오류 상황과 비슷한 테스트를 만들어봤다. Member의 id 생성 전략은 SEQUENCE이고, email에 unique 제약 조건이 있는 상황이다.
논리적 순서는 INSERT -> DELETE -> INSERT인데, 실제 쿼리는 INSERT -> INSERT -> DELETE 순서로 실행되기 때문에, unique 제약 조건을 위반해서 오류가 발생한다.
☁️ 엔티티의 영속 상태
영속성 컨텍스트와 엔티티 간의 관계 상태를 네 가지로 나타낼 수 있다.
- 영속(Managed/Persistent) ⇒ 영속성 컨텍스트에 의해 관리되는 상태
- 준영속(Detached) ⇒ 영속성 컨텍스트에 의해 관리된 적은 있었으나, 현재는 관리되지 않는 상태
- 비영속(New/Transient) ⇒ 영속성 컨텍스트에 의해 관리된 적도 없고, 지금도 관리되지 않는 상태
- 삭제(Removed) ⇒ 영속성 컨텍스트에 의해 관리되고 있으나, 삭제될 예정인 상태
내가 영속 상태를 공부하면서 헷갈렸던 부분을 정리해봤다.
✔️ 영속 상태에서 준영속 상태로의 전이, detach()는 어떤 상황에 필요할까?
처음에 엔티티의 영속 상태 전이에 대해 학습할 때, 영속 상태에서 준영속 상태로 전이시키는 경우가 언제 필요한건지 알지 못하고 넘어갔다. 이번에 다시 공부해보면서 가장 와닿았던 것은 대용량 데이터 처리에서의 필요성이다.
한 트랜잭션 내에서 엄청 많은 양의 레코드를 읽어온 후 또 다른 로직을들 수행한다고 하자(@Transactional(readOnly = false)). 읽어온 많은 레코드는 영속성 컨텍스트에서 영속 객체로 관리되기에 메모리를 많이 차지할 것이고, 더티 체킹에 대한 트래킹도 해줘야 한다. 그래서 많은 데이터를 읽어온 후에는 준영속 상태로 변경한다면 많은 비용을 아낄 수 있을 것이다.
✔️ 삭제 상태는 왜 필요할까?
그리고 또 궁금했던 것은 삭제 상태의 필요성이다. 삭제될 예정인 것을 표시해서 뭐할까? 그냥 바로 1차 캐시에서 삭제해버려도 되는 것 아닐까? 라고 생각했었다.
발생하지 않을 확률이 높은 시나리오지만, em.remove()호출 후 em.find()로 해당 객체를 찾으려는 논리적 오류 코드를 작성한다고 하자.
@Test
@Transactional
@DisplayName("삭제 상태로 영속성 컨텍스트에 존재하는 경우 null을 반환한다?")
void deleteState() {
Member oldMember = new Member("메이1", "may@gmail.com", "1234");
em.persist(oldMember);
em.remove(oldMember);
// ❗️ 논리적 오류 : 삭제했는데 접근하려 함
// 1차 캐시에는 존재하나 삭제 상태이므로 null을 반환한다.
Member findMember = em.find(Member.class, oldMember.getId());
Assertions.assertThat(findMember)
.isNull();
}
삭제 상태가 존재한다면, em.find()에서 1차 캐시에 존재하는 삭제 상태의 객체를 찾을 수 있을 것이다. 다만 삭제 상태이기 때문에 null을 반환하여 해당 객체가 존재하지 않음을 나타낸다.
만약 삭제 상태 없이 em.remove() 호출 후 영속 객체가 1차 캐시에서 바로 사라진다면 어떻게 될까? 1차 캐시에 존재하지 않기에 db에 select 쿼리를 날릴 것이고, 쓰기지연에 의해 아직 DELETE 쿼리가 나가지 않아 레코드가 존재하고, 성공적으로 데이터를 읽어올 것이다. 이것은 "삭제했는데 읽어와진다"는 논리적 오류를 발생시킨다.
☁️ jpql과 flush
나는 flush를 직접 명시하지 않는 한 무조건 트랜잭션 종료 시점에 일어난다고 생각했었다. 그런데 jpql을 실행하기 전에 flush를 수행한다는 것을 주워듣고, 이번에 학습 테스트를 수행하면서 직접 확인해봤다.
flush를 수행하는 이유는, 쓰기 지연에 의해 액션 큐에 쿼리들이 쌓여있는데, 이 쿼리들이 날아가야 우리가 예측한 결과를 얻을 수 있는 경우가 있기 때문이다.
@Test
@Transactional
@DisplayName("쿼리 메서드(JPQL)를 수행하기 전에 flush가 될까?")
void testJpqlFlush1() {
// insert
Member newMember1 = new Member("Alice", "alice@example.com", "1234");
em.persist(newMember1);
// update
newMember1.setName("앨리스");
// 쿼리 메서드를 수행하기 전에 flush가 될까?
Member member = memberRepository.findAll().getFirst();
Assertions.assertThat(member.getName())
.isEqualTo("앨리스");
// INSERT, UPDATE 쿼리 나감!
}
findAll()은 jpql을 파싱하고, 액션 큐에 연관 객체의 변경 사항이 존재한다면 flush를 수행한다. 위의 테스트에서 Member에 대한 UPDATE 쿼리가 쌓인 상태에서, Member를 모두 가져오는 findAll()을 수행하기에, flush되어 UPDATE 쿼리가 나가는 것을 확인할 수 있었다.
그러면 액션 큐에 관련 없는 객체의 수정만 있는 경우에도 flush가 일어날까?
@Test
@Transactional
@DisplayName("커스텀 쿼리 메서드(JPQL)여도 flush가 안 되는 경우가 있을까?")
void testJpqlFlush2() {
// insert
Member newMember1 = new Member("Alice", "alice@example.com", "1234");
em.persist(newMember1);
Order order = new Order(LocalDate.now(), "hello", newMember1);
em.persist(order);
// update
newMember1.setName("앨리스");
// update 쿼리 안 날아가는 것을 확인해보자
List<Order> orders = orderRepository.getOrdersByMemo("hello");
Assertions.assertThat(orders.size())
.isEqualTo(1);
// INSERT, INSERT, SELECT만 날아간다! UPDATE는 되지 않는다(flush X)
}
(여기서 Member, Order의 ID 생성 전략은 IDENTITY이다. 그리고 orderRepository.getOrdersByMemo()는 커스텀 쿼리 메서드다.)
Order가 Member를 참조하는 상황에서, Member UPDATE 쿼리가 큐에 존재하고, Order의 memo라는 컬럼을 기반으로 읽어오는 jpql이 실행된다. 결과를 확인하면 UPDATE쿼리가 나가지 않는다.(Flush되지 않는다)
참고로 Member, Order의 ID 생성 전략이 모두 SEQUENCE인 경우에는 flush가 발생한다. Order에 대한 INSERT 쿼리가 액션 큐에 존재하기 때문!
결론은, jpql을 실행하기 전에, jpql의 실행 결과가 달라질 수 있는 쿼리가 액션 큐에 존재하면 flush 해준다. 실행 결과가 달라질 수 있다는 것은 컬럼, 연관관계를 기반으로 판단한다.
이 외에도 영속성 컨텍스트를 공부하다가 사소하게 알게 된 부분들이 많다.
- merge()는 기존 준영속 객체를 재활용하지 않고, SELECT 쿼리를 날려서 영속 객체를 가져온다.
- findAll()은 일단 db에서 스캔해온 후 1차 캐시에 영속 객체가 존재하면 그걸 사용한다.
등등..
이번 학습에서 아쉬웠던 점은 공식 문서를 거의 활용하지 않았다는 것이다. Gemini랑 테스트를 기반으로 대부분의 학습을 수행했기에 정확하지 않은 내용이 있을 것 같다.
나 혼자는 궁금증이 잘 생기지 않아서, ai를 이용해서 더 공부해볼만한 방향을 제안받는 방식으로 공부했다. 그런데 내가 진짜로 궁금해서 공부한 내용이 아니다보니 너무 빠르게 휘발되는 것을 느꼈다. 그래서 적용해보려는 새로운 학습 방법은 아래와 같다.
- 깊게 공부하기 위한 질문 생성"을 하는 데에 ai의 도움을 받지 않기
- 대신 의식적으로 궁금해하기(왜 필요한건지, 단점은 뭔지 ...)
- 실습(학습 테스트) 비율을 늘려서 직접 경험하고 궁금증 느끼기
이 다음에는 영속성 컨텍스트를 공부하면서 계속 마주쳤던 키워드인 트랜잭션과 락까지 함께 정리해보려 한다.
정말 알면 알수록 모르는 것 투성이구나~~