[JPA] 프록시와 지연로딩/즉시로딩의 관계
💡 코드가 보이지 않으시다면 드래그 혹은 오른쪽 아래 🌜 아이콘을 눌러 테마 색을 변경해주세요.
안녕하세요!
키크니 개발자 입니다. 🦒
Team과 Member가 1 : N 으로 매핑된다고 가정합니다.
이 때 Member를 조회할 때 Team도 함께 조회해야 할까요?
이는 비즈니스 상황에 따라 다르게 해야합니다.
비즈니스 로직에서 Team을 필요로 하지 않을 때에는
꼭 Member를 조회할 때 Team까지 조회할필요가 없습니다.
왜냐하면 성능저하(낭비)가 발생할 수 있기 때문입니다.
JPA는 이러한 낭비를 하지 않기 위해 지연로딩과 프록시라는 개념으로 해결합니다.
프록시(Proxy)란?
간단하게 말해서 가짜 객체를 의미합니다.
EntityManager에는 em.find()와 em.getReference()가 있습니다.
- em.find() : 데이터베이스를 통해 실제 엔티티를 조회합니다.
- em.getReference() : 데이터베이스 조회를 미루고 가짜 엔티티 객체(프록시)를 조회합니다.
즉, em.getReference()는 DB에 쿼리가 날아가지 않는데도 객체가 조회되고
이 객체를 사용하는 순간 DB에 쿼리를 날려 진짜 객체를 가져옵니다.
프록시 특징
- 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같습니다. (getId(), getName())
- 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됩니다.
- 프록시 객체는 실제 객체의 참조(target = Entity)를 보관합니다.
- 처음에 호출할 때에는 조회한 적이 없기 때문에 target이 없습니다.
- 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출합니다.
- 프록시 객체는 처음 사용할 때 한 번만 초기화 합니다.
- 프록시 객체를 초기화 할 때 프록시 객체가 실제 엔티티로 바뀌는 것은 아니며
초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능합니다. - 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환합니다
→ 프록시로 먼저 조회하면 find로 조회해도 프록시로 반환합니다.
- 프록시 객체는 원본 엔티티를 상속받으며 따라서 타입 체크시 주의해야합니다.
(== 비교 실패, 대신 instance of 사용)
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);
em.flush(); // DB 반영
em.clear(); // 영속성 컨텍스트를 완전히 초기화
Member m1 = em.find(Member.class, member1.getId());
System.out.println("m1 = " + m1.getClass());
Member reference = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + reference.getClass());
System.out.println("a == a : " + (m1 == reference));
- System.out.println("a == a : " + (m1 == reference)); 에서는 항상 true가 나와야 합니다. (1차캐시로 인해서 조회된 것으로 m1, reference를 조회하고 있기 때문입니다.)
- 영속성컨텍스트에 있으면 프록시가 아니라 실제 entity를 반환하게 됩니다.
- transaction안에서의 동일성을 일치하게 해줍니다.
- 프록시도 true를 유지합니다.
- 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제 발생합니다.
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);
em.flush(); // DB 반영
em.clear(); // 영속성 컨텍스트를 완전히 초기화
// proxy
Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("refMember = " + refMember.getClass());
em.detach(refMember);
refMember.getUsername();
System.out.println("refMember = " + refMember.getUsername());
- em.detach() / em.close() / em.clear() 후 refMember.getUsername()을 하게 되면 영속성 컨텍스트에 있는 것이 관리 안되기 때문에 could not initialize proxy exception, no session 발생합니다. (하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트림)
→ 영속성 컨텍스트가 없다는 것을 알 수 있습니다.
프록시 객체의 초기화
프록시 객체의 초기화란?
DB에 직접 조회해서 값을 가져와 entity를 만들어 내는 과정을 의미합니다.
Member member = em.getReference(Member.class, "id1"); // (1)
member.getName(); // (2)
(1) em.getReference()로 가짜(프록시) 객체를 조회합니다.
(2) 영속성 컨텍스트에 초기화를 요청합니다.
그리고 영속성 컨텍스트는 실제 DB에서 값을 조회해서 실제 Entity를 생성한 후 MemberProxy의 target(Member)와 연결해줍니다.
이 후 member의 getName을 하게되면 기존에 조회된 것이 있기 때문에 target에서 name을 조회합니다.
지연로딩과 즉시로딩
지연로딩
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne(fetch = FetchType.LAZY) // (1)
@JoinColumn(name = "TEAM_ID")
private Team team;
..
}
(1) proxy 객체로 조회합니다. (지연로딩을 의미)
- Member table만 DB에서 조회하고, team은 proxy로 조회합니다.
- memeber.getTeam()으로 했을 때 실제 query로 조회됩니다.
Team team1 = new Team();
team1.setName(team1);
em.persist(team1);
Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(team);
em.persist(member1);
Member m = em.find(Member.class, member1.getId());
System.out.println("m = "+ m.getTeam().getName()); // (1)
- m에 있는 team을 가져올 때 프록시를 가져온다. (직접적으로 team을 사용하지 않았을 때)
(1) team 내의 어떤 속성을 조회하게 되면 이 시점에서 DB에서 쿼리로 조회한다. (team의 field를 사용할 때 실제 쿼리문이 나간다.)
즉시로딩
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "username")
private String name;
@ManyToOne(fetch = FetchType.EAGER) // 즉시로딩
@JoinColumn(name = "TEAM_ID")
private Team team;
..
}
Team team1 = new Team();
team1.setName(team1);
em.persist(team1);
Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(team);
em.persist(member1);
Member m = em.find(Member.class, member1.getId());
System.out.println("m = "+ m.getTeam().getName()); // (1)
(1) 이 시점에서 team에 대한 조회 쿼리가 나가는 것이 아니라, member를 조회할 때 team도 같이 조회합니다.
프록시와 즉시로딩 주의
- 가급적 지연 로딩만 사용해야합니다.(특히 실무에서)
- 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생
→ 2개일 때에는 상관없지만 만약 10개 테이블이 연관되어있으면 10개 테이블을 다 조회해오기 때문에 성능저하가 발생할 수 있습니다. - 즉시 로딩은 JPQL에서 N+1 문제가 발생합니다.
N + 1
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("teamA");
em.persist(teamB);
Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member1.setTeam(teamB);
em.persist(member2);
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
💡위의 코드는 N + 1 발생합니다. (DB에 저장된 row 수 = N , 처음 조회하는 첫 query = 1)
- 만약 em.find() 로 조회하게 되면 PK를 기준으로 JPA에서 조회하므로 내부적으로 한 번의 쿼리를 날려 실행합니다.
- 하지만 JPQL은 코드에 적은 쿼리가 그대로 DB에서 조회되기 때문에 쿼리 그대로 Member만 가져오고, Team은 가져오지 않을 것입니다.
- Team 이 즉시로딩(FetchType.Eager)이라서 가져올 때 무조건 값이 다 채워져 있어야 하기 때문에 member 하나를 조회할 때마다 그 member에 해당하는 Team을 조회하게 됩니다.
(하지만 member에 대해 어떤 team이 매핑되는지 모르기 때문에 team 모두를 조회하게 됩니다.) - member1 -> teamA, teamB 조회, member2 -> teamA, teamB 조회
즉, 2개의 member(member1, member2)를 조회했지만, team을 찾는 쿼리는 4개가 나가게 됩니다.
해결방안
1. jpql에서 fetchJoin을 사용한다. → 동적으로 내가 원하는 애들을 선택할 수 있음 → join query를 날립니다.
em.createQuery("select m from Member m join fetch m.team", Member.class);
2. @EntityGraph를 사용합니다.
3. batchSize를 설정합니다. → query문이 한번 더 나감 N + 1 → 1 + 1로 가능
지연로딩 활용(실무)
- ⭐️ 모든 연관관계에 지연 로딩을 사용해야 한다! (@ManyToOne에서 FetchType.LAZY)
@OneToMany는 Default가 FetchType.LAZY이다. - 실무에서 즉시 로딩(FetchType.EAGER)을 사용하면 안된다!
- JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해야 한다!
- 즉시 로딩은 상상하지 못한 쿼리가 나가기 때문에 사용하지 않아야 된다!
⭐️ 참고한 곳
https://www.inflearn.com/course/ORM-JPA-Basic/dashboard
https://derekpark.tistory.com/92
https://ict-nroo.tistory.com/131
https://velog.io/@sa1341/JPA-Proxy
https://eocoding.tistory.com/31
배워야 할 것이 더 많은 주니어 개발자입니다. 🐣
내용 전달보다는 정리를 목적으로 포스팅을 하고 있습니다.
잘못 된 내용이나 부족한 부분은 댓글로 주시면 감사드리겠습니다.