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

Updated:

프록시와 연관관계 관리

JPA에서 어려운 개념 중 하나인 프록시 및 프록시를 사용하는 즉시, 지연로딩에 대해 알아본다. 또한, CASCADE를 이용한 영속성 전이와 고아객체에 대해 살펴본다

배경지식

아래의 예시를 통해 프록시가 도입된 배경에 대해 알아보자

jpa1

  • 비즈니스 로직에 따라 Member 조회 시에 Team 정보가 필요할 수도 있고 필요없을 수도 있다
  • JPA는 프록시를 이용한 지연로딩, 즉시로딩으로 위 문제를 해결한다

1. 프록시

1-1. 개념

간단히 말하자면 실제 엔티티를 상속받은 가짜 엔티티 객체이다

우선 아래의 간단한 코드를 통해 살펴보자

Member realFindMember = em.find(Member.class, 1L);
Member proxyFindMember = em.getReference(Member.class, member.getId());
System.out.println("proxyFindMember = " + proxyFindMember.getClass());
  • em.find()는 DB로부터 실제 엔티티 객체를 조회한다
  • em.getReference()는 DB 조회를 미루고 프록시 엔티티 객체를 조회한다
    • 실제로 getClass()를 통해 출력된 것은 Member$HibernateProxy 처럼 프록시 객체이다

jpa2

  • 프록시 객체는 실제 객체를 상속받아서 만들어지므로 실제 클래스와 형태가 같다
  • 프록시 객체는 실제 객체의 참조값을 가진다
    • 프록시 객체의 메서드 호출 시에 참조값을 통해 실제 객체에 접근하여 메서드를 호출한다
    • 단, 초기화 전까지 참조값은 null이다
Member proxyFindMember = em.getReference(Member.class, member.getId());	//프록시 객체 조회

proxyFindMember.getName();	//프록시 객체와 실제 엔티티 연결

jpa3

  • 위 코드에서 getReference()를 통해 가져온 프록시 객체에는 target 값에 null이 들어있다
  • 프록시 객체에서 메서드 호출 시에 영속성 컨텍스트로 초기화 요청을 하게 된다. 영속성 컨텍스트는 DB에 접근하여 실제 엔티티를 가져와 프록시 객체의 target값에 넣어준다

1-2. 특징

  • 프록시 객체는 처음 초기화된 이후 다시는 초기화되지 않는다

  • 초기화 시에 프록시 객체가 실제 엔티티로 바뀌는 것이 아니라 참조값에 실제 엔티티 값이 들어가는 것이다

    • 따라서 타입 체크시에 == 비교 대신 instance of를 사용해야 한다
  • JPA는 동일한 PK값을 통해 조회한 객체는 == 연산자를 통해 비교했을 때 반드시 true 값이 나오도록 만든다

    Member reference = em.getReference(Member.class, member.getId());
            System.out.println("reference = " + reference.getClass());
      
            Member findMember = em.find(Member.class, member.getId());
            System.out.println("findMember = " + findMember.getClass());
            System.out.println("reference == findMember: " + (reference == findMember));	//true
    
    • 위 코드에서 em.getReference()는 프록시 객체를 반환하는 것이 당연하다
    • em.find()를 통해서 실제 엔티티 객체를 반환한다고 생각하기 때문에 2개의 객체를 비교했을때 false 값이 나올 거라 예상하는 것이 일반적이다
    • 하지만, JPA는 동일한 PK로 조회한 객체는 == 비교 시에 항상 true값이 나오게 만들기 때문에 em.find()는 프록시 객체를 반환하도록 한다
  • 위와 동일한 이유로 조회하는 엔티티가 영속성 컨텍스트(1차 캐시)에 이미 존재하면 em.getReference()는 프록시가 아닌 실제 엔티티 객체를 반환한다
  • 영속성 컨텍스트의 지원을 받지 않는 준영속 상태에서는 프록시 초기화 시에 문제가 발생한다
    • org.hibernate.LazyInitializationException 예외가 발생한다
    • 실무에서 정말 많이 발생하는 에러이니 알아두자!!!

2. 즉시 로딩과 지연 로딩

2-1. 지연 로딩

Member 조회 시에 Team까지 조회되는 것을 방지하고 싶다면 아래와 같이 Member 엔티티 코드를 수정하면 된다

@Entity
@Getter @Setter
public class Member {
  	...

    @ManyToOne(fetch = FetchType.LAZY)	//수정부분
    @JoinColumn(name = "team_id")
    private Team team;

    ...
}

이제 테스트 코드를 살펴보자

@Test
@Rollback(value = false)
@Transactional
void proxyTest() {
    Team team = new Team();
    team.setName("teamA");
    em.persist(team);

    Member member = new Member();
    member.setName("lim");
    member.setTeam(team);
    em.persist(member);

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

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

}
  • Member의 Team 필드값을 지연 로딩으로 설정했으므로 em.find()로 Member 조회 시에 Team은 조회하지 않는다
    • 실제로 출력을 보면 조인을 사용한 Team 조회를 수행하지 않는다
  • Team 엔티티는 Member 조회 시에 프록시로 가져온 후 실제 Team 엔티티 사용 시점에 초기화하여 사용한다
    • Team 엔티티를 가져오는 시점이 아닌 Team 내부의 무언가를 실제로 사용할 때 초기화한다
    • 실제로 출력을 보면 findMember.getTeam().getName() 호출 시에 영속성 컨텍스트가 DB에 실제 엔티티를 조회하는 SQL문이 수행된다

2-2. 즉시 로딩

Member 조회 시에 Team까지 한번에 조회하고 싶다면 아래와 같이 Member 엔티티 코드를 수정하면 된다

@Entity
@Getter @Setter
public class Member {
  	...

    @ManyToOne(fetch = FetchType.EAGER)	//수정부분
    @JoinColumn(name = "team_id")
    private Team team;

    ...
}
  • Member의 Team 필드값을 즉시 로딩으로 설정했으므로 em.find()로 Member 조회 시에 조인하여 Team까지 가져온다
  • 프록시를 사용하지 않고 Member 조회 시에 Team까지 실제 엔티티로 가져온다

2-3. 실무상 주의점

  • 모든 연관관계에서 지연 로딩을 사용하자!!!
  • 즉시 로딩은 JPQL에서 N + 1 문제를 유발한다
    • 해결책(지연 로딩 사용을 전제)
      1. JPQL fetch 조인
      2. 엔티티 그래프
  • @ManyToOne, @OneToOne은 default가 즉시 로딩이므로 지연 로딩으로 바꿔주자!!!
  • @OneToMany, @ManyToMany는 default가 지연 로딩이다

3. 영속성 전이: CASCADE

앞의 프록시나 즉시 로딩, 지연 로딩과는 관계없는 부분이다. 특정 엔티티를 영속 상태로 만드는 상황에서 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 사용한다

3-1. 개념

실제 코드를 통해 알아보자. 우선 Parent와 Child 엔티티 코드를 살펴보자

@Entity
@Getter @Setter
public class Parent {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> childList = new ArrayList<>();
}
@Entity
@Getter @Setter
public class Child {

    @Id @GeneratedValue
    private Long id;

    private String name;

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

    public void setParent(Parent parent) {
        this.parent = parent;
        parent.getChildList().add(this);
    }
}
  • Parent와 Child가 일대다 관계를 가진다
    • 일대다 관계에서 다쪽인 Child가 연관관계의 주인이다
  • 연관관계 편의 메서드를 이용하여 Parent와 Child 객체 모두 참조값을 넣어주었다
  • 추가적으로, CascadeType.ALL 옵션을 통해 Parent를 영속 상태로 만들면 자동으로 Child 또한 영속 상태가 되도록 설정하였다

다음은 테스트 코드이다

@Test
@Transactional
@Rollback(value = false)
void cascadeTest() {
    Parent parent = new Parent();

    Child child1 = new Child();
    Child child2 = new Child();
    child1.setParent(parent);
    child2.setParent(parent);

    em.persist(parent);
}
  • CascadeType.ALL 옵션을 사용한다면, 위와 같이 em.persist()를 통해 Parent만 영속 상태로 만들어주면 자동으로 Child도 em.persist()가 수행되어 영속 상태가 된다
    • em.persist(parent)만으로 em.persist(child1), em.persist(child2) 모두 수행한 결과를 만들어낼 수 있다

3-2. 종류

종류는 많지만 실제로는 아래 2가지를 주로 사용한다

  • ALL: 모든 LifeCycle에 적용
  • PERSIST: 저장 시에만 적용

3-3. 주의점

  • CASCADE를 통해 자동으로 영속 상태로 만들고자 하는 엔티티가 다른 엔티티 하나랑만 연관관계에 있을 경우에만 사용하는 것이 좋다
    • CASCADE를 적용할 엔티티가 중요한 도메인이어서 다른 엔티티들과 많은 연관관계를 맺고 있을 경우에는 사용하지 않는 것이 좋다

4. 고아 객체

부모 엔티티와 연관관계가 끊어진 자식 엔티티는 자동으로 삭제해주는 기능을 제공한다

4-1. 개념

아래 코드를 통해 살펴보자

@Entity
@Getter @Setter
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<>();
}
  • Child 엔티티는 변화가 없고 Parent 엔티티에 orphanRemoval = true 속성을 추가하였다

다음은 테스트 코드이다

@Test
@Transactional
@Rollback(value = false)
void cascadeTest() {
    Parent parent = new Parent();

    Child child1 = new Child();
    Child child2 = new Child();
    child1.setParent(parent);
    child2.setParent(parent);

    em.persist(parent);

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

    Parent findParent = em.find(Parent.class, parent.getId());
    findParent.getChildList().remove(0);
}
  • 맨 마지막줄 코드를 통해 Parent 엔티티와 연관관계가 끊었다
    • Parent 엔티티와 연관관계가 끊어진 Child 엔티티는 자동으로 DELETE SQL문이 호출되어 삭제된다

4-2. 주의점

  • CASCADE와 마찬가지로 해당 엔티티를 참조하는 곳이 하나일 때만 사용하는 것이 좋다
  • cascade = CascadeType.ALL 옵션과 orphanRemoval = true 옵션을 함께 사용하면 부모 엔티티를 통해 자식의 LifeCycle을 관리할 수 있다

Tags: ,

Categories:

Updated:

Leave a comment