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

zwundzwzig·2023년 10월 14일
0
post-thumbnail

프록시

  • 엔티티는 연관된 엔티티를 탐색하지만, 객체가 DB에 저장되므로, 마음껏 탐색하는 게 DB에 부하가 갈 수도 있다.
  • JPA는 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데, 이를 지연 로딩이라고 한다.
  • 지연 로딩을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데, 이것을 프록시 객체라고 한다.

JPA 표준 명세는 지연 로딩의 구현 방법을 JPA 구현체에 위임했고, 교재에선 하이버네이트를 통한 내용으로 설명됐다. 하이버네이트는 프록시를 사용하거나 바이트코드를 수정하는 방법으로 지연로딩을 지원한다고 한다! (책에서는 프록시를 활용한 지연 로딩을 기술했다.)

JPA에서 식별자로 엔티티 하나를 조회하는 EntityManager.find() 대신, EntityManager.getReference() 를 호출해 실제 사용하는 시점까지 DB 조회를 미루도록 한다.

이때 JPA는 DB 조회한 뒤 실제 엔티티 객체를 생성하는 것 대신 DB 접근을 위임한 프록시 객체를 반환한다.

프록시 기초

위임

프록시 객체는 실제 클래스를 상속받아 만들어지기 때문에 겉모양은 같다.
프록시 객체는 실제 객체에 대한 참조target를 보관하다가 프록시 객체의 메서드가 호출되면 실제 객체의 메서드를 호출한다.

초기화

프록시 객체는 member.getName() 처럼 실제 사용될 때 DB를 조회해 실제 엔티티 객체를 생성하는데, 이를 프록시 객체의 초기화라고 한다.

특징

  • 처음 사용할 때 한 번만 초기화된다.
  • 초기화가 프록시 객체가 실제 엔티티도 바뀌는 게 아니라, DB에 접근 후 엔티티 객체를 생성하는 것이다.
  • 프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의해야 한다.
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 DB 조회하지 않아도 되기에 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.
  • 초기화는 영속성 컨텍스트의 도움을 받아야 하기 때문에 영속성 컨텍스트의 도움을 받지 못하는 준영속 상태의 프록시를 초기화하면 하이버네이트에선 org.hibernate.LazyOnitializationException 예외가 발생한다.

프록시와 식별자

엔티티 객체를 프록시로 조회 시 식별자 값PK을 파라미터로 전달하면, 프록시 객체는 이 식별자 값을 보관하여 식별자 값에 대한 getter를 호출하여도 프록시가 초기화되지 않는다.

단, 엔티티 접근 방식을 프로퍼티 @Access(AccessType.PROPERTY)로 설정한 경우에는 초기화하지 않는다.

Team team = em.getReference(Team.class, "team1");
team.getId(); // 프록시가 초기화되지 않음.

연관관계 설정 시 식별자 값만 사용하므로 프록시를 사용하면 DB 접근 횟수를 줄일 수 있다.

프록시 확인

JPA가 제공하는 PersistenceunitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 알 수 있다. JPA 표준에는 강제 초기화 메서드가 없기 때문에 프록시의 메서드를 직접 호출해보면 된다.

getClass() 메소드로 현재 사용하는 인스턴스가 프록시 객체인지 진짜 객체인지 확인할 수 있다.

하이버네이트에서 initalize() 메소드를 통해 강제 초기화 메소드를 제공한다.

즉시 로딩

기존에 내가 사용하던 방식이다. 엔티티를 조회 시 연관된 엔티티도 함께 조회한다.

@Entity
public class Member {

    // ...
    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 설정
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    // ...
}

// 즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();

회원과 팀 두 테이블을 조회해야 하므로 주로 JOIN 쿼리로 한번에 DB에서 조회한다.

이때 @JoinColumn(nullable=true)면 외부 조인을 사용하고, 그렇지 않으면 내부 조인을 사용한다.

Null 제약 조건과 JPA 조인 전략

JPA는 매핑관계의 필수 여부에 따라 실제 DB로 보내는 SQL 구문이 달라지는데, Outer Join 보다 Inner Join이 성능과 최적화에서 더 유리하며 NotNull 조건을 걸면 Inner Join을 수행하게 하기 때문에 가능하면 NotNull 조건을 걸어보자.

지연 로딩

@Entity
public class Member {

    // ...
    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    // ...
}

// 지연 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();
team.getName();

team.getName() 을 호출하는 시점이 되어야 Team 엔티티를 조회한다. 즉, 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.

만약 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체가 아닌 실제 객체를 사용해 DB 접근을 최소화할 수도 있다.

연관된 엔티티를 프록시로 조회하며, 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.

회원과 팀은 조인 쿼리를 통해 함께 조회되고, 회원과 주문 내역은 주문 내역에 대한 결과를 프록시 객체로 반환한다. 따라서 회원을 조회했을 땐 주문 내역은 DB에 조회되지 않는다.

프록시와 컬렉션 래퍼

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

엔티티를 지연 로딩하면 프록시 객체가 지연로딩을 수행하지만, 주문 내역 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해 준다. 결국 둘다 프록시는 마찬가지지만, 명칭이 다르다.

다만, 위 코드에서 orders 컬렉션은 아직 초기화되지 않는다. member.getOrders().get(0) 처럼 컬렉션에서 실제 데이터를 조회할 때 초기화된다.

JPA 기본 fetch 전략

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

  • @ManyToOne, @OneToOne: 즉시 로딩
  • @OneToMany, @ManyToMany: 지연 로딩
  • 연관된 엔티티가 하나면 즉시 로딩, 컬렉션이면 지연 로딩을 사용한다.

모든 연관 관계에 지연 로딩을 사용하는 것을 추천한다. 그리고 애플리케이션 개발이 어느 정도 완료되었을 때 즉시 로딩을 일부 반영하도록 수정한다.

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

컬렉션을 하나 이상 즉시 로딩하는 것을 권장하지 않는다.

  • 예를 들어 A 테이블을 N, M 두 테이블과 일대다 조인하면 SQL 실행 결과가 N 곱하기 M이 되면서 너무 많은 데이터를 반환하게 된다.

컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.

  • 회원 테이블과 팀 테이블을 조인할 때, 회원 테이블의 외래 키에 NotNull 속성을 넣으면 내부 조인을 사용하지만, 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는다.

영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 CASCADE를 사용하자.

저장

@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

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

@Entity
public class Child {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    private Parent parent;
}
 

// 부모를 영속화할 때 연관된 자식들도 함께 영속화하는 코드이다.

Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();

parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);

위와 같이 부모만 영속화하더라도 그 안에 연관된 자식 엔티티도 영속화된다.

삭제

@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

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

@Entity
public class Child {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    private Parent parent;
}
 

부모를 제거할 때 자식 엔티티까지 모두 제거할 수 있다. 만약 해당 REMOVE 옵션을 주지 않고 부모 객체를 제거하면 자식 테이블에 걸려 있는 외래 키 무결성 예외가 발생한다.

CASCADE의 종류

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

참고로 PERSIST와 REMOVE는 플러시를 호출해야 전이가 발생한다.

고아 객체

부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 고아 객체라고 부르며, JPA는 이를 자동 삭제해 주는 기능을 제공한다.

@Entity
public class Parent {

    @Id
    @GeneratedValue
    private Long id;

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

@Entity
public class Child {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    private Parent parent;
}
 
// 이제 children 컬렉션에서 제거한 자식 엔티티는 자동으로 삭제된다.

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

위와 같이 children 컬렉션에서 첫 번째 코드를 제거하면, 해당 엔티티는 영속성 컨텍스트는 물론 데이테베이스의 데이터도 같이 삭제된다. 참고로 이 기능은 당연히 영속성 컨텍스트에서 플러시할 때 적용된다.

orphanRemoval 옵션을 사용할 때 주의할 점

고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.

이 기능은 참조하는 곳이 하나일 때만 사용해야 하며, 쉽게 이야기해서 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 사용해야 한다.

이런 이유로 orphanRemoval은 @OneToOne, @OneToMany에만 사용할 수 있다.

부모를 제거하면 자동으로 자식은 고아가 되므로 모든 자식 엔티티가 제거된다. 그래서 이때는 CascadeType.REMOVE 와 동일한 역할을 한다.

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

만약 orphanRemoval 과 CascadeType.ALL 을 같이 사용하면 어떨까?

일반적으로 엔티티는 em.persist() 를 통해 영속화되고 em.remove() 를 통해 제거된다. 이것은 엔티티 스스로 생명 주기를 관리한다는 의미이다.

그래서 두 옵션을 모두 활성화면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있다.

// 자식을 저장하려면 부모에 등록하면 됨
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);

// 자식을 제거하려면 부모에서 제거하면 됨
parent.getChildren().remove(child1);

🧷 참조 교재

  • 김영한, 『자바 ORM 표준 JPA 프로그래밍』 에이콘(2015)
profile
개발이란?

0개의 댓글