Spring Model/02. 객체 매핑과 P.C

06. 엔티티의 상태 관리

  • -

이번 시간에는 엔티티의 상태 관리와 관련된 메서드들을 사용해보면서 P.C의 동작 즉 1차캐시, 지연로딩, 식별자를 통한 엔티티 관리, 변경 감지, 쓰기 지연 방식에 대해 살펴보자.

Entity의 상태

 

P.C에서 엔티티의 상태

P.C에서 엔티티는 new(transient) / managed / detached / removed 의 4가지 상태로 관리된다.

EntityManager가 Persistence Context에서 관리하는 엔티티의 상태

상태 설명
New - 비영속 엔티티는 생성됐지만 아직 EntityManager가 관리하지 않음
Managed - 영속 persist 또는 find 계열의 메서드 동작 등으로 엔티티가 P.C의 1차캐시에 저장된 상태로 EntityManager에 의해 관리 됨
Detached - 준영속 영속 상태였다가 EntityManager가 close() 또는 clear()되거나 엔티티가 detach()되서 더 이상 관리되지 않는 상태
Removed - 삭제 영속 상태였다가 EntityManager에 의해 삭제된 상태

그럼 EntityManager가 어떻게 엔티티를 관리하는지 P.C의 동작과 연결해서 살펴보자.

엔티티의 C/R/U/D

 

엔티티 저장과 쓰기 지연 방식

JpaRepository의 save()는 EntityManager의 persist()를 호출한다. 대상 엔티티는 1차 저장소에 저장되고 영속상태로 변경된다. 새로운 엔티티가 영속화된 내용을 DB에 반영되기 위한 insert 쿼리가 작성되고 SQL 저장소에 저장된다. SQL 저장소는 "쓰기 지연" 방식으로 동작하므로 flush()가 호출되기 전까지는 DB에 반영되지 않는다.

실제로 'insert 쿼리가 실행되는가?'는 상당히 복잡한 과정을 거친다. 엔티티를 저장할 때는 P.K가 설정되었는지, 해당 컬럼이 자동 설정인지, 동일한 P.K로 존재하는 데이터가 있는지에 따라 다르게 동작한다. P.C에서 관리되기 위해서는 반드시 P.K가 필요하다는 점을 명심하면서 생각해보자.

P.K 설정여부와 자동 증가 컬럼의 상태에 따른 엔티티 저장 흐름

 

현재 DB에는 @Sql에 의해 18개의 Post 데이터가 존재하고 @GeneratedValue를 사용중이다. @GeneratedValue를 주석하고 테스트 코드에서 pno를 1L로 지정 후 save할 때와 pno를 1000L로 지정 후 저장할 때의 동작을 생각해보자.

 

두 개의 Post를 저장하는 동작을 테스트 해보자.

@Test
public void saveTest(){
  // given
  Post p1 = Post.builder().title("title1").content("content1").writer("writer1").build();
  Post p2 = Post.builder().title("title2").content("content2").writer("writer2").build();
  // when
  Post sp1 = prepo.save(p1);           // managed 상태
  Post sp2 = prepo.saveAndFlush(p2);
  // then
  Assertions.assertNotNull(sp1.getPno());
}

 

엔티티 조회와 1차캐시, 지연로딩, 식별자를 통한 엔티티 관리

JpaRepository에서 엔티티를 조회하기 위해서는 findXX() 계열의 메서드를 이용하면 된다. 이중 findById() P.K를 이용해서 자료를 조회하며 다음의 절차를 거쳐 Optional을 반환한다.

findById()의 동작 절차

  1. P.C의 1차 캐시에 해당 Id로 등록된 엔티티가 있는지 확인한다.
  2. 존재한다면 그 엔티티를 Optional 타입으로 반환하고 존재하지 않는다면 DB를 조회한다. 
  3. DB에서 Id를 키로 하는 데이터가 조회되지 않는다면 Optional.empty()를 반환한다.
  4. DB에서 데이터가 조회되었다면 P.C의 1차 캐시에 엔티티 등록 후 Optional로 엔티티를 반환한다.실제 동작이 어떻게 이뤄지는지 살펴보자.

 

테스트의 흐름은 아래 문장의 주석을 참조한다. 현재 DB에는 mno가 1L, 2L인 Member가 있다고 가정하자.

@Autowired
MemberRepository mrepo;

@Test
public void findByTest(){
  // given , when
  Member member = mrepo.findById(1L).get();    // 1차 캐시는 비어있음 -> Select -> 1차 캐시에 저장
  log.debug("member: {}", member.getName());
  Member already = mrepo.findById(1L).get();   // managed 상태 - > select 없음 -> 1차 캐시 활용
  log.debug("already: {}", already.getName()); // 식별자를 통한 엔티티 관리
  // then
  Assertions.assertSame(member, already);
  log.debug("member.nickname: {}", member.getNicknames());// 지연 로딩 - 필요할 때 조회한다.
}

 

이때 출력된 log를 살펴보자.

Hibernate: 
    select
        m1_0.mno, . . .
    from
        member m1_0 
    left join
        paid_member m1_1 
            on m1_0.mno=m1_1.mno 
    where
        m1_0.mno=?
11:29:26 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]
11:29:26 [DEBUG] [c.d.b.p.PersistenceContextTest.findByTest.63] member: hong gil dong
11:29:26 [DEBUG] [c.d.b.p.PersistenceContextTest.findByTest.65] already: hong gil dong
Hibernate: 
    select
        n1_0.member_mno,
        n1_0.nicknames 
    from
        member_nicknames n1_0 
    where
        n1_0.member_mno=?

처음 findById(1L)을 했을 때는 아직 1차 캐시에 엔티티가 없기 때문에 DB에서 조회 후 엔티티를 P.C의 1차 캐시에 등록 후 반환된다. 두 번째 findById(1L)을 했을 때는 이미 엔티티가 1차 캐시에 있기 때문에 별도로 조회 하지 않는다. 이것이 P.C의 1차 캐시의 역할이다.

Member 엔티티의 ID인 mno 1L을 통해 조회된 두 개의 엔티티인 member와 already는 단지 식별자를 통해서만 구분된다. 따라서 둘은 같은 엔티티이다.

마지막으로 Member를 조회했을 때는 NickNames를 조회하지 않았다는 것에도 관심을 기울일 필요가 있다. 평소 Member를 사용하는데 별명을 사용하지 않는다면 굳이 join 해서 조회하지 않는다. 이후 nicknames를 사용하는 시점 즉 필요해지면 그때 조회해서 로딩하게 된다. 이것이 지연 로딩이다.

 

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

영속 상태인 엔티티의 field는 수시로 변경될 수 있다. 이때마다 업데이트 쿼리가 실행 된다면 이것 또한 낭비이다. 이를 개선하기 위해서 snapshot과 '쓰기 지연 SQL 저장소'가 활용된다.

P.C에서는 1차 캐시에 원본 엔티티에 대한 snapshot을 가지고 있어서 flush()가 호출되는 시점에 엔티티와 snapshot에 다른 내용이 있다면 비로서 update 문장을 생성하고 SQL 저장소에  추가한다. 따라서 여러 차례 속성이 변경되더라도 update는 최대 1번 동작한다. 이후 성공적으로 commit 되면 snapshot도 변경한다. 

dirty checking과 엔티티 수정

@Test
public void updateTest(){
    // given
    Post post = prepo.findById(1L).get(); // 아직 1차 캐시에 없었음 -> DB 조회 후 P.C에 등록 후 반환
    
    // when
    post.setTitle("수정 title");
    prepo.flush();                        // dirty checking!
    Post updated = prepo.findById(1L).get();
    // then
    Assertions.assertSame(updated, post);
}
Hibernate: 
    select
        p1_0.pno,~~~
    from
        post p1_0 
    where
        p1_0.pno=?
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]
Hibernate: 
    update
        post 
    set
        content=?,
        title=?,
        update_time=?,
        writer=? 
    where
        pno=?
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [content_001]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:VARCHAR) <- [수정 title]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:TIMESTAMP) <- [...]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:VARCHAR) <- [1]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (5:BIGINT) <- [1]

 

엔티티의 삭제

P.C에서 엔티티를 삭제하기 위해서는 JpaRepository의 deleteXX 계열 메서드를 이용한다. 당연한 이야기겠지만 삭제대상인 엔티티는 반드시 영속상태여야 하므로 사전에 조회등을 통해 영속화 시킨 후 삭제한다. 

엔티티 삭제

 @Test
 public void deleteTest(){
     // given, when
     prepo.deleteById(1L);
     prepo.flush();

    // then
    Assertions.assertTrue(prepo.findById(1L).isEmpty());
}
Hibernate: # 삭제를 위한 첫 번째 entity 조회(1L) - 영속화
    select
        p1_0.pno,~~~
    from
        post p1_0 
    where
        p1_0.pno=?
23:58:38 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]
Hibernate: # 존재하는 1L 엔티티 삭제
    delete 
    from
        post 
    where
        pno=?
23:58:38 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]
Hibernate: # 1L 다시 조회 - 조회되지 않음
    select
        p1_0.pno,~~~
    from
        post p1_0 
    where
        p1_0.pno=?
23:58:38 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]

 

준영속 상태

이제까지는 주로 영속 상태의 엔티티에 대해서 살펴봤다면 여기서는 준영속 상태의 엔티티에 대해 살펴보자.

준영속 상태(detached)

준영속 상태는 한번은 영속 상태였던 엔티티가 여러가지 이유로 P.C에서 분리되는 상태이다. 언제 엔티티를 준영속 상태로 만들까?

일반적으로 계속해서 P.C에서 엔티티를 관리하기에는 부담스러운 경우이다. 예를 들면 다음의 경우를 생각할 수 있다.

  • 성능 최적화: 대량의 데이터를 처리할 때 모든 엔티티를 P.C에서 관리하면 메모리 소비가 증가하므로 일부 엔티티를 detach 상태로 처리
  • 데이터 일관성 관리: 특정 엔티티의 속성을 변경하는데 아직 DB에 반영할 계획은 아니어서 변경 감지를 중지할 때

준영속 상태의 엔티티는 당연히 P.C가 제공하는 1차 캐시, 쓰기 지연, 변경 감지 등 기능을 사용할 수 없다. 따라서 거의 비영속 상태와 유사하다. 차이점은 원래 영속 상태였기 때문에 '식별자를 반드시 가지고 있다'라는 정도다. 

 

영속 엔티티의 준영속화 및 재 영속화

엔티티의 C/R/U/D를 위해서는 JpaRepository의 메서드를 사용했는데 영속화, 준영속화를 처리할 때는 EntityManager의 detach() / merge()를 사용한다.

  • detach(): 영속상태의 엔티티를 준영속 상태로 만든다. 
  • merge(): 준영속 상태의 엔티티를 파라미터로 받아서 새로운 영속 상태의 엔티티를 반환한다. 원본인 준 영속 엔티티는 그대로 유지됨을 주의하자! (준영속상태 엔티티 !=영속상태 엔티티)

merge 과정에서는 P.C가 관리하는 엔티티의 식별자와 충돌 문제가 있을 수 있기 때문에 좀 복잡한 절차가 진행된다. 기본적으로 준영속 상태의 엔티티가 가진 내용이 높은 우선순위를 갖는다.

준영속상태의 엔티티를 D라고 할 때.

  1. D를 준영속 상태로 만든 후 다시 동일한 식별자로 엔티티(M)를 조회해서 영속상태로 관리하고 있는 경우 merge가 발생하면 D의 내용으로 M의 내용을 수정해서 반환한다. (즉 M의 내용이 업데이트된다.)
  2. P.C에서 동일한 식별자의 엔티티를 발견하지 못했을 때 DB에서 조회 해서 엔티티를 영속상태로 만든 후 1을 진행한다. 이를 위해 update 쿼리가 생성된다.
  3. merge되기 전에 데이터를 삭제해서 조회의 결과가 없다면 새로운 깡통 엔티티를 만들어 영속상태로 만든후 D의 내용으로 덮어쓴다. 이를 위해 insert 쿼리가 생성된다.

준영속 객체의 영속화

 

어떤 상태든 merge의 결과물은 준영속됐던 엔티티가 아니라 전혀 새로운 영속 객체라는 사실이 중요하다. 향후 객체를 사용할때에는 detach 시켰던 엔티티가 아니라 merge된 엔티티를 사용해야 한다.

@Autowired // 여기서는 @Persistence를 사용하지 않는다.
TestEntityManager em;
    
@Test
public void detachMergeTest(){
    // given
    Post post1 = prepo.findById(1L).get();
    Post post2 = prepo.findById(2L).get();
    // when
    em.detach(post1);               // 이제 P.C에는 1L의 Entity는 없는 상태
    post1.setContent("content 수정"); // 이 건은 update가 발생하지 않음
    post2.setContent("content 수정");
    prepo.flush();
    Post merged = em.merge(post1);
    // then
    Assertions.assertNotSame(post1, merged) ;
    Assertions.assertEquals(merged.getContent(), post1.getContent());
}
Hibernate: # 첫 번째 Post 조회(1L)
    select
        p1_0.pno,~~~
    from
        post p1_0 
    where
        p1_0.pno=?
00:19:48 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]
Hibernate: # 두 번째 Post 조회(2L)
    select
        p1_0.pno,~~~
    from
        post p1_0 
    where
        p1_0.pno=?
00:19:48 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [2]
Hibernate: # 2번째 Post 수정 처리
    update
        post 
    set
        content=?,~~~
    where
        pno=?
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [content 수정]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:VARCHAR) <- [title_002]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:TIMESTAMP) <- [...]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:VARCHAR) <- [2]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (5:BIGINT) <- [2]
Hibernate: # merge 하기 위해서 DB에서 1L의 엔티티를 조회함
    select
        p1_0.pno,~~~
    from
        post p1_0 
    where
        p1_0.pno=?
00:19:48 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]

'Spring Model > 02. 객체 매핑과 P.C' 카테고리의 다른 글

05. EntityManager와 Persistence Context  (0) 2022.03.19
04.자동 키 매핑 전략  (0) 2020.06.07
03. Value Type  (0) 2020.06.07
02. OR-Mapping과 상속  (0) 2020.06.02
01. Hello Spring Data JPA  (0) 2020.05.30
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.