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

민스킴·2023년 9월 29일
0

Spring

목록 보기
4/12

이전 이야기

JPA 영속성 관리

프록시

엔티티를 조회할 때 연관된 엔티티를 항상 사용하는 것은 아니다. 예를 들어 회원 엔티티를 조회할 때 연관된 팀 엔티티를 사용할 때도 있고, 사용하지 않을 때도 있다.
따라서 매번 회원 엔티티를 조회할 때 팀 엔티티까지 조회하는 것은 불필요한 행동이다. 이것을 어떻게 해결해야 할까?

(회원 엔티티 예시)

@Entity
public class Member {
	@Column
    private String name;    
    
    @ManyToOne
    private Team team; // <--- 여기 멤버와 연관된 팀이 있다.
    
    ...
    
}

자자, 이럴 때 사용하는 것이 프록시이다 😎

프록시를 사용하면 연관된 객체를 처음부터 데이터베이스에서 조회하는 것이 아니라, 실제 사용하는 시점에 데이터베이스에서 조회할 수 있다.

이렇게 엔티티가 실제 사용될 때까지 조회를 지연하는 방법을 지연 로딩이라고 한다.

"그럼 어떻게 지연 로딩을 하는데?"

방법은 생각보다 간단하다.

"연관 엔티티를 가짜로 만드는 것이다...!"

여기서 말하는 가짜는 프록시를 말한다. 위에 회원 엔티티를 예시로 들어서 설명해보겠다.

JPA에서 회원 엔티티를 조회하면, 영속성 컨텍스트에 엔티티가 없는 경우 데이터베이스를 조회한다.
(회원 엔티티는 연관된 팀 엔티티를 참조하고 지연 로딩을 하려는 상황이다.)

데이터베이스에서 회원 엔티티를 조회하고 영속화 시킬 때, 참조하고 있는 팀 엔티티는 사실 프록시 객체가 된다. (영속성 컨텍스트에 팀 프록시를 엔티티 대신에 영속화하고 참조시킨다.)
프록시 객체는 팀 엔티티를 상속하여 만들어진다. 따라서 회원 엔티티는 프록시인지 팀 엔티티인지 구분하지 못하고 프록시를 참조하고 있게 된다.

"그럼 팀 엔티티는 언제 조회하나요?"

바로 팀 엔티티를 사용하는 순간이다.
team.getName()같은 메소드를 호출 하는 시점에 프록시가 데이터베이스에서 팀 엔티티를 조회하고 메소드를 연결 시켜준다.
아래 프록시 예상 코드를 보면 이해가 될 것이다.

(팀 엔티티 프록시 예상 코드)

class TeamProxy extends Team {
	Team target = null;  // 실제 엔티티를 참조. 처음에는 참조 안함.
    
    public String getName() {
    	if (target == null) {  // 처음으로 팀 엔티티를 사용하려는 시점이면
        	// 초기화 요청 실행
            // DB 조회
            // 실제 엔티티 생성 및 참조 보관
            this.target = ...;  // 실제로 참조하게 만듬
        }
        // 실제 팀 엔티티의 getName()을 호출하고 결과 값 반환
        return target.getName();
    }
}

프록시 초기화 과정을 분석해보자

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

프록시 객체는 처음 사용할 때 한 번만 초기화된다. 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것도 아니다.

참고로 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스에 조회하지 않아도 되기에 굳이 프록시를 사용하지 않는다. 또한 초기화는 영속성 컨텍스트의 도움을 받아야 가능하기에 준영속 상태의 프록시를 초기화하면 문제가 발생한다.



즉시 로딩과 지연 로딩

프록시는 주로 연관된 엔티티를 지연 로딩 할 때 사용한다.

"그럼 무조건 지연 로딩이 짱 아니야?"

당연히 아니다. 상황에 따라서 즉시 로딩을 하는 것이 좋을 때가 있다.
즉시 로딩은 엔티티를 조회하는 시점에 연관 엔티티도 같이 조회하는 것을 말한다.

하이버네이트를 비롯한 대부분의 JPA 구현체가 조인 쿼리를 사용해서 한번에 조회한다.

어노테이션 설정

@ManyToOne(fetch = FetchType.EAGER) : 즉시 로딩으로 설정
@ManyToOne(fetch = FetchType.LAZY) : 지연 로딩으로 설정

@ManyToOne, @OneToOne : 즉시 로딩이 기본 설정
@OneToMany, @ManyToMany : 지연 로딩이 기본 설정이다



영속성 전이

연관관계를 사용한다면 영속성 전이도 알아두는 것이 좋다.
영속성 전이는 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 같이 영속 상태로 만드는 기능을 말한다.

아래에 부모 엔티티와 자식 엔티티의 예시를 살펴보자

// 부모 엔티티
@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에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다. 따라서 부모 엔티티를 저장할 때 자식 엔티티도 영속 상태여야 한다.

부모 하나와 자식 둘을 저장하는 코드를 작성한다고 하면 다음과 같아야 한다.

private static void saveNoCascade(EntityManager em) {
	// 부모 저장
    Parent parent = new Parent();
    em.persist(parent);  // 엔티티 매니저로 엔티티 컨텍스트에 영속화
    
    // 1번 자식 저장
    Child child1 = new Child();
    child1.setParent(parent);  // 자식1 -> 부모 연관관계 설정
    parent.getChild().add(child1);  // 부모 -> 자식1 연관관계 설정
    em.persist(child1);
    
    // 2번 자식 저장
    Child child2 = new Child();
    child2.setParent(parent);  // 자식2 -> 부모 연관관계 설정
    parent.getChild().add(child2);  // 부모 -> 자식2 연관관계 설정
    em.persist(child2);
}

자, 이 코드를 어떻게 간편하게 바꿀 수 있을까?

영속성 전이: 저장

영속성 전이를 활성화하는 CASCADE 옵션을 적용하면 쉽게 해결할 수 있다.

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

이렇게 cascade = CascadeType.PERSIST 옵션을 사용하면 부모를 영속화 시킬 때 자식도 한번에 영속화 할 수 있다.

아래에 저장 예시를 보자

private static void saveWithCascade(EntitiyManager em) {
	
    Child child1 = new Child();
	Child child2 = new Child();
    
    Parent parent = new Parent();
    child1.setParent(parent);  // 자식1 -> 부모 연관관계 설정
    child2.setParent(parent);  // 자식2 -> 부모 연관관계 설정
    parent.getChildren().add(child1);  // 부모 -> 자식1 연관관계 설정
    parent.getChildren().add(child2);  // 부모 -> 자식2 연관관계 설정
    
    em.persist(parent);  // 부모 저장으로 연관된 자식들도 같이 저장 
}

영속성 전이를 사용해서 em.persist(child) 코드를 생략할 수 있었다.

그렇다면 부모와 자식을 한번에 삭제하는 방법은 없을까?

영속성 전이: 삭제

방금 저장한 부모와 자식 엔티티를 모두 제거하려면 다음 코드와 같이 각각의 엔티티를 하나씩 제거해야 한다.

Parent parent = em.find(Parent.class, 1L);
Child child1 = em.find(Child.class, 1L);
Child child2 = em.find(Child.class, 2L);

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

이것도 영속성 전이를 사용하면 쉽게 해결할 수 있다. 이번에는 cascade = CascadeType.REMOVE 옵션을 사용해보자.

Parent parent = em.find(Parent.class, 1L);
em.remove(parent);  // 부모와 연관된 자식들 전부 삭제

삭제 코드가 매우 깔끔해졌다. 자식 엔티티를 하나하나 찾아서 삭제시킬 필요가 없기 때문이다!

CASCADE의 종류는 여러 개가 있다. 아래 코드를 보자.

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

고아 객체

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

놀랍게도 이 기능을 사용하면 부모 엔티티의 컬렉션에서 참조하는 자식 엔티티를 제거하면 자식 엔티티가 자동으로 삭제된다!

적용하는 방법은 아래와 같다.

@Entity
public class Parent {
	@Id @GeneratedValue
    Private Long id;
    
    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<Child>();
    ...
}

...

// 삭제하는 코드
Parent parent = em.find(Parent.class, id);
parent.getChildren().remove(0);  // 첫번째 자식 엔티티를 컬렉션에서 제거

영속성 전이와 고아 객체 제거 기능을 사용하면 부모 엔티티가 자식 엔티티의 생명주기를 관리할 수 있다!


요약

  1. 프록시를 사용하여 데이터베이스에서 연관 엔티티를 조회하는 시점을 늦출 수 있는데 이를 지연 로딩이라고 한다.
  2. 객체를 저장하거나 삭제할 때 연관된 객체도 함께 저장하거나 삭제할 수 있는데, 이것을 영속성 전이라고 한다.
  3. 고아 객체 제거 기능을 사용하면, 부모 엔티티와 연관관계를 끊어서 자식 엔티티를 자동 삭제할 수 있다.
  4. 영속성 전이 + 고아 객체 기능을 사용하면 부모가 자식 엔티티의 생명주기를 관리할 수 있다.

이번에 프록시와 연관관계 관리를 정리해보았는데 JPA를 사용하면서 매우 중요한 부분이라고 생각한다.

profile
Boys, be ambitious!

0개의 댓글