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

JPA에는 지연 로딩이라는 기능이 있다.
아래와 같은 엔티티가 있다고 할 때,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
class Member{
@Id
private Long id;

// ...

@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}

@Entity
class Team{
@Id
private Long id;

// ...
}

아래와 같이 Member를 조회하면 Team 테이블이 join 되어 같이 조회되었었다.

1
2
Member member = em.find(Member.class, 1);
// do something
1
2
3
4
SELECT T.*
FROM MEMBER M
LEFT JOIN TEAM T ON M.TEAM_ID = T.ID
WHERE M.ID = 1;

만약 Member 테이블만 필요하다고 하면, 매번 위처럼 조회되는 것은 매우 비효율적이다.
JPA는 이런 상황을 위해 지연 로딩이라는 기능(명세?)을 제공한다.
말 그대로 지연해서 로딩하는 것으로써, Member의 Team이 실제로 사용되는 순간에 해당 엔티티를 데이터베이스에서 조회해올 수 있다.

JPA의 표준 명세는 지연로딩의 구현 방법을 JPA 구현체에 위임했다.
하이버네이트는 지연로딩을 지원하기 위해 프록시를 사용하는 방법과 바이트코드를 수정하는 방법을 사용한다.

프록시

em.getReference()

EntityManager의 getRefernece() 메서드를 호출하면 엔티티를 바로 조회해오지 않고, 실제 사용하는 시점에 조회해올 수 있다.(find는 바로 조회해온다)
이러한 지연 로딩을 지원하기 위해 프록시 객체를 사용하는데, 반환되는 프록시 객체의 모습은 아마 아래와 같을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MemberProxy extends Member{
Member target = null;

public String getName(){
if(target == null){
// DB 조회
// 실제 엔티티 생성 및 참조 보관
this.target = // ...
}

return this.target.getName();
}
}

// 사용
@Test
public void getReferneceTest(){
Member member = em.getReference(Member.class, 1);
member.getName();
}

보다시피 프록시 객체는 상속을 사용하여 구현한다.
if(target == null) 내부에서 영속성 컨텍스트에 의해
데이터베이스를 조회해 실제 엔티티를 생성하는 것을 프록시 객체의 초기화라고 한다.
흐름은 아래와 같다.(영속성 컨텍스트는 비어있다고 가정한다)

  1. getReference()를 호출하면 프록시 객체를 생성한 뒤 영속성 컨텍스트(1차 캐시)에 저장한다.
  2. 실제 데이터를 얻기 위해 getName()을 호출한다.
  3. 프록시 객체는 영속성 컨텍스트에 실제 엔티티 생성을 요청한다(초기화)
  4. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성하고, 해당 객체의 참조를 target 변수에 보관한다.
  5. 프록시 객체는 target 변수에 저장된 실제 엔티티 객체의 getName()을 호출해서 결과를 반환한다.

영속성 컨텍스트는 1차 캐시를 포함한 큰 개념이므로, 헷갈릴 수 있음에 유의

프록시 객체는 다음과 같은 특징을 가진다.

  • 프록시 객체의 초기화는 딱 한번만 실행된다. target 객체에 저장되면 그것을 계속 사용하기 때문이다.
  • 원본 엔티티를 상속받은 객체이므로 타입 체크에 주의해야 한다.
  • em.getReference() 실행 시 영속성 컨텍스트에 찾는 엔티티가 이미 있으면(식별자로 조회), 프록시 객체 대신 실제 엔티티를 반환한다.
1
2
3
4
5
Member member1 = em.getReference(Member.class, 1);
Member member2 = em.find(Member.class, 1);

System.out.println("member1 : " + member1.getClass().getName());
System.out.println("member2 : " + member2.getClass().getName());

member1 : Member$HibernateProxy8guhz2idmemver2:Member8guhz2id memver2 : MemberHibernateProxy$8guhz2id

처음 호출될 때 식별자를 이용해 1차 캐시에 저장하고,
초기화 되면 해당 프록시 객체내의 target 변수에 값이 저장되게 되는것이다.
이후에 em.find로 엔티티를 조회해와도 이미 프록시 객체가 저장되어 있기 떄문에 해당 객체가 반환된다.
영속성 컨텍스트의 동작 방식을 더 잘 설명하기 위해 일부러 반대로 예시를 들었다.

참고로 아래는 getReference() 에서 추적한 내용인데, 보다시피 처음 초기화 될 때 proxy 객체들을 따로 저장해둔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 초기화
public void initialize(MetadataImplementor mappingMetadata, JpaMetaModelPopulationSetting jpaMetaModelPopulationSetting) {
// ...
for ( final PersistentClass model : mappingMetadata.getEntityBindings() ) {
// ...
// entity 객체 저장
entityPersisterMap.put( model.getEntityName(), cp );

// ....
// proxy 객체 저장
final String old = entityProxyInterfaceMap.put( cp.getConcreteProxyClass(), cp.getEntityName() );
// ...
}
}

// 사용
@Override
public EntityPersister locateEntityPersister(Class byClass) {
EntityPersister entityPersister = entityPersisterMap.get( byClass.getName() );
if ( entityPersister == null ) {
String mappedEntityName = entityProxyInterfaceMap.get( byClass );
// ...
}
}

프록시와 식별자

프록시 객체는 target 변수만 가지고 있는것이 아니라, 전달받은 식별자 값도 같이 저장한다.
그러므로 아래와 같이 식별자 값만 조회할 경우 직접적인 데이터베이스 조회가 일어나지 않는다.

1
2
Member member = em.getRefernece(Member.class, 1);
member.getId(); // SQL 실행하지 않음

이러한 특징을 이용하면 연관관게를 설정할 때 유용하게 사용할 수 있다.

1
2
3
4
5
6
7
8
9
Member member = new Member();
member.setName("joont");
member.setAge(27);

// team setting
Team team = em.getReference(Team.class, 1);
member.setTeam(team);

em.persist(member);

데이터베이스에서 연관관계를 설정할때 외래키로 해당 데이터베이스의 식별자밖에 사용하지 않는다.
즉, member를 persist 할 때 team의 id만 필요할것이고, 실제로도 그렇게 처리될것이다.
이럴 경우 team을 전체 조회해오는 find 보다는 getReference()를 사용해서 데이터베이스 접근 횟수를 줄일 수 있다.
참고로 현업에서는 외래키 제약조건을 안거는 경우가 많으니… DB레벨에서 오류가 발생하지 않으므로 위험할 수 있다.

프록시 확인

JPA에서 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메서드를 사용하면 프록시 객체의 초기화 여부를 확인할 수 있다.
아직 초기화 되지 않은 엔티티의 경우 false를 반환한다.
쓸일이 있을랑가…

즉시로딩, 지연로딩

JPA에서는 연관된 엔티티를 조회해올 때도 프록시 객체를 사용하여 지연로딩을 할 수 있다.
지연로딩 여부는 연관관계를 맺는 어노테이션(@ManyToOne, @OneToMany…)의 속성(fetch)으로 제공하여 상황에 따라 개발자가 선택해서 사용할 수 있게 해준다.

제공되는 속성은 즉시로딩, 지연로딩 두 가지이다.

즉시로딩

fetch 속성을 FetchType.EAGER로 주면 된다.

1
2
3
4
5
6
7
8
9
@Entity
class Member{
@Id
private Long id;

@ManyToOne(fetch = FetchType.EAGER) // 즉시로딩으로 설정
@JoinColumn(name = "team_id")
private Team team;
}
1
2
Member member = em.find(Member.class, 1); // team 까지 같이 조회됨  
Team team = em.getTeam(); // 실제 엔티티

이렇게 설정해두면 Member 엔티티가 조회될 때 Team 엔티티가 항상 같이 조회된다.
대부분의 JPA 구현체는 즉시로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.

지연로딩

fetch 속성을 FetchType.LAZY로 주면 된다.

1
2
3
4
5
6
7
8
9
@Entity
class Member{
@Id
private Long id;

@ManyToOne(fetch = FetchType.LAZY) // 지연로딩으로 설정
@JoinColumn(name = "team_id")
private Team team;
}
1
2
3
Member member = em.find(Member.class, 1);  
Team team = member.getTeam(); // 프록시 객체
team.getName(); // 이때 조회됨!

em.find(Member.class, 1)을 호출하면 Member만 조회하고 team 멤버변수에는 프록시 객체를 넣어둔다.
그리고 아래 실제 사용되는 부분에서 데이터가 조회된다.(동작 방식은 em.getReference()와 동일하다)
사용 시점에 조회해오므로 쿼리는 당연히 따로따로 날라간다.

컬렉션 래퍼
하이버네이트는 엔티티를 영속상태로 만들 때 엔티티에 컬렉션이 있으면
해당 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경한다.
이를 컬렉션 래퍼라고 하고, org.hibernate.collection.internal.PersistentBag 클래스이다.
에 클래스가 컬렉션 레벨에서 프록시 객체의 역할까지 같이 해주므로, 이 클래스를 통해 지연로딩을 달성할 수 있다.
참고로 컬렉션의 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화한다.

1
2
member.getTeam(); // SQL 실행안함  
member.getTeam().get(0); // SQL 실행

그래서 뭘 설정해야 하는데?

양방향 연관관계 설정과 똑같다. 사용되는 곳에 따라 어떤 전략을 선택하면 좋을지 체크해보고, 선택하면 된다.
참고로 각 연관관계들은 default fetch 값이 있다.

  • @OneToOne : EAGER
  • @ManyToOne : EAGER
  • @OneToMany : LAZY
  • @ManyToMany : LAZY

보다시피 default값은
추가적으로 하나만 로딩해도 될때는 즉시로딩 되도록,
추가적으로 많은 데이터가 로딩될 수 있을 경우에는 지연로딩 되도록 설정되어 있다.
(컬렉션을 로딩하는 것은 비용도 많이들고, 한번에 너무 많은 데이터를 로딩할 수 있기 때문이다.)

추천하는 방법은 전부 FetchType.LAZY를 사용하는 것이다.
그리고 어플리케이션 개발이 어느정도 완료단계에 왔을 때, 실제 사용 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.

참고로 SQL Mapper를 사용하면 이런 유연한 최적화가 어렵다.(ㅎㅎ)

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

  1. EAGER를 하나 이상 설정하는 것은 권장하지 않는다.
    컬렉션은 기본적으로 일대다 관계에서 사용되므로, 조인되는 테이블이 많아질수록 출력되는 row가 급격하게 증가하기 때문이다.
    예를 들어 A 테이블과 N, M 테이블을 일대다 조인하면 N * M 개수의 행이 반환되고, 결과적으로 성능이 저하될 수 있다.
    또한 JPA는 이렇게 조회된 결과 N과 M을 메모리에서 필터링 해서 반환하므로, 2개 이상의 컬렉션을 즉시 로딩으로 설정하는 것은 권장되지 않는다.

  2. 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
    내부 조인을 사용하면 자식이 없는 엔티티가 조회되지 않는 결과가 발생한다.
    이를 제약조건으로 막을 수 있는 방법이 없으므로, 무조건 외부 조인을 사용한다.

영속성 전이(CASCADE)

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 영속성 전이를 사용한다.
CASCADE라는 옵션으로 제공하고, 실제 데이터베이스의 CASCADE와 동일하다.

영속성 전이는 매우 간단하다.
EntityManager를 통해 영속성 객체에 수행하는 행동이 자식까지 전파된다고 보면 된다.
객체에 선언한 관계(@OneToOne, @OneToMany…)에 cascade 라는 속성값으로 지정해 줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
class Parent{
@Id
private Long id;

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

class Child{
@Id
private Long id;
}

public void save(){
Parent parent = new Parent();
// ...

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

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

em.persist(parent); // 한방으로 해결
}

원래라면 child, child2 따로따로 다 저장해줬어야 했을것이지만, 영속성 전이를 사용하여 편리하게 저장함을 볼 수 있다.

쿼리는 아래와 같이 날라간다.

1
2
3
4
insert into parent values(...);

insert into child values(...);
insert into child values(...);

간단하게 설명해 CascadeType.PERSIST를 설정함으로써 아래와 같아졌다고 보면 된다.

1
2
3
4
5
em.persist(parent);
==
em.persist(parent);
em.persist(child1);
em.persist(child2);

영속성 전이의 범위는 위의 cascade 속성의 값으로 준 애들에 대해서만 동작한다.
cascade의 종류는 아래와 같다.

1
2
3
4
5
6
7
8
public enum CascadeType { 
ALL,
PERSIST,
MERGE,
REMOVE,
REFRESH,
DETACH
}

CascadeType.MERGE를 주고 em.merge를 실행하면 자식까지 모두 em.merge가 실행되는 것이고,
CascadeType.REMOVE를 주고 em.remove를 실행하면 자식까지 모두 em.remove가 실행되는 것이다. 간단하다.
키워드는 간단히 부모가 XXX 될 때, 자식들도 전부 XXX 시켜라 정도로 이해하면 된다.

당연한 얘기지만 cascade 속성을 줬을 경우 속성이 설정된 엔티티의 자식까지 모두 cascade 속성을 줘야한다.
그렇지 않으면 CascadeType.PERSIST를 줬을 경우 자식 엔티티가 전부 저장되지 않을 것이고, CascadeType.REMOVE를 줬을 경우 FK 제약조건에 걸려 에러가 발생할 것이다.

어디에, 어떻게 사용하는게 좋은가?

https://vladmihalcea.com/a-beginners-guide-to-jpa-and-hibernate-cascade-types/
이 블로그에서 영속성 전이의 Best Practice에 대해 설명하고 있다(짱짱맨)

첫 부분에서 JPA와 Hibernate의 CascadeType 속성에 대해 비교해주고 있는데, 보다시피 hibernate가 더 많은 CascadeType을 지원함을 볼 수 있다.

여기서 문제가 될 수 있는건, CascadeType.ALL을 지정했을 경우다.
구현체가 hibernate이기 때문에, JPA의 CascadeType.ALL 을 지정하면 hibernate의 LOCK CascadeType 등등이 다 적용될 수 있다.
그러므로 주의해서 사용해야 한다. 개인적으로 ALL보다는 사용하는 애들만 적절히 써주는게 좋을것 같다고 생각함.

그리고 그 아래에서 각 관계에서의 Best Pracetice에 대해 설명해주고 있는데, 간단히 요약하면 아래와 같다.

  • @OneToOne은 bidirection 하게 설정해주는 것이 좋고, orphanRemoval 까지 주는 것이 좋다. 편의 메서드 또한 같이 넣어주는 것이 좋다.
  • 기본적으로 @OneToMany 에서 사용하는 것이 일반적이고, 가장 많이 사용된다.
  • @ManyToOne에서 cascade를 거는 것은 비정상적인 행위이니, 하지 않도록 한다.
  • @ManyToMany에서 CascadeType.REMOVE를 사용하게 되면 내가 예상한 것 보다 훨씬 많은 삭제가 일어날 수 있다.

고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데, 이를 고아(orphan)객체 제거라고 한다.
가 되는 엔티티에서 연관된 객체의 참조가 제거되면, 그것을 고아 객체로 보고 삭제하는 기능이다.

1
2
3
4
5
6
7
// @OneToOne
Member member = em.find(Member.class, 1);
member.setLocker(null); // locker 삭제됨

// @OneToMany
Parent parent = em.find(Parent.class, 1);
member.getChildren().remove(0); // child 삭제됨

자식이 부모의 생명주기에 묶여있는(특정 엔티티가 개인 소유하는) 엔티티에만 이 기능을 적용하는 것이 좋다.
삭제한 엔티티를 다른 곳에서도 참조하면 문제가 발생할 수 있기 떄문이다.
그래서 orphanRemoval은 @OneToMany, @OneToOne 관계에만 사용할 수 있다.

참고로 orphanRemoval에는 추가적인 기능이 하나 더 있는데,
부모를 자식까지 같이 제거되는 CascadeType.REMOVE의 기능이다.
개념적으로 부모를 제거하면 자식이 고아가 되기 떄문이다.

영속성 전이 + 고아객체

CacadeType.ALL + orphanRemoval = true 를 동시에 사용하면 부모를 통해서 자식 엔티티의 생명주기를 관리할 수 있게 된다.

1
2
3
4
5
6
7
// child insert
Parent parent = em.find(Parent.class, 1);
parent.addChild(child);

// child remove
Parent parent = em.find(Parent.class, 1);
parent.getChildren().remove(0);

CacadeType.PERSIST로 자식을 컨트롤 할 수 있게 한 이유가 이해가 안간다.

일단, EntityManager로 수행하는 메서드와 CascadeType은 별개라고 생각해야 한다.