👥 프록시(Proxy)

엔티티를 조회할 때 연관된 엔티티들을 항상 사용하는 것은 아닐 것이다. 예를 들어, Member를 조회할 때 Team도 함께 조회할 수도, 그럴 필요가 없는 상황일 수가 있다.

@Entity
public class Member {
		
	private String username;
		
	@ManyToOne
	private Team team;
		
	public Team getTeam() {
		return team;
	}
		
	public String getUsername() {
		return username;
	}
		
	... 
}
@Entity
public class Team {
		
	private String name;
		
	public String getName() {
		return name;
	}
		
	...
}
public void printUserAndTeam(String memberId) {

	Member member = em.find(Member.class, memberId);
	Team team = member.getTeam();
		
	// 회원과 팀 정보를 출력
	System.out.println("회원 이름: " + member.getUsername());
	System.out.println("소속팀: " + team.getName());
}
public void printUser(String memberId) {
	
    Member member = em.find(Member.class, memberId);
		
	// 회원 정보만 출력
	System.out.println("회원 이름: " + member.getUsername());
}

보다시피 printUserAndTeam() 메서드는 memberId로 회원 엔티티를 찾아서 회원은 물론이고, 그 회원과 연관된 팀의 이름도 출력하고 있다. 반면, printUser() 메서드는 회원 엔티티만 출력하고, 팀 엔티티는 전혀 사용하지 않고 있다. 그렇기 때문에 printUser() 메서드는 em.find()로 회원 엔티티를 조회할 때 회원과 연관된 팀 엔티티까지 DB에서 함께 조회하는 것은 그다지 효율적이지 않아 보인다.

JPA는 이런 문제를 해결하기 위해 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 방법을 제공하는데 이걸 바로 지연 로딩(Lazy Loading)이라고 한다. team.getName()처럼 팀 엔티티의 값을 실제로 사용하는 시점에 DB에 팁 엔티티에 필요한 데이터를 조회하는 것이다. 이 지연 로딩 기능을 사용하기 위해서 실제 엔티티 객체 대신, DB 조회를 지연할 수 있는 가짜 객체인 프록시 객체가 필요한 것이다.

em.find()로 엔티티를 직접 조회하면 조회한 엔티티를 실제 사용하든 사용하지 않든 DB를 조회하게 된다. 엔티티를 실제 사용하는 시점까지 DB 조회를 미루고 싶다면 em.getReference() 메서드를 사용하면 된다. 진짜 객체를 주는게 아니라 Hibernate가 내부의 라이브러리를 사용해서 프록시라고 하는 가짜 엔티티 객체를 주는 것이다.

 

📝 프록시의 특징

프록시는 먼저 실제 엔티티를 상속 받아서 만들어진다. 그래서 실제 클래스와 겉모양이 똑같다. 사용자 입장에서는 이론상 이게 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다. 그리고 프록시의 내부를 자세하게 보자면, 말했다시피 target이라는 실제 객체의 참조를 보관하고 있다.

예를 들어, getName()을 호출한다고 하면, target에 있는 getName()을 대신 호출해준다. 근데 처음에는 target은 null이다. 왜냐하면, 실제 DB에서 조회한 적이 없기 때문이다.

먼저 Member member = em.getReference(Member.class, "id1")로 프록시 객체를 가져 온다. 여기서 member.getName()을 호출하면 프록시 객체의 getName() 메서드를 쳐다본다. 근데 이 시점에는 target 값이 null이기 때문에 JPA가 이 영속성 컨텍스트에 진짜 Member 객체를 가져와 달라고 초기화 요청을 한다. 그럼 영속성 컨텍스트가 DB를 조회한 다음, 실제 엔티티 객체를 생성하고 target과 연결해주는 것이다. 그래서 getName()을 했을 때 target의 진짜 getName()을 통해서 Member에 있는 getName()이 반환되는 것이다.

프록시 객체는 처음 사용할 때 딱 한 번만 초기화된다. 그리고 프록시 객체를 조회할 때, 프록시 객체가 실제 엔티티로 바뀌는 것이 아니라는 점을 명심해야 한다. 초기화되면 프록시 객체를 통해 실제 엔티티에 접근 가능한 것이지, 프록시가 실제로 바뀌는 것이 아니다. 프록시 객체는 원본 엔티티를 상속 받는다고 했다. 따라서 타입 체크 시 주의해야 한다. == 비교가 아닌 항상 instanceof를 사용하도록 하자.

Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);

Member member2 = new Member();
member1.setUsername("member2");
em.persist(member2);

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

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

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())); 

// m1 == m2: true

이때는 당연히 참이 나온다. System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())) 의 비교는 단순하게 둘이 같은 런타임 클래스인지만 확인한다. 둘 다 실제 엔티티 객체든, 프록시 객체든 대부분 결과가 참이 나올 수 있다. 하지만, 이건 두 객체가 같은 타입인지도 정확히 보장하지 않고, 같은 엔티티인지도 알려줄 수 없다. 만약 하나는 프록시, 다른 하나는 실제 엔티티라면 서로 같은 Member를 가리켜도 거짓이 나온다.

따라서 상속을 포괄하는 instanceof를 사용해야 한다. Hibernate가 지연 로딩을 위해 만드는 프록시의 런타임 클래스는 대개 Member$HibernateProxy… 같은 Member의 서브 클래스다. 즉, == 이나 getClass()와 같은 정확한 클래스 비교는 프록시를 만나면 실패하고, instanceof는 상속 관계를 인정하기 때문에 타입 체크 용도로 아주 적절하다.

 

그리고 프록시의 또 다른 중요한 특징은 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 DB를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다는 것이다.

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

위 코드를 찍어보면, 프록시가 아닌 진짜 객체가 튀어나올 것이다. 이때 member1은 영속성 컨텍스트에 올라가 있을 것이다. 이때 getReference()를 하면 어떻게 될까?

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

JPA에서는 같은 인스턴스에 대해 ==을 해보면, 같은 영속성 컨텍스트 안에서, 같은 트랜잭션 레벨 안에서 조회하면 항상 같다고 나온다. 위 코드를 실행해보면, reference도 프록시가 아닌 Member로 나온다. 왜 reference로 했는데 Member가 나오지? 여기에는 2가지 이유가 있다.

 

일단 첫 번째 이유는 약간 허무한데, 이미 Member를 영속성 컨텍스트에 올려 놨는데, 다른 말로 1차 캐시에 있는데 그걸 굳이 프록시로 가져올 필요가 있을까? 그냥 원본을 반환하는 것이 성능 최적화 입장에서도 훨씬 나을 것이다. 두 번째 이유가 중요한데, JPA에서는 a == a를 하면 항상 참이 나와야 한다. 마찬가지로 m1 == reference도 항상 참이 나와야 한다. m1이 실제든 프록시든 상관없이 JPA에서는 마치 자바 컬렉션에서 가져온 걸 == 비교하듯이 m1 == reference의 == 비교가 한 영속성 컨텍스트 안에서 가져온 것이고 PK가 똑같으면 JPA에서는 항상 참을 반환해줘야 한다. 이것에 바로 JPA가 기본적으로 제공하는 메커니즘이다. JPA는 항상 한 트랜잭션 안에서 같은 것에 대해 보장을 해준다.

 

그리고 엔티티가 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하게 된다면 문제가 발생한다. 실무에서 정말 자주 마주치는 문제다.

// 먼저 프록시를 호출
Member reference = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + reference.getClass());

reference.getUsername();  // 프록시 객체가 초기화된다.
System.out.println("reference = " + reference.getUsername());

em.detach();  // 만약 여기서 영속성 컨텍스트에서 끄집어 내면?

reference.getUsername(); 를 실행했을 때, JPA 입장에서는 영속성 컨텍스트의 도움을 받아서 실제 데이터를 불러와야 한다. 근데 detach()를 했기 때문에 영속성 컨텍스트에서 더 이상 관리하지 않는다. 그럼 프록시로 초기화할 수 없다며 Hibernate는 org.hibernate.LazyInitializationException 예외를 터트린다.

이렇게 프록시의 메커니즘을 이해해야 즉시 로딩과 지연 로딩에 대해 더 깊이 있게 이해할 수 있다.


⏰ 즉시 로딩과 지연 로딩

JPA는 연관된 엔티티의 조회 시점을 선택할 수 있도록 2가지 방법을 제공하고 있다.

  • 즉시 로딩: 엔티티를 조회할 때 연관된 엔티티도 함께 조회
  • 지연 로딩: 연관된 엔티티를 실제 사용할 때 조회

 

먼저 즉시 로딩(Eagar Loading)에 대해 알아보자.

@Entity
public class Member {
		
	...
		
	@ManyToOne(fetch = FetchType.EAGAR)
	@JoinColumn(name = "TEAM_ID")
	private Team team;
		
	...
}

// 즉시 실행
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();

FetchType.EAGAR으로 설정하면, em.find()로 조회하는 순간 팀도 함께 조회한다. 이때 회원과 팀 두 테이블을 조회해야 하기 때문에 쿼리를 2번 실행할 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다. 여기서는 회원과 팀을 조인해서 쿼리 한 번으로 두 엔티티를 모두 조회한다.

이제 가장 중요한 지연 로딩(Lazy Loading)인데, 지연 로딩을 사용하기 위해서는 fetch 속성을 FetchType.LAZY로 설정하면 된다.

@Entity
public class Member {
		
	...
		
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "TEAM_ID")
	private Team team;
		
	...
}

// 지연 실행
Member member = em.find(Member.class, "member1");
member.getTeam().getName();  // 팀 객체를 실제 사용할 때 DB를 조회

Member member = em.find(Member.class, "member1")를 호출하면 회원만 조회하고 팀은 조회하지 않는다. 대신, 조회한 회원의 team 멤버 변수에 프록시 객체를 넣어둔다. 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룬다. 실제 데이터가 필요한 순간이 되어서야 DB를 조회해서 프록시 객체를 초기화하는 것이다.

Member member = em.find(Member.class, "member1") 호출 시 아래와 SQL 쿼리가 실행된다.

SELECT * FROM MEMBER
WHERE MEMBER_ID = 'member1';

그리고 member.getTeam().getName()을 호출하면 프록시 객체가 초기화되면서 아래 SQL 쿼리가 실행된다.

SELECT * FROM TEAM
WHERE TEAM_ID = 'team1';

m.getTeam().getClass()로 프록시 객체인 것을 확인할 수 있고, 실제 Team의 어떤 속성을 사용하는 시점에 m.getTeam().getName() 프록시 객체가 초기화되면서 쿼리가 날아가 DB에서 값을 가지고 오는 것이다.

 

정리하자.

  • 지연 로딩(LAZY): 연관된 엔티티를 프록시로 조회한다. 프록시를 실제 사용할 때 초기화하면서 DB를 조회한다.
  • 즉시 로딩(EAGAR): 연관된 엔티티를 즉시 조회한다. Hibernate는 가능하면 SQL 조인을 사용해서 한 번에 조회한다.

 

🚫 실무에서는 즉시 로딩 사용 금지!

실무에서는 즉시 로딩을 절대 절대 절대 사용하면 안 된다. 왜 그럴까? 한 번에 조인 쿼리로 가져오면 좋은거 아닌가?

일단 즉시로딩을 적용하면 전혀 예상하지 못한 쿼리가 발생한다. 예를 들어, em.find(Member.class, member1.getId()) 멤버를 가지고 왔는데, DBA한테 조인 쿼리가 너무 나가고 성능이 안 나온다고 연락이 온다. 왜냐? 멤버만 사용하는데 생뚱맞은 팀까지 싹 다 끌고 오기 때문이다. 지금이야 괜찮아 보이지, EAGAR로 되어 있는 테이블이 수십 개다? 상상도 하기 싫다…

 

그리고 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다. 멤버 전체를 JPQL로 SELECT 해보자.

List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

실행하면 SELECT 쿼리가 2방 나가는 것을 볼 수 있다. 분명 EAGAR로 싹 묶어서 쿼리가 나가도록 했는데, 무슨 일이지?

 

em.find()라는 것은 PK를 딱 찍어서 가져오는 것이기 때문에 JPA가 내부적으로 다 최적화 할 수 있다. 하지만, JPQL은 말 그대로 SQL로 번역이 된다. SELECT m FROM Member m으로 멤버를 조회해 왔더니 Team이 즉시로딩으로 되어 있네? 즉시로딩은 가져올 때 일단 무조건 값이 다 들어가 있어야 한다. 그럼 Member 조회하는 쿼리 나가고, 만약 멤버의 개수가 10개면, 그 10개만큼 EAGAR를 가져오기 위해서 쿼리가 별도로 나가는 것이다.

 

다시! SELECT * FROM Member 쿼리가 일단 DB에 나가고 멤버를 가져올 것이다. 근데 Member를 보니까... 어라?

@ManyToOne(fetch = FetchType.EAGAR) 
@JoinColumn 
private Team team;

Team도 가지고 와야 하네? 근데 EAGAR로 되어 있어? LAZY면 그냥 프록시를 집어 넣으면 됐겠지만, EAGAR는 List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList() 시점에 반환할 때, 이미 값이 싹 다 팀에 들어가 있어야 한다. 그럼 그때 부랴부랴 그만큼 SELECT * FROM Team 쿼리가 나가는 것이다.

 

그렇기 때문에 실무에서 즉시 로딩을 절대 사용하면 안 된다. 싹 다 LAZY를 걸어두도록 하자. @ManyToOne이랑 @OneToOne은 기본 값이 EAGAR이기 때문에 일일이 LAZY로 설정해줘야 한다. @OneToMany는 기본 값이 LAZY니까 그냥 사용하면 된다. 아무튼 결론은 그냥 닥치고 싹 다 지연 로딩을 사용하라는 것이다.


🧬 영속성 전이: CASCADE

영속성 전이는 부모를 저장할 때 연관된 자식도 함께 자동으로 persist()를 호출하고 싶을 때 사용하는 기능이다. JPA는 CASCADE 옵션으로 영속성 전이를 제공한다.

 

예를 들어, 부모 엔티티가 여러 자식 엔티티를 가지고 있다고 해보자.

@Entity
public class Parent {
		
	@Id @GeneratedValue
	private Long id;
		
	@OneToMany(mappedBy = "parent")
	private List<Child> children = new ArrayList<>();
		
	...
}
@Entity
public class Child {
		
	@Id @GeneratedValue
	private Long id;
		
	@ManyToOne
	@JoinColumn(name = "parent_id")
	private Parent parent;
		
	...
}
private static void saveNoCascade(EntityManager em) {
		
	// 부모 저장
	Parent parent = new Parent();
	em.persist(parent);
		
	// 첫 번째 자식 저장
	Child child1 = new Child();
	child1.setParent(parent);  // 자식 -> 부모 연관 관계 설정
	parent.getChildren().add(child1);  // 부모 -> 자식
	em.persist(child1);
		
	// 두 번째 자식 저장
	Child child2 = new Child();
	child2.setParent(parent);  // 자식 -> 부모 연관 관계 설정
	parent.getChildren().add(child2);  // 부모 -> 자식
	em.persist(child2);
}

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다. 따라서 위의 코드를 보면 부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만든다. 이럴 때 영속성 전이를 사용하면 부모만 영속 상태로 만들면 연관된 자식까지 한 번에 영속 상태로 만들 수 있다.

이제 영속성 전이를 활성화하는 CASCADE 옵션을 적용해보도록 하자.

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

부모만 영속화하면 CascadeType.PERSIST로 설정한 자식 엔티티까지 함께 영속화해서 저장한다. 이게 포인트인 것이다. 위 코드의 쿼리 결과를 보면 데이터가 정상적으로 2번 입력된 것을 확인할 수 있다. 영속성 전이는 연관 관계를 매핑하는 것과는 아무 상관 없다. 단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐이다.

 

CASCADE 옵션은 아래와 같은데, 삭제에 유의해야 하는 경우에 PERSIST, 그렇지 않고 그냥 라이프사이클을 다 맞춰야 한다면 ALL 정도를 사용하자.

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

주의할 점이 하나 있다. 바로 언제 사용하느냐인데 “그럼 @OneToMany에는 다 걸어야 하나?” 그건 아니다. 하나의 부모가 자식들을 관리할 경우에야 의미가 있다. 예를 들어, 한 게시판에 첨부 파일을 올리는 경우다. 그 첨부 파일의 경우는 그 게시판에서만 관리할 것이기 때문이다. 만약 파일을 여러 군데서 관리를 한다면 사용하면 안 된다. Parent와 Child만 연관 관계가 있으면 되는데 Child가 또 다른 엔티티랑 연관 관계가 있다? 사용하지 말자… 소유자가 하나일 때만 영속성 전이를 사용하자.


😢 고아 객체

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

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

// 자식 엔티티 제거
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0);

고아 객체 제거를 활성화하기 위해서는 orphanRemoval = true 옵션을 달아줘야 한다. 이제 컬렉션에서 제거한 엔티티는 자동으로 삭제된다. 위 코드를 실행하면 DELETE FROM CHILD WHERE ID=? 쿼리가 날라가 첫 번째 자식을 제거한다. 고아 객체 제거 기능은 영속성 컨텍스트를 플러쉬할 때 적용되므로 플러쉬 시점에 DELETE 문이 날라가는 것이다.

고아 객체 제거 기능도 영속성 전이와 마찬가지로, 참조하는 곳이 하나일 때만 사용해야 한다. 다시 말해, 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 적용해야 한다는 것이다. 만약 삭제한 엔티티를 다른 곳에서도 참조한다면 당연히 문제가 발생할 수 있기 때문에 orphanRemoval은 @OneToOne, @OneToMany에만 사용 가능하다.

 

그리고 기능이 하나 더 있는데, 개념적으로 부모를 제거하면 당연히 자식은 고아가 될 것이고, 제거되어야 할 것이다. 약간 CascadeType.REMOVE를 설정한 것과 비슷해보인다.

 

그렇다면, CascadeType.ALL과 orphanRemoval = true를 다 켜면 어떻게 될까? 일단 이 두 옵션은 아래와 같은 의미를 가진다.

  1. 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거할 수 있다.

  2. 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.

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

0개의 댓글