Java - SpringJPA

Spring JPA (8) - JPA 프록시와 연관관계 관리, 즉시로딩, 지연로딩, Cascade, 고아 객체

TerianP 2022. 10. 11.
728x90

이번 포스팅의 핵심은 지연 로딩!!! 꼭 기억하고 넘어가자

 

1. Member 를 조회할때 Team 을 함께 조회해야 할까?

아래의 Member 엔티티는 DB 에서 조회 시, team 을 함께 가져오게 된다.
만약 비즈니스상 member 와 team 을 함께 조회해야하는 경우에는 이렇게 함께 가져오는게 잘못된 것이 아니다. 그러나 member 만 조회해와도 충분한 경우,
즉 굳이 team 을 함께 조회하지 않아도 되는 경우에도 team 을 함께 조인해서 가져오는 것은 굉장한 자원 낭비 그 자체! => 이런 문제를 해결하기 위해 사용되는 방식이 JPA 프록시와 지연로딩 기술이다

Member 

@Entity(name = "teamMember")
@Table
@Getter
@Setter
public class Member extends BaseEntity {

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

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

     public void addMembers(Team team){
       this.team = team;

        team.getMembers().add(this);
    }

    private String userName;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;

    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

 

2. 프록시의 기초

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

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

=> JPA 가 프록시를 통해서 가짜 클래스만들고, 이 가짜 클래스 안에 DB 에서 조회한 결과를 가져와 채워넣음

=> 여기서 Proxy 라는 이름의 가짜 클래스 생성!! target 에는 실제 Entity 의 Reference 를 가리킴

1) 프록시의 특징

- 실제 클래스를 상속 받아서 만들어짐 => 실제 클래스와 겉 모양이 동일!

- 사용하는 입장에서 진짜 엔티티 객체인지 프록시 객체인지 구분하지 않고 사용해도 무방 : 물론 이론상! 주의점 존재

- 프록시 객체는 실제 클래스의 참조 target 를 보관

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

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

- 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것이 아니고 초기화 후 프록시 객체를 통해서 실제 엔티티에 접근하는 것!!

- 프록시 객체는 원본 엔티티를 상속받음, 따라서 타입 체크시 주의해야함 => 비교 시 == 가 아닌 instance of 를 사용해야함

- 영속성 컨텍스트에 찾은 엔티티가 이미 있다면, em.getReference() 를 호출하더라도 실제 엔티티를 반환

- 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때 프록시 초기화하면 에러 뿜뿜

 

2) 프록시 객체의 동작 원리와 특징

Member findMember = em.getReference(Member.class, 2L);
System.out.println(findMember.getClass());

Member$proxy 를 확인 가능

어떻게 동작할까?

1. 서버에서 em.getReference 로 DB 조회 
2. Member 를 상속받은 프록시 객체 생성 : Member$hibernateProxy
3. member.getName 실행 
4. 영속성 컨텍스트가 member target 에 해당하는 실제 Member 에 대한 DB 조회 및 Member 엔티티 생성
5. target 에 4에서 생성된 진짜 Member 엔티티를 연결시킴 
6. target.getName() 을 조회 => 결국 member.getName() 을 조회하는것과 동일


instanceof 로 비교한 findMember
일단 영속성 컨텍스트에 올라가면 getReference 해도 영속성 컨텍스트에 있는 엔티티를 가져온다
만약 getReference 를 통해서 생성한 엔티티라면, 즉 proxy 를 통해서 생성한 엔티티라면 영속성 컨텍스트에도 남아있는 것은 getReference 를 통해 생성한 엔티티임으로 proxy 객체가 튀어나온다

 

3) 프록시 관련 메소드

사용하는 때 메서드
프록시 인스턴스 초기화 여부 확인 EntityManagerFactory.PersistenceUnitUtil.isLoaded(Object entity)
프록시 클래스 확인 entity.getClass().getName() 출력
프록시 강제 초기화 Hibernate.initialize(entity)

 

3. 그래서 왜 Lazy 와 Eager

Lazy 는 대학 과제를 미리 준비는 해두고 마감일까지 미뤄뒀다가 제출하는 마감일에 내는거고,
Eager 은 그 과목의 대학과제 뿐만 아니라 다른 과목의 과제까지 미리 다 만들어버리는 것

 

LAZY : hibernate 를 통해서 요청했을 때 실제로 데이터를 불러오는 순간까지 데이터를 가져오는 것을 미뤘다가 데이터를 불러올 때 데이터를 조회해온다 => 시간적 '지연' 이라는 느낌보다는 행동의 개념으로서 '게으르다' 의 느낌

EAGER : hibernate 를 통해 요청 했을 때 요청과 관련된 모든 데이터를 가져오는 것 => 시간적 즉시의 개념보다는 행동의 개념으로서 '열심히' 라는 느낌

 

 

4. 지연로딩 : fetch =  FetchType.LAZY

이제 첫 질문으로 되돌아가자. 그래서 Member 를 조회할때 반드시 Team 도 함께 조회해야할까?
정답은 아니오이다. JPA 는 당연히 Member 만 조회할 수 있도록 기능을 제공하고
그 방법이 앞서 설명한 프록시 객체를 이용한 지연로딩 전략이다

 

1) Member 클래스 : 지연 로딩을 할 엔티티 요소에 지연 로딩 설정

// 연관관계 매핑의 요소에 fetch 를 사용하고, 파라미터의 값으로 로딩 전략을 설정한다
@ManyToOne(fetch = FetchType.LAZY) // team 에 대한 내용을 지연로딩으로 설정
@JoinColumn(name = "TEAM_ID")
private Team team;

 

2) 테스트 코드

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        Member member = new Member();
        member.setUserName("hello");

        Team team = new Team();
        team.setTeamName("hello");

        member.addMembers(team);

        em.persist(team);
        em.persist(member);

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

        // 아래만 실행했을 때는 단순히 member 테이블의 데이터만 selelct 해서 가져온다
        // 즉 team 을 가져오기 위한 join 문이 들어가지 않음
        // 다만 외래키의 값 -> teamId 는 가져온다!!!
        Member findMember = em.find(Member.class, member.getId());
        System.out.println(findMember.getClass() + " " + findMember.getId());

        System.out.println("------------ Member 엔티티 안 Team 내용 가져오기 ----------------");
        // member 에 있는 team 객체를 조회할 때 다시 select 쿼리가 나가고
        // 이때 member 에 있는 team id 를 기준으로 team 의 데이터를 가져오게 된다
        System.out.println(findMember.getTeam().getClass()+" "+findMember.getTeam().getTeamName());

        tx.commit();

        em.close();
        emf.close();

 

3) 테스트 코드 실행 사진

아래 사진을 보면 더욱 확실하게 알 수 있는데 처음 em.find 했을 때 가져오는 것은 단순히 member 테이블만 select 해서 가져온다. 이후 team 에 관련된 값을 조회했을 때 다시 team 테이블에 대한 select 쿼리가 나가고 값을 가져온다

이때 team 엔티티는 프록시 객체에 담긴다.

 

4) 지연 로딩 동작 원리

0. fetch = FetchType.LAZY 달기
1. Member 엔티티 조회

2. team 엔티티가 LAZY 전략으로 되어있다면 team 엔티티는 프록시 객체로 만들어 둠
3. team 을 값을 실제 조회하는 시점에 DB 에 select 쿼리 실행
=> getTeam 이 아닌 getTeam().getTeamName() 할때 쿼리가 실행된다!!!

 

5. 즉시 로딩 : fetch = FetchType.EAGER

다시 첫 질문으로 되돌아가자. 그래서 Member 를 조회할때 반드시 Team 도 함께 조회해야할까?
비지니스 로직 상 꼭 Member 와 Team 을 꼭 함께 가져와야하는 경우가 있을 수 있다.
이때 사용하는 방법이 즉시 로딩 전략 - Eager Loading - 전략이다

 

1) Member 클래스 : 즉시 로딩을 할 엔티티 요소에 즉시 로딩 전략 설정

// 연관관계 매핑의 요소에 fetch 를 사용하고, 파라미터의 값으로 로딩 전략을 설정한다
@ManyToOne(fetch = FetchType.EAGER) // member 조회 시 team 을 바로 함께 가져오도록 하는 즉시 로딩 전략
@JoinColumn(name = "TEAM_ID")
private Team team;

 

2) 테스트 코드

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        Member member = new Member();
        member.setUserName("hello");

        Team team = new Team();
        team.setTeamName("hello");

        member.addMembers(team);

        em.persist(team);
        em.persist(member);

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

        // 즉시 로딩으로 설정해두었기 때문에 member 를 가져오는 순간 team 테이블을 join 해서 
        // member 와 함께 team 에 대한 내용을 가져온다
        Member findMember = em.find(Member.class, member.getId());
        System.out.println(findMember.getClass() + " " + findMember.getId());

        System.out.println("------------ Member 엔티티 안 Team 내용 가져오기 ----------------");

        System.out.println(findMember.getTeam().getClass()+" "+findMember.getTeam().getTeamName());

        tx.commit();

        em.close();
        emf.close();

 

3) 테스트 코드 실행 확인

4) 즉시 로딩 동작 원리

1. member 조회
2. DB 에서 member 조회하면서 즉시 로딩으로 적용된 Team 을 함께 가져온다 => 테이블 Join SQL 사용

 

6. 즉시 로딩과 지연 로딩 정리

미리 1줄 요약
즉시 로딩말고 지연로 로딩 사용하자

1) 즉시 로딩의 주의사항

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

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

   => 만약 즉시 로딩을 해서 가져오는 가져오는 엔티티가 10개라면.....? join 하는 테이블이 10개
   => 성능 문제로 미쳐버린다

즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
ex) List<Member> result = em.createQuery("select m from Member m", Member.class) .getResultList();
   => em.find 를 사용하면 정확하게 PK 를 찾아서 가져오기 때문에 문제가 없음
   => 근데 JPQL 은 JPQL 이 SQL 로 변환된 후 엔티티를 가져오게 됨. 이때 문제가 발생!!
   => JPQL -> SQL 변환 -> member 전체 리스트 SQL 실행 -> eager 로 설정된 team 전체 리스트 SQL 실행
   => 즉 엔티티의 모든 요소가 eager 로 되어있다면 모든 테이블에 대해서 전체 리스트릴 가져오는 SQL 이 실행되는 것
   => 사진 참고
@ManyToOne, @OneToOne은 기본이 즉시 로딩 -> LAZY로 설정

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

즉시 로딩으로 한 경우 JPQL 쿼리 내용

2) 지연 로딩 특징

1. 모든 연관관계에 지연 로딩을 사용하자! => 실무에서 즉시 로딩을 사용하면 나도 모르는 SQL 이 나간다
2. JPQL fetch 조인이나 엔티티 그래프 기능을 사용하자(추후 정리 예정)
3. 데이터 로딩 속도 향상 : 자원의 효율성과 처리의 효율성
=> 즉시 로딩으로 데이터를 한번에 가져온 후 큰 데이터가 한번에 나가게 되면 메모리 단편화가 발생하면서 문제가 생긴다!! : 자세한 내용은 링크 참조
4. 데이터 호출 시점의 분산 

 

7. 영속성 전이 : CASCADE

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

ex) 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장 할 때 사용

 

1) 예제 코드 : Parent, Child

Child

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

    private String name;

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

 

Parent

@Entity
@Getter @Setter
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

    String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> childList = new ArrayList<>();

    public void addChild(Child child) {
        child.setParent(this);
        this.getChildList().add(child);
    }
}

 

2) CASCADE 설정이 없는 경우

- CASCADE 설정을 안한 경우에는 child 를 추가할 때마다 em.persist 를 호출해서 추가한 child 를 영속성 컨텍스트로 변경해야 함

// CASCADE 가 없는 경우
// child 를 추가할 때마다 모든 child 를 em.persist 해서 영속성 컨텍스트로 만들어야 함
Child child1 = new Child();
Child child2 = new Child();

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

// 이렇게 em.persist 를 총 3번 호출해야함
em.persist(parent);
em.persist(child1);
em.persist(child2);

 

3) CASCADE 설정을 한 경우

Parent 에서 Cascade 설정

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




    public void addChild(Child child) {
        child.setParent(this);
        this.getChildList().add(child);
    }

 

테스트 코드 확인

        // CASCADE 가 있는 경우 em.persist 를 한번만 호출해도 된다!!
        Child child1 = new Child();
        Child child2 = new Child();

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

        // CASCADE 를 설정한 경우 한번만 실행해도 parent 에서 CASCADE 설정이 되어 있음으로
        // 한번만 해도 무방함
        em.persist(parent);
//        em.persist(child1);
//        em.persist(child2);

persist 를 한번만 해도 모두 저장된다!

 

4) CASCADE 사용 주의점

부모-자식 간 라이프 사이클이 거의 유사할 때
자식 엔티티의 소유자가 단일 소유자 일 때
=> 즉 자식 엔티티에 관리를 부모 엔티티에서만 담당할 때
만 사용하는게 좋다

추가적으로 알아 둘 것!

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

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

 

8. 고아 객체

- 고아 객체란 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 의미한다.
- 고아 객체 제거란 고아 객체 즉, 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제해주는 기능을 의미한다.
- orphanRemoval = true 로 설정 => 고아 객체를 찾아서 자동으로 Delete query 실행

 

1) Parent : orphanRemoval = true 설정

// orphanRemoval = true : 고아 객체 삭제 설정
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL
        ,orphanRemoval = true)
private List<Child> childList = new ArrayList<>();

 

2) 테스트 코드

// 부모 객체의 childList 에서 0 번째 요소를 삭제한다
// 고아 객체를 삭제하는 delete 가 발생
Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0);

childlist 에서 0번째 요소를 삭제했을 때 해당 객체는 부모 객체와 연결이 끊어진 고아 객체가 되고, DB 에는 delete 쿼리가 실행된다

 

3) 고아 객체 삭제 기능 orphanRemoval 사용 시 주의점

부모-자식 간 라이프 사이클이 거의 유사할 때
자식 엔티티의 소유자가 단일 소유자 일 때
=> 즉 자식 엔티티에 관리를 부모 엔티티에서만 담당할 때
만 사용하는게 좋다

 

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

- CascadeType.ALL + orphanRemovel=true
- 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거
- 두 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있음
  => 결국 자식 Repository 가 따로 필요 없음
- 도메인 주도 설계(DDD)의 Aggregate Root개념을 구현할 때 유용
  => Parent 는 Aggregate Root 가 되고 Child 는 Aggregate Root 가 관리하는 친구

 

 

10. 실전 코드로 공부하기

모든 연관관계를 지연로딩으로 변경 : @ManyToOne, @OneToOne 은 기본이 즉시 로딩이므로 지연 로딩으로 변경
Order -> Delivery 를 영속성 전이 ALL 설정, Order -> OrderItem 을 영속성 전이 ALL 설정
=>
- Order 엔티티 생성 시점에 Delivery 도 같이 생성(라이프 사이클을 일원화?) -> Order 저장되면 Delivery 도 같이 저장됨
- Order 엔티티 생성 시점에 orderItem 도 같이 생성 될 수 있도록 함 -> Order 저장되면 orderItem 도 같이 저장될 수 있도록

 

1) Order 클래스 

- order 엔티티와 orderItems 엔티티 모두 order 엔티티의 라이프 사이클과 함께 할 수 있도록 설정

// Order - delivery 관계에서 order 이 연관관계의 주인
@OneToOne(fetch = FetchType.LAZY,  cascade = CascadeType.ALL)
@JoinColumn(name = "DELIVERY_ID")
private Delivery delivery;

// 양방향 연관관계 => 현재클래스 : OrderItem = 1: N
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

 

- Reference

https://gguljaem.tistory.com/entry/%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%8B%A8%ED%8E%B8%ED%99%94Fragmentation%EC%97%90-%EB%8C%80%ED%95%9C-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95

 

메모리 단편화(Fragmentation)에 대한 해결방법

메모리 단편화에 대한 개념과 이에대한 해결방법에 대하여 알아보겠다. 메모리 단편화 RAM에서 메모리의 공간이 작은 조각으로 나뉘어져 사용가능한 메모리가 충분히 존재하지만 할당(사용)이

gguljaem.tistory.com

 

댓글