[JPA] 팔로우 기능 구현하기

심씨·2024년 5월 10일
0

JPA

목록 보기
2/3
post-thumbnail

JPA 팔로우 기능 구현하기

0. 테이블 설계

팔로우 기능은 회원 간 서로를 구독할 수 있는 기능이기 때문에 회원 간 다대다(Many-to-Many) 관계로 해석할 수 있습니다. 하지만 관계형 데이터베이스에서 회원 테이블만으로는 회원 간 다대다(Many-to-Many) 관계를 표현할 수 없습니다.

따라서 회원 테이블을 연결해 줄 테이블을 추가해 일대다(One-to-Many), 다대일(Many-to-One) 관계로 회원 간 관계를 표현합니다. 연결 테이블로 중간에 Follow 테이블을 추가해 주어 회원 간 다대다(Many-to-Many) 관계를 아래와 같이 일대다(One-to-Many), 다대일(Many-to-One) 관계로 표현해 줍니다.

1. 도메인 모델링

1-1. Follow 엔티티

package com.example.myproject.follow.domain;

import com.example.myproject.member.domain.Member;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;

import static jakarta.persistence.FetchType.LAZY;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Follow {
    @Id
    @GeneratedValue
    @Column(name = "follow_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "follower_id")
    private Member follower;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "followee_id")
    private Member followee;

    public Follow(Member follower, Member followee) {
        this.follower = follower;
        this.followee = followee;
    }
    // ....
}

1-2. Member 엔티티

package com.example.myproject.member.domain;

import com.example.myproject.common.domain.Address;
import com.example.myproject.follow.domain.Follow;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column(name = "email", unique = true)
    private String email;

    @Column(name = "password")
    private String password;

    @Embedded
    private Profile profile;

    @OneToMany(mappedBy = "followee")
    private Set<Follow> followers = new HashSet<>();

    @OneToMany(mappedBy = "follower")
    private Set<Follow> followees = new HashSet<>();
    // ...
}

Follow 엔티티는 팔로우를 하는 회원 프로퍼티 follower와 팔로우의 대상 회원 프로퍼티 followee로 구성되어 있습니다. Member 엔티티는 팔로워의 목록 프로퍼티 followers와 팔로잉 목록 프로퍼티 followees로 구성되어있습니다.

주의할 점은 Member 엔티티의 followers 프로퍼티는 Follow 엔티티의 followee 프로퍼티와 Member 엔티티의 followees 프로퍼티는 Follow 엔티티의 follower 프로퍼티와 mappedBy 속성을 사용하여 연관관계가 설정되어 있습니다.

예를 들어 회원 'A'의 팔로워 목록을 조회하기 위해서는 Follow 엔티티에서 팔로우의 대상인 followee 필드가 'A'인 값을 조회해야 하기 때문입니다. 예상되는 쿼리문은 다음과 같습니다.

select 
	f 
from 
	follow f 
where 
	followee_id = 'A'    

2. 팔로우 구현

package com.example.myproject.follow.application;

import com.example.myproject.follow.domain.Follow;
import com.example.myproject.follow.domain.FollowRepository;
import com.example.myproject.member.domain.Member;
import com.example.myproject.member.domain.MemberRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class FollowService {

    private final MemberRepository memberRepository;
    private final FollowRepository followRepository;

    @Transactional
    public String follow(Long followerId, Long followeeId) throws RuntimeException {
        Member follower = memberRepository.findById(followerId).orElseThrow(RuntimeException::new);
        Member followee = memberRepository.findById(followeeId).orElseThrow(RuntimeException::new);

        Follow follow = new Follow(follower, followee);
        followRepository.save(follow);
        return "ok";
    }
    // ...
}

서비스 계층에서 Follow 객체를 생성하여 follower와 followee에 회원 정보만 저장해 주면 회원 간에 구독 관계가 형성되므로 팔로우 기능 구현이 완료됩니다. 결과적으로 다음과 같은 쿼리문이 실행됩니다.

insert into follow
(
	created_at, 
    followee_id, 
    follower_id, 
    updated_at, 
    follow_id
) 
values
(
	?, 
    ?, 
    ?, 
    ?, 
    ?
)

3. 팔로워/팔로잉 목록 조회 구현

@Service
@RequiredArgsConstructor
public class FollowService {

    private final MemberRepository memberRepository;
    private final FollowRepository followRepository;

    public List<Member> followers(Long memberId) {
        Member member = memberRepository.findById(memberId).orElseThrow(RuntimeException::new);

        return member.getFollowers()
                .stream().map(follow -> follow.getFollower()).collect(Collectors.toList());
    }

    public List<Member> followees(Long memberId) {
        Member member = memberRepository.findById(memberId).orElseThrow(RuntimeException::new);

        return member.getFollowees()
                .stream().map(follow -> follow.getFollowee()).collect(Collectors.toList());
    }

}

Member 엔티티의 followers, followees 값을 직접 조회할 경우 의도한 대로 쿼리문이 실행되지만 Member와 Follow 간 순환 참조로 인해 StackOverflowError가 발생하게 됩니다. 따라서 필요한 Follow 회원 정보만 DTO에 담아 데이터를 반환해 주는 작업이 추가되어야 합니다.

4. StackOverflowError 문제 해결

4-1. FollowDto DTO

package com.example.myproject.follow.query;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class FollowDto {
    private Long memberId;
    private String email;
}

4-2. 팔로워/팔로잉 목록 조회 수정

package com.example.myproject.follow.ui;

import com.example.myproject.follow.query.FollowDto;
import com.example.myproject.member.domain.Member;
import com.example.myproject.member.domain.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
@RestController
public class MyFollowController {

    private final MemberRepository memberRepository;

    // 팔로워 목록 조회
    @GetMapping("/my/follower")
    public List<FollowDto> follower() {
        Long myId = 1L;
        Member member = memberRepository.findById(myId).orElseThrow(RuntimeException::new);

        return member.getFollowers().stream()
                .map(follower -> getFollowDTO(follower.getFollower()))
                .collect(Collectors.toList());
    }

    // 팔로잉 목록 조회
    @GetMapping("/my/followee")
    public List<FollowDto> followee() {
        Long myId = 1L;
        Member member = memberRepository.findById(myId).orElseThrow(RuntimeException::new);

        return member.getFollowees().stream()
                .map(followee -> getFollowDTO(followee.getFollowee()))
                .collect(Collectors.toList());
    }

    private FollowDto getFollowDTO(Member member) {
        FollowDto followDTO = new FollowDto();
        followDTO.setMemberId(member.getId());
        followDTO.setEmail(member.getEmail());
        return followDTO;
    }
}

FollowDto에 팔로우 정보를 제외한 기본적인 회원 정보 id, email만 바이딩하여 순환 참조가 발생하지 않도록 하였습니다. 예를 들어 아래와 같은 상황에서 uesr1의 팔로워/팔로잉 목록을 조회하겠습니다.

  • 팔로워 목록
[
    {
        "memberId": 2,
        "email": "user2"
    },
    {
        "memberId": 3,
        "email": "user3"
    }
]
  • 팔로잉 목록
[
    {
        "memberId": 2,
        "email": "user2"
    }
]
profile
개발 뿌샤!

0개의 댓글