[JPA/김영한] 08. 프록시와 연관관계 관리

Ogu·2024년 3월 22일
0

이 시리즈는 김영한님의 자바 ORM 표준 JPA 프로그래밍 책을 학습하고 기록하기 위한 포스팅입니다.
해당 8장에서는 연관된 객체를 처음부터 DB에서 조회하는 것이 아닌 실제 사용하는 시점에 조회
할 수 있도록 지원하는 기능인 프록시, 즉시로딩, 지연로딩에 대해 알아보겠습니다.
또한 연관된 객체를 함께 저장하거나 함께 삭제할 수 있는 영속성 전이고아 객체 제거라는 기능에 대해서도 알아보겠습니다.

8.1 프록시

엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아닙니다.
예를 들어 회원 엔티티를 조회할 때, 팀 엔티티는 비즈니스 로직에 따라 같이 필요할 때도 있고 아닐 때도 있습니다.

다음과 같이 회원 엔티티와 팀 엔티티가 있다고 해봅시다.

// Member 엔티티
@Entity
public class Member {
    private String username; 
    
    @ManyToOne
    private Team team;
    
    public Team getTeam() {
    	return team;
    }
    public String getUsername() {
    	return username;
    }
    ...
}
// Team 엔티티
@Entity
public class Team {
	private String name;
    
    public String getName() {
    	return name;
    }
}

회원과 팀 정보 모두를 출력하는 비즈니스 로직과 회원 정보만 출력하는 비즈니스 로직은 다음과 같습니다.

public void printUserAndTeam(String memberId) {
	Member member = em.find(Member.class, memberId);
    Team team = member.getTeam();
    System.out.println("회원 이름: " + member.getUsername());
    System.out.println("소속팀: " + team.getName());
}

public void printUser(String memberId) {
	Member member = em.find(Member.class, memberId);
    System.out.println("회원 이름: " + member.getUsername());
}

printUserAndTeam() 메서드는 회원과 연관된 팀의 정보 모두를필요로 하지만, printUser() 메서드는 회원 엔티티와 관련된 정보만 필요합니다.em.find()로 회원 엔티티를 조회할 때 팀 엔티티까지 DB에서 함께 조회한다면 효율적이지 않겠죠.

JPA는 이러한 문제를 해결하기 위해 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 기능인 지연 로딩을 지원합니다.

하지만 지연 로딩을 사용하기 위해서는 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있는 가짜 객체인 프록시 객체가 필요합니다.

  • 프록시 : 가짜

💡 지연 로딩
JPA 표준 명세는 지연로딩의 구현 방법을 JPA 구현체에 위임합니다. 여기서 설명하는 지연 로딩은 하이버네이트 구현체에 대한 내용입니다. 하이버네이트는 지연 로딩을 위해 두가지 방법을 제공합니다.
1. 프록시
2. 바이트코드 수정
바이트 코드는 설정이 복잡해 별도의 설정이 필요 없는 프록시에 대해 알아봅니다.

📟 8.1.1. 프록시 기초

JPA에서 식별자로 엔티티 하나를 조회할 때 EntityManager.find() 를 사용하는데, 영속성 컨텍스트에 엔티티가 없으면 데이터베이스를 조회합니다.

하지만 엔티티를 직접 조회하면 조회한 엔티티 실제 사용 유무와 관계 없이 DB를 조회합니다.

만약 엔티티 실제 사용 시점까지 DB 조회를 미루고 싶다면 EntityManager.getReference() 메서드를 사용합니다.

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

이 메서드는 (영속성 컨텍스트에 찾는 엔티티가 없을 경우) DB 접근을 위임한 프록시 객체를 반환합니다.

  • EntityManager.find() : DB 조회로 실제 엔티티 객체 하나 조회
  • EntityManager.getReference() : DB 조회를 미루는 프록시 객체 반환

🎨 프록시의 특징

프록시 클래스는 실제 클래스를 상속받아서 만들어집니다. 따라서 실제 클래스와 겉 모양이 같으므로, 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용합니다.

프록시 객체는 실제 객체에 대한 참조(targe)를 보관하고 있습니다.
따라서 프록시 객체의 메서드를 호출하면 프록시 객체는 실제 객체의 메서드를 호출합니다.

🎨 프록시 객체의 초기화

프록시 객체는 member.getName() 처럼 실제로 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성합니다. 이것을 프록시 객체의 초기화 라고 합니다.

🎨 프록시 객체의 초기화 과정

프록시 객체의 초기화 과정은 다음과 같습니다.

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

5. 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환한다.

🎨 프록시 객체의 특징

  • 프록시 객체는 처음 사용할 때 한 번만 초기화

  • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 X, 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근할 수 있다.

  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용

  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.

  • 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다. 따라서 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태의 프록시를 초기화하면 문제가 발생한다. 하이버네이트는 org.hibernate.LazyInitializationException 예외를 발생시킴

🎨 준영속 상태와 초기화

다음과 같은 준영속 상태의 초기화는 예외를 발생시킨다.

// MemberProxy 반환
Member member = em.getReference(Member.class, "id1");
transaction.commit();
em.close(); // 영속성 컨텍스트 종료
member.getName(); // 준영속 상태 초기화 시도. org.hibernate.LazyInitializationException 예외 발생

-> em.close 로 영속성 컨텍스트를 종료했기 때문에 member는 준영속 상태 -> member.getName()을 호출하면 프록시를 초기화해야 하지만 영속성 컨텍스트가 없어 실제 엔티티 조회 불가능 -> 예외 발생

8.1.2. 프록시와 식별자

엔티티는 프록시로 조회할 때 식별자(PK) 값을 파라미터로 전달하는데, 프록시 객체는 이 식별자 값을 보관합니다.

엔티티 접근 방식이 프로퍼티(@Access(AccessType.PROPERTY))인 경우

  • 프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 메서드 team.getId()를 호출해도 프록시 초기화 X

엔티티 접근 방식이 프로퍼티(@Access(AccessType.FIELD))인 경우

  • JAP는 getId() 메서드가 id만 조회하는 메서드인지, 다른 필드까지 활용하는 메서드인지 알지 못하므로 프록시 객체를 초기화 시킵니다.
    `

8.2 즉시 로딩과 지연 로딩

프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용합니다.
엔티티의 조회 시점을 선택할 수 있는 즉시 로딩과 지연 로딩에 대해 알아봅시다.

📟 8.2.1 즉시 로딩

즉시 로딩은 엔티티를 조회할 떄 연관된 엔티티도 함께 조회합니다.
@ManyToOne(fetch = FetchType.EAGER) 로 설정하면 됩니다.

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

위와 같이 즉시 로딩으로 설정하면 em.find(Member.class, "member1")와 같이 회원을 조회하는 순간 팀도 함께 조회합니다.

회원 테이블 조회, 팀 테이블 조회를 위한 쿼리를 2번 실행하지 않고, JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용합니다.

위에서 실행되는 즉시 로딩 SQL은 아래와 같습니다.

SELECT
    M.MEMBER_ID AS MEMBER_ID,
    M.TEAM_ID AS TEAM_ID,
    M.USERNAME AS USERNAME,
    T.TEAM_ID AS TEAM_ID,
    T.NAME AS NAME
FROM MEMBER M 
LEFT OUTER JOIN TEAM T
	  ON M.TEAM一ID=T.TEAM一ID
WHERE
    M.MEMBER_ID='member1'

회원과 팀을 조인해 쿼리 한 번으로 조회하는 것을 볼 수 있습니다.

🎨 Null 제약조건과 JPA 조인 전략

JPA는 기본적으로 외래 키가 NOT NULL이 아닌 경우를 고려해서 외부 조인을 사용합니다. 하지만 외부 조인보다 내부 조인이 성능과 최적화에서 더 유리합니다.

내부 조인을 사용하려면 어떻게 해야할까요? 외래 키에 NOT NULL 제약조건을 설정하면 값이 무조건 있는 것을 보장합니다. 이런 경우에는 내부 조인만 써도 되겠죠.

JPA에 이렇게 외래 키에 NULL을 허용하지 않는다고 알려주면, 외부 조인 대신 내부 조인을 사용합니다.
그 방법은 다음과 같이 두가지가 있습니다.

  1. @JoinColumn(nullable = false)
    @JoinColumn(nullable = false)은 외래키의 NULL을 허용하지 않고, 내부 조인을 사용합니다.
    반대로 true로 설정하면 외래키에 NULL을 허용하고 외부 조인을 사용합니다. default는 true입니다.

  2. @ManyToOne(optional = false)
    @ManyToOne(optional = false) 와 같이 설정해도 내부 조인을 사용합니다.

따라서 JPA는 연관관계가 선택적이라면 외부 조인을 사용하고, 필수 관계라면 내부 조인을 사용합니다.

📟 8.2.2 지연 로딩

지연 로딩은 연관된 엔티티를 실제 사용할 때 조회합니다.
@ManyToOne(fetch = FetchType.LAZY) 로 설정하면 됩니다.

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

em.find(Member.class, "member1" 를 호출하면 회원만 조회하고, 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둡니다.
Team team = member.getTeam(); 시점까지도 실제 사용될 때까지 데이터 로딩을 미뤄 프록시 객체가 들어있지만,
team.getName(); 와 같이 팀 객체를 실제 사용 시에는 DB를 조회해서 프록시 객체를 초기화시킵니다.

💡 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없으므로 실제 객체를 사용합니다.

8.3 지연 로딩 활용

사내 주문 관리 시스템을 개발할 때, 클래스간의 연관관계가 다음과 같다고 해봅시다.

  • Member : Team = N : 0..1 -> 자주 함께 사용, 즉시 로딩
  • Member : Order = 1 : N -> 가끔 함께 사용, 지연로딩
  • Order : Product = N : 1 -> 자주 함께 사용, 즉시 로딩
// 회원 엔티티
@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();

만약, 회원 엔티티를 조회한다면 회원:팀의 연관관계는 즉시 로딩으로, 회원:주문 연관관계는 지연 로딩으로 설정함에 따라 다음과 같이 엔티티를 로딩합니다.

-> 즉시 로딩 : 실선, 지연 로딩 : 점선

회원과 팀은 조인 쿼리로 한번에 조회되지만, 주문내역은 결과를 프록시로 조회합니다.
따라서 SQL에서는 Team만 OUTER JOIN을 하고, Order는 나타나지 않습니다.

따라서 회원을 조회한 후 member.getTeam()을 호출하면, 이미 로딩된 팀 엔티티를 반환합니다.

📟 8.3.1. 프록시와 컬렉션 래퍼

프록시 객체로 지연 로딩이 되었던 주문을, 실제 조회해봅시다.

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인데, 컬랙션 래퍼인 것을 알 수 있습니다.

엔티티를 지연 로딩하면 프록시 객체를 사용해 지연 로딩을 수행하지만, 주문 내역과 같은 컬렉션은 컬랙션 래퍼가 지연 로딩을 처리합니다.
(member.getOrders() 를 호출해도 컬렉션은 초기화되지 않습니다.)


이제 member.getOrders().get(0) 처럼 컬렉션에서 실제 조회를 하면 DB를 조회 후 초기화시킵니다.

앞서 Order와 Product의 로딩 방법을 FetchType.EAGER인 즉시 로딩으로 설정했기에, 지연 로딩 상태인 주문 내역을 초기화 할 때 연관된 상품도 함께 즉시 로딩합니다.

📟 8.3.2. JPA 기본 fetch 전략

JPA 기본 fetch 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연 로딩을 사용합니다.
(컬렉션 로딩은 비용이 크고 자칫하면 너무 많은 데이터를 로딩)

  • @ManyToOne, @OneToOne: 즉시 로딩
  • @OneToMany, @ManyToMany: 지연 로딩

따라서 추천 방법은, 모든 연관관계에 지연 로딩을 사용하는 것입니다.
이후 애플리케이션의 개발이 어느정도 완료되었을 때 상황을 보고 일부를 즉시 로딩으로 반영합니다.

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

🎨 컬렉션을 하나 이상 즉시 로딩하는 것은 권장 X

서로 다른 컬렉션을 2개 이상 조회하는 경우 문제가 발생
ex) A 테이블을 N, M 두 테이블과 일대다 조인 -> SQL 실행 결과가 N * M이 되면서 너무 많은 데이터 반환
-> 성능 저하

🎨 컬렉션 즉시 로딩은 항상 외부 조인(OUTER JOIN) 사용

만약, 다대일 관계인 회원, 팀 테이블을 조회할 때 회원 테이블의 외래키에 NOT NULL 제약조건을 걸어두면, 모든 회원은 팀에 소속되므로 항상 내부조인을 사용해도 O

반대로, 팀 테이블에서 회원 테이블로 일대 다 관계를 조인할 때 회원이 한명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생합니다.

DB 제약 조건이기 때문에 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용합니다.

  • @ManyToOne, @OneToOne
    • (optional = false): 내부 조인
    • (optional = true): 외부 조인
  • @OneToMany, @ManyToMany
    • (optional = false): 외부 조인
    • (optional = true): 외부 조인

8.4 영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 영속성 전이(transitive persistence) 기능을 사용합니다.

JPA는 영속성 전이를 CASCADE 옵션으로 제공합니다.

📟 8.4.1. 영속성 전이: 저장

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 합니다.
때문에 기존에는 부모 엔티티를 영속 상태로 만든 이후 자식 엔티티를 각각 영속 상태를 만들었지만, 영속성 전이를 사용하면 부모만 영속 상태로 만들어도 연관된 자식 엔티티까지 함께 영속 화해서 저장할 수 있습니다.

저장을 위한 영속성 전이 옵션인 CasecadeType.PERSIST 를 적용하면 다음과 같습니다.

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

// CASCADE 저장 : 부모를 영속화할 때 연관된 자식들도 함께 영속화

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

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

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

💡 영속성 전이는 연관관계 매핑과는 아무련 관련 X
단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함 제공
따라서 양방향 연관관계를 추가한 다음 영속상태로 만듦

📟 8.4.2. 영속성 전이: 삭제

기존에는 저장한 부모와 자식 엔티티를 모드 제거하기 위해 각각의 엔티티를 하나씩 제거했습니다.

이에 CascadeType.REMOVE를 사용하면 부모 엔티티만 삭제해도 연관된 자식 엔티티도 함께 삭제됩니다.

Parent findparent = em.find(Parent.class, 1L);
em.remove(findParent);

위와 같이 부모 객체만 삭제해도 DELETE SQL을 3번 실행하여 연관된 자식도 모두 삭제합니다. 삭제 순서는 외래 키 제약 조건을 고려해 자식 먼저 삭제하고 부모를 삭제합니다.

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

📟 8.4.3. CASCADE 종류

CascadeType 옵션의 종류는 다음과 같습니다.

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

다음과 같이 여러 속성을 함께 사용할 수도 있습니다.

cascade = {CascadeType.PERSIST, CascadeType.REMOVE}

다만 PERSISTREMOVE em.persist(), em.remove()를 실행할 때 바로 전이가 발생하지 않고, 플러시를 호출할 때 전이가 발생합니다.

8.5 고아 객체

고아 객체(ORPHAN) 제거는 JPA에서 제공하는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능입니다.

📟 8.5.2. 자식 엔티티 참조 제거, 엔티티 자동 삭제

부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면, 자식 엔티티가 자동으로 삭제되도록 해봅시다.

// 고아 객체 제거 기능 설정
@Entity
public class Parent {
    @Id @GeneratedValue
    private ParentId id;

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

컬렉션에 설정한 orphanRemoval = true 옵션으로 인해 컬렉션에서 제거한 엔티티는 자동으로 삭제됩니다.

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

위와 같이 첫 번째 자식을 제거하면 다음과 같은 SQL이 실행되며, DB의 데이터도 삭제됩니다.

DELETE FROM CHILD WHERE ID=?

고아 객체 제거 기능은 영속성 컨텍스트를 플러시 할 때 적용되어, 플러시 시점에 DELETE SQL이 실행됩니다.

📟 8.5.2. 모든 자식 엔티티 제거

모든 자식 엔티티를 제거하는 경우에는 다음과 같이 컬렉션을 비웁니다.

parent1.getChildren().clear();

📌 고아 객체 제거는 참조하는 곳이 하나일 때만 사용해야 합니다.
참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이기 때문입니다.

따라서, orphanRemoval은 @OneToone, @OneToMany 에서만 사용할 수 있습니다.

또한, 부모를 제거하면 자식은 고아가 되므로, 부모를 제거하면 자식도 같이 제거됩니다. 이는 CascadeType.REMOVE의 기능과 같습니다.

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

CascadeType.ALL + orphanRemoval = true를 동시에 사용하면 어떻게 될까요?

일반적으로 엔티티는 EntityManager.persist()를 통해 영속화되고 EntityManger.remove()를 통해 제거됩니다.다. 이것은 엔티티 스스로 생명주기를 관리한다는 뜻이다.

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

ex) CascadeType.ALL + orphanRemoval = true 두 옵션 동시 활성화 예제

  1. 자식을 저장하려면 부모에 등록만 하면 된다. -> CASCADE(영속성 전이)
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);
  1. 자식을 삭제하려면 부모에서 제거(참조만 제거) -> orphanRemoval(고아 객체)
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(removeObject);
profile
私はゲームと日本が好きなBackend Developer志望生のOguです🐤🐤

0개의 댓글