본문 바로가기
Programming/JPA 프로그래밍

08. 프록시와 연관관계 관리

by JKROH 2023. 12. 5.
반응형

프록시

  • 엔티티를 조회할 때 연관 엔티티들이 항상 사용되는 것은 아니다.
  • 예를 들어, 회원 엔티티와 연관된 바버샵 엔티티는 사용될 때도 있지만 그렇지 않을 때도 있다.
    • 같은 회원 엔티티지만 바버는 연관된 바버샵 엔티티를 사용해 근무 중인 바버샵을 표시한다.
    • 그러나 고객은 바버샵 엔티티와 연관관계 설정이 되어있지 않기 때문에 제공하지 않는다.
public class Member extends BaseEntity {

    ...
    
    @ManyToOne
    @JoinColumn(name = "WORK_PLACE_ID")
    private BarberShop workPlace;
  • 그런데 매번 회원 엔티티를 조회할 때마다 바버샵 테이블과 조인하는 것은 효율적이지 않다.
  • JPA에서는 이런 문제를 해결하기 위해 엔티티가 실제로 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이를 지연 로딩이라 한다.
  • 지연 로딩은 실제로 연관된 엔티티를 사용할 시점에 데이터베이스에서 해당 데이터를 조회하는 것이다.
  • 그런데 지연 로딩을 사용하려면 실제 엔티티 객체 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요하다. 이를 프록시 객체라 한다.

- 프록시 기초

  • JPA에서 식별자를 이용해 엔티티 하나를 조회할 대는 EntityManager#find()메서드를 사용한다. 이렇게 엔티티를 직접 조회하면 조회한 엔티티를 사용하지 않더라도 데이터베이스를 조회한다.
  • 엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶다면 EntityManager#getReference() 메서드를 사용하면 된다.
  • 해당 메서드를 사용하면 데이터 베이스 접근을 위임한 프록시 객체를 반환한다.
  • 프록시의 특징
    • 프록시는 실제 객체를 상속받아 만들어지므로 실제 객체와 겉모습이 같다. 사용자 입장에서는 구분 없이 사용할 수 있다.
    • 프록시 객체는 실제 객체에 대한 참조를 보관한다. 프록시 객체의 메서드를 호출하면 프록시 객체는 실제 객체의 메서드를 호출한다.
    • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
    • 프록시 객체를 초기화한다고 프록시 객체가 실제 객체로 대체되는 것은 아니다. 내장된 타겟 객체에 실제 객체를 저장한다.
    • 원본 엔티티를 상속받은 객체이기에 타입 체크 시 유의해야 한다.
    • 영속성 컨텍스트에 실제 엔티티가 이미 있다면 굳이 데이터베이스를 조회할 필요가 없다. 따라서 getReference()를 호출해도 실제 객체를 반환한다.
    • 프록시 객체 초기화를 위해서는 영속성 컨텍스트를 통해 실제 객체를 찾아야한다. 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태의 프록시를 초기화하면 문제가 발생한다.
  • 프록시 객체의 초기화 : 프록시 객체는 참조하는 실제 객체가 사용될 때 데이터베이스를 조회해 실제 엔티티 객체를 생성한다. 이를 프록시 객체 초기화라 한다.
    • 프록시 객체에 proxy.getName()을 호출해 실제 데이터를 조회한다.
    • 프록시 객체는 실제 엔티티가 생성되어있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청한다(초기화)
    • 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
    • 프록시 객체는 생성된 실제 엔티티 객체의 참조를 멤버변수에 저장한다.
    • 저장한 실제 엔티티 객체의 getName()을 호출하고 결과를 반환한다.
class MemberProxy extends Member {
    
    Member target = null; // 실제 엔티티 참조
    
    public String getName() {
        if(target == null) {
            
            // 초기화 요청
            // DB 조회
            // 실제 엔티티 생성 및 참조 보관
            
            this.target = ...;
        }
        
        return target.getName();
    }
}
  • 준영속 상태의 초기화
    • 영속성 컨텍스트를 종료한 프록시 객체에 대해 초기화를 시도하면 LazyInitializationException이 발생한다.

- 프록시와 식별자

  • 엔티티를 프록시로 조회할 때는 식별자 값을 전달한다. 프록시는 이 식별자 값을 저장한다.
  • 프록시는 식별자 값을 가지고 있다. 따라서, 실제 객체의 식별자 값을 요구하는 메서드를 호출해도 실제 객체를 생성하지 않는다.
    • 단 엔티티 접근 방식을 프로퍼티로 설정한 경우에 한한다.
    • 엔티티 접근 방식을 필드로 정하면 JPA는 getId()가 id만 조회하는 메서드인지, 다른 필드까지 활용하는지 알 수 없다. 따라서 프록시 객체를 초기화한다.
    • 이러한 특징을 사용해 연관관계를 설정할 때 매우 용이하다. 연관관계를 설정할 때는 식별자 값만 사용하기 때문이다.

- 프록시 확인

  • PersistenceUnitUil#isLoaded(Object entity)메서드를 사용하면 프록시 인스턴스의 초기화 여부를 알 수 있다.
  • 초기화 되었거나 프록시가 아니라면 true, 그렇지 않다면 false를 반환한다.

즉시 로딩과 지연 로딩

  • 프록시 객체는 주로 연관된 엔티티를 지연로딩할 때 사용한다.
  • 즉시 로딩(EAGER LOADING) : 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
  • 지연 로딩(LAZY LOADING) : 연관된 엔티티를 실제로 사용할 때 조회한다.

- 즉시 로딩

  • 즉시 로딩을 사용하고 싶다면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 설정하면 된다.
public class Member extends BaseEntity {

    ...

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "WORK_PLACE_ID")
    private BarberShop workPlace;
  • 이렇게 설정하면, Member객체를 find()할 때 연관된 바버샵 객체도 조회한다.
  • 대부분의 JPA 구현체는 조회가 두 번 일어나는 것을 방지하기 위해 가능하면 조인 쿼리를 사용한다.
  • 이 때, 연관 테이블의 외래 키에 null을 허용하면 외부 조인을, null을 허용하지 않으면 성능 향상을 위해 내부 조인을 사용한다.

- 지연 로딩

  • 즉시 로딩을 사용하고 싶다면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 설정하면 된다.
public class Member extends BaseEntity {

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "WORK_PLACE_ID")
    private BarberShop workPlace;
  • 이렇게 설정하면, Member 객체를 find()할 때, 연관된 바버샵 객체는 프록시 객체로 대체된다.
  • 연관된 바버샵 프록시 객체를 사용할 때 데이터 로딩을 지연한다. 따라서 지연 로딩인 것이다.
  • 만일 조회 대상이 영속성 컨텍스트에 있으면 당연히 실제 객체를 넣어준다.

- 즉시 로딩, 지연 로딩 정리

  • 처음부터 모든 엔티티를 영속성 컨텍스트에 올리는 것도 별로고, 그렇다고 필요할 때마다 SQL을 매번 실행하는 것도 최적화 면에서 썩 좋지는 않다.
  • 따라서 즉시 로딩이 좋을지, 지연 로딩이 좋을지를 판단해서 상황에 맞게 사용하는 것이 좋다.
  • 일반적으로는 지연 로딩으로 설정하고, 로직 상 자주 사용되는 연관 객체라면 즉시 로딩으로 바꿔주는 편이 낫다.

지연 로딩 활용

  • Member와 연관된 BarberShop은 자주 사용되지 않는다. 따라서 지연 로딩을 사용한다.
  • 반면 Review는 자주 사용된다. 따라서 즉시 로딩을 사용한다.
public class Member extends BaseEntity {

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "WORK_PLACE_ID")
    private BarberShop workPlace;
    
    @OneToMany(mappedBy = "writer", fetch = FetchType.EAGER)
    private List<Review> reviews = new ArrayList<>();
  • 회원을 조회할 때, 바버샵을 조회하는 SQL은 실행되지 않는다. 하지만 하이버네이트는 조인 쿼리를 사용해 리뷰는 같이 제공한다.

- 프록시와 컬렉션 래퍼

  • 만약, 바버샵과 일대다로 연관된 List<Schedule> 정보를 지연 로딩한다고 생각해보자.
  • 스케쥴 내역을 조회하는 barberShop.getSchedules() 메서드를 호출하면 어떤 값을 반환할까?
  • 하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 이를 컬렉션 래퍼라 한다.
  • 위의 getSchedules()은 PersistentBag 클래스를 반환한다.
  • 즉, getSchedules()을 호출한다고 컬렉션을 초기화하지 않는 것이다. 해당 컬렉션은 getSchedules().get(0)와 같이 컬렉션에서 실제 데이터를 조회할 때 데이터베이스를 조회하고 초기화된다.

- JPA 기본 fetch전략

  • @ManyToOne, @OneToOne : 즉시 로딩
  • @OneToMany, @ManyToMany : 지연 로딩
  • 기본적으로 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연 로딩을 사용한다. 컬렉션 로딩에 더 많은 비용이 발생하기 때문이다.
  • 일단 모든 연관관계에 지연 로딩을 사용하도록 설정하고, 필요하면 즉시 로딩으로 전환하자.

- 컬렉션에 즉시 로딩 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하지 말자. N 크기의 컬렉션과 M 크기의 컬렉션을 동시에 즉시 로딩하면 N * M만큼의 비용이 발생한다.
  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다. 만약 컬렉션이 비어있다면 내부 조인이 불가능하기 때문이다.

영속성 전이 : CASCADE

  • 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있다.
  • JPA에서 엔티티를 저장하려면 연관된 모든 엔티티가 영속 상태여야한다. 따라서 부모 엔티티를 먼저 저장한 상태에서 자식 엔티티를 저장할 수 있다.
class Parent { 
   
   @OneToMany(mappedBy = "parent")
   List<Child> children = new ArrayList<>();
}


class Child {
    
    @ManyToOne
    @JoinColumn(name = "PARENT_ID)
    Parent parent;
}

//////////

Parent parent = new Parent();
em.persist(parent);

Child child = new Child();
child.setParent(parent);
parent.addChild(child);
em.persist(child);

 

- 영속성 전이 : 저장

class Parent { 
   
   @OneToMany(mappedBy = "parent", cascade = "CascadeType.PERSIST)
   List<Child> children = new ArrayList<>();
}


class Child {
    
    @ManyToOne
    @JoinColumn(name = "PARENT_ID)
    Parent parent;
}

//////////

Parent parent = new Parent();
Child child = new Child();
child.setParent(parent);
parent.addChild(child);

em.persist(parent);

 

  • 영속성 전이를 활성화하는 CASCADE 옵션을 적용했다.
  • 해당 옵션을 통해 부모를 영속화 할 때 자식도 함께 영속화된다.
    • em.persist(child)를 통해 자식 객체를 별도로 영속화하지 않았다.

- 영속성 전이 : 삭제

  • 기본적으로는 연관객체를 삭제하고 싶다면 자식 객체를 모두 삭제하고 부모 객체를 삭제해야한다. 그렇지 않으면 무결성 예외가 발생한다.
em.remove(child1);
em.remove(child2);
em.remove(parent);
  • 그러나 삭제의 영속성을 설정하면 부모 객체를 삭제하면 연관된 자식 객체는 모두 삭제된다.
class Parent { 
   
   @OneToMany(mappedBy = "parent", cascade = "CascadeType.REMOVE)
   List<Child> children = new ArrayList<>();
}


class Child {
    
    @ManyToOne
    @JoinColumn(name = "PARENT_ID)
    Parent parent;
}

//////////

Parent parent = em.find(Parent.class, 1L);

em.remove(parent);

 

- CASCADE의 종류

  • ALL : 모두 적용
  • PERSIST : 저장
  • MERGE : 병합
  • REMOVE : 삭제
  • REFRESH : refresh
  • DETACH : detach
  • 여러 개를 조합해서 사용할 수 있다.
    • cascade = {CascadeType.PERSIST, CascadeType.REMOVE}

고아 객체

  • JPA는 부모 객체와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공한다. 이를 고아 객체 제거라 한다.
  • 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.
class Parent { 
   
   @OneToMany(mappedBy = "parent", orphanRemoval = true)
   List<Child> children = new ArrayList<>();
}


class Child {
    
    @ManyToOne
    @JoinColumn(name = "PARENT_ID)
    Parent parent;
}

//////////

Parent parent = em.find(Parent.class, 1L);

parent.getChildren().remove(0); // 0 번째 child객체 삭제 (DELETE FROM CHILD WHERE ID = ?)

 

  • 고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.
  • 따라서 해당 기능은 참조하는 곳이 하나일 때만 사용해야 한다. 내 자식을 남이 죽이는 건 맞지 않다.
  • 즉, 특정 엔티티가 개인 소유면 가능하다.
  • 개념적으로 부모가 사라지면 자식은 고아가 된다. 따라서 CascadeType.REMOVE를 설정한 것과 같다.

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

  • CascadeType.ALL + orphanRemoval = true를 함께 설정하면, 부모 엔티티를 통해 자식의 생명주기를 관리할 수 있다.
  • 부모에 자식을 넣어주면 자식도 저장되고, 부모를 삭제하면 자식도 삭제된다.
반응형

'Programming > JPA 프로그래밍' 카테고리의 다른 글

07. 고급 매핑  (0) 2023.11.13
06. 다양한 연관관계 매핑  (1) 2023.11.06
05. 연관관계 매핑 기초  (0) 2023.10.12
03. 영속성 관리  (0) 2023.08.18
01. JPA 소개  (0) 2023.08.15

댓글