[JPA] 배워서 엔터프라이즈 개발하자 7일차 - 프록시와 연관관계 관리
본 글은 김영한님의 [인프런 - 자바 ORM 표준 JPA 프로그래밍 - 기본편] 강의와 자바 ORM 표준 JPA 프로그래밍 책을 기반으로 정리해 작성하였습니다.
1. 프록시
엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다.
예를 들어 내가 레알 마드리드라는 축구 클럽의 스카우터이고 손흥민 선수에 대한 정보를 얻고 싶을 때, 단순히 그 선수의 신체적 데이터만 알고 싶다면 굳이 선수가 소속된 팀의 정보까지 알 필요는 없다는 것이다. (잉크가 아까울수도)
아래 코드와 함께 살펴보자.
1) 선수 엔티티
@Entity
public class Player {
@Id @GeneratedValue
private Long id;
private Double height;
private Double weight;
@ManyToOne
private Team team;
}
2) 팀 엔티티
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany
private List<Player> players;
}
3) 선수와 팀 정보가 필요할 때
public void doYouKnowSon(String playerId){
Player player = em.find(Player.class, playerId);
Team team = player.getTeam();
System.out.println("sonny's full name ? : " + player.getPlayername());
System.out.println("where's sonny's team ? : " + team.getName());
}
4) 선수 정보만 필요할 때
public void doYouKnowSon(String playerId){
Player player = em.find(Player.class, playerId);
System.out.println("sonny's height ? : " + player.getheight());
}
3번처럼 팀의 정보까지 조회할 경우 플레이어 엔티티에 있는 팀의 정보까지 조인해서 조회하는 것이 더 효율적일 수 있으나
4번과 같은 사례가 생길 수 있다.
마치 우리가 손흥민 선수를 나무위키에서 찾아볼 때 파도타기로 토트넘 구단 정보까지 찾아보느냐 안보느냐의 차이랄까..
아무튼 JPA는 이런 문제를 해결하기 위해 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데, 이것을 지연 로딩이라 한다.
그런데 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 한다.
2. 프록시 기초부터
우리가 JPA를 사용해 엔티티 하나를 조회할 때는 EntityManager.find() 를 사용한다.
위 코드에 있던 Player player = em.find(Player.class, playerId) 랑 같다고 보면 된다.
이렇게 엔티티를 직접 조회하면 조회한 엔티티를 실제 사용하든 사용하지 않든 데이터베이스를 조회하게 된다.
만약 엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 된다.
Player player = em.getReference(Player.class, playerId);
프록시의 특징
위 그림은 손흥민 선수의 사진의 겉모습만 그린 것이다. 프록시도 이것과 같다.
프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.
따라서 사용하는 입장에서는 이것이 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.
대신..
텅 빈 프록시 객체는 실제 객체에 대한 참조를 보관한다.
만약 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출함으로써, 사용자 입장에서는 똑같이 느껴지도록 한다.
프록시 흐름도
프록시의 호출 및 초기화부터 결과를 반환하는 부분까지 그림과 함께 다시 살펴보자.
- 1. 프록시 객체에 player.getPlayer() 를 호출해서 실제 데이터를 조회한다.
- 2. 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데, 이것을 초기화라고 한다.
- 3. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
- 4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Player target 멤버변수에 보관한다.
- 5. 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환한다.
프록시의 특징
위 과정을 통해 프록시의 특징을 알아보자.
- 프록시 객체는 처음 사용할 때 (실제로 가져다 쓸 때) 한 번만 초기화된다.
- 프록시 객체를 초기화한다는 것은 프록시가 실제 엔티티로 바뀌는 것이 아니라, 프록시 객체를 통해 실제 엔티티에 접근할 수 있다는 것을 의미한다. (처음엔 영속성 -> DB 조회로 실제 엔티티 객체 생성)
- 프록시 객체는 원본을 상속받은 객체이므로 "==" 비교 대신 "instance of"를 사용해 타입 체크하는 것이 좋다.
- 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다. 따라서 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태의 프록시를 초기화하면 문제가 발생한다. (org.hibernate.LazyInitializationException 에러가 다음과 같은 상황일 때 발생함)
프록시와 식별자
프록시의 특성을 이용하면 연관관계를 선언할 때 데이터베이스 접근 횟수를 줄일 수도 있다.
엔티티를 프록시로 조회할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다.
Player player = em.getReference(Player.class, playerId); //식별자 보관
player.getId(); //초기화되지 않음
이미 프록시는 식별자 값을 가지고 있기 때문에 식별자 값을 조회하는 player.getId() 코드에는 초기화가 작동하지 않는다.
그렇다면 이 코드도 마찬가지이다.
Player player = em.find(Player.class, playerId);
Team team = em.getReference(Team.class, playerId); //sql 실행 x
Player.setTeam(team);
이렇게 선수와 팀 간의 연관관계를 설정할 때는 식별자 값만 사용하므로 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있는 것이다.
3. 즉시 로딩과 지연 로딩
프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용한다.
하지만 JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 다음 두 가지 방법을 지원한다.
1) 즉시 로딩 : 엔티티 조회 시 연관된 엔티티도 함께 조회 (프록시 x)
2) 지연 로딩 : 연관된 엔티티를 실제 사용할 때 조회 (프록시 o)
아래서 더 자세히 살펴보자.
1) 즉시 로딩
@Entity
public class Player {
@Id @GeneratedValue
private Long id;
private Double height;
private Double weight;
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
}
즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 EAGER로 지정해야 한다.
이렇게 연관되어 있는 엔티티들을 가져올 때 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.
2) 지연 로딩
@Entity
public class Player {
@Id @GeneratedValue
private Long id;
private Double height;
private Double weight;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
지연 로딩 사용 시 @ManyToOne의 fetch 속성을 LAZY로 바꾸거나, 아니면 따로 속성 지정을 하지 않으면 된다.(기본 값이므로)
책에서 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것이라고 한다.
만약 @OneToMany 상황인 경우 컬럼값은 컬렉션(배열)일텐데, 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문이다.
좀 더 구체적으로 설명하자면, 다음 사례를 보자.
@Entity
public class FootballClub {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(fetch = FetchType.EAGER)
private List<Player> players;
@OneToMany(fetch = FetchType.EAGER)
private List<Staff> staffs;
}
다음과 같은 축구 클럽 엔티티가 존재한다고 하자.
만약 서로 다른 컬렉션 players, staffs의 크기가 각각 N, M이라면, 축구 클럽 테이블의 SQL 실행 횟수는 두 컬렉션의 곱만큼 발생하게 되고(조인 연산) 결과적으로 애플리케이션 성능이 저하될 수 있다.
4. 영속성 전이 : CASCADE
특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶다면 영속성 전이 기능(cascade)을 사용하면 된다.
위에서 설명한 Player, Team 엔티티를 기준으로 예제를 통해 알아보자.
1) CASCADE 사용 전
public void createTottenham(EntityManager em){
//토트넘 구단 생성
Team tot = new Team();
em.persist(tot);
//케인 등장
Player kane = new Player();
kane.setTeam(tot); // 케인 -> 토트넘 연관관계 설정
tot.getPlayers().add(kane); // 토트넘 -> 케인 연관관계 설정
em.persist(kane);
//손흥민 등장
Player son = new Player();
son.setTeam(tot); // 손 -> 토트넘 연관관계 설정
tot.getPlayers().add(son); // 토트넘 -> 손 연관관계 설정
em.persist(son);
}
CASCADE를 사용하지 않는다면, 팀에 등록하려는 선수들 모두 각각 연관관계를 설정해야 한다.
이럴 때 영속성 전이(CASCADE)를 사용하면 부모만 영속 상태로 만들면 연관된 자식까지 한 번에 영속 상태로 만들 수 있다.
2) CASCADE 사용 후
일단 CASCADE 옵션을 적용해준다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team", cascade = CascadeType.PERSIST)
private List<Player> players;
}
그런 다음 영속 상태 코드는 다음과 같다.
public void createTottenham(EntityManager em){
//토트넘 구단 생성
Team tot = new Team();
//케인 등장
Player kane = new Player();
//손흥민 등장
Player son = new Player();
kane.setTeam(tot);
son.setTeam(tot);
tot.getPlayers().add(kane);
tot.getPlayers().add(son);
//구단 저장 시 연관된 선수들 모두 저장
em.persist(tot);
}
확실히 군더더기 없이 필요한 부분만 남게 되었다.
CASCADE 옵션에는 CascadeType.PERSIST 뿐만 아니라 (ALL, REMOVE, MERGE) 등 다양한 옵션이 있고 ALL 또는 여러 옵션을 조합해서 사용할 수 있는데, 자세한 설명은 다음을 참고하자.
https://www.baeldung.com/jpa-cascade-types