본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지 #034] 팔로우(Follow) 도메인 구현 및 테스트

by 뚜루리 2025. 5. 2.
728x90
320x100

🎯 오늘 개발 할 기능

  • 팔로우(Follow) 도메인 구현
  • 팔로우(Follow) 테스트

🛠️ 개발내용

📌  Follow.java 생성

package ddururi.bookbookclub.domain.follow.entity;

import ddururi.bookbookclub.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@AllArgsConstructor
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"follower_id", "following_id"}))
public class Follow {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "follower_id", nullable = false)
    private User follower;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "following_id", nullable = false)
    private User following;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    public static Follow create(User follower, User following) {
        Follow follow = new Follow();
        follow.follower = follower;
        follow.following = following;
        return follow;
    }
}

 

 

📌  FollowRepository.java 생성

package ddururi.bookbookclub.domain.follow.repository;

import ddururi.bookbookclub.domain.follow.entity.Follow;
import ddururi.bookbookclub.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface FollowRepository extends JpaRepository<Follow, Long> {

    boolean existsByFollowerAndFollowing(User follower, User following);

    Optional<Follow> findByFollowerAndFollowing(User follower, User following);

    List<Follow> findAllByFollower(User follower);

    List<Follow> findAllByFollowing(User following);

    long countByFollower(User follower);

    long countByFollowing(User following);
}

 

 

📌  FollowService.java 생성

package ddururi.bookbookclub.domain.follow.service;

import ddururi.bookbookclub.domain.follow.dto.FollowActionResponse;
import ddururi.bookbookclub.domain.follow.dto.FollowResponse;
import ddururi.bookbookclub.domain.follow.entity.Follow;
import ddururi.bookbookclub.domain.follow.exception.AlreadyFollowingException;
import ddururi.bookbookclub.domain.follow.exception.FollowNotFoundException;
import ddururi.bookbookclub.domain.follow.repository.FollowRepository;
import ddururi.bookbookclub.domain.user.dto.UserSummaryResponse;
import ddururi.bookbookclub.domain.user.entity.User;
import ddururi.bookbookclub.domain.user.exception.UserNotFoundException;
import ddururi.bookbookclub.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional
public class FollowService {

    private final FollowRepository followRepository;
    private final UserRepository userRepository;

    /**
     * 팔로우
     */
    public FollowActionResponse follow(Long followerId, Long followingId) {
        User follower = findUserById(followerId);
        User following = findUserById(followingId);

        if (followRepository.existsByFollowerAndFollowing(follower, following)) {
            throw new AlreadyFollowingException();
        }

        Follow follow = Follow.create(follower, following);
        followRepository.save(follow);

        long followerCount = followRepository.countByFollowing(following);

        return new FollowActionResponse(follow.getId(), "팔로우 완료", followerCount);
    }

    /**
     * 언팔로우
     */
    public FollowActionResponse unfollow(Long followerId, Long followingId) {
        User follower = findUserById(followerId);
        User following = findUserById(followingId);

        Follow follow = followRepository.findByFollowerAndFollowing(follower, following)
                .orElseThrow(FollowNotFoundException::new);

        followRepository.delete(follow);

        long followerCount = followRepository.countByFollowing(following);

        return new FollowActionResponse(follow.getId(), "언팔로우 완료", followerCount);
    }

    /**
     * 팔로워 목록
     */
    public List<FollowResponse> getFollowers(Long userId) {
        User user = findUserById(userId);
        return followRepository.findAllByFollowing(user).stream()
                .map(f -> new FollowResponse(f.getId(), UserSummaryResponse.from(f.getFollower())))
                .toList();
    }

    /**
     * 팔로잉 목록
     */
    public List<FollowResponse> getFollowings(Long userId) {
        User user = findUserById(userId);
        return followRepository.findAllByFollower(user).stream()
                .map(f -> new FollowResponse(f.getId(), UserSummaryResponse.from(f.getFollowing())))
                .toList();
    }

    private User findUserById(Long id) {
        return userRepository.findById(id).orElseThrow(UserNotFoundException::new);
    }
}

 

 

 

📌  Follow 관련 예외클래스 생성

package ddururi.bookbookclub.domain.follow.exception;

import ddururi.bookbookclub.global.exception.ErrorCode;
import lombok.Getter;

@Getter
public class AccessDeniedException extends RuntimeException {
    private final ErrorCode errorCode;

    public AccessDeniedException(String message) {
        super(message);
        this.errorCode = ErrorCode.ACCESS_DENIED;
    }
}
package ddururi.bookbookclub.domain.follow.exception;

import ddururi.bookbookclub.global.exception.ErrorCode;
import lombok.Getter;

@Getter
public class AlreadyFollowingException extends RuntimeException {
    private final ErrorCode errorCode;

    public AlreadyFollowingException() {
        super(ErrorCode.ALREADY_FOLLOWING.getMessage());
        this.errorCode = ErrorCode.ALREADY_FOLLOWING;
    }
}
package ddururi.bookbookclub.domain.follow.exception;

import ddururi.bookbookclub.global.exception.ErrorCode;
import lombok.Getter;

@Getter
public class FollowNotFoundException extends RuntimeException {
    private final ErrorCode errorCode;

    public FollowNotFoundException() {
        super(ErrorCode.FOLLOW_NOT_FOUND.getMessage());
        this.errorCode = ErrorCode.FOLLOW_NOT_FOUND;
    }
}

 

📌  FollowController.java 생성

package ddururi.bookbookclub.domain.follow.controller;

import ddururi.bookbookclub.domain.follow.dto.FollowActionResponse;
import ddururi.bookbookclub.domain.follow.dto.FollowResponse;
import ddururi.bookbookclub.domain.follow.exception.AccessDeniedException;
import ddururi.bookbookclub.domain.follow.service.FollowService;
import ddururi.bookbookclub.domain.user.dto.UserResponse;
import ddururi.bookbookclub.global.common.ApiResponse;
import ddururi.bookbookclub.global.security.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users/{userId}/follow")
public class FollowController {

    private final FollowService followService;

    @PostMapping("/{targetId}")
    public ResponseEntity<ApiResponse<FollowActionResponse>> follow(
            @AuthenticationPrincipal CustomUserDetails userDetails,
            @PathVariable Long userId,
            @PathVariable Long targetId) {

        validateUserAccess(userDetails, userId);
        FollowActionResponse response = followService.follow(userId, targetId);
        return ResponseEntity.ok(ApiResponse.success(response));
    }

    @DeleteMapping("/{targetId}")
    public ResponseEntity<ApiResponse<FollowActionResponse>> unfollow(
            @AuthenticationPrincipal CustomUserDetails userDetails,
            @PathVariable Long userId,
            @PathVariable Long targetId) {

        validateUserAccess(userDetails, userId);
        FollowActionResponse response = followService.unfollow(userId, targetId);
        return ResponseEntity.ok(ApiResponse.success(response));
    }

    @GetMapping("/followers")
    public ResponseEntity<ApiResponse<List<FollowResponse>>> getFollowers(@PathVariable Long userId) {
        List<FollowResponse> followers = followService.getFollowers(userId);
        return ResponseEntity.ok(ApiResponse.success(followers));
    }

    @GetMapping("/followings")
    public ResponseEntity<ApiResponse<List<FollowResponse>>> getFollowings(@PathVariable Long userId) {
        List<FollowResponse> followings = followService.getFollowings(userId);
        return ResponseEntity.ok(ApiResponse.success(followings));
    }

    private void validateUserAccess(CustomUserDetails userDetails, Long userId) {
        if (!userDetails.getUser().getId().equals(userId)) {
            throw new AccessDeniedException("본인 계정으로만 요청할 수 있습니다.");
        }
    }
}

 

 

📌  Follow 관련 DTO 생성

package ddururi.bookbookclub.domain.follow.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class FollowActionResponse {
    private Long followId;       // 팔로우 관계 ID
    private String message;      // 응답 메시지 ("팔로우 완료", "언팔로우 완료")
    private Long followerCount;  // 현재 대상의 팔로워 수 (선택)
}
package ddururi.bookbookclub.domain.follow.dto;

import ddururi.bookbookclub.domain.user.dto.UserSummaryResponse;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class FollowResponse {
    private Long followId;
    private UserSummaryResponse user;
}
728x90
320x100