배경
요즘 취업 준비때문에 정신이 없다. 틈내서 사이드 프로젝트를 진행중이다.
이번에 회원 팔로우 기능을 구현했다. 구현한 과정을 정리해본다.
개발 환경
- 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를 반환하고 있다.
'Spring' 카테고리의 다른 글
Spring Boot Test - Mockito로 Static 메소드 완벽 제어하기 (2) | 2024.11.01 |
---|---|
Spring Interceptor - 인터셉터로 로그인 체크하기 (0) | 2023.09.12 |
Spring - Spring Boot 초기 데이터 설정 (data.sql) (0) | 2023.05.29 |
Spring - 통합 테스트에서 S3 Mock 객체로 S3 자원 아끼기 (2) | 2023.05.24 |
Spring - 좋은 단위 테스트를 만드는 방법(JUnit) (2) | 2023.04.22 |