본문 바로가기
JPA

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

by 케로베로 2020. 9. 22.

프록시

 엔티티를 조회할 때 엔티티들이 항상 사용되는 것은 아니다. 예를 들어 회원 엔티티를 조회하여 정보를 출력할 때 팀 엔티티는 전혀 사용하지 않으므로 회원과 연관된 팀 엔티티까지 데이터베이스에서 함께 조회해 두는 것은 효율적이지 않다. JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연로딩이라고 한다.  지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 한다.

 

엔티티를 조회할 때는 find()를 사용한다. 하지만 엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 된다. 이 메소드를 호출할 때 JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체도 생성하지 않는다. 대신에 데이터베이스 접근을 위임한 프록시 객체를 반환한다.

 

프록시 클래스는 실제 클래스를 상속 받아서 만들어 지므로 겉 모양이 같다. 따라서 사용하는 입장에서 진짜 객체와 프록시 객체를 구분할 필요가 없다 프록시 객체는 실제 객체에 대한 참조(Target)를 보관한다. 그리고 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

 

// 프록시 클래스 예상 코드
class MemberProxy extends Member {
    Member target = null; // 실제 엔티티 참조
    
    public String getName(){
        if(target == null){
            // 초기화 요청
            // DB 조회
            // 실제 엔티티 생성 및 참조 보관
            this.target = ...;
        }
        return target.getName();
    }
}        

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

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

프록시의 특징은 다음과 같다.

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

 엔티티를 프록시로 조회할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다. 프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 getId()를 호출해도 프록시를 초기화하지 않는다. 단 엔티티 접근 방식을 프로퍼티(@Access(AccessType.PROPERTY))로 설정한 경우에만 초기화하지 않는다. 엔티티 접근 방식을 필드(@Access(AccessType.FIELD))로 설정하면 JPA는 getId() 메소드가 id만 조회하는 메소드인지 다른 필드까지 활용해서 어떤 일을 하는 메소드인지 알지 못하므로 프록시 객체를 초기화한다. 

 

 프록시는 아래 코드처럼 연관관계를 설정할 때 유용하게 사용할 수 있다. 연관관계를 설정할 때는 식별자 값만 사용하므로 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있다. 참고로 연관관계를 설정할 때는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않는다.

Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1");
member.setTeam(team);

 

PersistenceUnitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인 할 수 있다. 아직 초기화되지 않았다면 false를 반환하고, 초기화되었거나 프록시 인스턴스가 아니라면 true를 반환한다. 조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지 확인하려면 클래스명을 출력해보고 javasist라 되어있다면 프록시인 것을 확인할 수 있다. initialize() 메소드를 사용하면 프록시를 강제로 초기화 할 수 있다.

 

즉시 로딩과 지연 로딩

프록시 객체는 주로 연관된 엔티티를 지연로딩할 때 사용한다. 지연 로딩이란 연관된 엔티티를 실제 사용할 때 조회하는 방법이다. @ManyToOne(fetch = FetchType.LAZY)와 같이 설정할 수 있다. 그 반대로 즉시 로딩이란 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 방법이며 설정 방법은 @ManyToOne(fetch = FetchType.EAGER)와 같다.

 

즉시 로딩

 회원과 팀을 즉시로딩으로 설정했다고 한다면 find()로 회원을 조회하는 순간 연관된 팀도 함께 조회한다. 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용해서 1번만 조회한다. 이 후 getTeam()을 호출하게 되면 이미 로딩된 팀의 엔티티를 반환한다.

 

 지연 로딩

회원과 팀을 지연로딩으로 설정했다고 한다면 find()로 회원을 조회했을 때 회원만 조회하고 팀은 조회하지 않는다. 대신에 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둔다. 이 후 member.getTeam()을 호출할 때 반환된 팀 객체는 프록시 객체이다. 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룬다. team.getName()과 같은 함수를 호출했을 때 그제서야 데이터 베이스를 조회해서 프록시를 초기화한다.

 

 보통 애플리케이션 로직에서 연관된 엔티티를 같이 사용한다면 SQL 조인을 사용해서 두 엔티티를 한번에 조회하는 것이 더 효율적이며, 그렇지 않다면 지연로딩이 효율적이다. 결국 두 방법 중 뭐가 더 좋은지는 상황에 따라 다르다.

 

JPA 기본 페치 전략

fetch 속성의 기본 설정값은 다음과 같다.

  • @ManyToOne, @OneToOne: 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany: 지연 로딩(FetchTtype.LAZY)

JPA의 기본 페치 전략은 연관 엔티티가 하나일 때 즉시로딩, 컬렉션이라면 지연 로딩 사용이다. 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것이다. 그리고 어플레케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.

 

영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이 기능을 사용하면 된다. JPA는 CASCADE 옵션으로 영속성 전이를 제공한다. 쉽게 말해서 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있다.

 

영속성 전이: 저장

 부모를 영속화할 때 연관된 자식들도 함께 영속화하라고 옵션 설정한다면 부모와 자식 엔티티를 한 번에 영속화 할 수 있다. 부모만 영속화하면 자식 엔티티까지 함께 영속화해서 저장한다. 영속성 전이는 연관관계를 매핑하는 것과는 아무 관련이 없고, 단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐이다. 

// 영속성 전이 활성화 옵션 적용
@Entity
public class Parent {
    ...
    @OneToMany(mappedBy = "parent", casecade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList<Child>();
    ...
}

// CASECADE 저장 코드
private static void saveWhithCasecade(EntityManager em) {
    Child child1 = new Child();
    Child child2 = new Child();
    
    Parent parent = new Parent();
    child1.setParent(parent); // 편의함수로 구현 
    child2.setParent(parent); // (parent.children에도 추가)
    
    //부모 저장, 연관된 자식들까지 저장
    em.persist(parent);
}

 

영속성 전이: 삭제

영속성 전이는 엔티티를 삭제할 때도 사용할 수 있다. CascadeType.REMOVE로 설정하고 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제 된다. 삭제 순서는 외래 키 제약조건을 고려해서 자식을 먼저 삭제하고 부모를 삭제한다.

Parent findParent = em.find(Parent.class, 1L);
em.remove(findParent); // cascade에 REMOVE 옵션을 줬으므로 자식도 같이 삭제

 

CascadeType 코드를 보면 다양한 옵션이 잇는 것을 확인할 수 있다. 

// CascadeType 코드
public enum CascadeType {
    All,	//모두 적용
    PERSIST,	//영속
    MERGE,	//병합
    REMOVE,	//삭제
    REFRESH,	//REFRESH
    DETACH	//DETACH
}

caseCade = {CascadeType.PERSIST, CascadeType.REMOVE} 와 같이 여러 속성을 같이 사용할 수도 있으며 PERSIST와 REMOVE를 함께 사용한다면 영속화나 삭제를 실행할 때 바로 전이가 발생하지 않고 플러시를 호출할 때 전이가 발생한다.

 

고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체(ORPHAN) 제거라 한다. 고아 객체 제거 기능을 활성화하기 위해 컬렉션에는 orphanRemoval = true를 설정해야 한다. 그렇다면 컬렉션에서 제거한 엔티티는 자동으로 삭제된다.

//고아 객체 제거 기능 설정
@Entity
public class Parent {
    @Id @GenereatedValue
    private Long id;
    
    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children  = new ArrayList<Child>();
    ...
}

//자식 엔티티를 컬렉션에서 제거
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remover(0);

위 예제에서 orphanRemoval = true 옵션으로 인해 컬렉션에서 엔티티를 제거하면 데이터베이스의 데이터도 삭제된다. 고아 객체 제거 기능은 영속성 컨텍스트를 플러시할 때 적용되므로 플러시 시점에 DELETE SQL이 실행된다. 고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다. 따라서 이 기능은 참조하는 곳이 하나일 때만 사용해야한다. 쉽게 이야기해서 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 적용해야 한다. 만약 삭제한 엔티티를 다른 곳에서도 팜조한다면 문제가 발생할 수 있다. 이런 이유로 orphanRemoval은 @OneToOne, @OneToMany에만 사용할 수 있다.

 

고아 객체 제거에는 기능이 하나 더 있는데 개념적으로 볼때 부모를 제거하면 자식은 고아가 된다. 따라서 부모를 제거하면 자식도 같이 제거된다. 이것은 cascadeType.REMOVE를 설정한 것과 같다. 

'JPA' 카테고리의 다른 글

[JPA] 객체지향 쿼리 언어(1) : JPQL  (0) 2020.09.30
[JPA] 값 타입  (0) 2020.09.23
[JPA] 고급 매핑  (0) 2020.09.15
[JPA] 연관관계 매핑  (0) 2020.09.10
[JPA] 엔티티 매핑  (0) 2020.09.04