본문 바로가기

Spring/Data

엔티티 생명주기와 영속성 컨텍스트

엔티티 생명주기

 

먼저 엔티티의 생명주기를 알아야 영속성 컨텍스트를 이해할 수 있고 JPA의 특징을 살려 개발을 할 수 있게 된다.

@Entity를 적용한 클래스만이 엔티티가 될 수 있으며, 엔티티의 생명주기는 비영속(New), 영속(Managed), 준영속(Detached), 삭제(Removed)로 구분된다. 이 중에서 영속 상태에서만 영속성 컨텍스트의 관리 대상이 된다.

비영속

//비영속 상태
Member member = new Member();
member.setName("member");
member.setAddress(new Address("한국", "서울", "12345"));

//준영속 상태로 변경
//실제 DB에 들어갈 수 있는 식별자를 사용해야 영속으로 변경 가능
member.setid(1L);

//영속 상태로 변경(6번 라인 지우고 실행해야 한다.)
em.persist(member);

비영속 상태는 JPA와 전혀 관계가 없는 단순히 객체를 생성한 상태를 의미한다. 따라서 영속성 컨텍스트와도 관련이 없다. 스프링에서는 POJO라고 보면 된다. 

 

해당 코드에서 영속 상태와 준영속 상태로 갈 수 있는 방법이 있다.

영속 상태로 가기 위해서는 persist() 호출하면 된다.

준영속 상태로 가기 위해서는 setId()에 식별자를 넣어주면 된다. 다만, 준영속 상태일 경우 실제 존재하는 식별자여야 한다. (merge()를 호출해서 영속 상태로 갈 때 만약 식별자가 없는 식별자라면 오류가 발생할 수 있으니 반드시 존재하는 식별자여야 한다.)

준영속

준영속 상태란 과거에 영속성 컨텍스트의 관리 대상이었던 엔티티 데이터를 말한다. 만약 영속성 컨텍스트에 보관이 되어있다고 하더라도 준영속 상태가 되면 저장, 수정, 삭제가 일어나지 않는다.

 

준영속 상태가 되기 위해서는 크게 4 가지 방법이 있으며 2 가지의 상황을 만들어 볼 수 있다.

 

1. 요청 데이터에 식별자가 있는 경우

@PostMapping("members/{memberId}")
public String updateMember(@PathVariable Long memberId, 
                           @RequestBody RequestMemberDto requestMemberDto) {
                           
  Member member = new Member();
  
  //과거 생성된 식별자를 통해 요청이 있을 경우 값을 넣어준다.
  member.setId(memberId);
  member.setName(requestMemberDto.getname());
}

위와 같은 방법으로 준영속 상태를 만들지는 않겠지만 식별자를 비영속 상태의 엔티티에 넣어주면 우선 준영속 상태로 분류하게 된다. 향후 이러한 데이터는 merge()를 호출해서 영속 상태로 변경이 가능하다.

 

2. 조회 후 준영속 상태로 변경

//식별자를 통해 데이터를 조회하여 member를 영속 상태로 변경한다.
Member member = em.find(Member.class, 1L);

//1. 특정 엔티티만 준영속 상태로 전환
em.detach(member);

//2. 영속성 컨텍스트를 완전히 초기화
em.clear();

//3. 영속성 컨텍스트를 종료
em.close();

조회를 하게 되면 영속 상태가 되고 위와 같이 3가지 방법을 사용해서 준영속 상태로 변경할 수 있다.

영속

영속 상태가 되면 영속성 컨텍스트의 관리 대상이 된다.

영속 상태로 만들기 위한 방법은 크게 3가지 상황을 만들어 볼 수 있다.

 

1. persist() 호출

Member member = new Member();
member.setName("member");
member.setAddress(new Address("한국", "서울", "12345"));

//첫 번째 방법
em.persist(member); //저장

persist()를 호출 하게 되면 영속성 컨텍스트에 저장이 되어 관리 대상이 된다. 그리고 JPA는 생성 쿼리를 만들어 쓰기 지연 SQL 저장소에 보관한다. 이 방법으로 영속성 컨텍스트의 관리 대상이 되었다고 해서 DB에 저장되는 것은 아니다. 이 후에 트랜잭션 커밋이 발생되어야 DB에 저장된다.

 

2. find() 호출

//두 번째 방법
Member findMember = em.find(Member.class, 1L); //조회

find()를 호출 하게 되면 먼저 1차 캐시의 식별자를 찾는데 데이터가 있을 경우 해당 데이터를 가져온다. 만약 데이터가 없다면 DB에 있는 데이터를 조회한다. 조회한 데이터는 영속성 컨텍스트에 저장하고 영속 상태로 변경 후 호출한 엔티티에게 반환한다. find()는 보통 조회한 값을 전달하거나 업데이트가 필요할 경우 사용한다.

 

3. 준영속 상태 데이터 merge() 호출

private final EntityManager em;

@PostMapping("members/{memberId}")
public void updateMember(@PathVariable Long memberId, 
                           @RequestBody RequestMemberDto requestMemberDto) {
                           
  Member member = new Member();
  
  //과거 생성된 식별자를 통해 요청이 있을 경우 값을 넣어준다.
  member.setId(memberId);
  member.setName(requestMemberDto.getname());
  
  memberRepository.save(member);
}

public void save(Member member) {
   /**
    * 만약 ID가 없다면(새로 생성할 객체) persist 적용
    */
    if(member.getId() == null) {
        em.persist(member);
    } else {
        Member mergeMember = member.merge(member);
    }
}

마지막 방법은 바로 준영속 상태의 데이터를 merge() 를 호출해서 영속 상태로 변경하는 것이다.

 

Merge() 동작 방식

1. merge()를 실행한다. 

2. 파라미터로 넘어온 준영속 엔티티의 식별자(ID)로 1차 캐시에서 엔티티를 조회한다.
    2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다. 

3. 조회한 영속 엔티티( mergeMember )에 member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값 을 mergeMember에 밀어 넣는다. 이때 mergeMember의 “회원1”이라는 이름이 “회원명변경”으로 바뀐다.) 

4. 영속 상태인 mergeMember를 반환한다.

 

Merge() 주의점

Merge()를 호출할 때 사용된 엔티티값만 변경하게 된다. 즉 setName()만 변경했으므로 만약 member 엔티티에 여러 필드가 더 존재한다면 해당 필드는 모두 null이 되는 문제가 발생한다. 따라서 모든 필드를 교체할 때만 사용할 수 있다.

삭제

//식별자를 통해 데이터를 조회하여 member를 영속 상태로 변경한다.
Member findMember = em.find(Member.class, 1L);

// remove()를 사용해서 DB에서 삭제
em.remove(findMember);

삭제하는 방법은 find()를 호출해서 해당 엔티티를 영속 상태로 변경 후 remove()를 호출하면 된다. 마찬가지로 해당 메서드를 호출했다고 해서 바로 삭제되는건 아니고 트랜잭션 커밋이 호출되어야 한다.

영속성 컨텍스트

영속성 컨텍스트는 1차 캐시와 쓰기 지연 SQL 저장소를 가진다.

persist() 호출, find() 호출, merge() 호출을 하게 되면 영속성 컨텍스트에 저장되는데 이 말은 1차 캐시에 저장되었다는 말과 동일하다. 

1차 캐시는 엔티티의 식별자로(@Id가 적용된  필드) 각 데이터들을 구분한다. 스냅샷에는 처음 1차 캐시에 저장되었을 때의 값을 가지고 있다. Entity 안에는 현재 엔티티의 데이터를 가지고 있다. Entity의 값은 비즈니스 로직에 의해 변경될 수도 있다. 1차 캐시안에 있는 값은 보통은 트랜잭션이 종료되었을 때 비워지게 된다.

 

persist()가 호출되면 JPA가 생성 쿼리를 만들고 쓰기 지연 SQL 저장소에 보관을 한다. flush()가 호출될 때는 1차 캐시에 보관되어 있는 엔티티 값과 스냅샷을 비교하여 변경된 값들은 JPA가 수정 쿼리를 만들어 보관한다. 보관된 쿼리는 최종적으로 flush() 호출이 완료되는 시점에 DB에 반영하고 쓰기 지연 SQL 저장소를 비우게 된다. 

영속성 컨텍스트 활용

1차 캐시

Member findMember = em.find(Member.class, 1L);

조회를 하게되면 먼저 1차 캐시의 데이터를 조회하게 된다. 만약 식별자에 대한 데이터가 있다면 DB에 조회 쿼리를 날리지 않으므로 성능적으로 약간의 이점을 얻을 수 있다.

데이터가 없다면 DB에서 가져오게 되고 조회한 데이터는 1차 캐시에 저장한다.

 

영속 엔티티의 동일성 보장

//DB에서 조회
Member member1 = em.find(Member.class, 1L); 

//1차 캐시에서 조회
Member member2 = em.find(Member.class, 1L);
System.out.print(member1 == member2) //true

 

영속 엔티티는 1차 캐시에 저장되어 있기 때문에 동일한 식별자를 가진다면 계속해서 동일한 식별자를 가져온다. 따라서 해당 값들은 모두 동일성을 보장하게 된다.

 

변경 감지와 쓰기 지연

JPA는 변경 감지(Dirty Checking)와 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)라는 기능을 제공한다.

두 기능은 플러시라는 기능에 의해 동작된다.

 

순서

1. flush()를 호출한다.
2. 엔티티(1 캐싱된 ) 스냅샷(해당 값이 최초로 영속 컨텍스트에 들어온 시점) 비교한다. (변경 감지)

3. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 저장한다.
4. 쓰기 지연 저장소에 보관한 쿼리를 한 번에 DB에 반영한다. (쓰기 지연)

 

변경 감지를 활용하면 따로 업데이트 쿼리를 만들지 않아도 된다. 다만 트랜잭션이 커밋되어야만 실제 DB에 반영된다.

 

플러시

플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 역할을 한다.

플러시는 영속성 컨텍스트의 1차 캐시를 비우지 않고, 쓰기 지연 SQL 저장소의 내용을 비운다.

플러시를 통해 변경감지가 진행되면 이후 1차 캐시에 있는 Entity값과 스냅샷 같이 같아진다.

 

최종적으로 DB에 반영(저장 아님)을 하기 위해서는 flush() 가 호출되어야 한다.

플러시가 호출되는 방법은 크게 3가지가 있다.

1. flush() 직접 호출

2. 트랜잭션 커밋 시 자동 호출

3. JPQL 쿼리 실행 시 자동 호출

 

플래시 주의점

1. flush()를 호출하더라도 1차 캐시의 데이터는 비워지지 않는다.

2. 트랜잭션 커밋이 호출되기 전까지는 DB에 저장된게 아니다.

3. JPQL 실행할 사실 모든 내용을 플러시 하는 것이 아니라, 해당 JPQL 관련 있는 엔티티만 플러시한다.


참고 자료

JPA 강의 로드맵