✏️ [2]영속성 컨텍스트(Persistence Context)

박상민·2023년 9월 24일
0

JPA

목록 보기
4/24
post-thumbnail

✏️ [1]영속성 컨텍스트(Persistence Context)에 이어서 영속성 컨텍스트에 대해서 작성해보겠다.

⭐️ 영속성 컨텍스트의 이점

영속성 컨텍스트에 대해서 알아봤지만 알 것 같기도 하고 애매하다. '왜 이렇게 이상한 메커니즘을 두지?' 라는 의문이 생길 수도 있다.
애플리케이션과 데이터베이스 사이에 중간 계층이 하나 있다고 생각하자. 중간 계층이 영속성 컨텍스트이고 이로 인해 아래와 같은 큰 이점이 있다.

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)

📌 1차 캐시

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

//1차 캐시에 저장됨
em.persist(member);

//1차 캐시에서 조회
Member findMember = em.find(Member.class, "member1");

예를 들어 멤버 객체를 생성한다고 하자. 그리고 setId, setName 등을 통해 값을 세팅하자. 이것은 비영속 상태이다.

약간의 미묘한 차이는 있지만 엔티티 매니저 자체를 영속성 컨텍스트로 이해하자.
내부에는 1차 캐시라는 것이 있다.

1차 캐시를 영속성 컨텍스트로 이해해도 된다.

사진을 보면 @Id, Entity가 있다. 1차 캐시에는 맵이 있는데 키는 DB pk로 맵핑한 것이 키가 되고 값은 엔티티 객체 자체가 되는 것이다.
사진의 경우에는 키: 'member1', 값: '멤버 객체' 인 것이다.
이렇게 되면 무슨 이점이 있을까?

객체를 조회하는 경우를 생각해보자. em.find(Member.class, "member1");로 조회를 하면 JPA는 우선 DB를 뒤지는 것이 아닌 영속성 컨텍스트에서 1차 캐시를 먼저 뒤진다. 이때 1차 캐시에 찾고자 하는 멤버 엔티티가 있다면 캐시에 있는 값을 조회해온다.
이처럼 1차 캐시에서 조회한다는 이점이 있다.

이때 캐시에 없는 멤버 2번을 조회한다고 하자.

em.find(Member.class, "member2");를 한다면 마찬가지로 우선 1차 캐시를 뒤진다. 그러나 1차 캐시에 없다면 DB에서 조회를 한다. DB에서 멤버 2번을 발견하면 1차 캐시에 저장을 한다. 그리고 멤버 2번을 반환한다.

이후 멤버 2번을 다시 조회한다면 1차 캐시에 있기 때문에 DB를 조회할 필요가 없다.

그러나 이것이 큰 도움은 안된다.
엔티티 매니저는 보통 DB 트랜잭션 단위로 만들고 DB 트랜잭션이 끝날 때 같이 종료 시킨다.
고객의 요청이 하나 들어와서 보통 비즈니스가 끝나버리면 이 영속성 컨텍스트를 지운다는 것이다. 이때 1차 캐시도 다 날라간다.
1차 캐시는 DB 트랜잭션 안에서만 효과가 있기 때문에 큰 성능의 이점을 얻을 수는 없다. (비즈니스 로직이 굉장히 복잡하다면 도움이 될 수도..?)

📌 동일성(identity) 보장

Member a = em.find(Member.class, "member1"); 
Member b = em.find(Member.class, "member1");

System.out.println(a == b); //동일성 비교 true

1차 캐시로 반복 가능한 읽기(Repeatable read) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공
말이 조금.. 어렵다. 나도 이해하는데 시간이 걸렸다.

📌 엔티티 등록 - 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)

엔티티를 등록할 때 트랜잭션을 지원하는 쓰기 지연이라는게 가능하다. 코드로 설명하겠다.

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();

//엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin(); // [트랜잭션] 시작
em.persist(memberA);
em.persist(memberB);

//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
//커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
transaction.commit(); // [트랜잭션] 커밋

em.persist로 멤버 A,B를 넣어둔다고 하자. 이때 JPA는 기본적으로 Insert SQL을 데이터베이스로 보내지 않는다. JPA가 SQL을 쌓아둔다. 사진을 보자

영속성 컨텍스트 안에 있는 1차 캐시도 있는데 쓰기 지연 SQL 저장소라는 것이 있다.
em.persist(memberA)를 하면 일단 멤버 A가 1차 캐시에 들어간다.
그러면서 동시에 JPA가 이 엔티티를 분석해서 Insert 쿼리를 생성한다. 그리고 이 쿼리를 쓰기 지연 SQL 저장소에 쌓아둔다.
여기서 멤버 B도 저장해보자.


em.persist(memberB)를 하면 멤버 B도 1차 캐시에 집어 넣고 Insert SQL을 생성해서 쓰기 지연 SQL 저장소에 쌓아둔다. 이때 쓰기 지연 SQL 저장소에는 멤버 A, B 두 개의 Insert 쿼리문이 쌓여있다. 이 쿼리문이 언제 날라갈까?

바로 트랜잭션을 커밋하는 순간이다. 커밋을 하면 쓰기 지연 SQL 저장소에 있던 Insert 쿼리문들이 Flush가 되면서 날라간다. 그리고 실제 데이터베이스 트랜잭션이 딱 커밋된다.

📌 엔티티 수정 - 변경 감지(Dirty Checking)

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin(); // [트랜잭션] 시작

// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);

//em.update(member) 이런 코드가 있어야 하지 않을까?
transaction.commit(); // [트랜잭션] 커밋

엔티티를 조회해서 memberA로 받았다고 하자. 이때 memberA.setUsername("hi")를 해서 Username을 변경하자. 이때 이런 생각을 한다. '아 데이터를 변경했으니 persist를 호출해야 되는 거 아니야?'
하지만 이건 JPA의 목적을 알면 의문이 해결된다.

JPA의 목적?
JPA의 목적은 마치 Java 컬렉션 다루듯이 객체를 다루는 것이다. 생각을 해보면 Java 리스트 등의 컬렉션에서 값을 꺼내고 수정했을때 '데이터를 변경했으니 다시 컬렉션에 집어 넣어야지'라고 생각하지는 않는다. JPA도 마찬가지다.

오히려 persist를 해주면 안된다. 얻을 수 있는 게 없다. JPA는 객체를 조회하고 데이터를 변경하면 끝이다. 이게 어떻게 가능할까? 변경 감지(Dirty Checking)라는 기능을 사용한 것이다.


JPA는 변경 감지라는 기능으로 엔티티를 변경할 수 있게 된다.
우리가 생각할 때는 엔티티의 값을 바꾸려면 set을 해서 값을 바꾼 다음 JPA한테 이 값을 업데이트 해달라고 코드를 날려야 될 것 같지만 JAVA 컬렉션에서 하는 것처럼 그러지 않아도 DB의 값이 변경된다.

비밀은 바로 영속성 컨텍스트 안에 있다.

DB 트랜잭션을 커밋하면 엔티티랑 스냅샷을 비교한다. 1차 캐시 안에는 사실 PK인 ID가 있고 Entity, 스냅샷이 있다. 스냅샷이 뭐냐면 값을 읽어온 그 시점 최초의 상태를 스냅샷으로 가지고 있는 것이다.
이 상태에서 멤버의 값을 변경하면 JPA가 트랜잭션을 커밋하는 시점에 내부적으로 Flush라는게 호출되면서 JPA가 변경된 엔티티와 스냅샷에 있는 최초의 데이터를 다 비교한다.

Flush는 다음 글에서 설명한다.

비교를 해서 '어? 멤버A의 데이터가 바뀌었네?'하고 업데이트 쿼리를 쓰기 지연 SQL 저장소에 쌓는다. 이것이 변경 감지 기능이다.
이러한 매커니즘으로 엔티티 수정이 동작한다.

✔︎ 엔티티 삭제

//삭제 대상 엔티티 조회 
Member memberA = em.find(Member.class, “memberA");
em.remove(memberA); //엔티티 삭제

삭제의 매커니즘도 위와 같다. 다만 이때는 Delete 쿼리가 나간다.

📌 지연 로딩 (Lazy Loading)

  • 객체가 실제로 사용될 때 로딩하는 전략
  • memberDAO.find(memberId)에서는 Member 객체에 대한 SELECT 쿼리만 날린다.
  • Team team = member.getTeam()로 Team 객체를 가져온 후에 team.getName()처럼 실제로 team 객체를 건드릴 때 Team에 대한 SELECT 쿼리를 날린다.
    • 즉, 값이 실제로 필요한 시점에 JPA가 Team에 대한 SELECT 쿼리를 날린다.
  • Member와 Team 객체 각각 따로 조회하기 때문에 네트워크를 2번 타게 된다.
    • Member를 사용하는 경우에 대부분 Team도 같이 필요하다면 즉시 로딩을 사용한다.

즉시 로딩

  • JOIN SQL로 한 번에 연관된 객체까지 미리 조회하는 전략
  • Join을 통해 항상 연관된 모든 객체를 같이 가져온다.
  • 애플리케이션 개발할 때는 모두 지연 로딩으로 설정한 후에, 성능 최적화가 필요할 때에 옵션을 변경하는 것을 추천한다.

출처
자바 ORM 표준 JPA 프로그래밍 강의
게시글에서 사용한 모든 사진 자료는 위 강의 속 자료입니다.

profile
스프링 백엔드를 공부중인 대학생입니다!

0개의 댓글