[JPA] JPA를 제대로 이해하기 위한 영속성 컨텍스트 심층 분석 (flush, merge)
1. 영속성 컨텍스트의 정의 및 역할
** 영속성 컨텍스트란?
영속성 컨텍스트란, 엔티티를 영구 저장하는 환경을 뜻한다.
JPA에서는 데이터베이스와의 작업을 처리하기 위해서 엔티티 매니저(Entity Manager)를 사용하는데, 이 엔티티 매니저가 관리하는 범위가 바로 영속성 컨텍스트이다.
엔티티 매니저는 엔티티가 영속성 컨텍스트 환경에 저장되고 나면, JPA는 이 엔티티의 생명주기를 관리한다.
엔티티는 데이터베이스에 저장될 때 항상 ID 값으로 구분되어 저장하는데, JPA는 이 ID값을 사용해서 엔티티의 동일성을 보장한다.
즉 엔티티를 여러 번 조회하더라도, ID값이 같다면 해당 엔티티들은 모두 동일한 주소를 가지는 인스턴스가 된다.
이 매커니즘은 데이터의 일관성을 유지하는데 중요한 역할을 한다.
** 영속성 컨텍스트의 역할
크게 다섯가지로 분류할 수 있을 것 같다. 먼저 앞서 말한 엔티티의 동일성 보장을 건너 뛰고, 나머지 4개만 설명하자면,
1) 1차 캐시
2) 쓰기 지연
3) 변경 감지
4) 지연 로딩
★ 1차 캐시
먼저 새로운 멤버 객체를 생성했다고 하자. 그리고 ID 값을 직접 설정하여 DB에 저장했을 때, 엔티티 매니저는 persist 메서드를 호출해서 1차 캐시에 새로 만든 member를 저장한다.
그리고 만약 이후에 해당 Member를 다시 DB에서 조회하려는 경우, 엔티티 매니저는 먼저 DB에 가지 않고, 1차 캐시에 찾는 데이터가 있는지 먼저 조회한다.
이때 일치하는 데이터 값이 있으면, 그 데이터를 바로 반환한다.
이후에 DB에 저장된 다른 Member 객체인 member2를 조회하려고 한 경우, 엔티티 매니저는 먼저 1차 캐시 내부에 데이터가 저장되어 있는지 확인하고, 없다면 그때 DB를 조회한다.
위 요청이 성공적이었다면 찾은 데이터를 1차 캐시에 저장하고, 클라이언트에게 반환한다.
1차 캐시는 영속성 컨텍스트가 관리하는 엔티티를 메모리에 저장해서, 같은 엔티티를 반복적으로 조회할 때 데이터베이스에 다시 접근하지 않도록 해준다.
그래서 데이터베이스 요청 횟수를 줄여 성능을 개선하고, 동일성 보장으로 동일한 트랙잭션 안에서 조회한 객체가 항상 동일하게 유지될 수 있다.
★ 쓰기 지연
Transaction은, 데이터베이스의 상태를 변화시키기 위해 수행하는 작업의 단위를 뜻한다.
트랜잭션 내부에 있는 모든 쿼리문은 모아졌다가, 필요한 경우 한번에 DB에 요청된다.
이때 쿼리문은 [쓰기 지연 SQL 저장소]에 모아져 보관되며, 그림에 나온 INSERT 쿼리문 말고도 UPDATE, DELETE 등 다른 쿼리문이 나올 때 바로바로 DB에 실시간으로 변화가 반영되는 것이 아니라, 내부적으로 flush()가 호출되기 전까지 쓰기 지연 저장소에 해당 변화를 저장한다.
이를 통해서 최적화된 SQL 쿼리를 실행할 수 있게 된다.
★ 변경 감지(Dirty Checking)
엔티티가 DB에 저장될 때는, [Key(ID), Entity, Entity SnapShot]의 형태로 영속 상태의 Entity를 저장한다.
이때 SnapShot 이란, 영속성 컨텍스트 안에 정보가 저장될 때의 최초 상태를 뜻한다.
Transaction이 종료되고 commit이 이루어질 때, 영속성 컨텍스트에서 보관하고 있던 데이터에 변경이 일어났는지를 확인하게 되는데, 이때 Entity의 최종 값과 Entity SnapShot의 값이 다르다면, 쓰기 지연 SQL 저장소에 update 쿼리문을 추가로 생성한다.
그리고 변경된 값을 commit해서 DB에 저장한다.
따라서 Entity가 영속성 컨텍스트에 저장되어 있는 트랜잭션 내에서 변경이 일어났을 경우, 별도로 save()를 호출해 주지 않아도 자동으로 UPDATE 쿼리문이 날아간다.
★ 지연 로딩
엔티티를 DB에서 가져올 때 만약 이 엔티티가 연관 관계를 맺었던 다른 엔티티가 있었을 경우, 로딩 설정을 통해서 연관된 다른 엔티티의 정보들도 가져올 수 있다.
이때 옵션은 두가지가 있는데,
- 즉시 로딩: 엔티티를 조회할 때 연관된 객체도 함께 DB에서 바로 조회
- 지연 로딩: 연관된 객체는 조회하지 않고, 나중에 필요한 경우 그 때 DB에서 조회
연관된 데이터가 너무 많고, 당장에 필요하지 않은 데이터가 연관관계로 매핑되어 있는 경우라면, 지연 로딩을 통해서 불필요한 쿼리문을 줄여 성능을 높일 수 있다.
2. 트랜잭션과 영속성 컨텍스트의 관계
영속성 컨텍스트는 기본적으로 @Transactional의 생명주기와 동일하게 동작한다. 이는 JPA가 트랜잭션의 단위 안에서만 엔티티의 영속성 상태를 관리하기 때문인데, 즉 다시 말해 트랜잭션이 시작될 때 영속성 컨텍스트가 생성되고, 트랜잭션이 종료될 때 영속성 컨텍스트도 함께 종료된다.
이렇게 작동하는 이유는, 데이터의 일관성을 위해서, 그리고 트랜잭션의 원자성을 보장하는 데 트랜잭션 범위 내에서의 엔티티 관리가 필연적이기 때문이다.
3. 엔티티의 생명 주기(Lifecycle) 관리
영속성 컨텍스트에 의해 관리되는 엔티티의 생명주기로는 4가지가 있다.
- New / Transient(비영속 상태): 객체가 생성되지 얼마 되지 않은 상태와 같이, 영속성 컨텍스트와는 관련이 없는 상태
- Managed(영속 상태): 영속성 컨텍스트에 저장되어 관리를 받고 있는 상태
- Detached(준영속 상태): managed였던 상태였던 entity가 영속성 컨텍스트에서 분리된 상태
- Removed(삭제 상태): 삭제된 상태
1. 영속 상태에 있는 엔티티라고 해서, 절대로 그럼 DB에 저장되어 있는 상태라고 생각하면 안된다. 엔티티 매니저의 persist() 함수를 통해 (new)비영속 상태의 엔티티를 영속 상태로 만들 수 있다.
2. (managed)영속 상태에 있는 엔티티는 이후 flush()가 호출되어야만 DB에 비로소 저장되게 된다. flush 이후에도 1차 캐시 저장소에는 해당 엔티티 정보들이 남아 있게 된다.
3. 영속 상태에 있다가, detach() 메서드를 호출하면 그 엔티티는 준영속 상태가 된다. 1차 캐시에 담겨 있는 모든 데이터를 한번에 clear()로 준영속(detached) 상태로 만들어 버릴 수도 있다. 이때 준영속 상태였던 엔티티는 merge()를 호출하면 다시 영속 상태가 되지만, 자세한 동작 방식은 아래 [4. 중요 메서드]에서 소개한다.
여기서 준영속 상태의 엔티티를 지연 로딩으로 정보를 가져오는 요청을 하게 되면, 이 엔티티는 영속성 컨텍스트의 관리를 받지 않는 상태이기 때문에 예외가 발생하게 된다. 이 때 발생하는 예외는 LazyInitializedException이다.
4. 마지막으로 삭제(removed) 상태는, 엔티티를 영속성 컨텍스트에서 관리하지 않게 되고, 해당 엔티티를 DB에서 삭제하는 DELETE 쿼리문을 쿼리문 조장소에 보관하게 된다. 이후 flush() 호출 시 DB에 반영된다.
4. 중요 메서드( Flush, Merge )
★ ★ ★ ★ Flush
- 이 메서드는 쓰기 지연 SQL 저장소에 쌓여 있는 SQL 쿼리문을 실제 데이터베이스에 날려 실행시키는 메서드다.
- 이 메서드가 호출되는 시점(중요)
- 엔티티 매니저 객체의 flush()를 직접 호출할 수 있다. 혹은 Spring Data Repository 객체의 saveAndFlush()를 통해 직접 호출할 수 있다.
- Transaction이 Commit될 때 자동으로 호출된다.
- JPQL 쿼리를 이용한 요청 시 자동으로 호출된다.
여기서 마지막 호출 시점, JPQL을 이용해서 자동으로 호출 되는 이유를 알아보자.
우리는 JPQL 쿼리를 이용한 요청을 하기 위해 Spring Data Repository에 @Query를 아래와 같이 사용할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m WHERE m.email = :email")
Optional<Member> findByEmail(@Param("email") String email);
}
그리고 아래 예제에서도 JPQL 쿼리가 날아가는 경우를 살펴보면,
memberRepository.findById(1L); // 1. PK 를 이용한 단건조회
memberRepository.findAll(); // 2. 전체조회
memberRepository.findByEmail(email); // 3. JPA 추상메소드를 통한 조회
memberRepository.findAllEnabled(); // 4. @Query 를 이용한 JPQL 조회
memberRepository.findAllByPaging(page); // 5. QueryDSL 을 이용한 조회
---
// 4번 예제
@Query("SELECT m FROM Member m WHERE m.enabled = true")
List<Member> findAllEnabled();
---
// 5번 예제
Page<Member> findAllByPaging(PageRequest page) {
// QueryDSL 을 이용한 구현
}
1번의 경우를 제외하고 나머지 경우 모두 JPQL이 날아간다. ID(PK) 값을 사용해서 단건 조회하는 경우, 쿼리가 날아가지 않는다. 하지만 findAll 요청 시에는 쿼리가 날아가므로 기억해야 한다.
그렇다면 왜, JPQL 쿼리가 날아가기 전에 flush가 호출되는 걸까?
- 여기서 중요한 사실 하나는, JPQL 쿼리는 1차캐시를 보지 않고 바로 데이터베이스에 SQL 쿼리를 날린다는 것이다.
- 만약 변경 감지, 쓰기 지연을 통해서 1차 캐시에 담긴 엔티티에 변경사항이 생겼고, 그 변경 쿼리문이 SQL문 저장소에 저장되어 있다고 가정하자.
- 아직 flush()가 호출되기 전이므로, snapshot의 정보와 현재 엔티티가 가리키는 정보는 달라지게 된다.
- 이 상태에서 JPQL 쿼리를 요청하게 되면, 바로 데이터베이서에 그 쿼리를 날리게 되므로, 1차 캐시에 담긴 바뀐 엔티티의 정보를 가져오지 않게 된다. 그래서 실제로 변경이 일어난 값을 찾을 수 없게 된다.
- 이 때문에 JPQL 쿼리가 날라가기 전에 flush()가 자동으로 호출되게 된다.
★ ★ ★ ★ Merge
- 위에서 언급했었지만, Merge는 준영속(Detached) 상태였던 엔티티를 영속 상태로 되돌리는 메서드이다.
- 하지만, merge()로 전달한 엔티티와, 영속 상태로 반환된 엔티티는 서로 다른 객체다. 즉, 한번 준영속 상태가 되었던 엔티티는 다시 영속 상태가 될 수 없다.
- Merge의 동작 과정
- 1. 인수로 전달한 엔티티의 ID가 1차 캐시에 존재하는 Key인지 확인한다.
- 2. 1차 캐시에 존재하지 않으면, 데이터베이스에 SELECT 쿼리를 보내 데이터를 가져와 1차 캐시에 보관한다. (존재했다면 이 과정은 생략된다)
- 3. 1차 캐시에 저장된 엔티티와 인수로 전달된 엔티티를 합친다.
- 4. 합친 결과로 새로운 엔티티를 생성하고, 1차 캐시에 저장한 후 반환한다.
==> merge에서 반환된 엔티티는 인수로 전달한 엔티티와 항상 다른 엔티티이다.
@Transactional
public void main() {
Member member = new Member(name = "Simhun");
member.setId(1L);
// member Entity 는 id 가 세팅됨으로써 isNew() 의 결과가 False 가 되고 merge(Entity) 가 호출된다.
Member savedMember = memberRepository.save(member);
// 즉, member 와 savedMember 의 주소는 서로 다르다!!
member.setNickname("Simon");
// 여기서 member 에 대한 변경감지(Dirty Checking) 이 될거라 기대했지만,
// member 는 준영속(Detached) 상태기 때문에 변경감지가 되지 않게된다.
}
save가 호출될 때, 엔티티의 ID값이 null이 아니면 isNew()의 결과가 False가 되기 때문에, 영속성 컨텍스트에 올리기 위해 merge()가 호출된다. 이때 merge는 위에서 설명했듯이 반환값으로 인수로 전달한 member와 다른 값을 내보낸다.
그렇기 때문에 member.setNickname을 해도 변경 감지가 이루어지지 않는다. 이때는 savedMember.setNickname을 요청해야만 한다.
우리는 save를 사용하면서 의식적으로 내부적으로 persist가 호출될 지, merge가 호출될 지 생각하며 개발하지 않기 때문에, 의도치 않은 버그를 막기 위해서라도 save()에서 반환된 엔티티를 받아서 활용하는 것이 안전한 코드를 작성하는 좋은 습관이 될 것이다.