JPA ToOne 관계 초기화 방법 vs 컬렉션 초기화 방법 (강제 초기화 되지 않는 문제 해결)
들어가기 전
기본적으로 배치 사이즈를 조절해서 컬렉션을 조회할 때, In 쿼리로 최적화를 했다고 가정하고 코드를 짰습니다.
깃허브에 올린 소스는 default_batch_fetch_size 옵션으로 최적화를 했습니다.
참고 부탁드립니다.
JPA 의 로딩 전략
JPA 에서 연관관계를 맺을 때, 기본적으로 Lazy 로딩을 사용 합니다.
fetch = FetchType.LAZY
만약 EAGER 로딩을 사용한다면 Jpql 을 사용할 때 예상하지 못한 쿼리가 나가는 문제가 발생합니다.
따라서 기본적으로 Lazy 로딩으로 세팅을 한 뒤에 성능 최적화를 시작합니다.
한편, Open In View 옵션을 true 로 해두었다면, JPA 영속성 관리를 벗어난 시점에도 트랜젝션을 끊지 않기 때문에 Lazy 로딩으로 인해, 로딩 되지 않은 Entity 도 데이터를 당겨올 수 있습니다.
그러나 Open In View 는 그만큼 커넥션을 풀에 반환하지 않고 가지고 있다는 것을 의미하기에, 잘못하면 커넥션 풀이 말라버리는 문제가 발생할 수 있고 따라서 Open In View 옵션을 끄는 것을 권장합니다.
그런데 이 옵션을 끄면 트랜젝션을 관리하는 서비스 계층에서 필요한 데이터를 모두 로딩을 해놔야 함을 의미합니다.
에초에 JPQL 을 사용하여 DTO 로 반환하는 경우 문제가 될 것이 없겠지만 엔티티를 사용한다면 강제 초기화를 해주어야 합니다.
JPA 초기화에는 다음과 같은 두가지 방법이 있습니다.
지연로딩 객체 초기화
Hibernate 가 지원하는 강제 초기화 옵션 사용하기
Hibernate.initialize(Entity);
이는 Hibernate 가 지원하는 initialize 을 이용해서 강제로 초기화하는 방법으로 사용하기에 간편합니다.
그러나 이는 JPA 표준이 아니고 Hibernate 가 지원하는 기능입니다.
그래서 저는 이 방법을 사용하지 않기로 하였습니다.
연관관계의 값을 호출하기
JPA 의 관리를 받는 동안 프록시 객체(레이지 로딩으로 가져온 엔티티)에서 값 조회를 시도하면, JPA 는 엔티티 데이터 필요시점으로 간주하고, 쿼리를 날려 데이터를 당겨 옵니다.
저는 이 방법을 사용하기로 하였습니다.
그런데 이 방법을 사용할 때는 주의해야할 점이 있습니다.
이를 설명하기 위해서 다음과 같은 테이블을 만들었습니다.
소스는 아래 링크로 첨부하였습니다.
https://github.com/donghyeon0725/jpa_init_test
테이블 설계
Member 와 Post 에 각각 PK 로 ID가 존재합니다.
Member와 Post 는 일대다 관계입니다.
엔티티 설계
위 관계를 보시면 Member 에 List 타입으로 Post 엔티티를 참조하고 있습니다.
즉, 양방향 참조 관계 입니다.
Member 는 Post 를 컬렉션 참조하고 있고
Post 는 ToOne 관계로 참조하고 있습니다.
Member.java
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class Member {
@Id
@GeneratedValue
private Long id;
private String email;
private String password;
@Builder.Default
@OneToMany(mappedBy = "member")
private List<Post> posts = new ArrayList<>();
}
Post.java
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class Post {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
private String title;
}
[각각의 레포지토리는 data JPA 의 라이브러리를 이용해서 만들었습니다. data JPA 에 대해 설명하는 것은 주제에 벗어나므로 설명하지 않겠습니다.]
컬렉션 조회 강제 초기화
멤버를 통해서 Post까지 조회하고 싶은 상황이다. 다음과 같은 코드가 있다고 가정할 때 과연 Post까지 잘 로딩해올 수 있을까?
public List<Member> getMembers() {
return memberRepository.findAll();
}
정답은 아니다. Member 입장에서 Post 는 다대일 관계 어노테이션 @OneToMany 으로 관계를 맺어놓았다.
한편 @OneToMany 는 기본 로딩 전략이 Lazy이기 때문에 Post 를 프록시 객체로 가져왔다.
EntityManagerFactory 의 util 클래스를 로딩해서 Entity 의 초기화 여부를 알 수 있는데 다음과 같이 테스트 하면 실패한다.
(기본적으로 데이터는 모두 넣어 놓았습니다.)
getMembers 메소드 테스트 실패
final List<Member> members = memberService.getMembers();
assertTrue(emf.getPersistenceUnitUtil().isLoaded(members.get(0).getPosts())); // fail
위 테스트 케이스는 실패했습니다.
다음과 같이 사용해서 강제로 값을 초기화 해줄 수 있습니다.
getMembers 개선하기
public List<Member> getMembersAndPosts() {
final List<Member> all = memberRepository.findAll();
// 강제 초기화
all.forEach(
member ->
member.getPosts().forEach(
post -> post.getId()
)
);
return all;
}
여기서 all 변수에서 forEach 를 2번 호출한 것은 Post 가 컬렉션이기 때문에 각각의 컬렉션을 호출하기 위해서 이다.
각각 Member 를 순환 호출하면서, post 의 id 를 가져오고 있고 jpa 는 데이터를 가지고 있지 않기 때문에 필요한 id 를 가져오기 위해 Post 테이블에 select 쿼리를 날린다.
그리고 데이터를 당겨온다. 따라서 이를 테스트 하면 성공한다.
getMembersAndPosts 메소드 테스트 성공
final List<Member> members = memberService.getMembersAndPosts();
assertTrue(emf.getPersistenceUnitUtil().isLoaded(members.get(0).getPosts())); // success
ToOne 관계 강제 초기화
이제 Post 를 조회할 때 Member 까지 함께 조회해보자.
public List<Post> getPosts() {
return postRepository.findAll();
}
위 코드가 원하는대로 동작할까?
정답은 아니라는 것을 쉽게 알 수 있다. 왜냐하면 Lazy 로딩 전략을 사용하고 있기 때문이다.
당연하게 테스트 케이스는 실패했다.
getPosts 테스트 케이스 실패
final List<Post> posts = postService.getPosts();
assertTrue(emf.getPersistenceUnitUtil().isLoaded(posts.get(0).getMember())); // fail
따라서 위에서 사용했던 방법대로 ID 를 호출해서 메소드를 개선해보자.
getPosts 개선
public List<Post> getPostsWithMember1() {
final List<Post> all = postRepository.findAll();
all.forEach(
post -> post.getMember().getId()
);
return all;
}
과연 이 코드가 동작할까?
getPostsWithMember1 테스트 케이스 실패
final List<Post> posts = postService.getPostsWithMember1();
assertTrue(emf.getPersistenceUnitUtil().isLoaded(posts.get(0).getMember())); // fail
이는 동작하지 않는다.
분명 위에서 id 를 호출해서 강제로 객체를 로딩해 주었음에도 위와 같은 테스트 케이스를 통과하지 못했다.
왜일까? 다음과 같이 getPostsWithMember1 메소드 내부에 브레이크 포인트를 걸고 디버깅 해보자.
그랬더니 다음과 같은 결과를 나타냈다.
변수 all 을 리턴하는 시점에도 Post 에 Member 필드는 Proxy 객체로 값이 채워지지 않았다.
분명 getId 으로 호출하였지만 데이터를 로딩해오지 않는 이유는 hibernate_interceptor 에 있다.
hibernate_interceptor 는 Proxy 객체가 가진 필드중 하나로 해당 필드를 열어보면 다음과 같이 id 값이 세팅 되어 있음을 알 수 있다.
이렇게 값을 가지고 있고, Service 에서는 오직 getId 만 호출했기 때문에 JPA 입장에서 데이터를 채워 넣기 위해 쿼리를 날릴 필요가 없었던 것이다.
왜 그럴까?
테이블을 자세하게 보아야 한다.
Member 엔티티는 연관관계의 주인이 아니지만 Post 엔티티는 연관관계의 주인이다.
따라서 Post 가 FK (member_id) 를 관리하도록 설계를 하였는데, 이는 Post 데이터를 조회하기 위해서 쿼리를 날릴 때 Member 의 id를 가져올 수 있음을 의미한다.
그러면 ToOne 관계에서는 왜 getId 가 동작했을까?
Member 테이블 입장에서, 이 멤버가 어떤 포스트를 가졌는지 알기 위해서는 어떻게 해야할까?
Member 테이블에 post_id 를 관리하고 있지 않기 때문에 Post 테이블에 쿼리를 날리는 방법 밖엔 없다.
즉, Member 테이블만 조회했다면, Post 테이블에 조인을 사용하던, 아니면 개별 쿼리를 날리던 쿼리를 보내기 이전까지는 멤버가 어떤 Post 를 가지고 있는지 알 수 있는 방법이 없다는 말이다.
그래서 getId 를 하였을 때 Post 테이블에 쿼리를 날릴 수 밖에 없었고 자연스럽게 초기화 되었던 것이다.
만면 컬렉션 조회는 이미 해당 테이블에서 외래키를 가지고 있는 경우가 대부분이기에 getId 로는 초기화 되지 않았던 것이다.
getPostsWithMember1 개선
public List<Post> getPostsWithMember2() {
final List<Post> all = postRepository.findAll();
all.forEach(
post -> post.getMember().getEmail()
);
return all;
}
위 코드처럼 getId 가 아니라, Post 테이블이 관리하지 않는 값인 Member 의 Email 필드를 참조함으로써 Member 엔티티의 데이터를 초기화 할 수 있다.