Spring 입문주차/2주차

9. 영속성 컨텍스트의 기능

note994 2024. 8. 27. 14:56

1차 캐시

영속성 컨텍스트는 내부적으로 캐시 저장소를 가지고 있다.

우리가 저장하는 Entity 객체들이 1차 캐시 즉, 캐시 저장소에 저장된다고 생각하면 된다.

캐시 저장소는 Map 자료구조 형태로 되어있다.

key에는 @Id로 매핑한 기본 키 즉, 식별자 값을 저장한다.

value에는 해당 Entity 클래스의 객체를 저장한다.

영속성 컨텍스트는 캐시 저장소 Key에 저장한 식별자값을 사용하여 Entity 객체를 구분하고 관리한다.

영속성 컨텍스트가 이 캐시 저장소를 어떻게 활용하고 있는지 살펴보자


Entity 저장

em.persist(memo); 메서드가 호출되면 memo Entity 객체를 캐시 저장소에 저장한다.

 

jpa-core 프로젝트 실행

 

테스트 폴더에 PersistenceTest 클래스 파일 만들기

 

  EntityManagerFactory emf;
    EntityManager em;

    @BeforeEach
    void setUp() {
        emf = Persistence.createEntityManagerFactory("memo");
        em = emf.createEntityManager();
    }

EntityTest 파일에서 이 부분을 복붙한다. EntityManager를 사용해야 하기 때문이다.

 

그리고 그 밑에 아래 코드를 복붙

@Test
@DisplayName("1차 캐시 : Entity 저장")
void test1() {
    EntityTransaction et = em.getTransaction();

    et.begin();

    try {

        Memo memo = new Memo();
        memo.setId(1L);
        memo.setUsername("Robbie");
        memo.setContents("1차 캐시 Entity 저장");

        em.persist(memo);

        et.commit();

    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback();
    } finally {
        em.close();
    }

    emf.close();
}

29번째 줄을 디버그 표시하고 함수 실행을 디버그로 한다.

 

StepOver를 클릭하면서 한 줄씩 실행한다.

 

그리고 persistenceContext를 보면 entityKeys와 collectionKeys가 비어있다.

em.persist(memo); 부분을 실행하니 EntityKey에 Memo 객체가 들어갔다.

끝까지 실행하니 데이터베이스에 데이터가 들어갔다.


Entity 조회

@Test
@DisplayName("Entity 조회 : 캐시 저장소에 해당하는 Id가 존재하지 않은 경우")
void test2() {
    try {

        Memo memo = em.find(Memo.class, 1);
        System.out.println("memo.getId() = " + memo.getId());
        System.out.println("memo.getUsername() = " + memo.getUsername());
        System.out.println("memo.getContents() = " + memo.getContents());


    } catch (Exception ex) {
        ex.printStackTrace();
    } finally {
        em.close();
    }

    emf.close();
}

PersistenceTest 파일에 조회 함수 추가

 

캐시 저장소에 해당 PrimaryKey가 없다면 SELECT 문을 사용하여 그 값을 캐시 저장소에 저장한다.

 

 

@Test
@DisplayName("Entity 조회 : 캐시 저장소에 해당하는 Id가 존재하는 경우")
void test3() {
    try {

        Memo memo1 = em.find(Memo.class, 1);
        System.out.println("memo1 조회 후 캐시 저장소에 저장\n");

        Memo memo2 = em.find(Memo.class, 1);
        System.out.println("memo2.getId() = " + memo2.getId());
        System.out.println("memo2.getUsername() = " + memo2.getUsername());
        System.out.println("memo2.getContents() = " + memo2.getContents());


    } catch (Exception ex) {
        ex.printStackTrace();
    } finally {
        em.close();
    }

    emf.close();
}

 

존재한다면 바로 값들을 전달해준다.


객체 동일성 보장

@Test
@DisplayName("객체 동일성 보장")
void test4() {
    EntityTransaction et = em.getTransaction();

    et.begin();
    
    try {
        Memo memo3 = new Memo();
        memo3.setId(2L);
        memo3.setUsername("Robbert");
        memo3.setContents("객체 동일성 보장");
        em.persist(memo3);

        Memo memo1 = em.find(Memo.class, 1);
        Memo memo2 = em.find(Memo.class, 1);
        Memo memo  = em.find(Memo.class, 2);

        System.out.println(memo1 == memo2);
        System.out.println(memo1 == memo);

        et.commit();
    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback();
    } finally {
        em.close();
    }

    emf.close();
}

같은 값을 조회하는 memo1과 memo2는 == 결과 true를 반환

memo1과 다른 값을 조회하는 memo는 == 결과 false를 반환


Entity 삭제

Entity를 조회한 후 캐시 저장소에 없다면 DB에 조회해서 저장한다.

em.remove(memo); 호출 시 삭제할 Entity를 DELETED 상태로 만든 후 트랜잭션 commit 후 Delete SQL이 DB에 요청된다.

@Test
@DisplayName("Entity 삭제")
void test5() {
    EntityTransaction et = em.getTransaction();

    et.begin();

    try {

        Memo memo = em.find(Memo.class, 2);

        em.remove(memo);

        et.commit();

    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback();
    } finally {
        em.close();
    }

    emf.close();
}

쓰기 지연 저장소(ActionQueue)

JPA의 트랜잭션을 학습하면서 JPA가 트랜잭션 처럼 SQL을 모아서 한번에 DB에 반영한다는 것을 배웠다.

JPA는 이를 구현하기 위해 쓰기 지연 저장소를 만들어 SQL을 모아두고 있다가 트랜잭션 commit 후 한번에 DB에 반영한다.

Debugging을 통해 실제로 쓰기 지연 저장소에 SQL을 모아서 한번에 반영하는지 확인해보겠다.

 

쓰기 지연 저장소 (ActionQueue)

@Test
@DisplayName("쓰기 지연 저장소 확인")
void test6() {
    EntityTransaction et = em.getTransaction();

    et.begin();

    try {
        Memo memo = new Memo();
        memo.setId(2L);
        memo.setUsername("Robbert");
        memo.setContents("쓰기 지연 저장소");
        em.persist(memo);

        Memo memo2 = new Memo();
        memo2.setId(3L);
        memo2.setUsername("Bob");
        memo2.setContents("과연 저장을 잘 하고 있을까?");
        em.persist(memo2);

        System.out.println("트랜잭션 commit 전");
        et.commit();
        System.out.println("트랜잭션 commit 후");

    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback();
    } finally {
        em.close();
    }

    emf.close();
}



쓰기 지연 저장소 확인

em > actionQueue를 확인해보면 insertions > executables에 Insert할 memo#2, memo#3 Entity 객체 2개가 들어가 있는 것을 확인할 수 있다.

트랜잭션 commit 후

actionQueue에 있던 insertion 데이터가 사라진 것을 확인할 수 있다.

실제로 기록을 확인해보면 트랜잭션 commit 호출 전까지는 SQL 요청이 없다가 트랜잭션 commit 후 한번에 Insert SQL 2개가 순서대로 요청된 것을 확인할 수 있다.


flush()


트랜잭션 commit 후 쓰기 지연 저장소의 SQL들이 한번에 요청됨을 확인했다.

사실 트랜잭션 commit 후 추가적인 동작이 있는데 바로 em.flush(); 메서드의 호출이다.

flush 메서드는 영속성 컨텍스트의 변경 내용들을 DB에 반영하는 역할을 수행한다.

즉, 쓰기 지연 저장소의 SQL들을 DB에 요청하는 역할을 수행한다.

flush() 동작 확인을 위해 직접 호출해보겠다.

@Test
@DisplayName("flush() 메서드 확인")
void test7() {
    EntityTransaction et = em.getTransaction();

    et.begin();

    try {
        Memo memo = new Memo();
        memo.setId(4L);
        memo.setUsername("Flush");
        memo.setContents("Flush() 메서드 호출");
        em.persist(memo);

        System.out.println("flush() 전");
        em.flush(); // flush() 직접 호출
        System.out.println("flush() 후\n");
        

        System.out.println("트랜잭션 commit 전");
        et.commit();
        System.out.println("트랜잭션 commit 후");

    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback();
    } finally {
        em.close();
    }

    emf.close();
}

em.flush(); 메서드가 호출되자 바로 DB에 쓰기 지연 저장소의 SQL이 요청되었다.

이미 쓰기 지연 저장소의 SQL이 요청되었기 때문에 더 이상 요청할 SQL이 없어 트랜잭션이 commit된 후에 SQL 기록이 보이지 않는다.

추가) 트랜잭션을 설정하지 않고 플러시 메서드를 호출하면 no transaction is in progress 메시지와 함께 TransactionRequiredException 오류가 발생한다.

Insert, update, Delete 즉, 데이터 변경 SQL을 DB에 요청 및 반영하기 위해서는 트랜잭션이 필요하다.


변경 감지(Dirty Checking)

영속성 컨텍스트에 저장된 Entity가 변경될 때마다 Update SQL이 쓰기 지연 저장소에 저장된다면?

하나의 Update SQL로 처리할 수 있는 상황을 여러번 Update SQL을 요청하게 되기 때문에 비효율적이다.

그렇다면 JPA는 어떻게 Update를 처리할까?

JPA에서는 Update를 어떻게 처리하는지 살펴보자

JPA는 영속성 컨텍스트에 Entity를 저장할 때 최초 상태(LoadedState)를 저장한다.

트랜잭션이 commit되고 em.flush();가 호출되면 Entity의 현재 상태와 저장한 최초 상태를 비교한다.

변경 내용이 있다면 Update SQL을 생성하여 쓰기 지연 저장소에 저장하고 모든 쓰기지연 저장소의 SQL을 DB에 요청한다.

마지막으로 DB의 트랜잭션이 commit 되면서 반영된다.

따라서 변경하고 싶은 데이터가 있다면 먼저 데이터를 조회하고 해당 Entity 객체의 데이터를 변경하면 자동으로 Update SQL이 생성되고 DB에 반영된다.

이러한 과정을 변경 감지, Dirty Checking이라 부른다.

변경할 memo 데이터를 조회한 후 변경 해보겠다.

@Test
@DisplayName("변경 감지 확인")
void test8() {
    EntityTransaction et = em.getTransaction();

    et.begin();

    try {
        System.out.println("변경할 데이터를 조회합니다.");
        Memo memo = em.find(Memo.class, 4);
        System.out.println("memo.getId() = " + memo.getId());
        System.out.println("memo.getUsername() = " + memo.getUsername());
        System.out.println("memo.getContents() = " + memo.getContents());

        System.out.println("\n수정을 진행합니다.");
        memo.setUsername("Update");
        memo.setContents("변경 감지 확인");

        System.out.println("트랜잭션 commit 전");
        et.commit();
        System.out.println("트랜잭션 commit 후");

    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback();
    } finally {
        em.close();
    }

    emf.close();
}

entityInstance는 Entity 객체의 현재 상태이다.

entityEntry > loadedState는 조회했을 때 즉, 해당 Entity의 최초 상태이다.

트랜잭션 commit 후 em.flush(); 메서드가 호출되면 현재 상태와 최초 상태를 비교하고 변경이 있다면 Update SQL을 생성하여 쓰기 지연 저장소에 저장한 후 DB에 요청한다.

수정을 진행하고 트랜잭션 commit 후 Update SQL이 요청된 것을 확인할 수 있다.