BackEnd/JPA

[JPA] 프록시와 연관관계 관리

샤아이인 2022. 4. 6.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

1. 프록시

한가지 의문을 가져보자. Team이라는 필드를 갖고있는 Member를 조회할때 Team도 한번에 함께 조회되어야 할까?

 

예를 들어 다음과 같은 코드가 있다고 해보자.

Member member = entityManager.find(Member.class, 1L);
System.out.println("username = " + member.getUsername());
Team team = member.getTeam();

System.out.println("Team = " + team.getName());
 

위와같은 코드는 entityManager에서 member를 찾아올때 member에 대한 정보와, team에 대한 정보를 한번에 가져오면 좋을것이다.

 

하지만 다음과 같은 경우는 어떨까?

Member member = entityManager.find(Member.class, memberId);

System.out.println("회원 이름: " + member.getUsername());
 

Team에 대한 정보는 필요가 없는 상황이다. 이럴때까지 Team에 대한 정보를 한번에 가져오는것을 비효율적이지 않은가?

 

JPA는 이를 프록시, 지연로딩을 이용하여 해결하게 된다.

 

● 프록시 기초

- em.find() : 데이터베이스를 통해서 실제 엔티티 객체 조회해 온다.

- em.getReference(): 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회해 온다.

출처 - 인프런 김영한 JPA

다음 예제 코드를 살펴보자.

Member member = new Member();
member.setUsername("test");

entityManager.persist(member);

entityManager.flush();
entityManager.clear();

System.out.println("================");
Member findMember = entityManager.getReference(Member.class, member.getId());
System.out.println("================");
System.out.println("findMember CLASS = " + findMember.getClass());
System.out.println("findMember ID = " + findMember.getId());
System.out.println("================");
System.out.println("findMember NAME = " + findMember.getUsername());

transaction.commit();
 

실행 결과는 다음과 같다.

entityManager.getReference()를 하는 시점에는 데이터베이스에 쿼리를 날리지 않는다.

결과를 보면 첫 "========" 사이에 어떠한 쿼리도 나오지 않았다.

 

또한 ID같은 경우 바로 출력하는것을 볼수있는데, 이는 entityManager.getReference()를 할때 ID값을 넘겨주었기 때문이다.

ID값은 이미 알고있기때문에 DB에 쿼리를 전송하지 않는다.

 

NAME을 구해야 하는 시점이 되서야 SELECT 쿼리가 전송되는것을 확인할수가 있다.

 

그럼 getReference()로 찾아온 놈의 정체는 무엇일까?

위에서 확인할수 있듯, HibernateProxy에 해당하게된다. 하이버네이트가 만든 가짜 클레스 이다.

 

● 프록시 특징

실제 클래스를 상속받아서 만들어진다. 또한 실제 클래스와 겉 모양이 같다.

사용하는 입장에서는 진짜 객체인지 구분 필요가 없다(이론적으로)

 

프록시 객체는 실제 객체에 대한 참조(target)을 보관하고 있으며, 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출하게 된다.

출처 - 인프런 김영한 JPA

● 프록시 객체의 초기화

다음 코드를 생각해 보자.

Member member = em.getReference(Member.class, “id1”); 
member.getName();
 

getReference()로 찾아온 member는 Proxy이다. 이후 member.getName()을 호출할때 발생하는 일은 다음과 같다.

출처 - 인프런 김영한 JPA

1. proxy에게 getName()을 호출했는데 맨 처음에는 target에 값이 없다.

2. JPA가 영속성 컨텍스트에 진짜 member 객체를 찾아오도록 요청한다.

3. 영속성 컨텍스트가 DB에서 실제 객체를 찾아온다.

4. 실제 Entity로 생성한다.

5. target에 실제 객체를 연결한다.

 

● 특징

1. 프록시 객체는 맨 처음 사용할때 한번만 초기화 된다.

2. 프록시 객체가 초기화 되면, 프록시 객체가 실제 엔티티로 변환되는것이 아니다!

단지 초기화가되면 프록시를 통해 실제 객체에 접근이 가능해지는 것 일 뿐이다.

 

3. 프록시 객체는 원본 엔티티를 상속받음, 따라서 타입 체크시 주의해야함 (타입 비교는 == 대신 instanceof 사용하자)

Member member1 = new Member(); // 멤버 1
member1.setUsername("member1");
entityManager.persist(member1);

Member member2 = new Member(); // 멤버 2
member2.setUsername("member2");
entityManager.persist(member2);

entityManager.flush(); // DB와 동기화
entityManager.clear();

Member m1 = entityManager.find(Member.class, member1);
Member m2 = entityManager.getReference(Member.class, member2);

m1.getClass() == m2.getClass() //false
m1 instanceof Member //true
m2 instanceof Member //true
 

 

4. 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해 도 실제 엔티티 반환

우선 코드를 살펴보자.

 

- 실제 객체를 먼저 찾고, 그다음으로 프록시를 찾는경우

Member member1 = new Member();
member1.setUsername("member1");
entityManager.persist(member1);

entityManager.flush();
entityManager.clear();

Member m1 = entityManager.find(Member.class, member1.getId());
System.out.println("m1 CLASS = " + m1.getClass());

Member reference = entityManager.getReference(Member.class, member1.getId());
System.out.println("reference CLASS = " + reference.getClass());

transaction.commit();
 

과연 reference.getClass()는 무엇을 반환할까? 언뜻 보기에는 프록시 객체를 반환할것 같다.

하지만 결과를 보면 둘다 실제 객체 class를 반환하는것을 확인할수 있었다.

 

왜? 이렇게 작동하는 것 일까?

 

이유 1) 이미 1차 캐시에 올라가 있는 Member를 굳이 프록시로 가져 와봤자 성능상 이점이 없다.

이유 2) JPA에서는 같은 영속성 컨텍스트에서 가져왔으며, 식별자값이 같으면 JPA는 항상 == 연산에 대하여 true를 반환 해주어야 한다.

System.out.println("m1 == reference : " + (m1 == reference));
 

이 코드의 결과로 true를 반환하게 된다.

 

- 프록시를 먼저 찾고, 그 다음으로 실제 객체를 찾는경우

이미 처음에 Proxy로 조회 했으면, em.find()를 호출해 도 프록시를 반환한다.

Member member1 = new Member();
member1.setUsername("member1");
entityManager.persist(member1);

entityManager.flush();
entityManager.clear();

Member refMember = entityManager.getReference(Member.class, member1.getId());
System.out.println("refMember CLASS = " + refMember.getClass());

Member findMember = entityManager.find(Member.class, member1.getId());
System.out.println("findMember CLASS = " + findMember.getClass());

System.out.println("m1 == reference : " + (refMember == findMember));

transaction.commit();
 

결과는 다음과 같다.

refMember는 getReference로 조회 했기 때문에 Proxy를 반환하는것이 당연하다.

이후 find()를 호출할때 SELECT 쿼리가 전송되었다.

이렇게 찾은 findMember에 getClass를 해보면 Proxy를 반환한다.

=> 위에서 말했듯 == 비교에 true를 반환해주기 위함이다.

 

5. 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제 발생

Member member1 = new Member();
member1.setUsername("member1");
entityManager.persist(member1);

entityManager.flush();
entityManager.clear();

Member refMember = entityManager.getReference(Member.class, member1.getId());
System.out.println("refMember CLASS = " + refMember.getClass()); // Proxy

entityManager.detach(refMember); // 준영속화 진행

System.out.println("refMember = " + refMember.getUsername()); // 이후 찾아올려고 함

transaction.commit();
 

실행 결과는 다음과 같다.

프록시를 초기화 할수 없다고 알려준다.

이는 영속성 컨텍스트의 도움을 더이상 받지 못하기 때문이다.

 

● 프록시 확인

- 프록시 인스턴스의 초기화 여부 확인 => emf.getPersistenceUnitUtil.isLoaded(Object entity)

- 프록시 클래스 확인 방법 => entity.getClass().getName()

- 프록시 강제 초기화 => org.hibernate.Hibernate.initialize(entity);

(참고로 JPA 표준스펙 상에는 강제 초기화가 없다. member.getName()과 같이 강제 호출해서 초기화 해야한다.)

 

2. 즉시 로딩과 지연 로딩

2 - 1) 지연로딩

단순히 member 정보만 사용해야 한다면, member를 조회할 때 Team도 함께 조회해야 할까?

이는 비효율 적이다. 우선적으로 member만 조회후, Team에 대한 정보가 필요할 시점까지 Team에 대한 조회는 미루는것이 좋다

 

따라서 Member class의 Team 필드 부분에 (fetch = FetchType.LAZY)를 추가해 주자. 지연로딩을 사용하겠다는 의미

@Entity
public class Member extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Team team;
    // 생략...
}
 

이제 Member를 사용하는 코드를 만들어 보자.

Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setUsername("hello");
member.setTeam(team);
em.persist(member);

em.flush();
em.clear();

System.out.println("==============");
Member findMember = em.find(Member.class, member.getId()); // Member 객체 반환
System.out.println("==============");
System.out.println("findMember.getTeam() CLASS : " + findMember.getTeam().getClass());
System.out.println("==============");
 

결과는 다음과 같다. Member만 Select 하는 쿼리가 나가게 된다.

이후 findMember.getTeam()을 하면 Proxy Team을 전달받게 되고, Proxy에다 getClass()를 호출하기 프록시 정보를 얻게된다.

이처럼 연관관계에 있는 다른 엔티티를 사용하는 빈도수가 낮을 경우 지연로딩을 사용해 불필요한 엔티티 조회를 막을 수 있다.

 

그럼 Team은 어느 시점에 조회하게 될까? 다음 코드를 살펴보자.

System.out.println("==============");
Member findMember = em.find(Member.class, member.getId());
System.out.println("==============");
findMember.getTeam().getName(); // 실제로 team을 사용하는 시점
System.out.println("==============");
 

결과는 다음과 같다.

team 객체에게 getName()을 호출하는, 즉 실제 team을 사용하는 시점에 초기화 된다.

 

2 - 2) 즉시 로딩

Member와 Team을 같이 쓰는 빈도가 높을 경우에는 어떻게 해야 할까?

 

즉시 로딩을 사용하면 된다. fetch = FetchType.EAGER 과 같이 사용하면 된다.

@Entity
public class Member extends BaseEntity {
    // ...
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn
    private Team team;
    // 생략...
}
 

이렇게 사용하면 Member를 조회할때 Team도 항상 함께 조회된다.

JPA 구현체가 Join을 사용해서 SQL을 한번에 함께 조회하기 때문이다.

 

● 프록시와 즉시로딩 주의사항

1. 가급적 지연 로딩만 사용해라

2. 즉시 로딩을 적용하면 예상하지 못한 SQL 이 발생한다.

예를 들어 em.find(Member.class, 1L); 로 단순 member 하나를 찾아오는 코드를 작성했다고 해보자.

하지만 코드 작성자의 의도와는 다르게 Team 과 Join하는 코드가 생성될 수 있다. 응? 나는 member를 조회 한 것 인데?

=> 잘 생각해보면 Team을 EAGER로 설정되어 있어 한번에 조회하게 되는 것 이다.

=> Entity가 2개정도로 간단하면 즉시로딩을 해도 JOIN을 1번만 해서 간단하지만, Entity가 여러개 라면 여러번의 JOIN이 발생하게 되고, 이는 성능 저하의 원인이 될수 있다.

 

3. 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.

@Entity
public class Member {
	...
	@ManyToOne(fetch = FetchType.EAGER) //즉시로딩 사용
	@JoinColumn(name="TEAM_ID")
	private Team team;
	...
}

List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
//SQL: select * from Member
//SQL: select * from Team where TEAM_ID = xxx
...
 

JPQL은 우선적으로 SQL로 번역이 된다. 따라서 위 JPQL은 Member만 Select 하게 된다.

문제는 Member를 찾아왔더니 Team이 EAGER 이다.

따라서 다시 Team을 select 하는 쿼리를 보내야 한다.

 

즉, JPQL은 1번 쿼리가 나갔지만, 2개의 쿼리를 확인할수 있게된다. 다음 결과를 확인해 보자.

(1개의 쿼리를 날리면 + N개의 쿼리가 추가 수행 된다.)

=> 1개의 전체 Member를 조회하는 쿼리를 날리니 + 2(N)명 Member가 각각 자신의 team에 대한 조회 쿼리를 날리게 된것!

위 예시에서는 Team이 1개라 2번으로 끝났지, EAGER 필드가 5개 였다면 1 + 5 개의 쿼리가 나가게 됩니다...

맨 처음 Member를 찾아온 후, 이후 Team에 대해서도 찾아오게 된다.

 

N+1의 해결책!

우선, 전부 지연로딩으로 설정해준다. 그 다음 필요한 시점에 가져와야 하는 엔티티에 한해서 fetch join을 사용해서 가져온다.

List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class).getResultList();
 

JPQL이 실행될때 fetch 조인으로 인해 Team에 대한 정보도 JOIN하여 한번에 가져왔다.

따라서 Team도 바로 사용할수가 있다.

 

4. @ManyToOne, @OneToOne은 기본이 즉시 로딩으로 되어 있다.→ 직접 전부 LAZY로 설정

5. @OneToMany, @ManyToMany는 기본이 지연 로딩

 

● 지연로딩 활용

- Member와 Team 은 자주 함께 사용 → 즉시 로딩

- Member와 Order는 가끔 사용 → 지연 로딩

- Order와 Product는 자주 함께 사용 → 즉시 로딩

 

member1을 조회하는 순간 team 까지 한번에 찾아오게 된다. 즉시 로딩이기 때문이다.

출처 - 인프런 김영한 JPA

하지만 아직 주문내역을 Proxy이다. 지연 로딩이기 때문이다.

이후 주문 내역이 필요한 순간이 되면 해당 데이터를 찾아오게 된다.

출처 - 인프런 김영한 JPA

이때 주문내역을 찾아오면서 즉시 로딩으로 되어있는 상품 필드도 함께 찾아오게 된다.

 

● 지연로딩 활용 - 실무

모든 연관관계에 지연 로딩을 사용해라!

 

이후 JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해라. 즉시 로딩은 내가 의도하지 않은 쿼리가 수행된다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 값 타입 - 1  (0) 2022.04.07
[JPA] 영속성 전이와 고아 객체  (0) 2022.04.06
[JPA] 고급 매핑  (0) 2022.04.05
[JPA] 다양한 연관관계 매핑  (0) 2022.04.04
[JPA] 연관관계 매핑 기초  (0) 2022.04.03

댓글