[JPA 프로그래밍] 프록시와 연관관계 관리

최동근·2023년 1월 25일
0

JPA

목록 보기
7/13
post-custom-banner

해당 글은 김영한 님의 ["자바 ORM 표준 JPA 프로그래밍"] 을 스터디 하면서 정리하는 글 입니다 !👨‍💻

스프링 JPA 를 사용하면 엔티티끼리의 연관관계를 매핑하는 과정을 피할 수 없는 숙명이며, 스프링 개발자라면 능수능란하게 다룰 수 있어야 합니다.
이때 특정 엔티티를 사용할 때 영속성 컨텍스트에 엔티티가 존재하지 않는다면 DB 조회 과정을 거쳐야하는데 이때, 해당 엔티티와 연관관계를 맺는 다른 엔티티까지 조회해야하는지에 대한 문제가 발생합니다 🤔
여기서 프록시가 효과적으로 적용될 수 있습니다.

🎳 프록시

[1] 프록시란?

앞에서 이야기 했던 것처럼 엔티티를 조회할 때 연관된 엔티티들이 항상 필요한 것은 아닙니다.
회원(N) - 팀(1) 의 연관관계를 알아보겠습니다.

// MEMBER 엔티티
@Getter
@Setter
@Entity
public class Member {

	private String userName;
    
    @ManyToOne
    private Team team;
    
    ...
}
// TEAM 엔티티
@Getter
@Setter
@Entity
public class Team { 

	private String name;
    
    public String getName() {
   		return this.name;
    }
    
    ...
}

보이는 것처럼, 회원 엔티티는 팀 엔티티와 다대일 연관관계를 맺고 있습니다.
여기서 회원 엔티티를 조회하여 사용할 때, 팀 엔티티를 같이 사용하는 경우와 팀 엔티티를 같이 사용하는 경우 없이 회원 엔티티만 사용하는 경우 2가지가 존재합니다.
만약 회원 엔티티만 사용하는 경우에 연관관계를 맺는 팀 엔티티까지 조회한다면 효율적으로 좋지 않습니다 🥲
JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 합니다.
쉽게 이야기해서 team.getName() 처럼 팀 엔티티의 값을 실제 사용하는 시점에 DB 에서 해당 엔티티를 조회하는 방식을 의미합니다.

이렇게 지연 로딩을 사용하고자 할 때 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있는 가짜 객체가 필요한데 이를 프록시 객체라고 합니다 🧑🏼‍💻

EntityManager.find() 를 사용하면 엔티티를 조회하게 되고 해당 엔티티가 영속성 컨텍스트에 존재하지 않으면 DB를 조회합니다.

 Member member(실제 객체) = em.find(Member.class, 1); 

엔티티를 실제 사용하는 시점까지 DB 조회를 미루고 싶다면 EntityManager.getRefernece() 메소드를 사용하면 됩니다. 해당 메소드는 DB 접근을 위임한 프록시 객체를 반환합니다.

// 프록시 객체 얻는 방법
Member member(프록시 객체) = em.getReference(Member.class, 1);

[2] 프록시 구조

프록시 객체는 실제 엔티티 객체를 참조하고 있는 형태를 가집니다.
또한 실제 엔티티 클래스를 상속한 클래스의 인스턴스이기 때문에 동일한 구조를 가집니다.

이미지에서 알 수 있듯이 프록시 객체는 엔티티 객체를 참조하는 참조 변수를 필드로 가집니다.
또한 클래스가 동일한 형태를 가지고 있는 것을 확인할 수 있죠?
사용자는 프록시 객체를 통해 실제 엔티티에 접근할 수 있는 구조를 가지게 됩니다 👨‍💻

[3] 프록시 초기화

프록시 객체는 실제 사용될 때 DB 조회를 거쳐 실제 엔티티 객체를 생성하는데 이것을 프록시의 초기화 라고 합니다.

해당 이미지는 앞에서 예시로 들었던 Member 엔티티를 프록시 객체로 조회하는 과정을 설명합니다.

  1. Member 엔티티를 실제로 사용할 때( getName() 호출 시 ) 프록시 객체에 대해 초기화 요청을 합니다.

  2. 영속성 컨텍스트에 실제 엔티티가 존재하지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청합니다.

  3. 영속성 컨텍스트는 DB 조회를 통해 실제 엔티티 객체를 영속화 합니다.

  4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 필드로 보관합니다.

  5. 프록시 객체는 실제 엔티티 객체의 메소드를 통해 결과를 반환합니다.

[4] 프록시 특징

프록시 객체를 사용하게 되면 어플리케이션은 실제 엔티티 객체를 사용하게 되는 것이 아니라, 프록시 객체를 통해서 엔티티 객체에 접근합니다.
이외에도 여러가지 특징이 있습니다.

  • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
  • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다.
    (프록시 객체를 통해 실제 엔티티 객체에 접근하는 방식)
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크에 주의해야한다.
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference() 를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.
  • 초기화는 무조건 영속성 컨텍스트의 도움을 받아야 한다.
    만약 영속성 컨텍스트가 종료되었다면 기존 프록시 객체는 초기화가 불가능하다.

마지막 특징에 대해 예를 들어봅시다!

Member member = em.getReference(Member.class, "id1"); // 프록시 객체
transaction.commit(); // 커밋
em.close(); // 영속성 컨텍스트 종료

member.getName(); // 준영속 상태 초기회 시도 -> 실패

해당 코드를 보면 em.close() 메소드로 영속성 컨텍스트를 종료한 것을 볼 수 있습니다.
영속성 컨텍스트가 종료되었기 때문에 포함되었던 모든 영속 상태의 엔티티는 준영속 상태가 됩니다.
이때 member.getName() 호출로 프록시 객체의 초기화를 시도하면 예외가 발생합니다 ⛔️

[5] 즉시로딩 & 지연 로딩

지금까지 프록시 객체에 대해 알아보았습니다 💪
프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용합니다.
여기서 지연 로딩이란, 엔티티를 DB 에서 바로 조회하는 것이 아니라 실제로 사용될 때 해당 엔티티를 조회하는 방식을 의미합니다.
즉 프록시 객체를 이용한 로딩 방식이라고 생각하면 됩니다 👨‍💻
이와 대조되는 로딩 방식에는 즉시 로딩이라는 것이 있습니다.

  • 지연 로딩 : 연관된 엔티티를 실제 사용할 때 조회한다.
    • 설정 방법 : @ManyToOne(fetch = FetchTyep.EAGER)
  • 즉시 로딩 : 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
    • 설정 방법: @ManyToOne(fetch = FetchType.LAZY)

앞에서 예로 들었던 회원(N) - 팀(1) 연관관계를 통해 두 방식을 좀 더 알아보겠습니다.

// 지연 로딩 예시
@Entity
public class Member { 

	@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team; // 지연 로딩방식으로 Team 엔티티와 연관관계를 맺음
    
    // ...
    
}
// 지연 로딩으로 엔티티 객체 조회 
Member member = em.find(Member.class, 1); // Member 조회시 Team은 프록시로 조회
Team team = member.getTeam(); // 프록시 객체 반환
team.getTeam(); // Team 객체 실제 사용

회원(Member)와 팀(Team) 을 지연 로딩으로 설정하였습니다.
따라서 em.find(Member.class, 1) 호출시 회원만 조회하고 팀은 조회하지 않습니다.
대신 조회환 회원의 team 멤버 변수에 프록시 객체를 넣어둡니다 👨‍💻

여기서 Team team = member.getTeam() 을 하게 되면 Team 타입의 프록시 객체를 반환하게 되고
team.getName() 호출 시, 프록시 객체 초기화를 요청합니다.

이러한 과정을 거쳐 연관된 엔티티 객체를 조회하는 방식을 지연 로딩 이라고 합니다.


// 즉시 로딩 예시
@Entity
public class Member { 

	@ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID)
    private Team team
    
    // ...
    
}
// 즉시 로딩으로 엔티티 객체 조회 
Member member = em.find(Member.class, 1); // Member 엔티티와 Team 엔티티 같이 조회
Team team = member.getTeam(); 

회원(Member)와 팀(Team) 을 즉시 로딩으로 설정하였습니다.
따라서 em.find(Member.class,1) 호출시 회원과 팀 모두를 영속성 컨텍스트에 올려두게 됩니다.
이때 JPA 는 성능 최적화를 위해 가능하면 조인 쿼리를 이용하여 조회를 합니다.

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 = 1
// 외부 조인을 사용하여 두 엔티티 조회

이처럼 JPA 를 이용한 즉시로딩 은 가능하면 조인 쿼리를 이용하여 성능 최적화를 합니다 📚

[6] JPA 기본 패치 전략

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

JPA 기본 패치 전략은 연관된 엔티티가 하나면 즉시로딩을, 컬렉션이면 지연 로딩을 사용합니다.
컬렉션은 데이터가 기본적으로 여러개이기 때문에 로딩하는 것은 비용이 많이 들고 부담이 될 수 있습니다.
예를 들어 팀(Team) 엔티티를 조회 시 일대다 연관 관계의 회원(Member) 엔티티를 즉시 로딩으로 조회시 수만 개의 데이터를 조회한다면 성능 문제가 발생할 수 있습니다 🤖

추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것입니다.
그리고 어플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 됩니다.
[ 김영한님의 '자바 ORM 표준 JPA 프로그래밍' 305p]

🎳 영속성 전이

[1] 영속성 전이란?

우리가 사용하는 다양한 타입의 엔티티는 DB 작업을 필요로 합니다.
이때 JPA 에서는 영속성 컨텍스트라는 논리적인 공간을 두어 다양한 이점을 제공합니다.
예를 들어 사용하고자 하는 엔티티를 조회할 때 해당 엔티티는 무조건 영속성 컨텍스트를 거쳐서 조회 될 수 밖에 없습니다.

이때 특정 엔티티를 사용 할 때 동시에 연관된 엔티티를 사용하기 위해서는 두 엔티티 모두 영속 상태여야 합니다.
만약 사용하고자 하는 엔티티를 위해 일일히 영속화 하는 과정을 거치면 어떨까요? 매우 번거로운 작업이 될 수 있습니다. 이를 해결하기 위해 JPA 는 영속성 전이(Transitive Persistence) 기능을 제공합니다.

자녀 (N) - 부모 (1) 연관 관계를 통해 영속성 전이에 대해 알아보겠습니다.

// Parent 엔티티 클래스
@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;
// 부모, 자식 저장하는 코드

EntityManager em = EntityManagerFactory.creaeteEntityManager();

Parent parent = new Parent();
em.persist(parent); // 부모 엔티티 영속화

Child child1 = new Child();
child1.setParent(parent); // 자식 -> 부모 연관관계 설정
parent.getChildren().add(child1); // 부모 -> 자식 연관관계 설정
em.persist(child); // 자식 엔티티 영속화

Child child2 = new Child();
child2.setParent(parent); // 자식 -> 부모 연관관계 설정
parent.getChildren().add(child2); // 부모 -> 자식 연관관계 설정
em.persist(child2); // 자식 엔티티 영속화

JPA 에서 엔티티를 저장할 때 연관된 모든 엔티티는 이미 영속 상태여야 합니다 ❗️
따라서 위에 코드를 보면 알 수 있듯이, 관련된 모든 엔티티를 영속화 해야 정상적인 저장을 할 수 있습니다.
그러나 이때, 영속성 전이를 사용하면 부모 엔티티를 영속 상태로 만들 때 동시에 자식 엔티티도 영속 상태로 만듬으로써, 번거로운 영속화 작업을 줄일 수 있습니다.

영속성 전이 사용 방법은 CASCADE 옵션을 사용하면 됩니다 💪

[2] 영속성 전이 : 저장

영속성 전이를 이용해서 저장하는 예시를 공부해보겠습니다.
기존의 Parent 엔티티 클래스를 수정해봅시다!

// 영속성 전이를 적용한 Parent 엔티티 클래스
@Entity
public class Parent { 

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

CascadeType.PERSIST 옵션을 사용함으로써 부모 엔티티를 영속화할 때 동시에 자식 엔티티도
영속화 과정이 이루어집니다.

// CascadeType.PERSIST 영속성 전이 코드

Child c1 = new Child();
Child c2 = new Child();

Parent p = new Parent();
c1.setParent(p);
c2.setParent(p);
p.getChildren().add(c1);
p.getChildren().add(c2);

// 부모 영속화(자식 동시 영속화)
em.persist(parent);

해당 코드를 통해 정상적으로 자식 엔티티가 영속화되어 저장되는 것을 확인할 수 있습니다 👨‍💻
또한 영속성 전이는 연관관계를 매핑하는 것과는 아무 관련이 없습니다.
즉, Child 엔티티 쪽에 연관관계의 주인이 존재하지만 Parent 엔티티 쪽에서 영속성 전이를 적용할 수 있습니다.

[3] 영속성 전이 : 삭제

영속성 전이를 이용해서 삭제하는 예시를 공부해보겠습니다.
저장과 마찬가지로 삭제도 동일한 방식을 통해 부모 엔티티를 삭제시 자식 엔티티로 영속성 상태를 전이할 수 있습니다.

@OneToMany(mappedBy = "parent", CascadeType.REMOVE)

해당 설정을 통해 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제 됩니다.

Parent foundParent = em.find(Parent.class, 1);
em.remove(foundParent); // 부모 엔티티와 연관된 모든 자식 엔티티 삭제

[4] CASCADE 종류

앞에서 살펴본 저장과 삭제 말고도 다양한 Cascade 종류가 존재합니다.

이름역할
All모두 적용
PERSIST영속
MERGE병합
REMOVE삭제
REFRESHrefresh
DETACHdetach

🎳 고아 객체

[1] 고아 객체란?

JPA 는 부모 엔티티와 관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공합니다.
여기서 관계가 끊어진 자식 엔티티 객체를 고아 객체 라고 합니다.

@Entity
public class Parent { 
	
   @Id@GeneratedValue
   private Long id;
   
   @OneToMany(mappedBy = "parent", orphanRemoval = true) // 고아 객체 제거 기능 추가
   private List<Child> children = new ArrayList<>();
   ...
}
Parent p = em.find(Parent.class, id);
p.getChildren().remove(0); // 0번째 index 자식 엔티티를 제거

해당 코드는 0번째 인덱스의 자식 엔티티 객체를 컬렉션에서 제거하는 코드입니다.
이때 orphanRemoval = true 설정에 의해 데이터베이스에서 자식 엔티티 데이터가 삭제됩니다.
즉, 트랜잭션 플러쉬 시점에 SQL 삭제 쿼리가 실행됩니다.
만약 모든 자식 엔티티 객체를 제거하려면 p.getChildren().clear() 를 사용하면 됩니다.

orphanRemoval 설정의 원리는 참조가 제거된 자식 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능입니다.
만약 orphanRemoval 대상이 되는 자식 엔티티 객체가 다른 엔티티와 연관관계를 맺는다면 애플리케이션에 큰 문제가 발생할 수 있습니다.
따라서 orphanRemoval 은 부모 엔티티가 자식 엔티티를 개인 소유할 때 사용해야 합니다 🤔

또한 해당 기능은 만약 부모 엔티티 객체가 삭제된다면 모든 자식 엔티티는 삭제될 것임으로 CascadeType.REMOVE 기능을 포함합니다.

[2] 영속성 전이 + 고아 객체

만약 특정 자식 엔티티가 오로지 하나의 부모 엔티티에만 종속된다면 어떨까요?
이러한 경우는 부모 엔티티에 의해 자식 엔티티의 생명주기가 결정되는 상황입니다 🙆🏻
이때 CascadeType.ALL + OrphanRemoval = true 두 옵션을 활성화 하면 부모 엔티티를 통해 자식 엔티티를 관리할 수 있습니다.

// CascadeType.ALL + OrphanRemoval = true 저장
Parent p = em.find(Parent.class, 1);
p.addChild(c); // 영속성 전이 옵션에 의해 자식 엔티티 객체는 영속화된다.
// CascadeType.ALL + OrphanRemoval = true 저장
Parent p = em.find(Parent.clss, 1);
p.getChildren().remove(removedObject); // 자식 엔티티 데이터베이스에서 삭제

🎳 마치며

이번 포스팅에서 살펴보았던 프록시 객체 , 영속성 전이, 고아 객체는 실제 JPA 프로그래밍을 하면서 빈번하게 사용되는 개념들입니다.
실제 다양한 상황이 존재할 것이기 때문에 해당 개념에 대해 정확하게 이해가 되지 않은 상태로 프로그래밍 한다면 추후 큰 문제가 발생할 수 있습니다.
따라서 JPA 를 공부하는 누구든지 3가지 개념에 대해서는 확실히 알아야 할 것 같습니다 💪


참고

JPA 기초 프록시란 무엇인가
[JPA기본] 9. 프록시 객체
알고 쓰는 Cascade(영속성 전이)
[JPA] 영속성 전이와 고아 객체

profile
비즈니스가치를추구하는개발자
post-custom-banner

0개의 댓글