[JPA] 프록시와 연관관계 정리
JPA 스터디를 진행하며 작성한 글입니다.
프록시
- 엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다. 따라서 JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 사용하는데 이것을 지연 로딩이라고 한다.
- 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 한다.
프록시 기초
엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 된다.
Member member = em.getReference(Member.class, "id1");
프록시의 특징
프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.
프록시 객체는 실제 객체에 대한 참조(target)을 보관하며, 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
프록시 객체의 초기화
프록시 객체는 member.getName()처럼 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데 이것을 프록시 객체의 초기화라고 한다.
Member member = em.getReference(Member.class, "id1");
member.getName();
- 프록시 객체에 member.getName() 을 호출해서 실제 데이터를 조회한다.
- 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데 이것을 초기화라 한다.
- 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
- 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 멤버 변수에 보관한다.
- 프록시 객체는 실제 엔티티 객체의 getName() 을 호출해서 결과를 반환한다.
프록시의 특징
- 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
- 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근할 수 있다.
- 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다.
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.
- 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다. 따라서 준영속 상태의 프록시를 초기화하면 org.hibernate.LazyInitializationException 예외를 발생시킨다.
프록시와 식별자
엔티티를 프록시로 조회할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다.
Team team = em.getReference(Team.class, "team1"); //식별자 보관
프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 team.getId()를 호출해도 프록시를 초기화하지 않는다.
team.getId(); //초기화되지 않음
단, 엔티티 접근 방식이 @Access(AccessType.PROPERTY)로 설정한 경우에만 초기화하지 않고, @Access(AccessType.FIELD)인 경우 프록시 객체를 초기화한다.
프록시 확인
- JPA가 제공하는PersistenceunitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 알 수 있다.
- 아직 초기화하지 않은 프록시 인스턴스는 false를 반환한다.
즉시 로딩과 지연 로딩
즉시 로딩
- 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
- 즉시 로딩을 사용하려면 @ManyToOne 의 fetch 속성을 FetchType.EAGER 로 설정하면 된다.
- 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.
@ManyToOne(fetch = FetchType.EAGER) //즉시 로딩 설정
@JoinColumn(name = "TEAM_ID")
private Team team;
- 즉시 로딩은 외래 키의 NULL 값의 허용 여부에 따라 내부 조인 또는 외부 조인이 사용된다.
- null 값을 허용하면 외부 조인을 사용한다.
- null 값을 허용하지 않으면 내부 조인을 사용한다.
지연 로딩
- 연관된 엔티티를 실제로 사용할 때 조회한다.
- 지연 로딩을 사용하려면 @ManyToOne 의 fetch 속성을 FetchType.LAZY로 설정하면 된다.
@ManyToOne(fetch = FetchType.LAZY) //지연 로딩 설정
@JoinColumn(name = "TEAM_ID")
private Team team;
- team.getName()을 호출하는 시점에 TEAM 엔티티를 조회한다. 그 전까지는 프록시 객체다.
- 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시가 아닌 실제 객체가 사용된다.
지연 로딩 활용
프록시와 컬렉션 래퍼
@Entity
public class Member {
@Id
private String id;
private String username;
private Integer age;
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
@OneToMany(mappedBy = "member, fetch = FetchType.LAZY)
private List<Order> orders;
}
주문 내역을 조회해보자.
Member member = em.find(Member.class, "member1");
List<Order> orders = member.getOrders();
System.out.println("orders = " + orders.getClass().getName());
// 출력 결과: orders = org.hibernate.collections.internal.PersistenBag
- 하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라 한다.
- 출력 결과를 보면 컬렉션 래퍼인 org.hibernate.collections.internal.PersistenBag이 반환된 것을 확인할 수 있다.
- 위의 예제에서 주문내역과 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해준다.
- 위 예제의 orders 는 초기화도지 않았다. 컬렉션은 member.getOrders().get(0)처럼 컬렉션에서 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화한다.
JPA 기본 페치 전략
fetch 속성의 기본 설정값은 다음과 같다.
- @ManyToOne, @OneToOne : 즉시 로딩
- @OneToMany, @ManyToMany : 지연 로딩
추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것이다. 그리고 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.
컬렉션에 즉시 로딩 사용 시 주의점
- 컬렉션을 하나 이상 즉시 로딩하는 것을 권장하지 않는다.
- 테이블을 N, M 두 테이블과 일대다 조인하면 SQL 실행 결과가 N 곱하기 M이 되면서 너무 많은 데이터를 반환하게 되어 애플리케이션 성능이 저하될 수 있다.
- 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
- 회원 테이블과 팀 테이블을 조인할 때, 회원 테이블의 외래 키에 not null 속성을 넣으면 내부 조인을 사용해도 된다.
- 그러나 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생한다.
영속성 전이: CASCADE
특정 엔티티를 영속 상테로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이 기능을 사용하면 된다. JPA는 CASCADE 옵션으로 영속성 전이를 제공한다.
영속성 전이: 저장
@OneToMany(mappedBy = "parent", cascade = CasecadeType.PERSIST)
private List<Child> children = new ArrayList<>();
부모를 영속화할 때 연관된 자식들도 함께 영속화하라고 cascade = CasecadeType.PERSIST 옵션을 설정했다.
Child child1 = new Child();
Child child2 = new Child();
Parent parent = new Parent();
child1.setParent(parent);
child2.setParent(parent);
parent.addChild(child1);
parent.addChild(child2);
em.persist(parent);
위 예제와 같이 부모만 영속화하면 자식 엔티티까지 함꼐 영속화해서 저장한다.
영속성 전이: 삭제
@OneToMany(mappedBy = "parent", cascade = CasecadeType.REMOVE)
private List<Child> children = new ArrayList<>();
영속성 전이는 엔티티를 삭제할 때도 사용할 수 있다. cascade = CasecadeType.REMOVE 로 설정하고 아래 예제 처럼 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함꼐 삭제된다.
em.remove(parent);
CASCADE 종류
- ALL
- PERSIST
- MERGE
- REMOVE
- REFRESH
- DETACH
PERSIST와 REMOVE는 플로시를 호출할 때 전이가 발생한다.
고아 객체
JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라 한다.
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();
고아 객체 제거 기능을 활성화하기 위해 컬렉션에 orphanRemoval = true 을 설정하면 된다.
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0);
위와 같이 children 컬렉션에서 엔티티를 제거하면 영속성 컨텍스트, 데이터베이스에서 삭제된다. 참고로 이 기능도 플로시 시점에 DELETE 쿼리가 실행된다.
정리
- 고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.
- 이 기능은 특정 엔티티가 개인이 소유하는 엔티티에만 이 기능을 적용해야 한다. 만약 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있다. 따라서, orphanRemoval은 @OneToOne, @OneToMany에만 사용할 수 있다.
- CascadeType.REMOVE 를 설정한 것과 같다.
영속성 전이 + 고아 객체, 생명주기
만약 orphanRemoval 과 CascadeType.ALL 을 같이 사용하면 어떨까?
일반적으로 엔티티는 em.persist() 를 통해 영속화되고 em.remove() 를 통해 제거된다. 이것은 엔티티 스스로 생명 주기를 관리한다는 의미이다. 그런데 두 옵션을 모두 활성화하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.
예를 들어, 자식을 저장하려면 부모에 등록만 하면 된다(CASCADE).
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);
자식을 삭제하려면 부모에서 제거하면 된다(orphanRemoval).
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(child1);
참고
- 자바 ORM 표준 JPA 프로그래밍 - 김영한
'개발 > JPA' 카테고리의 다른 글
[JPA] N+1 문제 (0) | 2022.01.08 |
---|---|
[JPA] 값 타입 (0) | 2022.01.08 |
[JPA] 고급 매핑 (0) | 2021.12.21 |
[JPA] 다양한 연관관계 매핑 (0) | 2021.12.14 |
[JPA] 연관관계 매핑 기초 (0) | 2021.12.14 |
댓글