Lazy 로딩과 Eager 로딩
연관관계 매핑에서 중요한 부분은 엔티티 간의 관계를 어떻게 효율적으로 불러오는가에 대한 전략이다.
이는 Lazy 로딩과 Eager 로딩으로 나뉜다. 각각의 상황에 맞는 로딩 전략을 선택하는 것은 애플리케이션의 성능에 큰 영향을 미친다.
Lazy 로딩: 연관된 엔티티를 실제로 사용할 때까지 로딩을 지연시키는 방법이다. 이 방법은 메모리 사용량을 최적화하고, 성능을 향상시킬 수 있다.
Eager 로딩: 연관된 엔티티를 즉시 로딩하는 방법이다. 필요하지 않은 데이터까지 미리 로딩하기 때문에 성능 저하의 원인이 될 수 있다.
Lazy와 Eager 사용 사례
Lazy 로딩의 적용
Member 엔티티만 필요한 경우, Team 엔티티를 함께 로딩하지 않는 것이 효율적이다. 이는 메모리 사용을 줄이고, 성능을 향상시킨다.
// member만 조회시 연관관계 매핑이 걸려있다고해서 Team까지 갖고오게 될 경우 손해
private static void printMember(Member member) {
System.out.println("member = " + member);
}
Eager 로딩의 적용
하지만 코드가 아래와 같이 Member와 Member의 Team 엔티티 모두 필요한 경우, 쿼리를 가져올 때 한 번에 다 가져오는 것이 이상적이다.
// member와 team 둘 다 조회하므로 한 번에 가져오는 것이 이득
private static void printMemberAndTeam(Member member) {
String username = member.getUsername();
System.out.println("username = " + username);
Team team = member.getTeam();
System.out.println("team = " + team.getName());
}
em.find()와 em.getReference()의 차이
em.find(): 실제 엔티티 객체를 반환한다
em.getReference(): 엔티티의 프록시(대리) 객체, 참조값을 반환한다.
둘이 같이 쓰일 경우 먼저 사용된 것으로 덮어씌워지기 때문에(이는 밑에서 더 자세히 다룬다), 따로따로 살펴본다.
Member member = new Member();
member.setUsername("proxy");
// DB 반영
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.getId() = " + findMember.getId());
System.out.println("findMember.getUsername() = " + findMember.getUsername());
System.out.println("findMember.getClass() = " + findMember.getClass());
위 findMember는 Member 타입이다.
em.find()는 Entity 자체를 반환하는 것을 알 수 있다.
이후 위 em.find()를 주석처리하고, 아래를 실행해보자.
em.getReference()는 Proxy를 반환하는 것을 알 수 있다.
Member findMemberByReference = em.getReference(Member.class, member.getId());
System.out.println("findMemberByReference.getClass() = " + findMemberByReference.getClass());
System.out.println("findMemberByReference.getId() = " + findMemberByReference.getId());
System.out.println("findMemberByReference.getUsername() = " + findMemberByReference.getUsername());
만약 위 둘의 타입을 비교하고 싶다면 ==이 아닌 instanceOf를 사용해야한다.
proxy는 Entity를 상속받는 가짜를 상속받는다.
System.out.println(findMember instanceOf Member) // true
처음에 말했던 둘이 같이 쓰일 경우 먼저 사용된 것으로 덮어씌워지는 것에 대해 자세히 알아보자.
아래와 같이 한 트랜젝션에서 em.find()를 사용하여 엔티티를 반환하면, 그 후에 em.getReference()도 엔티티를 반환한다.
이전 글에서 DB에서 조회시 바로 반환하는 것이 아닌 영속성 컨텍스트 속 1차 캐시에 Data를 저장했던 것을 기억하는가? 거기서 데이터를 꺼내오기 때문에 엔티티 자체를 반환하게 된다.
Member findM = em.find(Member.class, member1.getId());
Member referenceM = em.getReference(Member.class, member1.getId());
System.out.println("----- 영속성 컨텍스트에 이미 존재하면 프록시가 아닌 엔티티를 반환 -----");
System.out.println("findM.getClass() = " + findM.getClass());
System.out.println("referenceM.getClass() = " + referenceM.getClass());
그렇다면, 위와 순서를 바꾸면 어떻게 될까?
System.out.println("----- 처음에 프록시로 반환하면, 그 후에도 프록시로 반환 -----");
System.out.println("findM.getClass() = " + findM.getClass());
System.out.println("referenceM.getClass() = " + referenceM.getClass());
System.out.println("----- JPA는 컬렉션처럼 비교 연산시 같아야하므로 reference와 find를 같게 연산 함 -----");
System.out.println("(findM == referenceM) = " + (findM == referenceM));
JPA는 JAVA와 호환성을 맞추기 위해, 컬렉션처럼 연산하므로 처음에 프록시로 반환하면, 그 후에도 프록시로 반환한다.
이전에 준영속 상태를 기억하는가?
em.detach()와 em.clear, em.close 등 엔티티를 영속성 컨텍스트에서 제외하여 관리되지 않는 상태를 말한다.
// 준영속 상태로 변경
System.out.println("----- 영속성 컨텍스트에서 제외시 접근 할 수 없음 -----");
em.detach(referenceM);
System.out.println("----- 여기서 오류 발생 \n" +
"could not initialize proxy [reviewjpa.Member#1] - no Session -----");
System.out.println("referenceM.getUsername() = " + referenceM.getUsername());
그러한 준영속 상태에서 접근하려하면, 더이상 관리되지 않기 때문에 LazyInitializationException가 발생하게 된다.
em.getReference()를 단일로 혹은 먼저 사용하면 프록시를 반환한다고 학습했다.
프록시는 진짜 엔티티의 주소값을 갖고 있기 때문에, 사용될 때 초기화하면서 값을 갖고와 사용하게 된다.
Member referenceM = em.getReference(Member.class, member1.getId());
System.out.println("referenceM.getClass() = " + referenceM.getClass()); // proxy
System.out.println("프록시 인스턴스 초기화 여부 확인 = " + emFactory.getPersistenceUnitUtil().isLoaded(referenceM));
System.out.println("----- referenceM.getUsername()으로 초기화 -----");
referenceM.getUsername();
System.out.println("프록시 인스턴스 초기화 여부 확인 = " + emFactory.getPersistenceUnitUtil().isLoaded(referenceM));
위와 같이 getUsername을 사용할 때 초기화하였고 초기화하기 전과 후에서 초기화 여부를 확인할때는 EntityFactory의 정적 메서드인 getPersistenceUnitUtil().isLoaded(엔티티)를 사용한다.
emFactory.getPersistenceUnitUtil().isLoaded(Etity));
우리는 값을 갖고오기 위해 getUsername을 하면서 초기화 되었다. 만약 직접 초기화하고 싶은 상황이라면 매번 getUsername으로 해야할까?
프록시를 초기화하기 위해 매번 프록시 객체의 필드에 접근하여 초기화하는 건 개발자스럽지 않다. ㅋㅋ
직접 초기화하는 방법은 아래와 같다.
System.out.println("----- JPA 표준은 강제 초기화가 없고 Hibernate만 제공함 -----");
// referenceM.getUsername(); // 강제 초기화 - 매번 이렇게 사용하기 애매함
Hibernate.initialize(referenceM); // 강제 초기화
N + 1 문제
N + 1 문제는 JPA를 사용할 때 자주 마주치는 성능 문제 중 하나다.
이 문제는 연관된 엔티티를 로딩할 때 발생하며, 특히 지연 로딩(FetchType.Lazy) 전략을 사용할 때 두드러진다.
이 문제를 해결하기 위한 여러 방법이 있으며, 여기에는 몇 가지 추가적인 설명과 해결 방법을 작성해보겠다.
Team teamA = new Team();
teamA.setName("TeamA");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setTeam(teamB);
em.persist(member2);
System.out.println("----- em.find는 pk를 찍어서 가져오기 때문에 JPA가 내부적으로 최적화 가능하다.");
Member member = em.find(Member.class, member1.getId());
System.out.println("----- JPQL은 우선 SQL로 번역이된다. ----- ");
System.out.println("----- Member를 전체 조회하는 쿼리인데, 즉시로딩이므로 Team의 값이 다 들어가있어야한다. -----");
List<Member> selectMFromMemberM = em.createQuery("select m from Member m", Member.class).getResultList();
System.out.println("----- SQL: select * " +
"from Member" +
" -----");
System.out.println("----- 이렇게 가져온 후, Member가 즉시로딩이므로 Team 조회하는 쿼리 출력 -----");
System.out.println("----- SQL: select * " +
"from Team " +
"Where TEAM_ID = MEMBER_ID -----");
System.out.println("----- team이 지금 2개인데, member를 전체조회하는 쿼리 1개와, " +
"각 team마다 조회하는 쿼리 n개가 나오기 때문에 n+1 문제라고한다. -----");
지금까지 프록시를 학습한 이유는 사실, fetch = FetchType.EAGER 타입을 사용하면 안되기 때문이다.
Member 엔티티가 Team 엔티티와 연관되어 있을 때, 여러 Member를 조회하는 쿼리를 실행하면, 각 Member에 대해 연관된 Team을 로드하기 위한 추가 쿼리가 실행된다. 이로 인해 예상보다 많은 수의 쿼리가 발생하게 된다.
예시: 만약 10명의 Member를 조회하고 각 Member가 다른 Team에 속해 있다면, 1개의 Member 조회 쿼리와 10개의 Team 조회 쿼리가 발생하여 총 11개의 쿼리가 실행된다.
그러므로 실무에서는 사용하지 않는다.
해결 방법
Fetch Join 사용
JPQL의 fetch join을 사용하여 연관된 엔티티를 한 번의 쿼리로 함께 로드한다.
em.createQuery("select m from Member m join fetch m.team", Member.class).getResultList();
System.out.println("----- fetch join을 했기 때문에 값이 다 채워져서 나온다. -----");
System.out.println("----- @EntityGraph or Batch size를 활용하여 해결할 수 있지만 대부분 fetch join으로 해결함 -----");
이 방법은 연관된 엔티티를 한 번의 쿼리로 불러오므로 N + 1 문제를 효과적으로 해결한다.
@EntityGraph 사용
@EntityGraph를 사용하여 특정 엔티티의 연관된 엔티티를 함께 로드할 수 있다.
이는 fetch join과 유사하지만, JPQL이 아닌 엔티티 레벨에서 설정한다.
Batch Size 설정
@BatchSize 애노테이션을 사용하여 한 번에 로드할 연관 엔티티의 수를 설정할 수 있다.
이 방법은 연관 엔티티를 일정 수량만큼 묶어서 로드하여 쿼리 수를 줄인다.
'JPA > ORM 표준 JPA' 카테고리의 다른 글
[ORM 표준 JPA] 12. JPQL 기본 함수 정리 (0) | 2023.10.14 |
---|---|
[ORM 표준 JPA] 11. Embadded, 값 타입 (0) | 2023.10.04 |
[ORM 표준 JPA] 9.고급매핑 - 상속관계 매핑, @MappedSuperclass (0) | 2023.09.30 |
[ORM 표준 JPA] 8.다양한 연관관계 매핑 (0) | 2023.09.30 |
[ORM 표준 JPA] 7. 연관관계 매핑2 양방향 연관관계와 연관관계의 주인 (0) | 2023.09.23 |