JPA 프록시와 연관관계 관리

프록시

  • 지연 로딩 : 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 방법
  • 프록시 객체 : 실제 엔티티 객체 대신 DB 조회를 지연하는 가짜 객체로 사용

프록시 특징

  • 실제 클래스를 상속 받아서 만들어지므로 겉 모양이 동일하다.
  • 사용하는 입장에서는 프록시 객체 여부를 구분하지 않고 사용한다.
  • 프록시 객체는 실제 객체에 대한 참조를 보관하고 있다. 그래서 프록시 객체의 메서드를 호출하면 실제 객체의 메서드가 호출된다.

프록시 객체의 초기화

  • em.find() : DB를 통해 실제 엔티티 객체 조회한다.
  • em.getReference() : DB를 조회하지 않고 객체도 생성하지 않는다. 프록시 객체를 반환한다.
Member member = em.getReference(Member.class, "id");
member.getName();

프록시 핵심

  • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
  • 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화 후 프록시 객체를 통해 실제 엔티티에 접근하는 것이다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입체크시 주의해서 사용해야 한다.
  • 영속성 컨텍스트에 찾고자 하는 엔티티가 존재하면 em.getReference()를 호출해도 실제 엔티티가 반환된다.
  • 초기화는 영속성 컨텍스트의 도움을 받아야 한다. 그래서 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하게 되면 문제가 발생한다. 하이버네이트의 예로 org.hibernate.LazyInitializationException을 던진다.

프록시와 식별자

  • 프록시 객체는 식별자(PK) 값을 보관한다.
  • 엔티티 접근 방식을 프로퍼티(@Access(AccessType.PROPERTY))로 설정한 경우는 프록시 객체가 초기화되지 않는다. 필드(@Access(AccessType.FIELD))로 설정해야 초기화된다.
  • 연관관계를 설정할 때는 엔티티 접근방식을 필드(@Access(AccessType.FIELD))로 설정해도 프록시 객체가 초기화되지 않는다.
  • 프록시를 사용하면 DB 접근 횟수를 줄일 수 있다.

프록시 확인

PersistenceUnitUtil.isLoaded(Object entity) 메서드를 통해 프록시 초기화 여부를 확인할 수 있다.

boolean isLoad = em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(entity);

클래스명을 직접 출력하여 조회한 엔티티가 진짜 엔티티인지 프록시인지 확인할 수 있다. 조회 결과가 ... javasist or HibernateProxy ...(프록시를 생성하는 라이브러리에 따라 다름)로 출력된다면 프록시 객체로 판단하면 된다.

System.out.println(entity.getClass().getName());

하이버네이트 구현체를 사용할 경우 initialize()를 사용하면 프록시를 강제로 초기화할 수 있다.

즉시 로딩과 지연 로딩

  • 프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용한다.
  • JPA는 연관된 엔티티의 조회 시점을 선택할 수 있는 즉시 로딩, 지연 로딩을 제공한다.

즉시 로딩

@Entity
public class Member {
    ...

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    ...
}

Member 엔티티를 조회할 때 Team 엔티티도 함께 조회된다. 이 때 대부분의 JPA 구현체들은 즉시 로딩을 최적화하기 위해 조인 쿼리를 사용한다.

즉시 로딩은 가급적 사용하지 않는 것이 좋다. 즉시 로딩을 적용하면 예상하지 못한 SQL(N+1문제)이 발생한다. @ManyToOne, @OneToOne은 기본 전략이 즉시 로딩이므로 LAZY로 설정해줘야 한다. (@OneToOne, @ManyToMany는 기본 전략이 지연 로딩)

NULL 제약조건과 JPA 조인 전략

외부 조인보다 내부 조인이 성능과 최적화에서 유리하다. 외래키에 NOT NULL 제약 조건을 설정하면 내부 조인만 사용

@JoinColumn(nullable = true); // NULL 허용(기본값), 외부 조인 사용

@JoinColumn(nullable = false); // NULL 허용 안함, 내부 조인 사용

@ManyToOne(fetch = FetchType.EAGER, optional = false); // NULL 허용 안함, 내부 조인 사용

지연 로딩

지연 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정해야 한다.

@Entity
public class Member {
    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    ...
}

em.find(Member.class, "member") 가 호출됐을 때, Team은 프록시 객체로 대체된다. 실제로 Member 엔티티에서 Team을 호출할 때 DB에서 조회해온다. 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시가 아닌 실제 객체를 사용한다.

대부분의 상황에서 가급적이면 지연 로딩을 사용하는 것이 좋다.

지연 로딩 활용

프록시와 컬렉션 래퍼

  • 컬렉션 래퍼 : 하이버네이트가 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있는 경우, 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는 것.
  • 지연 로딩에서 엔티티는 프록시 객체를 사용, 컬렉션은 컬렉션 래퍼(프록시 역할)을 사용.

JPA 기본 패치 전략

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

JPA 기본 패치 전략은 연관된 엔티티가 하나면 즉시 로딩, 컬렉션이면 지연 로딩을 사용한다. 모든 연관관계에 지연 로딩을 사용하는 것이 좋다.

컬렉션에 FetchType.EAGER 사용시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 피하자. 컬렉션의 조인은 테이블로 보면 일대다 조인이다. 일대다 조인은 결과 데이터가 N쪽에 있는 수만큼 증가한다. A, B 두 테이블을 조인할 경우 A*B 개의 데이터가 반환된다.

  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용하자. 일대다 관계에서 내부조인을 사용하면 데이터가 조회되지 않을 수 있다.

애너테이션 속성 설명
@ManyToOne, @OneToOne (fetch = FetchType.EAGER, optional = false) 내부조인
@ManyToOne, @OneToOne (fetch = FetchType.EAGER, optional = true) 외부조인
@OneToMany, @ManyToMany (fetch = FetchType.EAGER, optional = false) 외부조인
@OneToMany, @ManyToMany (fetch = FetchType.EAGER, optional = true) 외부조인

영속성 전이 CASCADE

  • 특정 엔티티를 영속 상태로 만들 때, 연관된 엔티티도 함께 영속 상태로 만들기 위해 사용한다.
  • 영속성 전이는 연관관계 매핑과는 아무 관련이 없다. 엔티티를 영속화할 때 연관 엔티티도 같이 영속화하는 편리함을 제공한다.
@Entity
public class Parent {
    ...

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

    ...
}

Child child = new Child();

Parent parent = new Parent();
chil.setParent(parent);
parent.getChildren().add(child);

// 부모, 연관된 자식 저장
em.persist(parent);

CascadeType 종류

속성 설명
ALL 모두 적용
PERSIST 영속
MERGE 병합
REMOVE 삭제
REFRESH Refresh
DETACH Detach

PERSIST, REMOVE 는 em.persist()em.remove()가 실행될 때 바로 전이가 일어나지 않고, 영속성 컨텍스트를 플러시 할 때 전이가 발생한다.

고아 객체

  • 고아 객체란 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 뜻한다.
  • JPA는 고아 객체를 자동으로 삭제하는 기능을 제공한다. 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.
@Entity
public class Parent {
    ...

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<>();

    ...
}

Parent parent = em.find(Parent.class, id);
parent.getChildren().remove(0);

// 실행 SQL
DELETE FROM CHILD WHERE ID = ?

참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제를 수행한다. 고아 객체 제거 기능은 영속성 컨텍스트를 플러시할 때 적용된다. 이 때 DELETE SQL을 실행한다.

영속성 전이 + 고아 객체

  • CascadeType.ALL + orphanRemoval = true 옵션을 활성화하면 부모 엔티티를 통해 자식의 생명 주기를 관리할 수 있다.
  • DDD의 Aggregate Root 개념을 구현할 때 유용하다.

Categories:

Updated:

Comments