인프런/자바 ORM 표준 JPA 프로그래밍 - 기본편

8) 프록시,즉시 로딩, 지연 로딩,영속성 전이, 고아객체

backend dev 2024. 5. 31.

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

멤버가 팀을 참조하고있는 상태 , 필드에 팀이 있으므로 연관관계

 

 

어떤 경우에는 회원과 팀을 함께 출력해야하므로 둘다 조회해야하고

어떤 경우에는 회원만 출력하므로 둘다 조회할 필요가 없다.

 

회원만 출력하는데 둘다 조회해오면 낭비가 되니까 

그걸 해결하기위해 jpa는 프록시를 이용한다. [ 지연로딩 , 즉시로딩]

 

 

 

프록시

 

em.find() vs em.getReference()

 

em.find()

 데이터베이스를 통해서 실제 엔티티 객체 조회

em.getReference()

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

[db에 쿼리가 실행되지않는데 객체가 조회된다. ]

target는 진짜 엔티티를 가리키는 참조변수이다.

 

 

em.find로 테스트

Member member = new Member();
member.setName("hello");
em.persist(member);


em.flush(); // 생성,수정,삭제등 변경사항 처리
em.clear(); // 영속성 컨테이너 비우기
// 1차캐시에서 가져오지않고 실제 DB에서 가져오는 SQL를 보기위해 영속성 컨테이너를 비운다.

Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.getId() = " + findMember.getId());
System.out.println("findMember.getName() = " + findMember.getName());

실제 DB에서 쿼리가 실행되었음.

 

em.find()를 사용하면  데이터베이스를 통해서 실제 엔티티 객체 조회하는것을 확인할 수 있다.

 

em.getReference()로 바꾸고 테스트

 

Member findMember = em.getReference(Member.class, member.getId());
//            System.out.println("findMember.getId() = " + findMember.getId());
//            System.out.println("findMember.getName() = " + findMember.getName());

콘솔출력해보는것은 주석처리한뒤 어플리케이션을 실행하면

select 쿼리를 찾아볼 수 없다 --> select 쿼리가 실행되지않았다.

 

 

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

아니만 콘솔출력의 주석을 해제한다면

 

보면 id를 콘솔출력할때는 쿼리가 발생하지않고 잘 출력되고

name을 콘솔출력할때 쿼리가 발생된후 콘솔출력이 된 모습을 확인할 수 있다.

 

 id를 콘솔출력할때는 쿼리가 발생하지않은 이유는 

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

에서 보면 member.getId()를 통해 이미 id값을 넣었기에 이미 알고있어서 조회 sql이 발생하지않은것이다.

 

    EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
    EntityManager em = emf.createEntityManager();
    EntityTransaction tx = em.getTransaction();
    tx.begin();
    try {
        Member member = new Member();
        member.setName("hello");
        em.persist(member);


        em.flush(); // 생성,수정,삭제등 변경사항 처리
        em.clear(); // 영속성 컨테이너 비우기
        // 1차캐시에서 가져오지않고 실제 DB에서 가져오는 SQL를 보기위해 영속성 컨테이너를 비운다.

//            Member findMember = em.find(Member.class, member.getId());
        Member findMember = em.getReference(Member.class, member.getId());
        System.out.println("findMember.getId() = " + findMember.getId());
        System.out.println("findMember.getName() = " + findMember.getName());
        tx.commit();
    }catch (Exception e) {
        tx.rollback();
    }finally {
        em.close();
    }
    emf.close();

하지만 name 같은경우 flush, clear로 인해 1차캐시가 다 비워졌기에 알수가 없으므로

db에 실제 select sql를 실행해서 조회해오기 때문에

sql문이 생성된것.

 

가짜(프록시)엔티티 객체가 어떤것이 들어와있는지 조회해본다면

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

 

프록시 클래스의 객체가 들어와있는것을 확인가능

target는 진짜 엔티티를 가리키는 참조변수이다.

 

프록시 특징

실제 클래스를 상속 받아서 만들어짐

 

실제 클래스와 겉 모양이 같다. [ 상속받아서 만들어지니까 ]

 

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

 

 

프록시 객체는 실제 객체의 참조(target)를 보관

 

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

 

em.getReference()

를 한 직후에는 target이 null일것이다. ( 조회 한 적이 없으니까 ) 

 

프록시 객체의 초기화

 

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

만약 다음과 같은 코드를 진행했다고 가정한다면

 

다음과 같이 진행된다.

 

getName()이 실행되면 실제 target은 현재 null이므로 jpa가 영속성 컨텍스트에 초기화 요청을 하면 영속성 컨텍스트는

DB를 조회한다. 

DB를 조회해서 실제 Entity를 생성 하고 target에 진짜 객체를 연결한다. 

그래서 getName()을 했을때 실제 타겟의 getName()이 실행된다.

 

target의 참조를 설정하기위해 진짜 DB에서 데이터를 가져오는 과정을 초기화라고 한다.

 

System.out.println("findMember.getName() = " + findMember.getName());
System.out.println("findMember.getName() = " + findMember.getName());

 

이렇게 두번하게되도 첫번쨰  getName()할때 초기화가 되므로

2번째 getName()에서는 sql 쿼리가 실행되지않고 프록시의 타겟을 이용해서 getName()을 실행해준다.

 

 

프록시의 특징

•  프록시 객체는 처음 사용할 때 한번만 초기화

 

  프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아님,

  초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능

 

•  프록시 객체는 원본 엔티티를 상속받음, 따라서 타입 체크시 주의해야함 (== 비교를 하면안된다, 대신 instance of 사용)

 프록시 객체는 원본 엔티티를 상속받은 다른 객체이므로 == 는 안되고, 상속했으니까 instance of 내가 찾는 엔티티의 자식클래스인지 확인하는 방법을 이용해야한다. 

 


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

 이미 영속성 컨텍스트에서 관리되는 엔티티라면 가짜 프록시 객체를 만들어서 얻을 이점이 없다.

영속성 컨텍스트에서 관리되는 원본 엔티티를 직접사용하는것이 이득이다.

 

또한
jpa에서는 영속성컨테이너에서 가져오든, 프록시객체를 이용하든 같은 엔티티를 가져오는 함수를 해서 반환받은 두 엔티티를 == 비교를 하게되면 항상 true를 반환해야하기 때문에 이미 영속성컨테이너에서 관리되는 엔티티를 em.getReference()를 이용해서 가져오려고 해도 프록시객체가 아닌 실제 원본엔티티를 가져온다.

Member member = new Member();
member.setName("hello");
em.persist(member);


em.flush(); // 생성,수정,삭제등 변경사항 처리
em.clear(); // 영속성 컨테이너 비우기
// 1차캐시에서 가져오지않고 실제 DB에서 가져오는 SQL를 보기위해 영속성 컨테이너를 비운다.

// persist(member) 후 flush를 통해 실제 insert sql이 실행되고나서 멤버의 아이디필드에 값이 들어가있으므로 member.getId()를 사용할 수 있다.
Member findMember = em.find(Member.class, member.getId()); // 해당 조회로 영속성 컨텍스트에는 멤버가 존재
Member findMember2 = em.getReference(Member.class, member.getId()); // 이때 getReference()를 하게 된다면?

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

 

만약 둘다 getReference()로 가져온다면 같은 프록시 객체가 반환되어서 ==가 true가 된다.

 

 

순서를 바꿔본다면?

Member findMember = em.getReference(Member.class, member.getId()); // 이번에는 프록시를 먼저가져오고
Member findMember2 = em.find(Member.class, member.getId());  // 실 DB에서 조회하는 find를 사용한다면?

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

 

jpa는 같은 객체를 조회하려고할때 같은 객체를 반환해줘야하고 즉 == 비교가 무조건 성립해야하므로

 

find로 받아온 객체 또한 프록시객체로 반환해준다.

[첫번쨰로 반환한게 프록시면 프록시를 반환, 이미 영속성컨테이너에 관리되고있으면 실제 엔티티객체를 반환해준다.]

[ 같은 데이터에 대한 == 비교를 맞추기 위해 ] 

 


 

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

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

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

            em.detach(refMember);
//            em.close();
//            em.clear();


            System.out.println("refMember = " + refMember.getName());// 프록시 객체 초기화
            tx.commit();

 

 

getReference()로 프록시 객체를 받아온후 영속성 컨텍스트는 프록시 객체의 초기화 여부와 관계없이 해당 프록시 객체를 관리한다. 일반 엔티티 객체 관리하는것처럼 프록시객체도 영속성 컨텍스트 1차캐시에서 관리된다.

 

그 후

원래 영속성컨테이너에 초기화 요청을 해야하는데 

그전에 영속성컨테이너를 비운다던지, 닫는다던지 , 관리되고 있던 영속성 컨텍스트를 준영속상태로 만든다던지

em.detach(refMember);
em.close();
em.clear();

 

를 하게 된 후에

 

초기화를 시도하면 예외가 발생한다.

[ 초기화는 영속성컨테이너를 통해 해야하는데 영속성컨테이너에서 관리되고있던것이 준영속상태가 되어버렸으므로]

 

 

프록시 확인

 

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

Member refMember = em.getReference(Member.class, member.getId());
System.out.println("refMember.getClass() = " + refMember.getClass());
System.out.println("isLoaded = "+emf.getPersistenceUnitUtil().isLoaded(refMember) );

false

 

• 프록시 클래스 확인 방법 entity.getClass().getName() 출력(..javasist.. or HibernateProxy…)

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

 

 

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

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

refMember.getName(); // 이런식으로 사용할떄 초기화가 되기도 하지만

Hibernate.initialize(refMember); // 해당 메소드로 강제 초기화 가능

[ 이것은 hibernate 기술 ]

 

• 참고: JPA 표준은 강제 초기화 없음 강제 호출: member.getName()


 

즉시 로딩과 지연 로딩

 

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

 

단순히 member 정보만 사용하는 비즈니스 로직

println(member.getName());

 

지연 로딩 LAZY을 사용해서 프록시로 조회

@Entity
 public class Member {
     @Id
     @GeneratedValue
     private Long id;

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

     @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.setName("hello");
member.setTeam(team);
em.persist(member);

em.flush(); // 생성,수정,삭제등 변경사항 처리
em.clear(); // 영속성 컨테이너 비우기

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

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


tx.commit();

 

멤버만 먼저 DB에서 가져오고,  Team은 지연로딩을 설정했기에 일단 프록시로 가져온다.

 

그 후  Team의 필드에 접근하려고할때 쿼리가 발생해서 DB에서 가져온다.

[ Team 프록시 객체를 초기화 해준다. ]

 

지연 로딩 LAZY을 사용해서 프록시로 조회

지연로딩 설정되어있으면 프록시 객체로 가져오고,

실제 사용해야할때 프록시 초기화

 

getTeam()으로는 어차피 프록시객체를 가져오는거니까 초기화가 되지않고

team.getName()하는 순간 필드값이 필요하므로 초기화가 동작한다. 

 

 

Member와 Team을 자주 함께 사용한다면?

멤버를 가져올때 바로 팀을 가져오는것이 좋다.

 

 

즉시 로딩 EAGER를 사용해서 함께 조회

@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;

     ..
 }

 

멤버와 팀을 join해서 한번에 가져오는것을 확인 할 수 있다.

그러므로 프록시객체가 아니라 실제 팀 엔티티를 가져온다.

 

 

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

기본적으로는 EAGER를 사용하면 조인을 이용해서 한번에 가져온다.

 

어떤 로딩을 기본값으로 하는지는 연관관계 매핑 어노테이션마다 다르다.

 

프록시와 즉시로딩 주의

 

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

해당 엔티티가 연관되어있는 다른 엔티티가 많은경우 엄청 많은 조인이 발생한다. --> 성능 저하 

연관된 엔티티가 5개만 되어도 조인쿼리가 엄청날것

 

즉시 로딩을 적용하면 예상하지 못한 SQL이 발생 [ 조인을 하니까 ]

 

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

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

find를 사용하지않고 JPQL를 사용하면 

쿼리가 2개가 나간다. 

작성한 SQL은 멤버테이블만 사용했는데 실제 SQL은 팀까지 가져오는 쿼리도 실행이 된다. 

 

sql이 수행되면서 멤버는 가져왔는데 , 즉시로딩 설정된 TEAM 필드 때문에 

팀까지 가져오는 쿼리도 실행되는것이다. 

 

LAZY로 설정되어있었다면 

설정한 SQL 쿼리대로 나간다. -> TEAM은 프록시 객체가 들어가있다.

 

자세한건 다음에 배운다.

 

@ManyToOne, @OneToOne은 기본[default]이 즉시 로딩 -> 직접 다 LAZY로 설정해줘야한다.

 

@OneToMany, @ManyToMany는 기본 [default] 이 지연 로딩

 

 

무슨 경우라도 무조건 지연로딩을 써야한다.

 

지연 로딩 활용

여기는 이론적인 이야기이고, 실무에서는 모두 지연로딩을 사용해야한다.

이론적인것, 실무에서는 전부 지연로딩을 써야한다.

EAGER니까 실제 엔티티를 가져오고

LAZY니까 프록시 객체를 가져온다.

주문내역의 필드로 상품이 있는데 상품이 EAGER라면

주문내역 프록시가 초기화될때 바로 상품까지 JOIN해서 가져온다.

 

 

지연 로딩 활용 - 실무

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

 

• 실무에서 즉시 로딩을 사용하지 마라!

 

JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해라! (뒤에서 설명)

 

• 즉시 로딩은 상상하지 못한 쿼리가 나간다.

 

 


영속성 전이: CASCADE

 

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들도 싶을 때 사용

 

• 예: 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장.

 

@Getter
@Setter
@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent") // 양방향 연관관계
    private List<Child> childList = new ArrayList<>();

    // 연관관계 편의 메소드
    public void addChild(Child child) {
        childList.add(child);
        child.setParent(this);
    }

}
@Getter
@Setter
@Entity
public class Child {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

 

Child child1 = new Child();
Child child2 = new Child();

Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);
em.persist(child1);
em.persist(child2);

tx.commit();

일반적으로는 3개 엔티티 모두 persist 진행해야한다.

 

 

하지만

@Getter
@Setter
@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent" ,cascade = CascadeType.ALL) // 양방향 연관관계
    private List<Child> childList = new ArrayList<>();

    // 연관관계 편의 메소드
    public void addChild(Child child) {
        childList.add(child);
        child.setParent(this);
    }

}

cascade = CascadeType.ALL를 사용하면

em.persist(parent);

parent만 persist 하더라도

child까지 persist 되는것을 확인가능하다.

 

즉 cascade = CascadeType.ALL 설정되어있는 필드의 엔티티 또한 persist 해주는 것이다.

여기서는 List이므로 그 List안에 들어있는 모든 엔티티를 persist해준것이다. 

ALL은 모든 작업 [persist,merge,remove,refresh,detach]를 같이 적용해준다는것이다.

• PERSIST: 영속만 같이 처리한다는것

 

 

영속성 전이: CASCADE - 주의!

영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없음

 

엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐

 

CASCADE의 종류

• ALL: 모두 적용

 

• PERSIST: 영속

 

• REMOVE: 삭제

 

• MERGE: 병합

 

• REFRESH: REFRESH

 

• DETACH: DETACH

 

CASCADE는 해당 엔티티를 관리하는 소유자가 하나일때만 사용 
- 단일 소유자일때

- 라이프 사이클이 비슷할때 
두가지가 다 맞아야 사용

 

예)

멤버도 child와 연관되어있는데

parent가 삭제되면서 child까지 cascade 로인해 삭제되었다면

문제가발생하니까 위의 조건들이 맞아야한다.


고아 객체

고아 객체 제거: 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제

 

package hellojpa;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Getter
@Setter
@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent" ,cascade = CascadeType.ALL, orphanRemoval = true) 
    private List<Child> childList = new ArrayList<>();

    // 연관관계 편의 메소드
    public void addChild(Child child) {
        childList.add(child);
        child.setParent(this);
    }

}

orphanRemoval = true 속성 추가.

Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0);

parent 필드의 자식리스트에서 해당 자식객체를 제거했는데

 

orphanRemoval = true 속성이 추가되어있기에 실제 엔티티까지 삭제되었다.

[ 해당 컬렉션에서 삭제된 엔티티를 실제 DB에서 삭제된다. ]

 

 

 

고아 객체 - 주의

• 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능

 

참조하는 곳이 하나일 때 사용해야함!

 

특정 엔티티가 개인 소유할 때 사용

 

• @OneToOne, @OneToMany만 가능

 

• 참고: 개념적으로 부모를 제거하면 자식은 고아가 된다.

따라서 고아 객체 제거 기능을 활성화 하면,

부모를 제거할 때 자식도 함께 제거된다.

이것은 CascadeType.REMOVE처럼 동작한다.

 

 

 

영속성 전이 + 고아 객체, 생명주기

cascade = CascadeType.ALL, orphanRemoval = true

 

• 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거

 

• 두 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있음

 

• 도메인 주도 설계(DDD)의 Aggregate Root개념을 구현할 때 유용

 

 

글로벌 Fetch 전략 설정

fetch = FetchType.LAZY

모든 연관관계를 지연 로딩으로

 

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

 

댓글