들어가기 전

기본적으로 배치 사이즈를 조절해서 컬렉션을 조회할 때, 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

 

GitHub - donghyeon0725/jpa_init_test

Contribute to donghyeon0725/jpa_init_test development by creating an account on GitHub.

github.com

 

테이블 설계

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 엔티티의 데이터를 초기화 할 수 있다.