스터디/JPA 스터디

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

제이온 (Jayon) 2021. 12. 25.

jpa-study에서 스터디를 진행하고 있습니다.

프록시

  • 엔티티를 조회할 때 연관된 엔티티가 항상 사용되는 것은 아니다.
    • ex) 멤버는 팀 엔티티만 연관되어있지만, 멤버의 이름만 가져오길 원할 수 있음. 이때는 굳이 팀 엔티티 정보는 없어도 무방함.
  • JPA는 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데, 이를 지연 로딩이라고 한다.
  • 지연 로딩을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라고 한다.

 

프록시 기초

  • EntityManager.getReference() 를 호출하면, JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체를 생성하지 않는다. 대신에 데이터베이스 접근을 위임한 프록시 객체를 반환한다.

 

프록시의 특징

  • 프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 사용하는 입장에서는 진짜 객체와 프록시 객체를 구분하지 못한다. (getClass() 를 통해 가능은 한데, 일반적으로 어려움)
  • 실제 객체에 대한 참조를 보관하며, 프록시 객체의 메소드를 호출하면 실제 객체의 메소드를 호출한다.

 

Untitled

 

프록시 객체의 초기화

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

 

Member member = em.getReference(Member.class, "id1");
member.getName();

 

 

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

 

프록시의 특징

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

 

프록시와 식별자

  • 프록시를 조회할 때 식별자 값을 파라미터로 전달하면, 프록시 객체는 이 식별자 값을 보관하여 식별자 값에 대한 getter를 호출하여도 프록시가 초기화되지 않는다.
  • 단, 엔티티 접근 방식이 @Access(AccessType.PROPERTY)로 설정한 경우에만 초기화하지 않는다.

 

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

 

프록시 확인

  • PersistenceunitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 알 수 있다.
  • getClass() 메소드를 통해 현재 인스턴스가 프록시 객체인지 진짜 객체인지 확인할 수 있다.
  • JPA 표준에는 프록시 강제 초기화 메소드가 없으나, 하이버네이트에서 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();
  • 회원과 팀 두 테이블을 조회해야 하므로 주로 조인 쿼리 하나를 사용한다. 회원 따로, 팀 따로가 아니다.
    • 이때 @JoinColumn(nullable=true)면 외부 조인을 사용하고, 그렇지 않으면 내부 조인을 사용한다.

 

지연 로딩

  • 연관된 엔티티를 실제 사용할 때 조회한다.

 

@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 엔티티를 조회한다.
  • 만약 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체가 아닌 실제 객체를 사용한다. 예를 들어 team1 엔티티가 영속성 컨텍스트에 이미 로딩되어 있다면 프록시가 아닌 실제 team1 엔티티를 사용한다. (쿼리 자체가 발생하지 않음)

 

즉시 로딩, 지연 로딩 정리

  • 지연 로딩: 연관된 엔티티를 프록시로 조회하며, 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.
  • 즉시 로딩: 연관된 엔티티를 즉시 조회하며, 하이버네이트는 가능하면 SQL 조인을 사용해서 한 번에 조회한다.

 

지연 로딩 활용

Untitled

  • 회원은 팀 하나에만 소속할 수 있다. (N:1)
  • 회원은 여러 주문 내역을 가진다. (1:N)
  • 주문 내역은 상품 정보를 가진다. (N:1)

 

애플리케이션 로직을 분석해 보니 다음과 같았다.

 

  • Member와 연관된 Team은 자주 함께 사용되므로 Member와 Team은 즉시 로딩으로 설정한다.
  • Member와 연관된 Order는 가끔 사용되므로 Member와 Order는 지연 로딩으로 설정한다.
  • Order와 연관된 Product는 자주 함께 사용되므로 Order와 product는 즉시 로딩으로 설정한다.

 

@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;

} 

 

 

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

 

프록시와 컬렉션 래퍼

  • 앞서 말했듯이, 지연 로딩으로 설정하면 실제 엔티티 대신에 프록시 객체를 사용하며, 프록시 객체는 실제 자신이 사용될 때까지 데이터베이스를 조회하지 않는다.
  • 이제 주문 내역을 조회해 보자.

 

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 속성의 기본 설정 값은 다음과 같다.
    • @ManyToOne, @OneToOne: 즉시 로딩
    • @OneToMany, @ManyToMany: 지연 로딩
    • 연관된 엔티티가 하나면 즉시 로딩, 컬렉션이면 지연 로딩을 사용함
  • 모든 연관 관계에 지연 로딩을 사용하는 것을 추천한다. 그리고 애플리케이션 개발이 어느 정도 완료되었을 때 즉시 로딩을 일부 반영하도록 수정한다.

 

// Person와 Team은 일대다 관계이고, Person는 Team에 대해 즉시 로딩 설정
entityManager.createQuery("select p from Person p")
            .getResultList();

 

위와 같은 코드로 Person를 조회하면 다음과 같은 쿼리를 얻을 수 있다.

 

Hibernate: 
    select
        person0_.id as id1_7_,
        person0_.name as name2_7_,
        person0_.team_id as team_id3_7_ 
    from
        person person0_
Hibernate: 
    select
        team0_.id as id1_8_0_,
        team0_.name as name2_8_0_ 
    from
        team team0_ 
    where
        team0_.id=?
Hibernate: 
    select
        team0_.id as id1_8_0_,
        team0_.name as name2_8_0_ 
    from
        team team0_ 
    where
        team0_.id=?

 

분명 조회 쿼리는 한 번이지만, Person의 수만큼 추가 조회 쿼리가 나가는 것을 알 수 있다. 이를 N+1 문제라고 한다. 만약, Person가 Team에 대해 지연 로딩을 설정하면 어떨까?

 

Hibernate: 
    select
        person0_.id as id1_7_,
        person0_.name as name2_7_,
        person0_.team_id as team_id3_7_ 
    from
        person person0_

 

원하는 대로 조회 쿼리가 하나만 나가는 것을 알 수 있다. 다만, Person에서 Team을 조회하고 싶다면 어쩔 수 없이 추가 쿼리가 나가게 된다. 이 경우에는 JPQL의 페치 조인 등의 방법으로 해결할 수 있다.

 

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

  • 컬렉션을 하나 이상 즉시 로딩하는 것을 권장하지 않는다.
    • 예를 들어 A 테이블을 N, M 두 테이블과 일대다 조인하면 SQL 실행 결과가 N 곱하기 M이 되면서 너무 많은 데이터를 반환하게 된다.
  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
    • 회원 테이블과 팀 테이블을 조인할 때, 회원 테이블의 외래 키에 Not Null 속성을 넣으면 내부 조인을 사용하지만, 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는다.

 

영속성 전이: 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의 종류

  • ALL
  • PERSIST
  • MERGE
  • REMOVE
  • REFRESH
  • 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 와 동일한 역할을 한다.

 

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

  • 만약 orphanRemovalCascadeType.ALL 을 같이 사용하면 어떨까?
  • 일반적으로 엔티티는 em.persist() 를 통해 영속화되고 em.remove() 를 통해 제거된다. 이것은 엔티티 스스로 생명 주기를 관리한다는 의미이다.
  • 그래서 두 옵션을 모두 활성화면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있다.

 

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

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

출처

김영한 - 자바 ORM 표준 JPA 프로그래밍

'스터디 > JPA 스터디' 카테고리의 다른 글

[JPA] Qna 미션을 통해 새롭게 배운 내용 정리  (0) 2022.01.17
[JPA] 값 타입  (0) 2022.01.01
[JPA] 고급 매핑  (0) 2021.12.15
[JPA] 다양한 연관 관계 매핑  (0) 2021.12.11
[JPA] 연관 관계 매핑 기초  (0) 2021.12.09

댓글

추천 글