본문 바로가기
BackEnd/JPA

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

by 개발 Blog 2024. 7. 16.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

프록시

Member를 조회할 때 Team도 함께 조회해야 할까?

public void printUserAndTeam(String memberId) {
    Member member = em.find(Member.class, memberId);
    Team team = member.getTeam();
    
    System.out.println("회원 이름: " + member.getUsername());
    System.out.println("소속팀: " + team.getName());
}

- 위 코드는 DB를 Member를 조회할 때 Team도 같이 조회된다. 

 

둘 다 가져오는 것을 의도를 한 것이라면 괜찮다. 다음 코드를 보자.

public void printUser(String memberId) {
    Member member = em.find(Member.class, memberId);
    System.out.println("회원 이름: " + member.getUsername());
}

- 이렇게 Member 정보만 필요할 수도 있다. 

- 이런 경우에는 Team 정보까지 같이 가져오는 건 비효율적이다.

 

어느 경우에는 Member,Team 정보가 둘 다 필요하고, 

어느 경우에는 하나만 필요할 때 어떻게 해야 할까?

 

JPA는 이를 프록시와 지연로딩을 이용해서 해결한다.

 

프록시 기초

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

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

 

public class JpaMain {
    public static void main(String[] args) {

       //생략
       
        try {
            Member member = new Member();
            member.setUsername("hello");

            em.persist(member);

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

            System.out.println("================");
            Member findMember = em.getReference(Member.class, member.getId());
            System.out.println("================");
            System.out.println("findMember = " + findMember.getClass());
            System.out.println("findMember.id = " + findMember.getId());
            System.out.println("findMember.username = " + findMember.getUsername());

            tx.commit();
    }
}

- em.getReference로 Id를 가져올 때는 쿼리가 없다. 왜냐하면 getReference를 찾을 때 파라미터로 Id값을 넘겨주었기 때문이다.

즉, 이미 값이 있기 때문에 SQL을 DB로 보낼 필요가 없는 것이다.

- 그런데 getUsername은 DB에 있는 값이기 때문에 SQL을 보내서 데이터를 가져온다.

- 결과창 젤 위쪽에 hellojpa.Member$HibernateProxy$OwHbDG7u 라고 적힌 부분이 프록시 클래스다.

 

프록시 특징1

- 실제 클래스를 상속받아서 만들어진다.

- 그래서 실제 클래스와 겉모양이 같다.

- 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.(이론상)

- 프록시 객체는 실제 객체의 참조를 보관한다.

- 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

 

프록시 객체의 초기화

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

- getReference로 가져온 member 객체는 프록시 객체다.

- get.Name을 호출하면 처음에는 target에 값이 없기 때문에 JPA는 영속성 컨텍스트에 실제 멤버 객체를 요청한다.

- 영속성 컨텍스트는 DB를 조회해서 실제 엔티티 객체를 생성 후 target에 실제 객체를 연결시켜 준다.

 

프록시 특징2

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

- 프록시 객체를 초기화 할 때 프록시 객체가 실제 엔티티로 바뀌는 것이 아니다. 초기화 되면 프록시 객체를 통해서 실제 엔티티에 접근이 가능해지는 것이다.

 

- 프록시 객체는 원본 엔티티를 상속받는다. 따라서 타입 체크 시 주의해야 한다. (== 비교 대신 instance of 사용)

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

Member member2 = new Member();
member2.setUsername("member2");
em.persist(member2);

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

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

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())); //false
System.out.println("m1 == m2: " + (m1 instanceof Member)); //true
System.out.println("m1 == m2: " + (m2 instanceof Member)); //true
  • == 연산자는 두 객체의 참조(메모리 주소)를 비교한다. 원본 엔티티와 프록시 객체는 서로 다른 메모리 주소를 가진다. 따라서 == 비교는 실패한다.
  • instanceof 연산자는 객체가 특정 클래스 또는 그 클래스를 상속받은 객체인지 확인한다. 프록시 객체는 원본 엔티티를 상속받았기 때문에 true가 된다.

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

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

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

Member m1 = em.find(Member.class, member1.getId());
System.out.println("m1 = " + m1.getClass()); // m1 = class hellojpa.Member

Member reference = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + reference.getClass()); // reference = class hellojpa.Member

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

tx.commit();
  • Member는 1차 캐시에 있기 때문에 프록시로 가져와도 성능상 이점이 없다. 따라서 실제 엔티티를 반환한다.
  • JPA는 한 영속성 컨텍스트에서 가져오고 PK가 같으면 항상 true를 반환한다.

- 위와 반대로 프록시로 먼저 Member를 찾고 그다음 실제 객체를 호출해도 프록시를 반환한다.

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

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

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

Member findMember = em.getReference(Member.class, member1.getId());
System.out.println("findMember = " + findMember.getClass()); 

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

  • JPA는 == 비교를 보장하기 위해 프록시를 먼저 호출한 경우에는 이후에 실제 객체를 호출해도 프록시를 반환한다.

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

  (하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트린다.)

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

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

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

em.detach(refMember);

refMember.getUsername();

tx.commit();

  • 영속성 컨텍스트를 사용할 수 없는 상태일 때, 영속성 컨텍스트를 이용해서 DB에 실제 객체를 요청해야 하는 프록시를 호출하면 문제가 발생한다.

프록시 확인

- 프록시 인스턴스의 초기화 여부 확인 : PersistenceUnitUtil.isLoaded(Object entity)

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

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

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

//isLoaded = true
System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember)); 


refMember.getUsername();

tx.commit();

 

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

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

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

	Member refMember = em.getReference(Member.class, member1.getId());
    
    //refMember = class hellojpa.Member$HibernateProxy$CSdK4l61
    System.out.println("refMember = " + refMember.getClass());
    
    refMember.getUsername();

    tx.commit();

 

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

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

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

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

Hibernate.initialize(refMember); //강제 초기화

tx.commit();

 

- 참고 : JPA 표준은 강제 초기화가 없다. member.getName()으로 강제 호출하는 방법만 있다.

 

즉시 로딩과 지연 로딩

 

지연로딩

Member만 조회하고 Team은 정보가 필요할 때까지 미뤄보자.

@Entity
public class Member extends BaseEntity{
	//생략

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

- FetchType.LAZY는 지연로딩을 사용하겠다는 뜻이다.

 

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

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

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

Member m = em.find(Member.class, member1.getId());

System.out.println("m = " + m.getTeam().getClass());

tx.commit();

- Member만 DB로 쿼리가 보내지고 team에 대한 정보는 프록시로 얻는다.

- 지연로딩을 사용하면 불필요한 엔티티 조회를 막을 수 있다.

 

team을 실제로 사용할 때 쿼리가 나간다.

Member m = em.find(Member.class, member1.getId());
System.out.println("m = " + m.getTeam().getClass());

System.out.println("==================");
m.getTeam().getName();
System.out.println("==================");

- getTeam 하는 시점에 쿼리가 나가는 것을 볼 수 있다.

 

위의 두 과정을 그림으로 보자

- Member만 조회할 때는 Team을 프록시로 가져온다.

- Team을 실제 사용하는 시점에 DB에서 조회한다.

 

위와 반대로 Member와 Team을 자주 함께 사용해야 한다면 어떻게 해야 할까

 

즉시로딩

@Entity
public class Member extends BaseEntity{

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

- FetchType.EAGER는 즉시로딩을 사용하겠다는 뜻이다.

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

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

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

Member m = em.find(Member.class, member1.getId());

System.out.println("m = " + m.getTeam().getClass());

System.out.println("==================");
System.out.println("m = " + m.getTeam().getName());
System.out.println("==================");

tx.commit();

- 위와 같은 코드다.

- 처음부터 Member와 Team의 정보를 DB에서 모두 가져오는 것을 확인할 수 있다.

 

프록시와 즉시로딩 주의

1. 가급적 지연 로딩만 사용하자(특히 실무에서)

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

  • 테이블이 수십 개라고 가정했을 때 즉시로딩을 하면 한 번의 쿼리로 조회하기 위해 모든 테이블을 다 조인을 한다. 
  • 그러면 의도하지 않은 데이터가 나올 수 있다. 

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

@Entity
public class Member extends BaseEntity{

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn
    private Team team;
}
Team team = new Team();
team.setName("teamA");
em.persist(team);

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

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

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

tx.commit();

  • Member에서 EAGER로 설정했는데 쿼리가 두 번 나갔다.
  • JPQL에서 "select m from Member m" 이 부분이 "select * from Member"로 치환되어 DB에서 Member 정보를 가져온다.
  • 이후에 Member에 EAGER로 설정되어 있으니, Member의 select 정보만큼 Team과 조인을 걸어서 Team 정보를 가져온다.
  • 그래서 쿼리가 두 번 발생한 것이다.

만약 Team1, Team2.... Team10이 있다고 가정하면 쿼리를 1번 날려도 select는 11번(member 1번 team 10번) 나간다.

 

해결책은 모든 연관관계를 지연로딩으로 설정하고 fech join으로 동적으로 내가 원하는 정보만 가져올 수 있다.

@Entity
public class Member extends BaseEntity{

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

- Member를 Lazy로 지연로딩으로 설정한다. 

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

Team team2 = new Team();
team2.setName("team2");
em.persist(team2);

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

Member member2 = new Member();
member2.setUsername("member2");
member2.setTeam(team2);
em.persist(member2);

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

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

tx.commit();

- fetch join으로 team의 정보까지 조회하도록 설정한다.

- 지연로딩으로 설정했지만 원하는 정보는 이렇게 한 번에 가져올 수 있다.

 

4. @ManyToOne, @OneToOne은 기본이 즉시 로딩이다 -> 일일이 전부 Lazy로 설정해줘야 한다.

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

 

지연 로딩 활용

- Member Team 자주 함께 사용 -> 즉시 로딩

- Member Order 가끔 사용 -> 지연 로딩

- Order Product 자주 함께 사용 -> 즉시 로딩

- 조회를 하면 EAGER 설정이기 때문에 member1과 teamA는 조인되어 한번에 정보를 가져온다.

- orders는 LAZY로 지연로딩이기 때문에 프록시로 가져와서 조회되지 않는다.(실제로 사용하기 전까지)

 

- 주문내역을 조회하면 EAGER 설정이기 때문에 상품 A와 조인을 해서 한 번에 정보를 가져온다.

 

지연 로딩 활용 - 실무

- 모든 연관관계에서 지연 로딩을 사용하자.

 

실전 예제 5 - 연관관계 관리

 

글로벌 패치 전략 설정

- 모든 연관관계를 지연 로딩으로 설정한다.

- @ManyToOne, @OneToOne은 기본이 즉시 로딩이므로 지연 로딩으로 변경한다.

@Entity
public class Category extends BaseEntity{

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "PARENT_ID")
    private Category parent;
}
-----------------------------------------------------
@Entity
public class Delivery extends BaseEntity{
		
    @OneToOne(mappedBy = "delivery", fetch = LAZY)
    private Order order;
}
-----------------------------------------------------
@Entity
@Table(name = "ORDERS")
public class Order extends BaseEntity{
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToOne(fetch = LAZY)
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
}
-----------------------------------------------------
@Entity
public class OrderItem extends BaseEntity{
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "ORDER_ID")
    private Order order;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "ITEM_ID")
    private Item item;
}

 

영속성 전이 설정

- Order -> Delivery를 영속성 전이 ALL 설정

- Order -> OrderItem을 영속성 전이 ALL 설정

@Entity
@Table(name = "ORDERS")
public class Order extends BaseEntity{

    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
    
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
}

 

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

[JPA] 값 타입(1)  (0) 2024.07.17
[JPA] 영속성 전이와 고아 객체  (0) 2024.07.16
[JPA] 고급 매핑  (0) 2024.07.15
[JPA] 다양한 연관관계 매핑  (0) 2024.07.14
[JPA] 연관관계 매핑 기초  (2) 2024.07.14