Spring

Spring - 회원 팔로우 기능 구현

Cold Bean 2023. 6. 8. 22:22
728x90

배경

요즘 취업 준비때문에 정신이 없다. 틈내서 사이드 프로젝트를 진행중이다.

이번에 회원 팔로우 기능을 구현했다. 구현한 과정을 정리해본다.

 

개발 환경

  • Java 11
  • Spring 2.x
  • JPA
  • Gradle
  • MySQL, Redis
  • IntelliJ

 

요구사항

  • 회원을 팔로우할 수 있습니다.
  • 팔로우한 회원을 언팔로우 할 수 있습니다.
  • 회원의 팔로워 수와 팔로잉 수를 확인할 수 있습니다.

 

관계

Member의 자기 참조 관계를 사용해서 Follow 엔티티를 구현했다.

Follow는 Member를 참조하고 있고, 이를 통해서 팔로워(follower)와 팔로잉(following) 간의 관계를 나타내게 했다.

 

구현

Member

@Entity
@Getter
@DynamicInsert
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...

    @OneToMany(mappedBy = "follower", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Follow> followers = new ArrayList<>();

    @OneToMany(mappedBy = "following", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Follow> followings = new ArrayList<>();

    ...
}

 

Follow

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Follow {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "FOLLOWER_ID")
    private Member follower;

    @ManyToOne
    @JoinColumn(name = "FOLLOWING_ID")
    private Member following;

    @Builder
    public Follow(Member follower, Member following) {
        this.follower = follower;
        this.following = following;
    }
}
  • follower: 팔로우한 Member를 참조한다. ManyToOne 관계로 정의되어 있으며, FOLLOWER_ID 컬럼을 사용하여 Member와 조인한다. 
  • following: 팔로잉하는 Member를 참조한다. ManyToOne 관계로 정의되어 있으며, FOLLOWING_ID 컬럼을 사용하여 Member와 조인한다.
  • 이를 통해서 특정 Member의 팔로워들을 조회하거나, 특정 Member가 팔로잉하는 다른 Member들을 조회할 수 있다.

 

MemberController

회원의 팔로우 요청을 처리한다.

@Slf4j
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    ...

    @PostMapping("/follow/{followee-email}")
    public ResponseEntity follow(@PathVariable("followee-email") String followeeEmail,
                                 @AuthenticationPrincipal CustomUserDetails user) {
        String followerEmail = user.getEmail();
        memberService.follow(followerEmail, followeeEmail);

        return new ResponseEntity<>(new SingleResponseDto<>(SUCCESS_MEMBER_FOLLOW.getDescription()), HttpStatus.OK);
    }

    @DeleteMapping("/unfollow/{followee-email}")
    public ResponseEntity unfollow(@PathVariable("followee-email") String followeeEmail,
                                   @AuthenticationPrincipal CustomUserDetails user) {
        String followerEmail = user.getEmail();
        memberService.unfollow(followerEmail, followeeEmail);

        return new ResponseEntity<>(new SingleResponseDto<>(SUCCESS_MEMBER_UNFOLLOW.getDescription()), HttpStatus.OK);
    }
}

/members/follow/{followee-email}와 /members/unfollow/{followee-email} 엔드포인트를 통해 사용자가 팔로우 및 언팔로우 작업을 수행할 수 있다. 팔로우는 POST, 언팔로우는 DELETE 메서드를 사용했다.

 

팔로잉할 회원의 email은 Path Parameter로 받도록 했고 팔로잉을 하려는 회원(현재 사용자)는 @AuthenticationPrincipal을 통해 SecurityContext에서 CustomUserDetails를 가져와 email을 조회해오도록 했다.

 

요청이 문제없이 성공하면 Enum으로 만든 메시지를 반환하도록 했다.

@Slf4j
public class EnumCollection {

	...

    @AllArgsConstructor
    public enum ResponseBody implements EnumType {
        ...
        SUCCESS_MEMBER_FOLLOW("Member follow successfully"),
        SUCCESS_MEMBER_UNFOLLOW("Member unfollow successfully");

        private final String description;

        @Override
        public String getName() {
            return this.name();
        }

        @Override
        public String getDescription() {
            return this.description;
        }
    }
}

 

MemberService

 @Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

    private final FollowService followService;
    private final MemberRepository memberRepository;
    
    ...
 
    public void follow(String followerEmail, String followeeEmail) {
        Member follower = this.findMember(followerEmail);
        Member followee = this.findMember(followeeEmail);
        followService.follow(follower, followee);
    }

    public void unfollow(String followerEmail, String followeeEmail) {
        Member follower = this.findMember(followerEmail);
        Member followee = this.findMember(followeeEmail);
        followService.unfollow(follower, followee);
    }

    @Transactional(readOnly = true)
    public Member findMember(String email) {
        return memberRepository.findByEmail(email)
                .orElseThrow(() -> {
                    log.debug("MemberServiceImpl.findMemberAndCheckMemberExists exception occur email: {}", email);
                    return new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
                });
    }
}

MemberSerivce에서는 단순 email을 통해 Follower와 Followee를 찾아 FollowService에 매개변수로 넘겨주는 역할을 한다. 

Follow와 관련된 로직은 FollowService에 처리하도록 했다.(SRP)

 

FollowService

Follow 로직을 처리하는 서비스

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class FollowService {

    private final FollowRepository followRepository;

    public void follow(Member follower, Member following) {
        this.checkDuplicatedFollow(follower.getId(), following.getId());
        Follow follow = Follow.builder()
                .follower(follower)
                .following(following)
                .build();
        followRepository.save(follow);
    }

    public void unfollow(Member follower, Member following) {
        Follow follow = this.findByFollowerIdAndFollowingId(follower.getId(), following.getId());
        followRepository.deleteById(follow.getId());
    }

    private void checkDuplicatedFollow(Long followerId, Long followingId) {
        Optional<Follow> optionalFollow = followRepository.findByFollowerIdAndFollowingId(followerId, followingId);
        if (optionalFollow.isPresent()) {
            log.debug("FollowService.checkDuplicatedFollow exception occur " +
                    "followerId : {}, followingId : {}", followerId, followingId);
            throw new BusinessLogicException(ExceptionCode.FOLLOW_EXISTS);
        }
    }

    private Follow findByFollowerIdAndFollowingId(Long followerId, Long followingId) {
        return followRepository.findByFollowerIdAndFollowingId(followerId, followingId)
                .orElseThrow(() -> {
                    log.debug("FollowService.findByFollowerIdAndFollowingId exception occur" +
                            " followerId : {}, followingId : {}", followerId, followingId);
                    throw new BusinessLogicException(ExceptionCode.FOLLOW_NOT_FOUND);
                });
    }
}
  • follow(Member follower, Member following) : 회원을 팔로우하는 메서드
    • checkDuplicatedFollow 메서드를 통해 팔로우를 했는지 확인한다. 이미 팔로우했다면 예외를 던진다.
    • follower와 following Member를 매개변수로 받아 Follow를 생성해서 DB에 저장한다.
  • unfollow(Member follower, Member following) : 팔로우한 회원을 언팔로우하는 메서드
    • findByFollowerIdAndFollowingId 메서드를 통해 팔로우를 했는지 확인하고 팔로우하지 않았다면 예외를 던진다.
    • 팔로우를 했다면 해당 팔로우 레코드를 DB에서 삭제한다.

 

FollowRepository

Follow 데이터를 관리하는 Repository. JPA 메서드를 사용했다.

public interface FollowRepository extends JpaRepository<Follow, Long> {
    Optional<Follow> findByFollowerIdAndFollowingId(Long followerId, Long followingId);
}

followerId와 followingId가 일치하는 Follow 객체를 조회하는 메서드 객체가 존재하지 않을 경우 null safety하도록 Optional로 감싸서 반환하도록 했다.

 

MemberDto

회원을 조회할 때 해당 회원의 follower 수와 해당 회원이 following한 회원의 수를 알 수 있어야 한다.

회원 조회할 때 followerCount, followingCount, isFollow 필드를 추가했다.

public class MemberDto {
    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class Response {
        ...
        private Long followerCount;
        private Long followingCount;
        private boolean isFollow;
    }
}
  • followerCount : 회원의 팔로워 수
  • followingCount : 회원이 팔로잉한 회원의 수
  • isFollow : 팔로우 여부

 

MemberMapper

Entity에서 DTO로 매핑되는 과정에서 Mapstruct 라이브러리를 사용했다.

Mapstruct 라이브러리는 Java bean 유형 간의 매핑 구현을 단순화하는 코드 생성기이다.

@Mapping 애너테이션을 통해 매핑 과정에서 Member가 참조하고 있는 팔로워와 팔로잉 수를 구하도록 했다.

 

Member 엔티티에 followerCount, followingCount 필드를 두어서 팔로워를 할 때 마다 add 연산을 통해 계산할까도 고민했지만 팔로우 언팔로우가 발생할 때마다 update 쿼리가 날라가야되기 때문에 이보다는 회원 정보를 조회하는 시점에서 참조하고 있는 객체 수를 읽어오는 방향으로 결정했다.

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface MemberMapper {
    @Mapping(target = "followerCount", expression = "java(Long.valueOf(member.getFollowings().size()))")
    @Mapping(target = "followingCount", expression = "java(Long.valueOf(member.getFollowers().size()))")
    @Mapping(target = "follow", source = "member.followings", qualifiedByName = "isFollow")
    MemberDto.Response toDto(Member member);
}

    @Named("isFollow")
    default boolean isFollow(List<Follow> follows) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || follows.isEmpty()) {
            return false;
        }
        Object principal = authentication.getPrincipal();
        if (principal.equals("anonymousUser")) {
            return false;
        }
        CustomUserDetails user = (CustomUserDetails) principal;
        List<String> followerEmails = follows.stream()
                .map(follow -> follow.getFollower().getEmail()).collect(Collectors.toList());

        return followerEmails.contains(user.getEmail());
    }
}
@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2023-06-07T23:01:04+0900",
    comments = "version: 1.5.3.Final, compiler: javac, environment: Java 11.0.16 (Azul Systems, Inc.)"
)
@Component
public class MemberMapperImpl implements MemberMapper {
    @Override
    public MemberDto.Response toDto(Member member) {
        if ( member == null ) {
            return null;
        }

        MemberDto.Response.ResponseBuilder response = MemberDto.Response.builder();

        ...

		response.follow( isFollow( member.getFollowings() ) );
        response.followerCount( Long.valueOf(member.getFollowings().size()) );
        response.followingCount( Long.valueOf(member.getFollowers().size()) );

        return response.build();
    }
}

 

포스트맨을 날려보자

회원가입

두 개의 회원을 만든다. ID 1과 2를 가진 회원 2명이 생겼다.

 

로그인

ID1인 회원으로 로그인했다.

 

팔로우

gksmfcksqls1@gmail.com인 회원으로 gksmfcksqls2@gmail.com인 회원을 팔로우한다.

 

회원 조회

gksmfcksqls2@gmail.com 회원을 조회해보면 followerCount가 1인 것을 확인할 수 있다.

gksmfcksqls1@gmail.com으로 gksmfcksqls2@gmail.com 회원을 조회했기 때문에 isFollow역시 true를 반환하고 있다.

 

반대로 gksmfcksqls1@gmail.com 회원을 조회해보면 followingCount가 1인 것을 확인할 수 있다.

그리고 자기 자신을 조회한  것이기 때문에 isFollow는 false를 반환하고 있다.

728x90