프록시와 연관관계 관리

김민우·2022년 9월 14일
0

JPA

목록 보기
7/10
post-thumbnail

이전에 배웠던 즉시, 지연 로딩을 생각해보자. 이것들이 어떤 방식으로 작동하는 것일까? 그 비밀은 프록시에 있다.

JPA에서 프록시는 매우 어려운 개념이다. (C언어의 포인터정도의 개념이라 어떤 느낌인지 알 것이다.) 이제부터 알아보자.


프록시

다음은 Member 안에 Team 객체 필드가 있다.

@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;
    
     @OneToOne
    private Team team;
    
    ...
}

우리가 Member 객체를 조회하는 이유는 내부의 필드값을 사용하거나 메소드를 호출하거나 등 다양하다. 그러면 Member 객체를 조회할 때 항상 Team 객체를 조회해야 할까?

아니다. 앞서 얘기했듯 비지니스 상황에 따라 Member 객체만 필요한 경우가 존재하고, Member 객체와 Team 객체도 필요할 수 있다. 사용하지 않을 때에도 Team 객체를 조회하면 비효율적이다.

JPA는 이러한 낭비가 발생할 수 있는 상황을 해결할 수 있는 방법이 존재하는데 그것이 바로 프록시와 그것을 기반으로 하는 지연 로딩(Lazy Loading)이다.

프록시 기초

JPA는 em.find() 메소드 외에 em.getReference() 라는 참조를 가져오는 흥미로운 메소드를 제공한다.

  • em.find() : DB에 저장되있는 실제 엔티티 객체를 조회
  • em.getReference : DB 조회를 지연시키는 가짜(프록시) 객체를 조회

예시

Member member = new Member();
member.setId(1L);
member.setUsername("hello");
em.persist(member);

em.flush();
em.clear();

Member findMember1 = em.find(Member.class, member.getId());
System.out.println("findMember1 = " + findMember1.getClass());

Member findMember2 = em.getReference(Member.class, member.getId());
System.out.println("findMember2 = " + findMember2.getClass());

tx.commit();

참고

em.flush();
em.clear();

영속성 컨텍스트를 초기화 하고 어플리케이션을 다시 띄운다. (처음처럼 시작한다고 생각하면 된다.)

출력 결과

findMember1 = hellojpa.Member
findMember2 = hellojpa.Member@38eb0f4d
  • 하이버네이트가 강제로 만든 클래스이다. 이를 프록시 클래스라 한다.

하이버네이트가 내부의 라이브러리를 사용해서 가짜(프록시) 엔티티 객체를 준다. 이 가짜 엔티티 객체는 껍데기는 동일하나 안이 텅 비었다. 내부에는 타겟이 존재하여 이는 실제 객체의 참조이다. 즉, 실제 엔티티 객체를 가리킨다.

프록시 객체는 실제 엔티티 객체를 상속받아 만들어진다. 물론 이 과정은 하이버네이트가 내부적으로 프록시 라이브러리를 사용하여 만들어낸다. 따라서, 실제 클래스와 겉모양이 같다. 이론상 사용하는 입장에선 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.

프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

출력 결과에서 더 눈여겨볼 점은 쿼리이다. em.find()는 Select Query가 나가는 반면, em.getReference()는 Select Query가 나가지 않는다.

그러면 언제 DB에 Query를 날릴까? 이 값이 실제 사용되는 시점에서 DB에 쿼리를 날린다. 그래서 객체에 값을 채운다. (물론 실제로 값을 채우는 과정에는 복잡한 과정이 존재한다.)

프록시 객체의 초기화

프록시 객체를 통해 메소드를 호출 매커니즘

Member member = em.getReference(Member.class, "id1"); // member는 프록시 객체
String name = member.getName();

  1. getName() 메소드를 호출했을 때 프록시 객체의 타겟에 값이 있는지 없는지 확인한다.
  2. 값이 없으면 JPA가 영속성 컨텍스트에 실제 객체를 요구한다.
  3. 영속성 컨텍스트는 DB를 조회해서 실제 엔티티 객체를 생성하여 반환하고 프록시 객체의 타겟과 연결시켜준다.
  4. 타겟과 연결된 실제 객체의 getName() 메소드가 호출된다.

2~3번 과정 : 영속성 컨텍스트를 통해서 초기화 요청하는 과정이
물론, 한 번 초기화하면 타켓에 값이 입력됬으므로 다시 DB를 조회할 일(초기화 요청할 일)은 없다.

중요!
프록시 객체는 처음 사용할 때 한 번만 초기화된다. 재사용시에는 처음 입력된 값으로 사용된다. 프록시 객체를 초기화할 때 프록시 객체가 실제 엔티티로 바뀌는 것이 아니라 처음 null 값인 프록시 객체 내부의 타겟이 초기화되는 것이다.

따라서, JPA에서 타입 비교를 할 때에는 == 비교는 가급적 하지 말고, instanceof 를 사용하자. JPA가 개발자 모르게 내부적으로 실제 객체가 아닌 프록시 객체를 만들어 대입할 수 있으니깐. 이는 코드상으로 알아차리기 힘들다.

프록시 특징

만약, 조회하려는 엔티티가 DB가 아닌 영속성 컨텍스트에 있으면 어떨까? 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference() 를 호출해도 실제 엔티티를 반환한다.

// member1는 영속성 컨텍스트에 저장된다.
Member m1 = em.find(Member.class, member1.getId());

// 영속성 컨텍스트에 저장되있는 객체를 가져오므로 실제 엔티티가 반환된다.
Member m2 = em.getReference(Member.class, member1.getId()); 

반대로, DB에 저장된 객체에 대해 프록시 객체를 먼저 호출하고 실제 객체를 호출해도 한 트랜젝션 안에서는 서로 같은 참조를 갖는다. (이 때, 둘은 실제 객체가 아닌 프록시 객체지만 참조는 같다.) 즉, 프록시 객체는 처음 사용할 때 한 번만 초기화된다.

JPA는 같은 영속성 컨텍스트 (같은 트랜젝션 레벨)안에서 같은 인스턴스 라는 의미인 == 값은 항상 같다( true )라고 나와야 한다. JPA는 자바 컬렉션의 특징을 가진다는 걸 명심하자. 또한, 자바 컬렉션에서 똑같은 참조는 같은 인스턴스인게 당연하다.

Member m1 = em.getReference(Member.class, member1.getId());
m1.getUsername();

Member m2 = em.find(Member.class, member1.getId());

실제로 프록스 객체의 타겟에 값이 입력되고 나서 실제 객체가 호출되므로 원래대로라면 m1m2의 참조는 다를 것이다. 그러나, JPA는 이 둘이 같은 참조임을 보장해줘야 한다. 어떻게 할까? JPA는 em.find() 메소드애서 기존에 불렸던 프록시 객체를 리턴한다.

그러면 em.find() 메소드를 통해서 프록시 객체가 불릴 수 있다는 의문이 든다. 맞지만 중요한 건 그것이 아니다. 우리가 개발할 때 이 객체가 프록시인지 아닌지는 크게 상관이 없다.

JPA는 이러한 것에 혼동을 주지 않기 위해 같은 트랜잭션 안에서 같은 참조에 대한 == 값은 항상 true 가 나오게 하므로 이러한 것도 가능한 것이다. (물론, 실무에선 이렇게 복잡할 일은 없다...)

영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태에서, 프록시를 초기화하면 문제가 발생한다. 실무에서 이 문제를 진짜 많이 만나게 된다.
이유는 무엇일까? 프록시 객체의 초기화 과정은 영속성 컨텍스트를 통해 실행된다. 만약에, 영속성 컨텍스트를 종료시킨 경우나 해당 프록시 객체를 더이상 영속성 컨텍스트가 관리하지 않는 경우 프록시 객체를 초기화 하면 당연히 예외가 발생한다.
(참고로 하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트린다.)

프록시 확인

프록시 확인하는 유틸리티 메소드들

  1. 프록시 인스턴스의 초기화 여부 확인
  • PersistenceUnitUtil.isLoaded(Object entity)
    • 예) emf.getPersistenceUnitUtil().isLoaded(refMember);
    • 특징 : EntityManagerFactory 객체를 통해 PersistenceUnitUtil 객체를 얻어야 한다.
    • 프록시 인스턴스의 초기화 여부를 boolean 타입으로 리턴한다.
  1. 프록시 클래스 확인 방법
  • entity.getClass().getName() 출력(..javasist.. or HibernateProxy...)
  1. 프록시 강제 초기화
  • org.hibernate.Hibernate.initialize(entity);

참고로 JPA 표준은 강제 초기화가 없지만 하이버네이트에는 존재한다.
강제 호출: member.getName();

이거 뭔가 이상하다. 다른 용도로 정의된 메소드를 초기화 용도로 사용한다니..
하이버네이트는 이러한 이상한 점을 해결하기 위해 Hibernate.initialize(프록시 객체) 메소드를 지원한다. 이 메소드를 통해 프록시 객체를 강제로 초기화할 수 있다.

사실, em.getReference()는 실무에서 잘 사용하진 않는다. 그럼 굳이 이걸 왜 공부했을까? 프록시의 매커니즘을 이해해야 즉시 로딩과 지연 로딩을 깊게 이해할 수 있기 때문에 공부한 것이다.


즉시 로딩과 지연 로딩

프록시에서 살펴봤던 상황을 다시 생각해보자. 단순히 Member 만 필요로 하는 상황을 생각해보자. 굳이 연관관계가 맺어져 있는 Team 객체까지 호출할 필요가 없을 것이다. JPA는 이러한 상황을 위해 지연 로딩이라는 옵션을 제공한다.

이와 반대로 항상 Member 객체가 호출될 때 반드시 Team 객체도 호출되는 상황을 생각해보자. 이럴 때는 지연 로딩을 사용하면 오히려 성능이 저하될 것이다. JPA이 제공하는 즉시 로딩이라는 옵션을 사용하면 이러한 문제점을 해결할 수 있다.

이 둘에 대해 자세히 알아보자.

지연 로딩

위 같은 상황처럼 Member 객체를 조회할 때 마다 Team 객체가 조회되는 것을 프록시 객체를 통해 지연시켜주는 옵션이다.

사용법은 간단하다. 다음 같이 적용하고 싶은 필드의 연관관계 어노테이션에 속성 추가하면 된다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
...

이렇게 두면 실제 Team 객체를 사용하는 시점에 초기화(DB를 조회)가 된다.

Member m = em.find(Member.class, member1.getId());
System.out.println("m = " + m.getTeam().getClass());
<출력>
m = class hellojpa.Team$HibernateProxy$DHW6v9tI
  • Member 객체만 가져오고 Team 객체는 프록시 객체로 가져온다.

그러면 실제 객체는 언제 초기화 될까? 실제 Team 객체를 사용하는 시점에 초기화한다.

  • member.getTeam(); : 초기화 X
  • member.getTeam.getName(); : 초기화 O

실제 객체를 단순히 호출만 하는 것이 아닌 호출하고 건들때(?) 초기화된다고 생각하자.

즉시 로딩


말 그대로 즉시 로딩을 시킨다. 연관관계에 있는 모든 엔티티들을 즉시 로딩시킨다.
사용법은 지연 로딩과 동일하다. 다만 어노테이션 속성값만 다르다.

@ManyToOne(fetch = FetchType.EAGER) //
@JoinColumn(name = "TEAM_ID")
private Team team;
...

Member 객체를 생성하고 다음 코드을 실행해보자.

System.out.println("==========");
m.getTeam().getName();
System.out.println("==========");

Team 객체를 호출하는 순간 Team 객체 쿼리가 날라온다. 즉시 로딩으로 하면 MemberTeam 을 조인 후 한 번에 쿼리를 날린다. (JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회한다.) 이럴 때는 당연히 프록시 객체가 필요가 없다.

프록시와 즉시 로딩 주의

지금까지 살펴본 지연 로딩과 즉시 로딩은 실무에서 상황에 따라 적절하게 선택을 하면 될 것 같지만 실무에서는 즉시 로딩을 사용하면 안된다. 그래서 연관관계를 맺을 때는 다음을 꼭 참고하자.

  • @ManyToOne, @OneToOne 어노테이션은 디폴트가 EAGER이므로 LAZY로 설정해야 한다.
  • 반대로, @OneToMany, @ManyToMany 어노테이션은 디폴트가 LAZY이므로 별도의 설정을 하지 않아도 된다.

@XXXToOne..EAGER, XXXToManyLAZY라 알아두자.

그러면 대표적인 문제점들을 알아보자.

  1. 즉시 로딩을 적용하면 전혀 예상하지 못한 SQL이 발생한다.
    DB 입장에서 조인하는 쿼리가 많아지면 성능이 급격하게 다운된다. Member 객체에 연관관계가 맺어져 있는 객체가 무수히 많다고 생각해보자. 엄청난 성능 저하가 발생할 것이다.
  1. N + 1 문제
    em.find()라는 메소드는 pk를 한 번 찍어서 가져오는 것이기 때문에 JPA에서 내부적으로 찾아서 할 수 있다.
    그러나, em.createQuery()같은 경우는 파라미터문을 그대로 sql로 번역을 한다.
    번역을 하면 다음과 같다.
  • SQL : select * from Member 이 DB에 나가서 Member 를 가져온다
  • Member 객체를 보니깐 Team 객체도 가져와야 되네? (EAGER 이므로)
  • List 로 리턴할 때 값이 이미 입력되어야 한다. 그래서 부랴부랴 SQL이 또 나간다.
    (SQL : select * from Team where TEAM_ID = xxx)

이 때문에 속칭 N + 1 문제를 일으킨다. (1 : 최초 쿼리) 이 문제를 해결하는 방법은 모두 지연 로딩으로 하기, join fetch 사용, 배치 사이즈가 있다.

참고) N + 1 문제
최초 쿼리 때문에 N개의 쿼리가 추가적으로 나간다라는 뜻

결론을 말하자면 왠만하면 다 LAZY로 설정하고 정말 EAGER가 필요한 경우에는 join fetch를 사용하자.


영속성 전이 : CASCADE

영속성 전이는 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들도 싶을 때 사용한다.
즉시, 지연 로딩과 관련이 있다고 오해를 많이 하는 부분인데 이 둘과 전혀 관련 없다.
(혼선을 겪는 부분 : 예를 들어 부모를 저장할 때 자식을 저장하고 싶은 경우에 사용하는데 이러한 과정에서 지연, 즉시 로딩이 사용된다고 오해한다.)

그러면 주로 어떤 상황에서 사용할까? 다음을 보자.

Child 객체는 Parent 객체의 자식 객체이고, 일대다 매핑이다.
나는 Parent 객체가 Child 객체를 알아서 관리해줬으면 한다. 즉, Parent 객체를 persist할 때 Child 객체들도 자동으로 persist 되기를 원한다.

그러나 지금까지 배운 것으로는 다음과 같이 직접 em.persist()를 통해 Child 객체를 저장해야 한다.

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

Parent parent = new Parent();

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

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

이럴 때 사용하는 것이 영속성 전이(CASCADE) 이다. 영속성 전이를 사용하면 이러한 귀찮음을 해결할 수 있다!

위 상황을 이어서 생각해보자.

Parent 객체를 저장할 때 자동으로 children의 모든 Child 객체들도 저장한다. 설정하는 방법은 연관관계 어노테이션에 속성을 추가하면 된다.

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

CASCADE의 종류

CASCADE의 종류는 여러 가지가 있는데 용도에 따라 잘 사용하면 된다.

  • ALL: 모두 적용
  • PERSIST: 영속
  • REMOVE: 삭제
  • MERGE: 병합
  • REFRESH: REFRESH
  • DETACH: DETACH

주로, PERSIST, ALL 정도만 사용한다. (PERSIST : 저장할 때만 적용)

주의점

주의할 점은 영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없다는 것이다. 단순히, 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐이다.

실무에서 자주 사용한다. 그럼 언제 사용할까? 하나의 부모가 여러 자식 객체들을 관리할 때 영속성 전이는 의미가 있다. (예 : 게시판, 첨부 파일 등...)
물론 해당 첨부파일을 다른 엔티티에서도 관리하는 상황에서는 쓰면 안된다.
즉, 소유자가 하나일 때(연관관계가 한 개의 객체랑만 있는 경우, 단일 엔티티 객체에 완전히 종속적인 경우)는 사용해도 된다.

요약하자면, 전제 조건은 다음과 같다.

  • 두 객체의 라이프 사이클이 같을 때
  • 단일 소유자일 때

고아 객체

고아라는 단어의 뜻을 생각해보고 이를 JPA에 그대로 적용해보자. 고아 객체가 어떤 뜻인지 짐작이 갈 것이다. 바로 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 의미한다.

고아 객체 제거는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제한다는 뜻이다.

적용 방법은 연관관계 어노테이션에 별도의 속성(orphanRemoval = true)을 추가하면 된다.

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> childList = new ArrayList<>();
  • 해당 컬렉션에서 제외된 객체는 자동으로 삭제된다.

주의점

참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로
보고 삭제하는 기능이므로 반드시 참조하는 곳이 하나일 때 사용해야한다. 참조가 여러 개인 엔티티가 하나의 참조가 없어졌다고 삭제해버리면 진짜 큰일난다..

그리고, @OneToOne, @OneToMany만 사용 가능하다.

마지막으로 특정 엔티티가 개인 소유 할 때 사용한다는 것을 명심하자.

참고
개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 고 아 객체 제거 기능을 활성화 하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 CascadeType.REMOVE처럼 동작한다.


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

영속성 전이는 생성에 관련있고, 고아 객체는 삭제에 관련이 있으니 같이 사용이 가능할 것 같다.
그러면, 연관관계 매핑 어노테이션에 CascadeType.ALL + orphanRemovel=true을 추가해보자.

이렇게 하면 자식 엔티티의 생성, 삭제를 모두 부모 엔티티를 통해서 관리한다.
즉, 부모 엔티티를 통해 자식의 생명 주기를 관리할 수 있다.

이는 도메인 주도 설계(DDD)의 Aggregate Root개념을 구현할 때 유용하다.

0개의 댓글