Spring boot, JPA (2) - 영속성 컨텍스트(Persistence Context)

JPA 영속성 컨텍스트 요약
김주혁's avatar
Dec 03, 2024
Spring boot, JPA (2) - 영속성 컨텍스트(Persistence Context)
 

 

영속성 컨텍스트(Persistence Context)


 
notion image
영속성 컨텍스트란, 서버와 데이터베이스 사이의 엔티티를 저장하고 관리하는 논리적인 영역입니다.
 
영속성 컨텍스트는 다음과 같은 장점을 제공합니다.
  • 1차 캐시를 통한 성능 최적화
  • 동일성(identity) 보장
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)
  • 쓰기 지연(Transactional Write-Behind)
 
영속성 컨텍스트라는 용어는 이러한 엔티티 보관소의 논리적인 개념을 설명하는 것이고, 실제 구현체는 엔티티 매니저 안에 있는 자료구조입니다. 예를들면 이런식입니다.
class PersistenceContext { Map<EntityId, Entity> cache; // 엔티티 저장소 Map<EntityId, EntitySnapshot> snapshot; // 스냅샷 - 최초 상태 Map<Entity, List<SQL>> sqlLog; // 쓰기 지연 SQL 저장소 }
 

1차 캐시


 
1차 캐시에 대한 내용은 아래 글에서 다루도록 하겠습니다.

동일성 보장(Identity)


 
JPA에서 동일성(identity) 보장은 영속성 컨텍스트의 1차 캐시를 통해 이루어집니다.
// 최초 DB 조회 => 1차 캐시 등록 Member member1 = em.find(Member.class, "id1"); // 두 번째 조회 => 1차 캐시 조회 Member member2 = em.find(Member.class, "id1"); System.out.println(member1 == member2); // true
이것이 가능한 이유는
  • 영속성 컨텍스트가 1차 캐시에 엔티티를 보관할 때 ID를 키로 사용
  • 같은 ID로 조회 시 새로운 객체를 생성하지 않고 기존 캐시된 인스턴스를 반환
  • 따라서 같은 영속성 컨텍스트 안에서는 동일한 ID의 엔티티는 항상 같은 인스턴스
 
동일성은 다양한 상황에서 다양하게 적용됩니다.
 

다양한 동일성 보장 예시

 
  • 다른 영속성 컨텍스트에서는 동일성이 보장되지 않습니다.
    • EntityManager em1 = emf.createEntityManager(); EntityManager em2 = emf.createEntityManager(); Member member1 = em1.find(Member.class, "id1"); Member member2 = em2.find(Member.class, "id1"); System.out.println(member1 == member2); // false
 
  • JPQL을 사용하는 경우에도 동일성이 보장됩니다.
    • @Service public class MemberService { private final MemberRepository memberRepository; @PersistenceContext private EntityManager em; public void example() { // 각각의 조회가 별도의 트랜잭션으로 실행될 수 있음 Member member1 = em.find(Member.class, "id1"); Member member2 = em.createQuery(...) Member member3 = memberRepository.findById("id1").get(); // false가 나올 수 있음 System.out.println(member1 == member2); // true System.out.println(member2 == member3); // true System.out.println(member1 == member3); // false } } @Service @Transactional // 하나의 트랜잭션으로 묶임 public class MemberService { private final MemberRepository memberRepository; @PersistenceContext private EntityManager em; public void example() { Member member1 = em.find(Member.class, "id1"); Member member2 = em.createQuery(...) Member member3 = memberRepository.findById("id1").get(); System.out.println(member1 == member2); // true System.out.println(member2 == member3); // true System.out.println(member1 == member3); // true } }
 
  • 영속 상태가 된 엔티티는 항상 동일성 보장
    • 여기서 영속 상태란, 엔티티가 영속성 컨텍스트내의 엔티티로 등록 됐다는 것으로 1차 캐시에 등록됐다는 것과 같은 의미합니다.
      Member newMember = new Member("id1", "kim"); em.persist(newMember); // 영속화 Member findMember = em.find(Member.class, "id1"); System.out.println(newMember == findMember); // true
 
  • 연관관계를 통한 조회시에도 동일성 보장
    • // Order에서 Member 참조 Order order = em.find(Order.class, 1L); Member member1 = order.getMember(); // 직접 Member 조회 Member member2 = em.find(Member.class, member1.getId()); System.out.println(member1 == member2); // true
 
  • 엔티티 변경 후 flush / execute / commit 후 조회시 동일성이 깨질 수 있음
    • Member member = em.find(Member.class, "id1"); System.out.println(member.getName()); // "kim" // 벌크 연산 수행 em.createQuery("update Member m set m.name = 'lee' where m.id = :id") .setParameter("id", "id1") .executeUpdate(); // DB 반영 // 1차 캐시는 여전히 이전 데이터 상태 System.out.println(member.getName()); // "kim" // 다시 조회해도 1차 캐시에 있는 값을 반환 Member member2 = em.find(Member.class, "id1"); System.out.println(member2.getName()); // "kim" System.out.println(member == member2); // true // 영속성 컨텍스트 초기화 후 조회해야 새로운 값이 반영됨 em.clear(); Member member3 = em.find(Member.class, "id1"); System.out.println(member3.getName()); // "lee" System.out.println(member == member3); // false
 
  • 병합(merge) 사용시 동일성
    • // 준영속 상태의 엔티티 Member detached = new Member("id1", "kim"); // 병합으로 영속 상태로 변경 Member merged = em.merge(detached); // 다시 조회 Member found = em.find(Member.class, "id1"); System.out.println(detached == merged); // false System.out.println(merged == found); // true
 

변경 감지(Dirty Checking)

 
변경 감지, 이른 바 더티 체킹은 영속 상태의 엔티티가 변경될 때 이를 감지하여 변경된 부분을 DB에 반영하는 JPA 영속성 컨텍스트의 기능입니다.
@Transactional public void updateMember() { Member member = em.find(Member.class, "id1"); member.setName("newName"); // 변경 감지 동작 member.setAge(32); // 변경 감지 동작 // em.update() 이런 코드가 없어도 @Transactional이 자동으로 변경 감지 동작 }
 
변경 감지 내부 동작 순서는 다음과 같습니다.
  1. 트랜잭션 커밋 시점에, Entity Manager 내부에서 flush 호출
  1. 기존 엔티티와 스냅샷(최초 캐시로 등록된 엔티티)을 비교
  1. 변경된 엔티티가 있으면 UPDATE SQL 생성
  1. 쓰기 지연 SQL 저장소에 UPDATE SQL 등록
  1. DB에 SQL 반영
 
참고로 준영속 상태에서는 더티 체킹이 동작하지 않습니다.
em.detach(member); member.setName("newName"); // 변경 감지 동작 안함
 
@Transactioanl이 없으면 더티 체킹이 동작하지 않습니다.
public void updateMember() { // @Transactional 없음 Member member = em.find(Member.class, "id1"); member.setName("newName"); // 변경이 DB에 반영되지 않을 수 있음 }
 
@DynamicUpdate를 통해 변경된 필드만으로 UPDATE SQL을 생성할 수 있습니다.
@Entity @DynamicUpdate public class Member { @Id private String id; private String name; private Integer age; }
 

지연 로딩(Lazy Loading)

 
지연 로딩(Lazy Loading)은 연관된 엔티티의 정보를 실제로 사용하는 시점에 조회하는 방식입니다.
// Member 조회 - 이때는 Team 정보를 조회하지 않음 Member member = em.find(Member.class, "id1"); // Team 정보가 실제로 필요한 시점에 쿼리 실행 String teamName = member.getTeam().getName();
 
JPA(Java Persistence API)는 엔티티 간의 관계를 정의할 때, 연관된 엔티티를 어떻게 로드할지를 결정하는 설정으로 fetch를 사용합니다.
 
FetchType은 2가지 enum을 가지고 있는데, LAZY와 EAGER입니다.
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Team team;
  • Lazy (지연 로딩)
    • Lazy 이 설정은 연관된 엔티티를 실제로 필요할 때까지 로드하지 않도록 합니다. 즉, 부모 엔티티를 조회할 때 자식 엔티티는 즉시 로드되지 않고, 해당 자식 엔티티에 접근할 때 쿼리가 실행되어 로드됩니다.
    • 즉, 필요할 때만 로드합니다.
    • 간혹가다 Lazy loading not working 이라는 키워드를 발견할 수 있는데, 제목과 같은 에러가 발생했다면 Lazy loading이 적용 중인 걸로 생각해주시면 됩니다.
  • Eager (즉시 로딩)
    • 이 설정은 부모 엔티티를 조회할 때 연관된 자식 엔티티도 즉시 로드하도록 합니다. 즉, 부모 엔티티를 가져오는 쿼리와 함께 자식 엔티티를 가져오는 쿼리가 동시에 실행됩니다.
    •  연관된 데이터를 항상 필요로 할 때 유용하지만, 불필요한 데이터 로드를 초래할 수 있어 성능 저하를 일으킬 수 있습니다.
    • 즉, 항상 로드합니다.
 
Eager 방식을 사용하면 즉시 참조 관계에 있는 엔티티를 불러와 쉽게 이용할 수 있지만 JPQL을 사용하면 N + 1 문제가 발생할 수 있습니다. 물론 Lazy 로딩을 사용한다고 해서 N + 1문제가 예방되는 것은 아닙니다. 실제로 참조 관계에 있는 데이터를 불러올 때는 N + 1 문제가 마찬가지로 발생하게 됩니다.
 
간단하게 예를들어보면 N+1 문제는
  1. 예를 들어 Member 10명을 조회하는 상황
  1. 각 Member는 Team과 연결되어 있고, 지연로딩으로 설정
List<Member> members = memberRepository.findAll(); // Member 10명 조회 -> 1번의 쿼리 for(Member member : members) { System.out.println(member.getTeam().getName()); // 각 멤버마다 Team 조회 쿼리 발생 -> 10번의 쿼리 }
 
이렇게 지연 로딩을 사용하면 불필요한 데이터는 로딩하지 않고, 필요한 시점에만 데이터를 조회할 수 있습니다.
결과적으로 Member 조회 1번과 Team 조회 10번, 총 11번의 쿼리가 실행됩니다. 이게 N+1 문제입니다.
Lazy loading과 관련된 더 구체적인 Session과 문제 해결은 아래 글에서 다루도록 하겠습니다.
 
Lazy loading은 이 처럼 필요한 데이터만 불러와 효율적일 수 있으나, 참조 관계가 JPA 엔티티에 걸려 있는 상태 자체가 Application을 복잡하게 만들 수 있어 참조관계에 대해서는 실질적으로 코드 상으로 구현하는 것에 대해 고민을 해보는 것이 좋습니다.
 

쓰기 지연(Transactional Write-Behind)

 
엔티티 매니저를 통해 엔티티를 저장할 때 바로 데이터베이스에 저장하지 않고, 트랜잭션을 커밋하는 순간 데이터베이스에 반영하는을 의미합니다.
 
@Transactional public void saveMembers() { // 1. 첫번째 저장 Member member1 = new Member("member1"); em.persist(member1); // INSERT SQL을 데이터베이스에 보내지 않음 // 2. 두번째 저장 Member member2 = new Member("member2"); em.persist(member2); // INSERT SQL을 데이터베이스에 보내지 않음 // 여기까지 INSERT SQL을 데이터베이스에 보내지 않음 // 트랜잭션 커밋 // 이때 모아둔 INSERT SQL을 데이터베이스에 보냄 }
 
내부 동작은 다음과 같이 동작합니다.
  1. em.persist()를 호출하면 엔티티를 영속성 컨텍스트에 저장
  1. 이때 INSERT SQL을 생성해서 쓰기 지연 SQL 저장소에 보관
  1. 트랜잭션 커밋 시점에 쓰기 지연 SQL 저장소에 모인 쿼리들을 데이터베이스에 보냄
장점으로 여러 건의 INSERT SQL을 한 번에 데이터베이스에 보낼 수 있음 Batch Job에 유리합니다. JDBC 배치 기능을 활용하면 성능 최적화도 가능합니다. 추가적으로 아직 데이터베이스에 반영되지 않았다는 점에서 격리 수준도 명확히 구분하여 작업할 수 있습니다.
Share article

vlogue