[자바 ORM 표준 JPA 프로그래밍 - 기본편] 프록시와 연관관계 관리

이재표·2023년 10월 1일
0

프록시

왜사용해야할까??
Member를 조회할때 Team도 함께 조회해야할까?

다음과 같은 연관관계가 있을때 member객체만 필요할수도 있고, member와 team객체가 둘다 필요할수도 있다. 코드로 확인해보자

// 영속성과 트랜잭션에 관련된 코드는 제외

Member member = em.find(Member.class, 1L);
1. printMemberAndTeam(member); //member와 team을 둘다 필요로 하는 경우
2. printMember(member); //member만 필요로 하는 경우

member객체와 team객체가 모두 필요한 경우면 상관없을수 있지만, member객체만 필요한데 연관관계로 인해 team객체까지 쿼리를 날려 가져오게 되면 성능상 낭비일수 밖에 없다. 이것을 지연로딩과 프록시라는 개념으로 해결해보자!

프록시?

엔티티 매니저에는 em.find()와 em.reference() 두가지 조회 메서드가 존재한다.

em.find() : 데이터베이스를 통해 실제 엔티티 객체조회
em.getReference() : 데이터베이스 조회를 통해 가짜(프록시)엔티티 객체 조회

메서드를 확인해보자

...
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.id = " + findMember.getId());
System.out.println("findMember.name = " + findMember.getName());

코드 실행시 모든 엔티티(Member,Team)를 조회하는 쿼리가 나가는 것을 볼수 있다.

reference()메서드를 확인해보자

...
Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember.id = " + findMember.getId());
System.out.println("findMember.name = " + findMember.getName());

이전과 달리 id가 출력된후 쿼리가 나가는것을 볼수 있는데, id의 경우 조회할때 값을 넣어줬기때문에 바로 출력된것이다. 즉, 특정 엔티티를 실제로 사용할때 쿼리가 나간것을 볼수 있다.

이때 findMember를 출력해보면 class hellojpa.Member$HibernateProxy$hybNbeUr처럼 프록시 객체가 출력되어있는 것을 볼수 있다.

em.getReference()실행하면 프록시 객체가 반환되는데, 프록시 객체는 다음과 같이 구성되어있다.

실제 엔티티의 메서드를 가지고, 실제엔티티를 가르기는 target이라는 값을 가지고 있다. 만약 실제 엔티티가 존재하지 않을때는 null을 가지고 있다.

좀 더 자세히 살펴보면 위의 그림과 같이 프록시객체는 실제 엔티티를 상속받아 만들어지기에, 실제 클래스와 겉모습은 똑같다. 그래서 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출하게 된다.

그렇다면 프록시 객체는 어떻게 엔티티와 연결되어 가져오는 것일까?
다음과 같은 과정을 통해 호출한다.

  1. 특정 엔티티의 메서드를 호출한다.
  2. 프록시 객체를 호출한다.
  3. 이때 프록시 객체의 target이 null이라면 영속성 컨텍스트에 실제 객체를 가져오도록 초기화를 요청한다.
  4. 실제 엔티티를 데이터베이스에서 가져와 생성한다.
  5. 프록시객체가 실제 엔티티를 연결하고, 메서드를 호출한다.

프록시의 특징!!

그럼 프록시의 특징을 마저 살펴보자(중요!!)

  • 프록시 객체는 처음 사용할때 한번만 초기화 되지만, 초기화 하는것이 실제 엔티티로 바뀌는 것이 아니다! 단지 실제 엔티티와 연결이 되는것이다.

    프록시 객체가 초기화 된후 실제 엔티티를 찾더라도(em.find()) 프록시 객체가 나가게 된다.

  • 프록시 객체는 원본 엔티티를 상속받기 때문에, 타입체크시 주의해야한다.(==비교가 실패된다.)

    타입 비교는 정말 똑같아야만 true가 나오기 때문이다. 실무의 경우 메서드의 인자로 특정 클래스를 받아 비교하는 경우가 많은데, 이때 인자로 무엇이 들어올지 모르기때문에 타입 체크시 ==가 아닌 instanceof를 이용해야한다.

  • 영속성 컨텍스트에 찾는 엔티티가 이미 있다면 em.getReference()를 호출해도 실제 엔티티를 반환한다.

    jpa는 같은 객체라면 타입체크가 true가 되어야하도록 만들었다. 때문에 프록시 객체를 만들었으면, find를 해도 프록시 객체를, find를 통해 실제 엔티티를 받았다면 getReference를 해도 실제 엔티티가 반환된다. 따라서 영속성 컨텍스트에 엔티티가 들어있다면 프록시 객체를 만들지 않는다.

  • 영속성 컨텍스트의 도움을 받을수 없는 준영속 상태일때, 프록시를 초기화하면 문제 발생한다.

    영속성 컨텍스트를 통해 프록시가 초기화 되기 때문에 영속성 컨텍스트의 상태가 준영속 상태가 된다면 에러가 반환된다.(detach,close,clear 등 영속성 컨텍스트를 삭제 또는 완전 지워버리면 같은 에러 반환). 꽤많이 발생하기 때문에 org.hibernate.LazyInitializationException 에러 발생시 영속성 컨텍스트의 문제라 생각하면 된다.

프록시를 확인하는 법!!

프록시에 대한 정보를 확인하는 방법에는 여러가지가 있다.

  • 프록시 인스턴스의 초기화 여부 확인

    EntityManagerFactory.PersistenceUnitUtil.isLoaded(Object entity) 를 통해 초기화 여부 확인가능

  • 프록시 클래스 확인 방법

    entity.getClass().getName() 출력(..javasist.. or
    HibernateProxy…)

  • 프록시 강제 초기화

    org.hibernate.Hibernate.initialize(entity);
    참고) JPA 표준은 강제 초기화 없음
    강제 호출: member.getName()

즉시로딩와 지연로딩

프록시의 개념을 모두 인지하였으면 즉시로딩과 지연로딩에 대해 알아보자!

지연로딩

해당 글의 처음시작할때 작성한 질문을 다시해보겠다. " Member정보만 필요한 경우 Member를 조회할때 Team도 함께 조회해야할까?" 답은 지연로딩으로 해결가능하다 이다.

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

다음과 같이 연관관계가 있을때 연관관계 어노테이션의 옵션에 fetch = FetchType.LAZY옵션을 넣을시 지연로딩이 적용된다.

다음과 같이 member를 조회할때 '지연로딩' 옵션이 걸려있다면 해당 연관관계 엔티티는 프록시객체가 연결되어 조회되지 않게 된다.

  • 지연로딩을 이용할때 중요한것은 지연로딩이 설정되어 있는 객체를 조회할때가 아닌 프록시 객체의 메서드나 필드를 실제로 사용하는 시점에 초기화(DB조회)가 된다는 점이다!!
  • 단순히 객체를 조회하면 프록시 객체가 조회되고 만다!!

즉시로딩

그렇다면 반대로 Member와 Team을 한번에 자주쓰는 경우가 많다면? 그런경우는 즉시 로딩을 사용하면 된다!!
연관관계 어노테이션에 옵션으로 fetch = FetchType.EAGER)을 선택하면 된다.
이렇게 하면 한번의 쿼리로 모든 객체를 조회하기 때문에 굳이 프록시객체를 만드는 것이 아닌 실제 엔티티를 조회할수 있다

하지만 실무에서는 즉시로딩을 가급적이면 지양해라!!

즉시로딩에는 몇가지 문제점이 있어 실무에서 지양해야한다. 문제점들에 대해 알아보겠다.

  • 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생

    즉시로딩을 이용하면 즉시 로딩으로 연관관계가 걸려있는 모든 객체를 JOIN해 오기 때문에 예상치 못하게 엄청나게 큰 쿼리를 조회해온다. 물론 예제는 2개 객체만 가져오지만 실무에서는 수십개의 객체를 끌고올수도 있으니 큰 문제가 될수 있다.

  • 즉시로딩은 JPQL에서 N+1 문제를 일으킨다.

    JPQL의 경우 쿼리를 날릴때 우선 JPQL의 쿼리문을 SQL의 쿼리문으로 수정하여 보낸다. 이때 멤버객체를 조회한다면 멤버객체를 조회하고 끝나느것이 아닌, 엔티티를 확인하여 즉시로딩으로 되어있는 객체를 각각 조회해온다. 즉 10개의 즉시로딩 연관관계가 있다면 10개의 추가적인 쿼리가 나가게 된다. 따라서 1개의 객체를 조회하더라도, N개의 쿼리가 더 나타나서 성능상의 문제를 일으키는 N+1 문제가 일어날수 있다. 따라서 지연로딩을 이용하여 실제 객체가 아닌 프록시 객체를 넣어주면 된다.
    ! 그렇다면 지연로딩을 사용하여도, 어쩔수 없이 즉시로딩처럼 한번에 객체를 조회하는 경우가 있다면? Fetch를 이용하여 설정해주면 된다!!

영속성 전이

지연로딩과 즉시로딩들과 연관된 내용은 아니다.
영속성 전이는 단지 한 객체가 영속상태가 될때 연관된 다른 객체들 또한 영속 상태로 만들고 싶을때 사용하는 옵션으로 @OneToMany(mappedBy="parent", cascade=CascadeType.PERSIST) 과 같이 사용할수 있다.
그러면 그림과 같이 parent가 영속상태가 된후 parent안의 연관관계로 맺어있는 child 리스트 안의 child객체들 또한 영속 상태가 된다!!

영속성 전이는 연관관계를 매핑하는 것과 관련이 없고, 엔티티를 영속화할때 연고나된 엔티티도 함께 영속화하는 편리함을 제공할 뿐이다

영속성 전이의 종류는 6가지가 있지만 그중 ALLPERSIST정도를 많이 쓴다!

영속성 전이는 한 게시물안의 첨부파일을 관리하는것과 같이 한개의 객체에서만 관리할때 사용하면 의미가 있지만, 한개의 객체가 아닌 여러 객체에서 관리될때는 사용하면 안된다!!

고아객체

고아객체는 부모 엔티티와 연관관계가 끊어진 자식엔티티를 말하는데, 고아객체 제거 옵션을 넣는다면 연관관계가 끊어진 자식 엔티티를 자동으로 삭제한다.
옵션은 orphanRemoval = true를 이용하면 된다.

@OneToMany(mappedBy = "parent",orphanRemoval = true)
private List<Child> childList = new ArrayList<>();
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0);
// 자식 엔티티를 컬렉션에서 제거

DELETE FROM CHILD WHERE ID=? 와 같이 삭제쿼리가 나간다.

고아객체 또한 관리하는 부모가 하나일때만 가능하다. 따라서 @OneToOne과 @OneToMany 어노테이션에만 사용가능하며, CascadeType.REMOVE와 같은 역할로 볼수 있다.

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

CascadeType.ALL + orphanRemovel=true 을 모두 설정함으로써 영속성 컨텍스트를 통해 부모객체는 영속성을 관리받지만, 자식객체는 부모객체로부터 영속성을 관리받게 된다.

도메인 주도설계(DDD)의 Aggregate Root 개념(레레파지토리 생성을 지양한다는 개념?)을 구현할때 유용하다.

0개의 댓글