팔로우 기능 구현기 1 - N+1 문제 발생

코딩하는 감자·2023년 12월 10일
0
post-custom-banner

글에 앞서서, 보시는 분이 계실지는 모르겠지만 일단 초벌로 작성한 코드이기 때문에 보기 불편한 부분이 있으셔도 넓은 마음으로 넘어가주시면 제가 감사합니다...
부족한 부분을 계속 리팩토링해나가는 과정을 기록하려고 합니다!!

간략한 구현 코드

Member를 애그리거트 루트로 하고, 팔로잉 목록과 팔로워 목록을 Member에서 일급컬렉션으로 관리하도록 구현했다.

@Entity
public class Follow {

	// ...

    @ManyToOne(fetch = FetchType.LAZY)
    private Member follower;
    @ManyToOne(fetch = FetchType.LAZY)
    private Member followed;

	// ...

    public Member getFollower() {
        return follower;
    }

    public Member getFollowed() {
        return followed;
    }
}

@Entity
public class Member {
	
    // ...
    
    @Embedded
    private MyFollowings myFollowings;
    @Embedded
    private MyFollowers myFollowers;
    
    // ...

    public void follow(Member target) {
        if (Objects.isNull(target) || Objects.equals(target, this)) {
            throw new IllegalArgumentException("팔로우 할 수 없는 회원입니다");
        }
        this.myFollowings.add(this, target);
    }

    public void unfollow(Member target) {
        if (Objects.isNull(target) || Objects.equals(target, this)) {
            throw new IllegalArgumentException("언팔로우 할 수 없는 회원입니다");
        }
        this.myFollowings.remove(target);
    }

    public boolean isMyFollowing(Member member) {
        return myFollowings.contains(member);
    }

    public boolean isMyFollower(Member member) {
        return myFollowers.contains(member);
    }

}

@Embeddable
public class MyFollowers {

    @OneToMany(mappedBy = "followed", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    List<Follow> follows = new ArrayList<>();

    public boolean contains(Member member) {
        return follows.stream()
                .anyMatch(follow -> follow.getFollower().equals(member));
    }

}

@Embeddable
public class MyFollowings {

    @OneToMany(mappedBy = "follower", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    List<Follow> follows = new ArrayList<>();

    public void add(Member me, Member target) {
        if (contains(target)) {
            throw new FollowFailedByAlreadyFollowingException();
        }
        this.follows.add(Follow.of(me, target));
    }

    public boolean contains(Member member) {
        return this.follows.stream()
                .anyMatch(follow -> follow.getFollowed().equals(member));
    }

    public void remove(Member target) {
        Follow follow = get(target)
                .orElseThrow(UnfollowFailedByNotFollowingException::new);
        follows.remove(follow);
    }

    private Optional<Follow> get(Member target) {
        return this.follows.stream()
                .filter(follow -> follow.getFollowed().equals(target))
                .findAny();
    }
}

@Service
@Transactional(readOnly = true)
public class MemberService {

    // ...
    
    private final FollowRepository followRepository;

	// ...
    
    @Transactional
    public void follow(String id, String targetId) {
        Member member = findById(id);
        Member target = findById(targetId);
        member.follow(target);
    }

    @Transactional
    public void unfollow(String id, String targetId) {
        Member member = findById(id);
        Member target = findById(targetId);
        member.unfollow(target);
    }

    public Slice<FollowInfoResponse> listFollowings(String loginId, String id, Pageable pageable) {
    	Member loginMember = findById(id);
        Member member = findById(id);
        Slice<Member> followings = followRepository.findMemberByFollower(member, pageable);
        return MemberMapper.toFollowingInfo(loginMember, followings);
    }

    public Slice<FollowInfoResponse> listFollowers(String loginId, String id, Pageable pageable) {
    	Member loginMember = findById(id);
        Member member = findById(id);
        Slice<Follow> followers = followRepository.findByFollowed(member, pageable);
        return MemberMapper.toFollowerInfo(loginMember, followers);
    }

}

public class MemberMapper {

    // ...

    public static Slice<FollowInfoResponse> toFollowingInfo(Member member, Slice<Follow> followings) {
        return followings.map(
                follow -> {
                    Member followed = follow.getFollowed();
                    return FollowInfoResponse.of(
                            followed.getId().getValue(),
                            followed.getNickname(),
                            followed.getProfileImageUrl(),
                            member.isMyFollowing(followed),
                            member.isMyFollower(followed));
                });
    }

    public static Slice<FollowInfoResponse> toFollowerInfo(Member member, Slice<Follow> follows) {
        return follows.map(
                follow -> {
                    Member follower = follow.getFollower();
                    return FollowInfoResponse.of(
                            follower.getId().getValue(),
                            follower.getNickname(),
                            follower.getProfileImageUrl(),
                            member.isMyFollowing(follower),
                            member.isMyFollower(follower));
                });
    }

}

Mapper에서 N+1 문제 발생

public class MemberMapper {

    // ...

    public static Slice<FollowInfoResponse> toFollowingInfo(Member member, Slice<Follow> followings) {
        return followings.map(
                follow -> {
                    System.out.println("------------follow------------");
                    Member followed = follow.getFollowed();
                    return FollowInfoResponse.of(
                            followed.getId().getValue(),
                            followed.getNickname(),
                            followed.getProfileImageUrl(),
                            member.isMyFollowing(followed),
                            member.isMyFollower(followed));
                });
    }

}

class MemberAcceptanceTest {

		@BeforeEach
        void setup {
        	회원가입한다(MemberFixture.푸반_회원가입_요청(), new RequestSpecBuilder().build());
            String 푸반_액세스토큰 = 로그인_한다(MemberFixture.푸반_로그인_요청(), new RequestSpecBuilder().build())
        }
		
		@DisplayName("푸반이 아티를 팔로우할 때, 푸반의 팔로잉 목록에 아티가 조회된다")
        @Test
        void when_list_follow_if_success_then_response_code_200_and_follows() {
            // docs
            api_문서_타이틀("list_following_success", spec);

            // given
            회원가입한다(MemberFixture.회원1_회원가입_요청(), new RequestSpecBuilder().build());
            회원가입한다(MemberFixture.회원2_회원가입_요청(), new RequestSpecBuilder().build());
            회원가입한다(MemberFixture.회원3_회원가입_요청(), new RequestSpecBuilder().build());
            String 회원1_액세스토큰 = 로그인_한다(MemberFixture.회원1_로그인_요청(), new RequestSpecBuilder().build())
                    .jsonPath().getString("accessToken");
            String 회원2_액세스토큰 = 로그인_한다(MemberFixture.회원2_로그인_요청(), new RequestSpecBuilder().build())
                    .jsonPath().getString("accessToken");
            String 회원3_액세스토큰 = 로그인_한다(MemberFixture.회원3_로그인_요청(), new RequestSpecBuilder().build())
                    .jsonPath().getString("accessToken");
            String 회원1_아이디 = jwtUtil.parseAccessToken(회원1_액세스토큰).get("id");
            String 회원2_아이디 = jwtUtil.parseAccessToken(회원2_액세스토큰).get("id");
            String 회원3_아이디 = jwtUtil.parseAccessToken(회원3_액세스토큰).get("id");
            팔로우한다(푸반_액세스토큰, 아티_아이디, new RequestSpecBuilder().build());
            팔로우한다(푸반_액세스토큰, 회원1_아이디, new RequestSpecBuilder().build());
            팔로우한다(푸반_액세스토큰, 회원2_아이디, new RequestSpecBuilder().build());
            팔로우한다(푸반_액세스토큰, 회원3_아이디, new RequestSpecBuilder().build());

            // when
            var response = 팔로잉_목록을_조회한다(푸반_아이디, spec);

            // then
            Assertions.assertAll(
                    () -> 상태코드를_검증한다(response, HttpStatus.OK),
                    () -> assertThat(response.jsonPath().getList("content"))
                            .extracting("id")
                            .contains(아티_아이디)
            );
        }
        
}

위 테스트의 로그를 찍어보면


----------------MemberMapper.toFollowingInfo(member, followings);------------------------------
------------follow------------
// Member followed = follow.getFollowed();
Hibernate: 
    select
        member0_.id as id1_16_0_,
        member0_.email as email2_16_0_,
        member0_.nickname as nickname3_16_0_,
        member0_.password as password4_16_0_,
        member0_.profile_image_id as profile_5_16_0_,
        member0_.profile_image_url as profile_6_16_0_,
        member0_.taste_mood_id as taste_mo7_16_0_ 
    from
        member member0_ 
    where
        member0_.id=?
// member.isMyFollowing(followed),        
Hibernate: 
    select
        follows0_.follower_id as follower3_13_0_,
        follows0_.id as id1_13_0_,
        follows0_.id as id1_13_1_,
        follows0_.followed_id as followed2_13_1_,
        follows0_.follower_id as follower3_13_1_ 
    from
        follow follows0_ 
    where
        follows0_.follower_id=?
// member.isMyFollower(followed));        
Hibernate: 
    select
        follows0_.followed_id as followed2_13_0_,
        follows0_.id as id1_13_0_,
        follows0_.id as id1_13_1_,
        follows0_.followed_id as followed2_13_1_,
        follows0_.follower_id as follower3_13_1_ 
    from
        follow follows0_ 
    where
        follows0_.followed_id=?
------------follow------------
// Member followed = follow.getFollowed();
Hibernate: 
    select
        member0_.id as id1_16_0_,
        member0_.email as email2_16_0_,
        member0_.nickname as nickname3_16_0_,
        member0_.password as password4_16_0_,
        member0_.profile_image_id as profile_5_16_0_,
        member0_.profile_image_url as profile_6_16_0_,
        member0_.taste_mood_id as taste_mo7_16_0_ 
    from
        member member0_ 
    where
        member0_.id=?
------------follow------------
// Member followed = follow.getFollowed();
Hibernate: 
    select
        member0_.id as id1_16_0_,
        member0_.email as email2_16_0_,
        member0_.nickname as nickname3_16_0_,
        member0_.password as password4_16_0_,
        member0_.profile_image_id as profile_5_16_0_,
        member0_.profile_image_url as profile_6_16_0_,
        member0_.taste_mood_id as taste_mo7_16_0_ 
    from
        member member0_ 
    where
        member0_.id=?
------------follow------------
// Member followed = follow.getFollowed();
Hibernate: 
    select
        member0_.id as id1_16_0_,
        member0_.email as email2_16_0_,
        member0_.nickname as nickname3_16_0_,
        member0_.password as password4_16_0_,
        member0_.profile_image_id as profile_5_16_0_,
        member0_.profile_image_url as profile_6_16_0_,
        member0_.taste_mood_id as taste_mo7_16_0_ 
    from
        member member0_ 
    where
        member0_.id=?

이렇게 follow.getFollowed()가 실행될 때마다 DB를 조회하고 있다.

해결

public interface FollowRepository extends JpaRepository<Follow, Long> {

    @Query("SELECT f.followed FROM Follow f WHERE f.follower = :member")
    Slice<Member> findFollowedByFollower(Member member, Pageable pageable);
   
}

@Service
@Transactional(readOnly = true)
public class MemberService {

    public Slice<FollowInfoResponse> listFollowings(String loginId, String id, Pageable pageable) {
    	Member loginMember = findById(id);
        Member member = findById(id);
        Slice<Member> followings = followRepository.findMemberByFollower(member, pageable);
        return MemberMapper.toFollowingInfo(loginMember, followings);
    }
    
}

public class MemberMapper {

	// ...

    public static Slice<FollowInfoResponse> toFollowingInfo(Member member, Slice<Member> followings) {
        return followings.map(
                followed -> FollowInfoResponse.of(
                            followed.getId().getValue(),
                            followed.getNickname(),
                            followed.getProfileImageUrl(),
                            member.isMyFollowing(followed),
                            member.isMyFollower(followed)));
    }

}
Hibernate: 
    select
        member1_.id as id1_16_,
        member1_.email as email2_16_,
        member1_.nickname as nickname3_16_,
        member1_.password as password4_16_,
        member1_.profile_image_id as profile_5_16_,
        member1_.profile_image_url as profile_6_16_,
        member1_.taste_mood_id as taste_mo7_16_ 
    from
        follow follow0_ 
    inner join
        member member1_ 
            on follow0_.followed_id=member1_.id 
    where
        follow0_.follower_id=? limit ?

Follow와 Member를 join해서 Member 컬렉션으로 받아온 뒤 Mapper에서는 dto로 변환만 하도록 해서 해결했다.
Follow의 getFollowed()랑 getFollower()를 사용하게 되면 Member를 조회하는 쿼리가 별도로 나가게 된다는 점에 유의해서 코드를 작성해야 겠다.

join으로 response dto의 필드를 모두 조회해올 수 없었던 이유

조회할 엔티티들을 쿼리로 전부 join해서 response dto를 바로 조회하는 것이 N+1을 피할 수 있는 기본적인 방법이라고 생각한다.
하지만 dto를 바로 조회하지 않고 엔티티를 조회한 뒤 mapper에서 dto로 변환한 이유는,

member.isMyFollowing(followed),
member.isMyFollower(followed))

이 부분에서 조회해온 member들이 내 팔로잉이나 팔로워인지 확인해야하는 로직이 있기 때문이다.

쿼리에서 이 로직을 구현하려면, 조회되는 member 행마다 Follow를 조회해 나의 팔로워인지 확인해야 한다.

하지만 지금 내 방식으로는 JPA가 나의 팔로워 목록과 팔로잉 목록을 한 번만 조회하고 그 뒤로는 프록시를 사용하기 때문에 DB에 접근하지 않고 member가 나의 팔로잉 목록과 팔로워 목록에 있는지 확인할 수 있다.

post-custom-banner

0개의 댓글