JPA 프록시 개념잡기!

maketheworldwise·2023년 1월 27일


이 글의 목적?

드디어 JPA에서 가장 어려운 파트로 넘어왔다. 심지어 영상의 길이도 굉장히 길다... 그만큼 중요하다는 것이니까 한번 기깔나게 정리해보자. 🫡

어떤 문제에서 시작했는가?

회원 테이블과 팀 테이블이 다대일로 연결되어있을 경우를 생각해보자.

  • 회원을 조회할 때 팀에 대한 정보도 조회하는게 맞을까?
  • 회원에 대한 내용만 조회하는게 맞을까?
Member member = em.find(Member.class, 1L);
Team team = member.getTeam();

System.out.println(member.getName());
System.out.println(team.getName());

위의 코드에서는 한번에 회원과 팀에 대한 모든 정보를 가지고 오는 것이 유리하겠지만, 만약 회원만 출력할 경우에는 쓸모없는 낭비가 발생할 것이다. 따라서 JPA에서는 이러한 낭비를 줄이기 위해 지연로딩과 즉시로딩을 제공한다.

그리고 지연로딩, 즉시로딩에 대한 개념을 잡으려면 먼저 프록시에 대한 내용부터 숙지해야한다.

가짜 엔티티 조회 메서드

우리는 지금까지 em.find() 메서드를 이용하여 DB에서 원하는 데이터를 조회해왔다. 하지만 em.getReference() 메서드를 통해서도 조회가 가능하다.

기존에 사용했던 방법을 먼저 살펴보자.

try {
  Member member = new Member();
  member.setName("member");
  entityManager.persist(member);

  entityManager.flush();
  entityManager.clear();

  Member findMember = entityManager.find(Member.class, member.getId());
  System.out.println("id = " + findMember.getId());
  System.out.println("name = " + findMember.getName());
  
  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

예상한대로 DB에 SELECT 쿼리가 전달되었다. 그럼 가짜 엔티티 조회 메서드를 사용하면 어떻게 될까?

try {
  Member member = new Member();
  member.setName("member");
  entityManager.persist(member);

  entityManager.flush();
  entityManager.clear();

  Member findMember = entityManager.getReference(Member.class, member.getId());
  System.out.println("findMember = " + findMember.getClass());
  System.out.println("id = " + findMember.getId()); // DB에서 안가져와도 되는 내용
  System.out.println("name = " + findMember.getName()); // DB에서 가져와야하는 내용

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

신기하게도 DB에서 가져와야하는 내용일 때 SELECT 쿼리가 전달되는 것을 볼 수 있다. 추가적으로 가짜 엔티티 조회 메서드의 결과를 출력해보면 실제 회원 객체가 아닌 Member$HibernateProxy$xxx라는 하이버네이트가 내부의 라이브러리를 이용하여 만든 가짜 객체가 콘솔에 찍히는 것을 확인할 수 있다.

지금까지의 내용을 토대로 프록시의 구조를 하단의 그림으로 간략하게 살펴본다면 - 비어있는 껍데기에 실제 레퍼런스 주소를 가르키는 target이 존재하는 구조로 되어있다.

그래서 프록시가 뭔데?

나는 이전에 스프링의 디자인 패턴에서 프록시 패턴을 정리했었는데, 그때 학습한 내용과 강의에서 말하는 가짜 객체라는 내용이 동일한 개념인 것 같으면서도 다르게 느껴져서 받아들이기 힘들었다. 그래서 나는 가짜 객체로 이해하지 않고 프록시 패턴 자체의 개념으로 받아들였다.

프록시 패턴은 요청자가 타깃 객체를 바로 조회하는 것이 아닌 프록시 객체를 조회하고, 해당 프록시 객체가 타깃 객체를 호출하는 방식의 구조를 의미한다.

나의 경우에는 가짜 객체라는 개념보다 프록시 패턴의 관점에서 프록시의 특징을 이해하는게 더 쉬웠다.

  • 실제 클래스를 상속받아 생성
  • 실제 클래스와 겉모양이 동일
  • 프록시 객체는 실제 객체의 참조(target)를 보관
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드 호출

프록시 동작 과정

프록시의 동작 순서는 하단의 이미지에 잘 표현되어있으니 나만의 방법으로 순서를 표현해보자.

Member member = em.getReference(Member.class, 1L);
member.getName();

  1. 회원의 이름 요청
  2. 프록시 객체에서 target에 실제 객체에 대한 정보가 없기 때문에 영속성 컨텍스트에 정보 요청
  3. 영속성 컨텍스트가 DB에서 조회
  4. DB로부터 얻은 정보를 토대로 실제 객체 생성
  5. 프록시 객체에서 실제 객체를 통해 회원의 이름을 조회
  6. 그 이후에는 프록시 객체에 실제 객체가 걸려있기 때문에 더이상 DB에서 조회하지 않음

하단의 이미지는 내가 살펴본 블로그에서 더 자세하게 동작의 흐름을 표현한 것 같아 가져와보았다.

맞을지는 모르겠지만 "참조를 프록시 객체에서 실제 엔티티에 대한 참조값을 가져온다"라는 의미 때문에 getReference()라는 이름을 지은게 아닌가 싶다...😅

프록시에서의 주의할점!

영상에서는 프록시의 특징이라 정리했는데 주의할 점이라고 표현하는게 맞을 것 같다.

처음 사용할 때 한번만 초기화

이 내용은 코드로 이해하는 편이 더 빠르다.

try {
  Member member = new Member();
  member.setName("member");
  entityManager.persist(member);

  entityManager.flush();
  entityManager.clear();

  Member findMember = entityManager.getReference(Member.class, member.getId());
  System.out.println("name1 = " + findMember.getName());
  System.out.println("name2 = " + findMember.getName());

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

DB에서 정보를 가져와야하는 코드를 동일하게 작성해보면 단 한번의 SELECT 쿼리가 날아가는 것을 볼 수 있다.

동일성 비교 대신 instanceof

이 파트에서 가장 중요한 부분은 - 프록시 객체를 초기화 할 때 프록시 객체가 실제 엔티티로 변경되는 것이 아닌, 초기화 후 프록시 객체를 통해 실제 엔티티에 접근하는 것이다.

따라서 프록시 객체가 넘어올 수도 있고 혹은 실제 객체가 넘어올 수 있기 때문에 동일성 비교를 해서는 안된다. 만약 타입 체크가 필요할 경우에는 프록시 객체가 원본 엔티티를 상속받는다는 점을 이용하여 instanceof 키워드를 이용해야한다.

코드로 이해해보자.

try {
  Member member1 = new Member();
  member1.setName("member1");
  entityManager.persist(member1);
  
  Member member2 = new Member();
  member2.setName("member2");
  entityManager.persist(member2);

  entityManager.flush();
  entityManager.clear();

  Member findMember1 = entityManager.find(Member.class, member1.getId());
  Member findMember2 = entityManager.getReference(Member.class, member2.getId());
  
  logic(findMember1, findMember2);

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();
private static void logic(Member m1, Member m2) {
  // 이 메서드에 넘어오는 값이 프록시 객체인지 실제 엔티티 객체인지 모름
  System.out.println("m1 == m2 : " + (m1.getClass() == m2.getClass()));
  
  // 따라서 instanceof를 이용하여 비교 필요
  System.out.println("m1 instanceof Member : " + (m1 instanceof Member));
  System.out.println("m2 instanceof Member : " + (m2 instanceof Member));
}

한 명의 회원은 em.find()로 실제 엔티티 객체를 가져오고 다른 한명은 em.getReference()로 가짜 객체를 받아온 후 logic()이라는 메서드를 호출했을 때를 생각해보자. 내가 만약 logic() 메서드만 작업하는 개발자였다면 파라미터로 들어오는 객체가 프록시 객체인지 실제 엔티티 객체인지 모르고 개발할 것이다. 그럼 다양한 문제가 생길 것이 뻔하다...😇

JPA에서의 타입체크는 반드시 instanceof를 사용해야하는 것을 잊지말자.

영속성 컨텍스트에 엔티티가 있을 경우 실제 엔티티 반환

지금까지 em.getReference() 메서드를 호출했을 때는 프록시 객체가 출력되는 것을 확인했었다. 하지만 영속성 컨텍스트에 찾는 엔티티가 있을 경우에는 em.getReference() 메서드를 호출해도 실제 엔티티를 반환한다.

try {
  Member member = new Member();
  member.setName("member");
  entityManager.persist(member);

  entityManager.flush();
  entityManager.clear();

  Member findMember1 = entityManager.find(Member.class, member.getId());
  System.out.println("findMember1 = " + findMember1.getClass()); // Member
  
  Member findMember2 = entityManager.getReference(Member.class, member.getId());
  System.out.println("findMember2 = " + findMember2.getClass()); // 프록시 객체가 아닌 Member 출력

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

결과를 보면 둘 다 동일한 실제 엔티티를 반환하는 것을 볼 수 있다. (반대로 em.find()em.getReference() 순서를 뒤바꿀 경우에는 동일한 프록시 객체를 반환해준다.)

그 이유는 하나의 영속성 컨텍스트에서 가져온 것, 하나의 트랜잭션이라면 JPA에서는 항상 같은 것을 보장해주기 때문이다. 그리고 이미 영속성 컨텍스트의 1차 캐시에 존재하는데 굳이 프록시로 가져와봐야 이점이 없지 않은가? ㅎㅎ 😀

영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때 프록시 초기화시 문제 발생

당연한 내용이다. 영속성 컨텍스트에서 detach() 하거나 clear(), close() 할 경우에는 영속성 컨텍스트로부터 더이상 도움을 받지 못하기 때문에 LazyInitializationException이 발생한다.

try {
  Member member = new Member();
  member.setName("member");
  entityManager.persist(member);

  entityManager.flush();
  entityManager.clear();

  Member findMember1 = entityManager.find(Member.class, member.getId());
  System.out.println("findMember1 = " + findMember1.getClass()); // Member
  
  entityManager.detach(findMember1);
  // entityManager.clear();
  // entityManager.close();

  System.out.println(findMember1.getName());

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

보통 트랜잭션이 시작하고 끝날 때 영속성 컨텍스트도 시작하고 끝나도록 맞추는데, 트랜잭션이 끝나고나서 프록시를 조회할 때 이 에러가 많이 발생한다. (실제로 나도 한번 경험해봤었다... 당시에 어떻게 해결했는지 기억이 안나지만.. 😂)


마지막으로 프록시를 확인하기 위해 다양한 유틸리티를 제공해주고 있는데, 그렇게 많이 사용할 것 같지 않으니 나중에 필요하면 찾아서 사용해보는 방향으로 가져가자. 🙂

이 글의 레퍼런스

profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글