본문 바로가기

Spring/JPA

[JPA] 연관 객체 불러오기.. Fetch Join 정말 괜찮을까?

728x90

결론부터 말하자면 약간 뻘짓한 글이다. 그래도 긍정적으로 본다면 고민을 해봤다는 의의는 있으니.. 초보자의 수준에서 한 번 고민을 공유한다고 생각하면 될 것 같다.

 

(1) 개요 

 

 

서비스단을 개발하다 고민하게 된 부분이 있어서, 정리해보면서 결론을 만들어 나가보기로 했다. 우선 개요는 다음과 같다. Group 객체가 있고, Group 활동 안에 게시물인 Post 객체가 있으며, Post 에는 댓글인 Comment 객체가 있는 다음과 같은 상황이다. 아래에는 표시되어 있지 않지만, Member 객체가 당연히 있으며, Member 와 Group 사이에는 현재 상태가 기록되고, [강퇴 / 휴면 / 활동] 의 세 가지 상태로 관리된다.

 

 

간략한 Entity 구조

 

 

이와 같은 관계는 많이 발생하는 일반적인 상황으로, 모두 비식별 관계이다. 이 때, Comment 에 대한 로직을 작성하고 있으며, [생성 / 수정 / 삭제] 에 대한 로직을 작성하고 있다고 가정해보자. 세가지 로직은 다음과 같은 규칙을 따른다. 

 

 

1. 생성하려는 요청 유저는 활동 중인 그룹원이어야 한다

2. 수정하려는 요청 유저는 활동 중인 그룹원이어야 하며, 댓글 작성자만이 수정할 수 있다

3. 삭제하려는 요청 유저는 활동 중인 그룹원이어야 하며, 댓글 작성자와 그룹 생성자만이 삭제할 수 있다

 

 

각 로직은 다음과 같은 일들을 수행해야 할 것이다.

 

 

생성 수정 삭제

1. 속한 게시물을 DB 조회 후, 연관관계 매핑


2. 활동중인 그룹원임을 확인


1. 댓글이 존재하는지 확인


2. 활동중인 그룹원이며, 댓글 작성자임을 확인


1. 댓글이 존재하는지 확인


2. 활동중인 그룹원이며, 댓글 작성자이거나 그룹 생성자임을 확인

 

 

위와 같이 로직을 작성하던 중, ID 값을 전달받아 일일이 findById 혹은 연관관계 탐색으로 PK 조회를 하는 것이 나을지, 아니면 연관 관계를 이용하여 Fetch Join 하는 것이 나을지 고민이 되었다. 이 생각을 기준으로 앞으로 나올 세 가지 로직 설계가 가능하다고 생각이 들었다. 제일 검증 과정이 복잡한 [삭제] 로직을 대표적으로 확인하며, 이 일반적인 상황이 뭐가 문제라는건지 한 번 살펴보자. 

 

 

 

(2) PK 조회 vs Fetch Join

 

 

1. 조회가 필요한 객체들에 대한 PK들을 전달받고 조회한다

 

 

인 앱 로직에서 사용되는 모든 객체들의 PK 를 요청에 알맞게 보내주도록 API를 설계하면, 각각 PK 조회를 할 수 있다. 다음과 같이 진행된다고 해볼 수 있겠다. 

 

 

void deleteCommentV1(Long commentId, Long postId, Long groupId, Member reqMember){

    Comment cmt = em.find(Comment.class, commentId);
    Post post = em.find(Post.class, postId);
    Group group = em.find(Group.class, groupId);
    
    System.out.println("=== 추가적 쿼리 발생하지 않음 확인 === ");
    
    if(cmt.getPost().getId() != post.getId()
        || post.getGroup().getId() != group.getId()){
        throw new RuntimeException("정합성 오류");
    }

    // 이후 실제 로직 실행 
    GroupMember gMember = gmRepo.findByEachId(reqMember.getId(), group.getId());
    if(gMember.getState() != ACTIVE){
        throw new RuntimeException("활동중인 그룹원이 아님")
    }

    Long reqId = reqMember.getId();
    if(cmt.getMember().getId() != reqId // 댓글 작성자 아님
        || group.getCreatorId() != reqId){ // 그룹 생성자 아님
        throw new RuntimeException("댓글을 삭제할 권한이 없음");
    }
    
    cmt.delete();

}

 

 

위와 같이 모든 PK 들을 API 로 받아 일일이 조회 후, 비즈니스 로직을 수행하는 방법이 있다. 이와 같은 방법은 많이 사용하지 않겠지만, 막상 해보니 나쁘지 않다는 생각도 들었다. 장점과 단점은 다음과 같이 정리해볼 수 있다. 

 

 

+) 제일 직관적이다

+) Entity 관계 변경에 유동적이다

+) PK 조회로 인덱싱이 보장된다

 

-) 전달해야할 데이터가 많아진다 (Client 단에서 보내기 어려운 상황일 수도 있음) 

-) 코드가 길어진다 (테스트가 어려워짐) 

-) 통일성을 위해 EntityManager 을 사용했지만, 실제론 도메인별 Repository 를 사용할테니 주입받아야 하는 Bean 들이 많아진다 (PostRepository, GroupMemberRepository, GroupRepository 등등 ..)

-) API 특성상 정합성 검증이 매번 따로 필요하다

 

 

 

2. 객체 탐색을 하면서 필요할 때 PK 를 발생시키도록 둔다

 

 

JPA 를 사용하는 가장 대표적인 이유 중 하나는 객체 탐색이다. Comment.getPost().getGroup() 과 같은 방식으로 탐색을 하면서 필요한 정보를 불러올 수 있는 장점을 가지고 있기 때문이고, 이를 위해서는 조회 방식 (Lazy, Eager) 을 잘 성정해 두어야 하는 것은 모두 알 것이다. 다음과 같이 간단히 마련해볼 수 있다.

 

 

void deleteCommentV2(Long commentId, Member reqMember){

    Comment cmt = em.find(Comment.class, commentId);
    System.out.println("=========== 쿼리 분기 점 ========== ");

    // 실제 로직 실행 
    // Post -> getGroup() 으로 인한 Post 조회 발생
    GroupMember gMember = gmRepo.findByEachId(reqMember.getId()
        , cmt.getPost().getGroup().getId());
    if(gMember.getState() != ACTIVE){
        throw new RuntimeException("활동중인 그룹원이 아님")
    }
    
    System.out.println("=========== 쿼리 분기 점 ========== ");

    // Group -> getCreatorId() 로 인한 Group 조회 발생
    Long reqId = reqMember.getId();
    if(cmt.getMember().getId() != reqId
        || cmt.getPost().getGroup().getCreatorId() != reqId){
        throw new RuntimeException("댓글을 삭제할 권한이 없음");
    }
    
    cmt.delete();

}

 

 

다소 간단해진 로직을 확인할 수 있다. 객체 탐색이 되는 모습을 명확하게 정리해놓지 않은 상황이라면 성능 이슈가 발생할 수 있는 모델이지만, 정확하게 제어한다면 좋은 모델이라고 생각한다. 

 

 

+) API 데이터가 간단하다

+) 정합성 검증을 따로 진행할 필요가 없다

+) PK 조회로 인덱싱이 보장된다

 

-) 연관 관계 변경시, 유지 보수할 사항이 많다

-) 어떤 시점에 어떤 조회가 나가는지 확실히 알고 사용해야 한다

-) 연관 관계 주인 쪽에서만 사용해야 한다

 

 

 

3. Fetch Join 을 활용한다

 

 

void deleteCommentV3(Long commentId, Member reqMember){

    String jpql = "select c from Comment c " + 
                "join fetch c.post p " +
                "join fetch p.group g " +
                "where c.id = :commentId";
    
    Comment cmt = em.createQuery(jpql, Comment.class)
            .setParameter("commentId", commentId)
            .getSingleResult();
            
    System.out.println("=========== 쿼리 분기 점 : 더 이상 쿼리 발생하지 않음 ========== ");

    Post post = cmt.getPost();
    Group group = post.getGroup();

    // 실제 로직 실행 
    GroupMember gMember = gmRepo.findByEachId(reqMember.getId(), group.getId());
    if(gMember.getState() != ACTIVE){
        throw new RuntimeException("활동중인 그룹원이 아님")
    }
  
    Long reqId = reqMember.getId();
    if(cmt.getMember().getId() != reqId
        || gruop.getCreatorId() != reqId){
        throw new RuntimeException("댓글을 삭제할 권한이 없음");
    }
    
    cmt.delete();

}

 

 

 

대부분 한방 쿼리를 좋다고 하기 때문에, 이와 같은 상황에서는 Fetch Join 을 활용하도록 많이 알려져 있고, 결코 틀린말이 아니다. Fetch Join 이 옳은 경우가 대부분일 것이라 생각한다. 하지만 위와 같이 Join 을 두 번 이상 수행해야 하거나, 인덱스 탐색을 타는지 판단 해야하는 상황 (데이터가 많을 것으로 예상되는 테이블) 에서는 좀 더 생각해볼 필요가 있다는 것이다. 

 

 

이유는 PK 조회는 인덱스를 타기 때문에 생각보다 매우 빠르지만, Join 자체는 (실행계획을 튜닝하는 경우를 제외) 기본적으로 Looping 이기 때문에, 상대적으로 오래걸리는 과정이기 때문이다. 그렇다면 위 세가지 방식 중 어떤 성능이 가장 좋을지 예상해보자. 개인적으로 예상은 데이터가 많을 수록 2 = 1 > 3 으로 예상해본다. 

 

 

 

(3) 성능 실험

 

 

각 V1, V2, V3 를 실제 DB 에 데이터를 넣고 시간을 테스트 해보자. 데이터 규모는 다음과 같이 준비하고 진행하였다. (데이터 준비가 꽤 어려워, 다양한 연관 관계를 준비하진 못함, 컴퓨터가 터질라 함 ㅠㅠ)

 

 

Group 10,000개 // group_id 4567L 에게 Post 600,000개 // post_id 555555L 에게 Comment 10,000 개

 

 

V1, V2, V3 각각 다른 Test 로 연속적으로 수행되게 설계했는데, 첫 순서인 Test 는 코드가 같은 시간 계산 로직이여도 훨씬 오래 걸렸다. 아마 영속성 컨텍스트 외에 다른 Java 객체들의 관리 등 이런 것 때문인 것 같아서, 각각 따로 비교를 해보았다. 결과는 3~4 번 수행의 평균값이다.

 

 

ms 기준 첫 실행 테스트 이후 실행 테스트 발생 쿼리
V1 (각자 PK) 174 13 4
V2 (객체 탐색) 171 12 4
V3 (Fetch Join) 182 14 2

 

 

내 예상과는 다르게 비슷했다. 그 이유를 다음 Join 쿼리 기준으로 생각해보자. 

 

 

SELECT * FROM Comment c

JOIN Post p ON c.post_id = p.post_id

JOIN Group g ON g.group_id = p.group_id

WHERE c. comment_id = 5678;

 

 

나는 PK 를 통한 조회는 DB 들이 자동으로 INDEX 테이블을 기본적으로 만들기 때문에 PK 조회가 훨씬 빠르게 이루어질 것으로 예상을 했다. 그리고 FK 기준으로 탐색을 하면 Full Scan 을 해야 하기 때문에 Join 오버헤드가 존재할 것이라 생각했었는데, 이 상황은 FK 기준으로 인덱스를 생성하냐 마냐의 차이인 부분이다.

 

 

만약 FK 기준으로 인덱스를 생성하지 않는다면, 내가 생각한대로 Full Scan 을 하기 위해 훨씬 오래 걸렸을 것으로 예상된다. 하지만 위 쿼리는 Comment 를 인덱스로 레코드를 찾은 후, Post 와 Group 모두 Comment 테이블에 존재하는 FK Table 로 빠르게 해당하는 레코드들을 조회하여 Join 시켜줄 수 있는 것이다. 나는 이것을 생각하지 못했고, MySQL 은 자동으로 FK 제약조건에 대해 Index Table 을 만들어 준다는 점을 확실히 알 수 있었다. 

 

 

단순한 결과를 위해 솔직히 과정이 좀 뻘짓이였던 것 같다. 조금만 더 생각했으면 쉽게 결론이 날 수 있었을 것 같다. 그래서 결론이 뭐냐? 셋 다 비슷하니, 상황과 선호도에 맞게 사용하면 된다. 

 

 

정합성을 검증해야 하더라도 정확한 식별자 조회를 통해 통일된 형식의 서비스단을 만들고 싶으면 V1, API를 간단하게 제공하고 JPA 의 장점을 살리고 싶으면 V2, 한방 쿼리를 선호하면 V3를 사용하면 된다. 근데 사용하다보면 종류별로 조회를 해야할 쿼리들이 다양하게 생기기 때문에, 한방 쿼리가 정말 줄줄이 쌓일 수도 있다. 따라서 개인적으로는 V2 와 V3 를 함께 사용하는 것이 좋을 것 같다고 생각한다.  

 

 

부가적으로 Join 의 판단 부분에선 상황에 따라 다르게 판단하면 될 것 같다. 위 실험에서 배제한 내용들이 있다. 아래와 같은 상황이라면, Join 을 사용하기보단 PK 를 통해 빠르게 조회하여 서비스를 수행하는 것이 좋을 것 같다.

 

 

  • Fetch Join 특성상 Entity 를 모두 불러와야 하기 때문에, 칼럼 데이터들이 매우 많을 경우, overhead 가 더 발생할 수도 있다

  • FK 칼럼에 대해 Index Table 을 의도적으로 생성하지 않은 경우 (자주 사용되지 않는 쿼리)

  • 인덱싱을 타지 않는 쿼리를 날리는 경우 (OR절 사용, IN절 사용 등)

  • 자동으로 Index Table 을 생성해주는 DBMS 가 아닐경우 (ex: Oracle)

 

 

무분별한 Join 은 생각보다 성능에 큰 영향을 준다

 

 

사실 오버엔지니어링 느낌이 없잖아 있는데... 그래도 로직을 짜보면서 깊어진 고민을 조금 길게 정리해봤다. SHOW INDEX FROM COMMENT; 와 같은 인덱스 테이블이 있는지 한 번 확인해봤으면 훨씬 금방 해결될 수 있었을텐데 ^^.. 뻘짓을 좀 한 덕분에 이런 상황은 그래도 유의하며 서비스 설계를 할 수 있을 것 같다

 

 

 

부가적 의문 사항

 

 

쿼리의 갯수에 대한 부분이 조금 의문이긴 하다. 인덱스 탐색 하는 갯수가 다 동일한 것 같은데, 그렇다면 왜 [어느정도]는 V3 가 살짝 더 오래걸리는지. 쿼리가 제일 적게 나가는데 오히려 제일 빨라야 하는거 아닌가 싶은 의문이 들었다. 근데 ms 수준에서 이 정도 차이는 무의미한 것 같고, 훨씬 잘 설계된 대량 데이터에서 테스트해보면 더욱 정확한 결과를 얻을 수 있지 않을까 싶다. 

 

 

 

출처

 

 

- 출처 따윈 없다. 온전히 내 뻘짓 과정과 뇌피셜

728x90