프록시 객체로 Fetch Join 없이도 추가 조회를 줄이는 방법

chanbyeong·2025년 3월 18일

문제의 발견

  • 연관된 엔터티를 조회할 때 Fetch Join을 사용하는 것이 항상 최적의 방법이라고 생각했다. 하지만 실제로는 불필요한 조인으로 인해 같은 데이터를 중복 조회하는 문제가 발생할 수 있었다.
  • 게시글을 조회할 때, 좋아요 여부를 확인하는 추가적인 쿼리가 필요했다. Fetch Join을 사용하여 좋아요 엔터티까지 함께 불러올 수도 있었지만, 이미 1:N 관계를 하나 Fetch Join한 상태였기 때문에 추가적인 조회를 수행하는 방식으로 결정했다.
  • 하지만 이 과정에서 같은 게시글과 회원 정보를 다시 가져오는 불필요한 데이터 로딩 문제가 발생했다.
	// PostServiceImpl.class
    @Override
    @Transactional
    public PostGetDto getPost(Long postId, Long memberId, boolean flag) {
        Post post = postRepository.findPostWithField(postId)
                .orElseThrow(() -> new NotFoundException("Post not found"));

        // 조회수 추가
        if (flag) post.updateViewCount();

        // 로그인 하지 않은 사용자의 경우 게시글만 반환
        if (memberId.equals(GUEST_USER)) {
            return PostGetDto.toDto(post, new PostUserStatusDto());
        }

        // 좋아요 표시 여부 및 작성자 여부
        PostUserStatusDto postUserStatusDto = getPostAuthInfo(memberId, postId,
                post.getMember().getId());

        return PostGetDto.toDto(post, postUserStatusDto);
    }
  
      private PostUserStatusDto getPostAuthInfo(Long memberId, Long postId,
            Long postOwnerId) {

        boolean isLiked = likeRepository.findByPostIdAndMemberId(postId, memberId)
                .isPresent();
        boolean isOwner = postOwnerId.equals(memberId);

        return new PostUserStatusDto(isLiked, isOwner);
    }
    
	// PostRepository.class
    @Query("""
           select p
           from Post p
           join fetch p.member m
           join fetch p.recruitmentInfo r
           join fetch r.fieldList f
           left join fetch p.image
           where p.id = :id
           """)
    Optional<Post> findPostWithField(@Param("id") Long id);
    
	// LikeRepository.class
    
        @Query("select l from Like l join fetch l.post p join fetch l.member m where p.id = :postId and m.id = :memberId")
    Optional<Like> findByPostIdAndMemberId(@Param("postId") Long postId,
            @Param("memberId") Long memberId);
           
  • 실행 결과
    select
        p1_0.post_id,
        p1_0.category,
        p1_0.content,
        p1_0.created_at,
        p1_0.created_by,
        i1_0.image_id,
        i1_0.full_path,
        i1_0.store_file_name,
        i1_0.upload_file_name,
        p1_0.like_count,
        m1_0.member_id,
        m1_0.content,
        m1_0.email,
        m1_0.login_id,
        m1_0.major,
        m1_0.nickname,
        m1_0.password,
        m1_0.username,
        p1_0.modified_at,
        p1_0.modified_by,
        ri1_0.recruitment_info_id,
        ri1_0.end_date,
        fl1_0.recruitment_info_id,
        fl1_0.id,
        fl1_0.current_recruitment,
        fl1_0.field_category,
        fl1_0.total_recruitment,
        ri1_0.start_date,
        ri1_0.status,
        p1_0.title,
        p1_0.view_count 
    from
        post p1_0 
    join
        member m1_0 
            on m1_0.member_id=p1_0.member_id 
    join
        recruitment_info ri1_0 
            on p1_0.post_id=ri1_0.post_id 
    join
        field fl1_0 
            on ri1_0.recruitment_info_id=fl1_0.recruitment_info_id 
    left join
        image i1_0 
            on p1_0.post_id=i1_0.post_id 
    where
        p1_0.post_id=?
2025-03-18T00:26:34.280+09:00 DEBUG 60969 --- [nio-8080-exec-5] org.hibernate.SQL                        : 
    select
        l1_0.id,
        m1_0.member_id,
        m1_0.content,
        m1_0.email,
        m1_0.login_id,
        m1_0.major,
        m1_0.nickname,
        m1_0.password,
        m1_0.username,
        p1_0.post_id,
        p1_0.category,
        p1_0.content,
        p1_0.created_at,
        p1_0.created_by,
        p1_0.like_count,
        p1_0.member_id,
        p1_0.modified_at,
        p1_0.modified_by,
        p1_0.title,
        p1_0.view_count 
    from
        likes l1_0 
    join
        post p1_0 
            on p1_0.post_id=l1_0.post_id 
    join
        member m1_0 
            on m1_0.member_id=l1_0.member_id 
    where
        p1_0.post_id=? 
        and m1_0.member_id=?
  • 로그를 보면 같은 게시글의 정보를 한번 더 불러온다.
  • 결국 성능 자체에는 큰 영향을 주지 않더라도, 데이터 중복 조회와 불필요한 조인으로 인해 설계의 명료성과 효율성 측면에서는 개선을 해야겠다고 판단했다.

해결

  • 이는 간단하게 해결할 수 있었다. 그냥 단순히 FetchJoin을 없애주는 거 하나만으로 중복된 내용을 조회하는 문제를 해결할 수 있었다
   @Query("select l from Like l where l.post.id = :postId and l.member.id = :memberId")
    Optional<Like> findByPostIdAndMemberId(@Param("postId") Long postId,
            @Param("memberId") Long memberId);

왜 Fetch Join을 안해줘도 될까?

영속성 컨텍스트의 역할?

  • 한 번 조회된 엔터티는 영속성 컨텍스트(1차 캐시)에 저장된다.
  • 선행 쿼리에서 Post 조회 시 이미 Fetch Join을 사용해 연관 데이터(Member)를 함께 불러왔으므로, 이후 같은 트랜잭션 내에서 해당 엔터티에 접근할 때 추가적인 DB 조회 없이 영속성 컨텍스트에 저장된 데이터를 재사용한다.
  • 단, 이 방식이 적용되는 경우는 게시글 작성자가 현재 로그인한 사용자인 경우에 한정된다.
  • 반면, 다른 사용자가 게시글을 조회하는 경우에는 작성자와 조회하는 사용자가 다르므로 추가적인 조회가 발생할 수 있다.
  • 실제로 작성자가 아닐 때, like.get().getMember().getNickname();을 수행한 경우, Member을 조회하는 추가적인 쿼리가 발생했다.

ID 값만 조회하면 추가적인 SELECT 쿼리를 발생시키지 않는다

  • JPA에서 연관 엔터티를 지연 로딩(Lazy Loading)하면 실제 데이터가 아닌 프록시 객체를 반환한다.
  • 하지만 Hibernate는 프록시 객체를 생성할 때, 연관 엔터티의 ID 값을 이미 알고 있기 때문에 post.getMember().getId()와 같이 ID 값만 조회하는 경우에는 추가적인 쿼리를 실행하지 않는다.
  • 만약 post.getMember().getNickname() 같은 다른 필드를 조회하면 그제야 SELECT 쿼리가 실행됨 (Lazy Loading 동작).
  • 즉, 연관 엔터티의 ID 값만 사용하면 Hibernate가 추가적인 데이터 로딩 없이 ID 값을 바로 반환할 수 있기 때문에 Fetch Join 없이도 최적화된 쿼리가 실행될 수 있다.

0개의 댓글