Spring boot, JPA (2) - 영속성 컨텍스트(Persistence Context)
JPA 영속성 컨텍스트 요약
Dec 03, 2024
영속성 컨텍스트(Persistence Context)1차 캐시동일성 보장(Identity)다양한 동일성 보장 예시변경 감지(Dirty Checking)지연 로딩(Lazy Loading)쓰기 지연(Transactional Write-Behind)
영속성 컨텍스트(Persistence Context)
영속성 컨텍스트란, 서버와 데이터베이스 사이의 엔티티를 저장하고 관리하는 논리적인 영역입니다.
영속성 컨텍스트는 다음과 같은 장점을 제공합니다.
- 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이 자동으로 변경 감지 동작 }
변경 감지 내부 동작 순서는 다음과 같습니다.
- 트랜잭션 커밋 시점에, Entity Manager 내부에서 flush 호출
- 기존 엔티티와 스냅샷(최초 캐시로 등록된 엔티티)을 비교
- 변경된 엔티티가 있으면 UPDATE SQL 생성
- 쓰기 지연 SQL 저장소에 UPDATE SQL 등록
- 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 문제는
- 예를 들어 Member 10명을 조회하는 상황
- 각 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을 데이터베이스에 보냄 }
내부 동작은 다음과 같이 동작합니다.
- em.persist()를 호출하면 엔티티를 영속성 컨텍스트에 저장
- 이때 INSERT SQL을 생성해서 쓰기 지연 SQL 저장소에 보관
- 트랜잭션 커밋 시점에 쓰기 지연 SQL 저장소에 모인 쿼리들을 데이터베이스에 보냄
장점으로 여러 건의 INSERT SQL을 한 번에 데이터베이스에 보낼 수 있음 Batch Job에 유리합니다. JDBC 배치 기능을 활용하면 성능 최적화도 가능합니다. 추가적으로 아직 데이터베이스에 반영되지 않았다는 점에서 격리 수준도 명확히 구분하여 작업할 수 있습니다.
Share article