Back-End/JPA

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

wisdom11 2022. 6. 7. 11:01

 

1. 프록시

엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다. 사용하지 않는 연관된 엔티티까지 데이터베이스에서 함께 조회해두는 것이 효율적이지 않다. JPA는 이런 문제를 해결하기 위해, 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법지연 로딩을 제공한다.지연 로딩 기능을 사용하려면 실제 엔티티 객체 데신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요하고, 이것을 프록시 객체라 한다.

📌 참고! JPA의 지연 로딩
JPA 표준 명세는 지연 로딩의 구현 방법을 JPA 구현체에 위임했다. 이후의 내용은 하이버네이트 구현체에 대한 내용이다.

 

1.1. 프록시 기초

JPA에서 식별자로 엔티티 하나를 조회할 때는 EntityManager.find() 를 사용한다. 이 메소드는 엔티티를 실제로 사용하든 사용하지 않든 데이터베이스를 조회한다.

엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 된다.

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

이 메소드를 호출할 때 JPA는 데이터베이스를 조회하지 않고, 실제 엔티티 객체도 생성하지 않는다.
대신에 데이터베이스 접근을 위임한 프록시 객체를 반환한다.

 

✔️ 프록시의 특징

프록시 클래스는 실제 클래스를 상속받아서 만들어진다. 즉, 실제 클래스와 겉모양은 같다.


프록시 객체는 실제 객체에 대한 참조(target)를 보관한다. 그리고 프록시 객체의 메소드를 호출하면 프록시 객체가 실제 객체의 메소드를 호출한다.

 

✔️ 프록시 객체의 초기화

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

Member member = em.getReference(Member.class, "id1"); // MemberProxy 객체 반환
member.getName(); // 프록시 초기화: DB 조회, 실제 엔티티 생성 및 참조 보관

위의 예제 코드에 대한 프록시의 초기화 과정은 다음과 같다.

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

 

✔️ 프록시의 특징

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

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

    📍 JPA 표준 명세는 지연로딩에 대한 내용을 JPA 구현체에 맡겼고, 준영속 상태의 엔티티를 초기화할 때 어떤 일이 발생할지 또한 표준 명세에 정의되어 있지 않다. 하이버네이트의 경우에는 org.hibernate.LazyInitializationException 예외가 발생한다.

 

1.2. 프록시와 식별자

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

엔티티 접근 방식을 @Access(AccessType.PROPERTY)로 설정한 경우, 식별자 값을 조회하는 메소드 (예를 들어, team.getId())를 호출해도 프록시가 초기화되지 않는다.

엔티티 접근 방식을 @Access(AccessType.PROPERTY)로 설정한 경우, JPA는 getId() 메소드가 id만 조회하는 메소드인지 아닌지를 알지 못하므로 프록시 객체를 초기화한다.

프록시는 연관관계를 설정할 때 유용하게 사용할 수 있다.

Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1");	//SQL을 실행하지 않음
member.setTeam(team);

위와 같이 연관관계를 설정할 때는 식별자 값만 사용하므로 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있다. 참고로 연관관계를 설정할 때는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않는다.

 

1.3. 프록시 확인

✔️ 프록시 초기화 여부 확인

JPA가 제공하는 PersistenceUnitUtil.isLoaded.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인 할 수 있다. 아직 초기화되지 않은 프록시 인스턴스는 false를, 이미 초기화되었거나 프록시 인스턴스가 아니면 true를 반환한다.

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

// 또는
// boolean isLoad = emf.getPersistenceUnitUtil().isLoaded(entity);
System.out.println("isLoad = " + isLoad); // 초기화 여부 확인

 

✔️ 프록시인지 확인

조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지 확인하려면 클래스명을 직접 출력해보면 된다.
예를 들어 클래스명 뒤에 ..javassist... 라 되어있으면 프록시인 것을 알 수 있다. 프록시를 생성하는 라이브러리에 따라 출력 결과는 달라질 수 있다.

System.out.println("memberProxy = " + member.getClass().getName());
// 결과: memberProxy = jpabook.domain.Member_$$_javassist_0

 

2. 즉시 로딩과 지연 로딩

JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 다음 두 가지 방법을 제공한다.

1️⃣ 즉시 로딩

  • 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
  • 설정 방법: @ManyToOne(fetch = FetchType.EAGER)

 

2️⃣ 지연 로딩

  • 연관된 엔티티를 실제 사용할 때 조회한다.
  • 설정 방법: @ManyToOne(fetch = FetchType.LAZY)

 

2.1. 즉시 로딩

// 즉시 로딩 설정
@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()로 회원을 조회하는 순간 팀도 함께 조회한다.

이때 회원과 팀 두 테이블을 조회할 때, 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해 조인 쿼리를 사용한다.

조인 쿼리의 경우 연관관계가 선택적 관계(null 허용)면 외부 조인을, 필수 관계(null 허용하지 않음)면 내부 조인을 사용한다.

  • @JoinColumn(nullable = true): 기본값. 외부 조인 사용.
  • @JoinColumn(nullable = false) 혹은 @ManyToOne(fetch = FetchType.Eager, optional = false): 내부 조인 사용

 

2.2. 지연 로딩

// 지연 로딩 설정
@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() 메소드 호출하면 회원만 조회하고, 팀은 조회하지 않는다.
대신, 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둔다.

이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룬다. 그래서 지연 로딩이라고 한다.

em.find(Member,class, "member1")  호출 시 실행되는 SQL은 다음과 같다.

SELECT * FROM MEMBER
WHERE MEMBER_ID = 'member1'

team.getName() 호출 시 프록시 객체가 초기화되면서 실행되는 SQL은 다음과 같다.

SELECT * FROM TEAM
WHERE TEAM_ID = 'team1'

 

📌 참고
조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체가 아닌 실제 객체를 사용한다.
예를 들어 team1 엔티티가 영속성 컨텍스트에 이미 로딩되어 있으면 프록시가 아닌 실제 team1 엔티티를 사용한다.

 

3. 지연 로딩 활용

지금부터 즉시 로딩과 지연 로딩을 어떻게 사용하는지 구체적인 예제를 통해 알아보자.

@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;
	
	// Getter, Setter
}

✔️ @ManyToOne(fetch = FetchType.EAGER) private Team team;

  • 회원 엔티티 조회 시, 팀 엔티티도 즉시 조회

 

✔️ @OneToMany(mappedBy = "member", fetch = FetchType.LAZY) private List<Order> orders;

  • 회원 엔티티 조회 시, 연관된 주문내역 엔티티는 프록시로 조회해서 실제 사용될 때까지 로딩을 지연한다.

 

3.1 프록시와 컬랙션 래퍼

엔티티에 컬랙션이 있는 경우, 하이버네이트는 원본 컬랙션을 내장컬랙션으로 변경하는데 이를 컬랙션 래퍼라고 한다.

엔티티를 지연 로딩하면 프록시 객체를 사용해서 수행하고, 주문 내역 같은 컬랙션은 컬랙션 래퍼가 지연 로딩을 처리해준다.

참고로, 컬랙션은 컬랙션의 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화한다.

  • member.getOrders() → 컬랙션 초기화되지 않음.
  • member.getOrders().get(0) → 컬랙션 초기화됨.

 

3.2 JPA 기본 페치 전략

✔️ fetch의 기본 설정값

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

⇒ 연관된 엔티티가 하나면 즉시 로딩, 컬랙션이면 지연 로딩!

모든 연관관계에 지연 로딩을 사용하는 것을 추천한다.
그리고 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하자.

 

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

1️⃣  컬랙션 하나 이상 즉시 로딩하는 것은 권장하지 않는다.

컬랙션과 조인하는 것 → 일대다 조인

조인 시, 다 쪽에 있는 수만큼 데이터가 증가하게 된다.
서로 다른 컬랙션을 2개 이상 조인하는 경우 너무 많은 데이터가 반환되어 애플리케이션 성능이 저하될 수 있다.
따라서 2개 이상의 컬랙션을 즉시 로딩으로 설정하는 것은 권장하지 않는다.

 

2️⃣  컬랙션 즉시 로딩은 항상 외부 조인을 사용한다.

FetchType.EAGER 설정과 조인 전략

@ManyToOne, @OneToOne

  • optional = false : 내부 조인
  • optional = true : 외부 조인

 

@OneToMany, @ManyToMany

  • optional = false : 외부 조인
  • optional = true : 외부 조인

 

4. 영속성 전이: CASCADE 

✔️ 영속성 전이 (Transitive Persistence)

  • 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들 때 사용할 수 있다.
  • JPA는 CASCADE 옵션으로 영속성 전이를 제공한다.
// 부모
@Entity
public class Parent {
	
	@Id @GeneratedValue
	private Long id;

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

//자식
@Entity
public class Child {
	
	@Id @GeneratedValue
	private Long id;

	@ManyToOne
	private Parent parent;
	...	
}

JPA는 엔티티를 저장할 때 연관된 모든 엔티티가 영속 상태여야 한다.

 

영속성 전이를 사용하지 않는 경우

부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만들어야 한다.

em.persist(parent);

child1.setParent(parent);
parent.getChildren().add(child1);
em.persist(child1);

 

영속성 전이를 사용하는 경우

부모만 영속 상태로 만들면 연관된 자식까지 한 번에 영속 상태로 만들 수 있다.

 

4.1 영속성 전이: 저장

CasecadeType.PERSIST 로 설정하여 엔티티를 저장할 때 영속성 전이를 사용할 수 있다.

@Entity
public class Parent {
	...
	@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
	private List<Child> children = new ArrayList<Child>;
	...	
}
child1.setParent(parent);
parent.getChildren().add(child1);
em.persist(parent);

이때 영속성 전이는 연관관계를 매핑하는 것과는 아무 관련이 없다.
단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐이다.

 

4.2 영속성 전이: 삭제

기본적으로, 부모 엔티티와 자식 엔티티를 모두 제거하려면 각각의 엔티티를 하나씩 제거해야 한다.

em.remove(child1);
em.remove(parent);

영속성 전이는 삭제할 때도 사용할 수 있다. CasecadeType.REMOVE 로 설정하면, 부모 엔티티만 삭제하면 자식 엔티티도 함께 삭제된다.

em.remove(parent);

 

4.3 CASECADE의 종류

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

 

✔️ 여러 속성을 같이 사용하는 방법

casecade = {CascadeType.PERSIST, CascadeType.REMOVE}

 

 

📌 참고
PERSIST, REMOVE 의 경우, 플러시를 호출할 때 전이가 발생한다.

 

5. 고아 객체

고아 객체 (ORPHAN) 제거

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하고, 이것을 고아 객체 제거라 한다.

이 기능을 사용하면 부모 엔티티의 컬레션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제되도록 할 수 있다.
다음 예제를 보자.

// 고아 객체 삭제 기능 활성화
@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany (mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<Child)>();
    ...
}



Parent parent1 = em.find(Parent.class, id);

// 하나의 자식 엔티티 제거
parent1.getChildren().remove(0);	// 첫 번째 자식 엔티티 제거

// 모든 자식 엔티티 제거
parent1.getChildren().clear();		// 컬렉션 비움

 

✔️ orphanRemoval = true

  • 고아 객체 제거 기능을 활성화하기 위한 설정
  • 고아 객체 제거 기능은 영속성 컨텍스를 플러시할 때 적용(Delete SQL 실행)된다.
  • @OneToOne 혹은 @OneToMany 에만 사용할 수 있다.
    ➡️ 참조하는 곳이 하나일 때만 사용할 수 있다. 
  • 부모를 제거하면 자식도 같이 제거된다. 이는 CascadeType.REMOVE 설정과 같다.

 

✔️ 자식 엔티티 제거

parent1.getChildren().remove(0);

  • 첫 번째 자식 엔티티를 제거한다.


parent1.getChildren().clear();

  • 모든 자식 엔티티를 제거하려면, 컬렉션을 비우면 된다.

 

 

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

CasecadeType.ALL + orphanRemoval = true 를 동시에 사용하면 어떨까?

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

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

  • 자식을 저장하려면 부모에 등록만 하면 된다.
    Parent parent = em.find(Parent.class, parentId);
    parent.addChild(child);
  • 자식을 삭제하려면 부모에서 제거하면 된다.
    Parent parent = em.find(Parent.class, parentId);
    parent.getChildren().remove(removeObject);

 

7. 정리

지금까지 프록시의 동작 원리에 대해 알아보고, 즉시 로딩과 지연 로딩에 관해서도 알아보았다.
그리고 영속성 전이와 고아 객체 제거 기능도 알아보았다.

주요 내용은 다음과 같다.

  • JPA 구현체들은 객체 그래프를 마음껏 탐색할 수 있도록 지원하는데 이때 프록시 기술을 사용한다.
  • 객체를 조회할 때 연관된 객체를 즉시 로딩하는 방법을 즉시 로딩이라 하고, 연관된 객체를 지연해서 로딩하는 방법을 지연 로딩이라 한다.
  • 객체를 저장하거나 삭제할 때 연관된 객첵도 함께 저장하거나 삭제할 수 있는데 이것을 영속성 전이라 한다.
  • 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하려면 고아 객체 제거 기능을 사용하면 된다.

 

 

 

728x90