프록시와 연관관계 관리

LeeKyoungChang·2022년 3월 11일
0
post-thumbnail

자바 ORM 표준 JPA 프로그래밍 - 기본편 수업을 듣고 정리한 내용입니다.

 

✏️ 들어가기 전에

  • 프록시와 즉시로딩, 지연로딩 : 객체는 객체 그래프로 연관된 객체들을 탐색한다. 그런데 객체가 데이터베이스에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다. JPA 구현체들은 이 문제를 해결하려고 프록시라는 기술을 사용한다. 프록시를 사용하면 연관된 객체를 처음부터 데이터베이스에서 조회하는 것이 아니라, 실제 사용하는 시점에 데이터베이스에서 조회할 수 있다. 하지만, 자주 함께 사용하는 객체들은 조인을 사용해서 함께 조회하는 것이 효과적이다. JPA는 즉시 로딩과 지연 로딩이라는 방법으로 둘을 모두 지원한다.
  • 영속성 전이와 고아 객체 : JPA는 연관된 객체를 함께 저장하거나 함께 삭제할 수 있는 영속성 전이와 고아 객체 제거라는 편리한 기능을 제공한다.

 

📚 1. 프록시

  • 엔터티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다.
  • 예를 들어 회원 엔티티를 조회할 때 연관된 팀 엔티티는 비즈니스 로직에 따라 사용될 때도 있지만 그렇지 않을 때도 있다.

 

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

스크린샷 2022-03-10 오후 5 30 02

Member - 회원 엔티티

@Entity
public class Member {

    private String username;
    
    @ManyToOne
    private Team team;
    
    public Team getTeam() {
        return team;
    }
    
    public String getUsername() {
        return username;
    }
    ...
}

 

Team - 팀 엔티티

@Entity
public class Team {

    private String name;
    
    public String getName() {
        return name;
    }
    ...
}

 

printUserAndTeam - 회원과 팀 정보를 출력하는 비즈니스 로직

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());
}

 

printUser - 회원 정보만 출력하는 비즈니스 로직

public String printUser(String memberId) {
    Member member = em.find(Member.class, memberId);
    System.out.println("회원 이름 : " + member.getUsername());
}
  • 위의 예제(회원과 팀 정보를 출력하는 비즈니스 로직)의 printUserAndTeam() 메소드는 memberId로 회원 엔티티를 찾아서 회원은 물론이고 회원과 연관된 팀의 이름도 출력한다.
  • 반면에 위의 예제(회원 정보만 출력하는 비즈니스 로직)의 printUser() 메소드는 회원 엔티티만 출력하는데 사용하고 회원과 연관된 팀 엔티티는 전혀 사용하지 않는다.
  • printUser() 메소드는 회원 엔티티만 사용하므로 em.find()로 회원 엔티티를 조회할 때 회원과 연관된 팀 엔티티(Member.team)까지 데이터베이스에서 함께 조회해 두는 것은 효율적이지 않다.

➡️ 한쪽에서는 member만 출력하고, 한쪽에서는 member와 Team을 출력한다. (이럴 경우 member만 출력하는 쪽을 호출하려고 할 때 낭비가 발생한다. 구지 하나만 꺼내야 하는 건데 클래스 호출까지 해야하니? 좀 복잡한 과정이 추가되는 것 같다.)

JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라 한다.
team.getTeam()처럼 팀 엔티티의 값을 실제 사용하는 시점에 데이터베이스에서 팀 엔티티에 필요한 데이터를 조회하는 것이다.
이 방법을 사용하면 printUser() 메소드는 회원 데이터만 데이터베이스에서 조회해도 된다.

➡️ 그런데 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 한다.

 

💡 참고
JPA 표준 명세는 지연 로딩의 구현 방법을 JPA 구현체에 위임했다. 따라서 지금부터는 하이버네이트 구현체에 대한 내용이다. 하이버네이트는 지연 로딩을 지원하기 위해 프록시를 사용하는 방법과 바이트코드를 수정하는 두 가지 방법을 제공하는데 바이트코드를 수정하는 방법은 설정이 복잡하므로 여기서는 별도의 설정이 필요 없는 프록시에 대해서만 알아보겠다. 바이트 코드를 수정하는 방법은 하이버네이트 공식 사이트를 참고하자!

 

1. 프록시 기초

JPA에서 식별자로 엔티티 하나를 조회할 때는 EntityManager.find()를 사용한다. 이 메소드는 영속성 컨텍스트에 엔티티가 없으면 데이터베이스를 조회한다.

Member member = em.find(Member.class, "member1");

이렇게 엔티티를 직접 조회하면 조회한 엔티티를 실제 사용하든 사용하지 않든 데이터베이스를 조회하게 된다.
엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 된다.

Member member = em.getReference(Member.class, "member1");
스크린샷 2022-03-10 오후 5 35 45

이 메소드를 호출할 때 JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체도 생성하지 않는다. 대신에 데이터베이스 접근을 위임한 프록시 객체를 반환한다. (가짜 : 프록시)

 

em.find와 em.getReference의 차이점

JpaMain

        try {
            Member member = new Member();
            member.setUsername("user");

            em.persist(member);

            em.flush();
            em.clear();
			// 추가
		}

 

em.find

Member findMember = em.find(Member.class, member.getId());
스크린샷 2022-03-25 오후 6 43 33
  • em.find를 호출할 시 Database에 쿼리를 한다.

 

em.getReference

Member findMember = em.getReference(Member.class, member.getId());
스크린샷 2022-03-25 오후 6 43 13
  • em.getReference를 호출할 시 바로 Database에 쿼리를 하지 않는다.

 

스크린샷 2022-03-25 오후 6 47 22
  • id나 Username이 호출되어야 Database에서 쿼리를 한다.

 

📌 정리

  • em.find() : 데이터베이스를 통해서 실제 엔티티 객체 조회
  • em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회

 

2. 프록시의 특징

스크린샷 2022-03-10 오후 5 35 51
  • 프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.
  • 따라서 사용하는 입장에서는 이것이 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.

 

스크린샷 2022-03-10 오후 5 35 55
  • 위 그림을 보면, 프록시 객체는 실제 객체에 대한 참조(target)를 보관한다.
  • 그리고 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

 

3. 프록시 객체의 초기화

프록시 객체의 초기화 : 프록시 객체는 member.getName()처럼 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.

 

프록시 초기화 예제

// MemberProxy 반환
Member member = em.getReference(Member.class, "id1");
member.getName();   // 1. getName();

 

프록시 클래스 예상 코드

class MemberProxy extends Member {

    Member target = null;   // 실제 엔티티 참조
    
    public String getName() {
    
        if(target == null) {
        
            // 2. 초기화 요청
            // 3. DB 조회
            // 4. 실제 엔티티 생성 및 참조 보관
            this.target = ...;
        }
        
        // 5. target.getName();
        return target.getName();
    }
}
스크린샷 2022-03-10 오후 5 36 01

위의 그림(프록시 초기화)와 위의 예제 코드(프록시 클래스 예상 코드)로 프록시의 초기화 과정을 분석해보자!

  1. 프록시 객체에 member.getName()을 호출해서 실제 데이터를 조회한다.
  2. 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데 이것을 초기화라 한다.
  3. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
  4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 멤버변수에 보관한다.
  5. 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환한다.

 

4. 프록시의 특징

  • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
  • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근할 수 있다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다. (==로는 비교 실패, 대신 instance of 사용하여 타입 체크를 해야 한다.)
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다. (이미 있으므로, 영속성 컨텍스트에 저장되어있는 실제 엔티티를 반환한다.)
  • 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다. 따라서 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태의 프록시를 초기화하면 문제가 발생한다. 하이버네이트는 org.hibernate.LazyInitializationException 예외를 발생시킨다.

4번째 특징 관련 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다. 실행 해볼시

JpaMain

  try {
            Member member = new Member();
            member.setUsername("user");

            em.persist(member);

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

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


            Member findMember = em.getReference(Member.class, member.getId());  // Member

            // 프록시가 한번 반환되면 이후 프록시를 호출하면 똑같은 프록시가 나온다.
            // 프록시가 아니다. 개발하는 것이 중요하다.

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

            tx.commit();
  }

 

실행 결과

스크린샷 2022-03-25 오후 7 08 48

 

5번째 특징 관련 - 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태의 프록시를 초기화하면 문제가 발생한다.
org.hibernate.LazyInitializationException 예외를 발생할 시 의심할만 상황들

JpaMain

        try {
            Member member = new Member();
            member.setUsername("user");

            em.persist(member);

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

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

//            em.detach(refMember); // 영속성 컨텍스트에서 끄집어 낸다. refMember를 영속성 컨텍스트에서 관리안하겠다.

            em.close();  // 영속성 컨텍스트를 종료해버린다. 똑같이 refMember를 호출해봐야 조회되지 않는다.
            System.out.println("refMember.getUsername() = " + refMember.getUsername()); //


            //  could not initialize proxy [Member#1] - no Session  proxy를 초기화 할 수 없다.
            // 영속성 컨텍스트에 해당 refMember이 없어요!
            tx.commit();
        } catch (Exception e) {
            System.out.println("예외 발생");
            tx.rollback();
            e.printStackTrace();
        }finally{
            em.close();
        }

 

실행 결과

스크린샷 2022-03-25 오후 7 15 38

em.detach로 영속성 컨텍스트에서 끄집어 낸다. refMember를 영속성 컨텍스트에서 관리안하겠다.

 

스크린샷 2022-03-25 오후 7 16 07

em.close로 영속성 컨텍스트를 종료해버린다. 똑같이 refMember를 호출해봐야 조회되지 않는다.

 

5. 준영속 상태와 초기화

// MemberProxy 반환
Member member = em.getReference(Member.class, "id1");
transaction.commit();
em.close();         // 영속성 컨텍스트 종료

member.getName();   // 준영속 상태 초기화 시도,
                    // org.hibernate.LazyInitializationException 예외 발생
  • 이 코드를 보면 em.close() 메소드로 영속성 컨텍스트를 종료해서 member는 준영속 상태다.
  • member.getName()을 호출하면 프록시를 초기화해야 하는데 영속성 컨텍스트가 없으므로 실제 엔티티를 조회할 수 없다. 따라서 예외가 발생한다.

 

💡 참고
JPA 표준 명세는 지연 로딩(프록시)에 대한 내용을 JPA 구현체에 맡겼다. 따라서 준영속 상태의 엔티티를 초기화할 때 어떤 일이 발생할지 표준 명세에는 정의되어 있지 않다. 하이버네이트를 사용하면 org.hibernate.LazyInitializationException 예외가 발생한다.

 

6. 프록시와 식별자

엔티티를 프록시로 조회할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다.

Team team = em.getReference(Team.class, "team1");  // 식별자 보관
team.getId();   // 초기화되지 않음

프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 team.getId()를 호출해도 프록시를 초기화하지 않는다. 단 엔티티 접근 방식을 프로퍼티(@Access(AccessType.PROPERTY))로 설정한 경우에만 초기화하지 않는다.

엔티티 접근 방식을 필드(@Access(AccessType.FIELD))로 설정하면 JPAgetId() 메소드가 id만 조회하는 메소드인지 다른 필드까지 활용해서 어떤 일을 하는 메소드인지 알지 못하므로 프록시 객체를 초기화한다!

프록시는 다음 코드처럼 연관관계를 설정할 때 유용하게 사용할 수 있다.

Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1");    // SQL을 실행하지 않음
member.setTeam(team);
  • 연관관계를 설정할 때는 식별자 값만 사용하므로 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있다.
  • 참고로 연관관계를 설정할 때는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않는다.

 

7. 프록시 확인

  • JPA가 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인할 수 있다.
  • 아직 초기화되지 않은 프록시 인스턴스는 false를 반환한다.
  • 이미 초기화되었거나 프록시 인스턴스가 아니면 true를 반환한다.
boolean isLoad = em.getEntityManagerFactory()
                   .getPersistenceUnitUtil().isLoaded(entity);
// 또는 boolean isLoad = emf.getPersistenceUnitUtil().isLoaded(entity);

System.out.println("isLoad = " + isLoad);   // 초기화 여부 확인

조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지 확인하려면 클래스명을 직접 출력해보면 된다.

System.out.println("memberProxy = " + member.getClass().getName());
// 결과 : memberProxy = jpabook.domain.Member_$$_javassist_0

위 예를 보면 클래스 명 뒤에 javasist가 있는데 이것으로 프록시인 것을 확인할 수 있다.
프록시를 생성하는 라이브러이에 따라 출력 결과는 달라질 수 있다.

 

✔️ 프록시 강제 초기화
하이버네이트의 initialize() 메서드를 사용하면 프록시를 강제로 초기화할 수 있다.

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

JPA 표준에는 프록시 강제 초기화 메서드가 없다.
따라서 강제로 초기화하려면 member.getName()처럼 프록시의 메서드를 직접(강제) 호출하면 된다.
JPA 표준은 단지 초기화 여부만 확인할 수 있다.

 

📚 2. 즉시 로딩과 지연 로딩

프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용한다.

member1team1에 소속해 있다고 가정해보면

Member member = em.find(Member.class, "member1");
Team team = member.getTeam();         // 객체 그래프 탐색
System.out.println(team.getName());   // 팀 엔티티 사용
  • 회원 엔티티를 조회할 때 연관된 팀 엔티티도 함께 데이터베이스에서 조회하는 것이 좋을까?
  • 회원 엔티티만 조회해두고 팀 엔티티는 실제 사용하는 시점에 데이터베이스에서 조회하는 것이 좋을까?

 

📣 JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 두 가지 방법을 제공한다.
(1) 즉시 로딩 : 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.

  • ex) em.find(Member.class, "member1")를 호출할 때 회원 엔티티와 연관된 팀 엔티티도 함께 조회한다.
  • 설정 방법 : @ManyToOne(fetch=FetchType.EAGER)

(2) 지연 로딩 : 연관된 엔티티를 실제 사용할 때 조회한다.

  • ex) member.getTeam().getName()처럼 조회한 팀 엔티티를 실제 사용하는 시점에 JPASQL을 호출해서 팀 엔티티를 조회한다.
  • 설정 방법 : @ManyToOne(fetch=FetchType.LAZY)

 

📖 A. 지연 로딩

지연 로딩(LAZY LOADING)을 사용하려면 @ManyToOnefetch 속성을 FetchType.LAZY로 지정한다.

✔️ 지연 로딩 설정

@Entity
public class Member {
    // ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    // ...
}

 

✔️ 지연 로딩 실행 코드

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

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

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

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

            // Member 클래스에서 team에 Lazy를 사용할 시, 멤버에서 팀을 조회할 때는 프록시로 조회를 한다.

            System.out.println("m = " + m.getTeam().getClass());  // 객체 그래프 탐색
            // 결과 : m = class Team$HibernateProxy$nNhK9NQK
            // 연관관계를 프록시로 가져온 것이다.

            Team team1 = team = member.getTeam();

            System.out.println("=============");
            System.out.println("Member로 team 조회");
            m.getTeam().getName(); // team을 조회할 때
            System.out.println("=============");
            System.out.println("조회한 후, member.getTeam()로부터 받은 team 객체를 실행");
            System.out.println("team1 = " + team1.getClass());  // 객체 그래프 탐색
            // Team 엔티티를 실제 사용하여 조회할 때는 DB에서 값을 가져온 것을 확인할 수 있다.

            // m은 프록시, team1은 클래스 객체


            tx.commit();

 

실행 결과

스크린샷 2022-03-28 오후 1 46 40
  • Member 클래스에서 team에 Lazy를 사용할 시, 멤버에서 팀을 조회할 때는 프록시로 조회를 한다.
  • Team 엔티티를 실제 사용하여 조회할 때는 DB에서 값을 가져온 것을 확인할 수 있다.

 

지연 로딩 설정을 보면 회원과 팀을 지연 로딩으로 설정했다. 따라서 지연 로딩 실행 코드에서 em.find(Member.class, "member1")를 호출하면 회원만 조회하고 팀은 조회하지 않는다.
대신에 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둔다.

스크린샷 2022-03-11 오후 3 13 05
Team team = member.getTeam();   // 프록시 객체

반환된 팀 객체는 프록시 객체다. 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룬다. 그래서 지연 로딩이라 한다.

team.getName();   // 팀 객체 실제 사용

실제 데이터가 필요한 순간이 되어서야(실제 team 객체를 사용할 때) 데이터베이스를 조회해서 프록시 객체를 초기화한다.

 

✔️ 지연 로딩 LAZY을 사용해서 프록시 조회 추가 설명

스크린샷 2022-03-28 오후 1 49 04
Member member = em.find(Member.class, 1L);
스크린샷 2022-03-28 오후 1 49 08
Team team = member.getTeam();
team.getName(); // 실제 team을 사용하는 시점에 초기화(DB 조회)
// getTeam()다음 getName, Team의 메서드를 호출하는 순간 초기화가 일어난다.
  • getTeam() 다음 getName(Team의 메서드)를 호출하는 순간 초기화가 일어난다.

 

✔️ 즉시로딩 관련 추가 내용

em.find(Member.class, "member1") 호출시 실행되는 SQL

SELECT * FROM MEMBER
WHERE MEMBER_ID = 'member1'

 

team.getName() 호출로 프록시 객체가 초기화되면서 실행되는 SQL

SELECT * FROM TEAM
WHERE TEAM_ID = 'team1'
  • 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없다.
  • 따라서 프록시가 아닌 실제 객체를 사용한다.
    • 예를 들어 team1 엔티티가 영속성 컨텍스트에 이미 로딩되어 있으면 프록시가 아닌 실제 team1 엔티티를 사용한다.

 

📖 B. 즉시 로딩

  • Member와 Team을 자주 함께 사용한다면? 즉시로딩을 써야 한다.
스크린샷 2022-03-28 오후 2 00 34
  • 즉시 로딩(EAGER LOADING)을 사용하려면 @ManyToOnefetch 속성을 FetchType.EAGER로 지정한다.

✔️ 즉시 로딩 설정

@Entity
public class Member {
    // ...
    @ManyToOne(fetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    // ...
}

 

✔️ 즉시 로딩 실행 코드

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

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

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

            Member m = em.find(Member.class, member.getId());
            // 즉시 한번에 DB에서 데이터를 가져와버린다.
            
            System.out.println("m = " + m.getTeam().getClass());  // 객체 그래프 탐색

            Team team1 = team = member.getTeam();

            System.out.println("=============");
            System.out.println("Member로 team 조회");
            m.getTeam().getName();
            // 초기화가 끝난 상태라 아무 것도 출력되지 않는다.
            // 초기화 될 때 select문이 실행된다.
            System.out.println("=============");
            System.out.println("조회한 후, member.getTeam()로부터 받은 team 객체를 실행");
            System.out.println("team1 = " + team1.getClass());  // 객체 그래프 탐색

            tx.commit();
  • 즉시 한번에 DB에서 데이터를 가져와버린다.
  • 그러므로 프록시가 필요없다.
  • 또한 클래스 타입을 호출해보면, 해당 클래스 이름이 출력된다.
    • m.getTeam().getClass() : m = class Team
    • team1.getClass() : team1 = class Team
  • 초기화가 끝난 상태라서 m.getTeam().getName()을 출력해보면 select문이 실행되지 않는다.
    • 참고로, 초기화할 때 select문이 실행된다.

 

위의 즉시 로딩 설정 코드를 보면 회원과 팀을 즉시 로딩으로 설정했다. 따라서 즉시 로딩 실행 코드에서 em.find(Member.class, "member1")로 회원을 조회하는 순간 팀도 함께 조회한다.

스크린샷 2022-03-11 오후 2 57 05

✔️ 즉시 로딩(EAGER), Member조회시 항상 Team도 조회

스크린샷 2022-03-28 오후 3 34 08

JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회한다.

 

이때 회원과 팀 두 테이블을 조회해야 하므로 쿼리를 2번 실행할 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.

회원과 팀을 조회해서 쿼리 한 번으로 두 엔티티를 모두 조회한다.

SELECT
    M.MEMBER_ID AS MEMBER_ID,
    M.TEAM_ID AS TEAM_ID,
    M.USERNAME AS USERNAME,
    T.TEAM_ID AS TEAM_ID,
    T.NAME AS NAME
FROM
    MEMBER M LEFT OUTER JOIN TEAM T
        ON M.TEAM_ID=T.TEAM_ID
WHERE
    M.MEMBER_ID='member1'

실행되는 SQL을 분석해보면 회원과 팀을 조회해서 쿼리 한 번으로 조회한 것을 알 수 있다.
이후, member.getTeam()을 호출하면 이미 로딩된 Team1 엔티티를 반환한다.

 

💡 참고 - NULL 제약조건과 JPA 조인 전략

  • 현재 회원 테이블에 TEAM_ID 외래 키는 NULL 값을 허용하고 있다. 따라서 팀에 소속되지 않은 회원이 있을 가능성이 있다.
  • JPA에게도 이런 사실을 알려줘야 한다. 다음 코드처럼 @JoinColumn에 nullable = false을 설정해서 이 외래 키는 NULL 값을 허용하지 않는다고 알려주면 JPA는 외부 조인 대신에 내부 조인을 사용한다.
@Entity
public class Member {
 // ...
   @ManyToOne(fetch = FetchType.EAGER)
   @JoinColumn(name = "TEAM_ID", nullable = false)
   private Team team;
   // ...
}

 

💡 참고 - nullable 설정에 따른 조인 전략

  • @JoinColumn(nullable = true) : NULL 허용(기본값), 외부 조인 사용
  • @JoinColumn(nullable = false) : NULL 허용하지 않음, 내부 조인 사용 또는
@Entity
public class Member {
   // ...
   @ManyToOne(fetch = FetchType.EAGER, optional = false)
   @JoinColumn(name = "TEAM_ID")
   private Team team;
   // ...
}

이와 같이 @ManyToOne.optional = false로 설정해도 내부 조인을 사용한다.

📌 정리
JPA는 선택적 관계면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용한다.

 

📌 정리
지연 로딩(LAZY)

  • 연관된 엔티티를 프록시로 조회한다. 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.
  • 언제 사용할까? Member와 team 클래스를 보았을 때, Member 클래스에서 member만 조회할 때 즉, member클래스 안에 있는 team 객체 외부 조인을 사용하지 않을 때 지연 로딩을 사용한다.

 

즉시 로딩(EAGER)

  • 연관된 데이터를 즉시 조회한다. 하이버네이트는 가능하면 SQL 조인을 사용해서 한 번에 조회한다.
  • 언제 사용할까? 비즈니스 로직에서 많이 사용하는데 Member, team 클래스처럼 연관관계 과정을 가진 클래스들을 같이 조회할 때 그러니까 Member클래스를 조회할 때 team 객체 외부조인을 같이 조회할 때 즉시 로딩을 사용한다.

 

⚠️ 주의

  • 실무에서는 가급적 지연 로딩만 사용한다. (조인 많으면 즉시 로딩시 복잡해진다. 연관된 테이블 다 가져와버림)

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

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

  • @ManyToOne, @OneToOne : 기본이 즉시 로딩 (LAZY로 설정)

  • @OneToMany, @ManyToMany : 기본이 지연 로딩

List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
// n + 1 : 처음 쿼리 select 문이 한 번 실행되고, 추가 쿼리 n개가 실행된다.
  • 실무에서는 왠만하면 Lazy(지연) 조인을 사용하고, 만약 a, b, c 데이터들을 한 번에 조회할 때는 패치 조인을 사용한다.

 

📚 3. 지연 로딩 활용

사내 주문 관리 시스템을 개발

스크린샷 2022-03-11 오후 4 07 59

✔️ 사용할 모델 분석

  • 회원(Member)은 팀(Team) 하나에만 소속할 수 있다. N : 1
  • 회원(Member)은 여러 주문내역(Order)을 가진다. 1 : N
  • 주문내역(Order)은 상품정보(Product)를 가진다. N : 1

&nsp;

✔️ 애플리케이션 로직을 분석

  • Member와 연관된 Team은 자주 함께 사용되었다. 그래서 MemberTeam은 즉시 로딩으로 설정했다.
  • Member와 연관된 Order는 가끔 사용되었다. 그래서 MemberOrder는 지연 로딩으로 설정했다.
  • Order와 연관된 Product는 자주 함께 사용되었다. 그래서 OrderProduct는 즉시 로딩으로 설정했다.

 

✔️ 회원 엔티티

@Entity
public class Member {

    @Id
    private String id;
    private String username;
    private Integer age;
    
    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;
    
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders;
    
    // Getter, Setter ...
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
  • 회원과 팀의 연관관계를 FetchType.EAGER로 설정
  • 따라서 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회한다.

 

@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
  • 회원과 주문내역의 연관관계를 FetchType.LAZY로 설정
  • 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시로 조회해서 실제 사용될 때까지 로딩을 지연한다.

 

Member member = em.find(Member.class, "member1");
  • 회원 엔티티를 조회하면 아래 그림처럼 엔티티를 로딩한다.
스크린샷 2022-03-11 오후 4 08 05

회원과 팀은 즉시 로딩(FetchType.EAGER)로 설정했다.
따라서 회원을 조회할 때 연관된 teamA도 함께 조회한다.

 

✔️ 회원 엔티티를 조회할 때 JPA가 실행한 SQL

SELECT
    MEMBER.ID AS MEMBERID,
    MEMBER.AGE AS AGE,
    MEMBER.TEAM_ID AS TEAM_ID,
    MEMBER.USERNAME AS USERNAME,
    TEAM.ID AS TEAMID,
    TEAM.NAME AS NAME
FROM 
    MEMBER MEMBER
LEFT OUTER JOIN
    TEAM TEAM ON MEMBER.TEAM_ID=TEAM1.ID
WHERE
    MEMBER0_.ID='member1'
  • (위)회원 엔티티에서 회원과 팀은 FetchType.EAGER로 설정했으므로 하이버네이트는 조인 쿼리를 만들어 회원과 팀을 한 번에 조회한다.
  • 반면에 회원과 주문내역은 FetchType.LAZY로 설정했으므로 결과를 프록시로 조회한다.
    • 따라서, 회원을 조회할 때 실행된 SQL에 전혀 나타나지 않는다.
  • 회원을 조회한 후에 member.getTeam()을 호출하면 이미 로딩된 팀 엔티티를 반환한다.

 

📖 A. 프록시와 컬렉션 래퍼

스크린샷 2022-03-11 오후 4 08 11

즉시 로딩한 teamA는 실선으로 표현했고 지연 로딩한 주문내역은 점선으로 표현했다.
이렇게 지연 로딩으로 설정하면 실제 엔티티 대신에 프록시 객체를 사용한다.
(프록시 객체는 실제 자신이 사용할 때까지 데이터베이스를 조회하지 않는다.)

 

✔️ 주문내역 조회

Member member = em.find(Member.class, "member1");
List<Order> orders = member.getOrders();
System.out.println("orders = " + orders.getClass().getName());
// 결과 : orders = org.hibernate.collection.internal.PersistentBag

컬렉션 래퍼 : 하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경한다.

 

💡 참고

  • 컬렉션 래퍼도 컬렉션에 대한 프록시 역할을 하므로 프록시라고 불러도 된다.
  • member.getOrders()를 호출해도 컬렉션은 초기화되지 않는다. 컬렉션은 member.getOrders().get(0)처럼 컬렉션에서 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화한다.

 

  • 위에 있는 그림을 보면 주문내역과 상품의 로딩 방법을 FetchType.EAGER로 설정했다.
  • 따라서 지연 로딩 상태인 주문내역을 초기화할 때 연관된 상품도 함께 로딩된다.

 

📖 B. JPA 기본 패치 전략

✏️ fetch 속성의 기본 설정 값

  • @ManyToOne, @OneToOne : 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany : 지연 로딩(FetchType.LAZY)
  • JPA의 기본 페치(fetch) 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연 로딩을 사용한다.
  • 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문이다.

 

✔️ 왠만하면 모든 연관관계에 지연 로딩을 사용하자!
즉시로딩은? 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다!

 

🔔 FetchType.EAGER 설정과 조인 전략을 정리

  • @ManyToOne, @OneToOne

    • (optional = false) : 내부 조인
    • (optional = true) : 외부 조인
  • @OneToMany, @ManyToMany

    • (optional = false) : 내부 조인
    • (optional = true) : 외부 조인

 

📌 정리

  • 모든 연관관계에 지연 로딩을 사용해라!
  • 실무에서 즉시 로딩을 사용하지 마라!
  • JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해라! (뒤에서 공부)
  • 즉시 로딩은 상상하지 못한 쿼리가 나간다.

 


참고

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글