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

[개발일지 #020] 좋아요(Like) 도메인 개발 및 단위테스트

by 뚜루리 2025. 4. 29.
728x90
320x100

🎯 오늘의 목표

  • 좋아요(Like) 도메인 개발

⚙️ 진행한 작업

  • 좋아요(Like) 엔티티 생성
  • 좋아요(Like) 레파지토리 생성
  • 좋아요(Like)  서비스 생성
  • 좋아요(Like) 관련 예외 생성
  • 좋아요(Like) 도메인 단위테스트

🛠️ 개발내용

📌  좋아요(Like) 엔티티 생성

package ddururi.bookbookclub.domain.like.entity;

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

import java.time.LocalDateTime;

/**
 * 좋아요(Like) 엔티티
 * - 사용자가 특정 피드에 좋아요를 누른 기록을 저장
 * - 사용자(User)와 피드(Feed)와 다대일 관계
 */
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "likes", uniqueConstraints = {
        @UniqueConstraint(columnNames = {"user_id", "feed_id"})
})
public class Like {

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

    /** 좋아요를 누른 사용자 */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    /** 좋아요를 누른 피드 */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "feed_id", nullable = false)
    private Feed feed;

    /** 좋아요 누른 시간 */
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    /**
     * 좋아요 생성 정적 메서드
     */
    public static Like create(User user, Feed feed) {
        Like like = new Like();
        like.user = user;
        like.feed = feed;
        return like;
    }
}

 

📌  좋아요(Like) 레파지토리 생성

package ddururi.bookbookclub.domain.like.repository;

import ddururi.bookbookclub.domain.like.entity.Like;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

/**
 * 좋아요(Like) 관련 데이터 액세스 레포지토리
 */
public interface LikeRepository extends JpaRepository<Like, Long> {

    boolean existsByUserIdAndFeedId(Long userId, Long feedId);

    Optional<Like> findByUserIdAndFeedId(Long userId, Long feedId);

    long countByFeedId(Long feedId);
}

 

📌  좋아요(Like) 서비스 생성

package ddururi.bookbookclub.domain.like.service;

import ddururi.bookbookclub.domain.feed.entity.Feed;
import ddururi.bookbookclub.domain.feed.repository.FeedRepository;
import ddururi.bookbookclub.domain.like.entity.Like;
import ddururi.bookbookclub.domain.like.exception.LikeException;
import ddururi.bookbookclub.domain.like.repository.LikeRepository;
import ddururi.bookbookclub.domain.user.entity.User;
import ddururi.bookbookclub.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 좋아요(Like) 관련 비즈니스 로직 처리 서비스
 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LikeService {

    private final LikeRepository likeRepository;
    private final FeedRepository feedRepository;

    /**
     * 피드 좋아요
     * @param user 좋아요 누른 사용자
     * @param feedId 좋아요 누를 피드 ID
     */
    @Transactional
    public void likeFeed(User user, Long feedId) {
        // 이미 좋아요를 눌렀는지 확인
        boolean alreadyLiked = likeRepository.existsByUserIdAndFeedId(user.getId(), feedId);
        if (alreadyLiked) {
            throw new LikeException(ErrorCode.ALREADY_LIKED);
        }

        // 피드 존재 여부 확인
        Feed feed = feedRepository.findById(feedId)
                .orElseThrow(() -> new LikeException(ErrorCode.FEED_NOT_FOUND));

        // 좋아요 저장
        Like like = Like.create(user, feed);
        likeRepository.save(like);
    }

    /**
     * 피드 좋아요 취소
     * @param user 좋아요 취소할 사용자
     * @param feedId 좋아요 취소할 피드 ID
     */
    @Transactional
    public void unlikeFeed(User user, Long feedId) {
        // 좋아요 존재 여부 확인
        Like like = likeRepository.findByUserIdAndFeedId(user.getId(), feedId)
                .orElseThrow(() -> new LikeException(ErrorCode.LIKE_NOT_FOUND));

        likeRepository.delete(like);
    }

    /**
     * 피드의 좋아요 수 조회
     * @param feedId 피드 ID
     * @return 좋아요 수
     */
    public long getLikeCount(Long feedId) {
        return likeRepository.countByFeedId(feedId);
    }

    /**
     * 사용자가 특정 피드에 좋아요를 눌렀는지 여부 조회
     * @param userId 사용자 ID
     * @param feedId 피드 ID
     * @return 좋아요 여부 (true/false)
     */
    @Transactional(readOnly = true)
    public boolean hasUserLiked(Long userId, Long feedId) {
        return likeRepository.existsByUserIdAndFeedId(userId, feedId);
    }
}

 

📌  좋아요(Like) 관련 예외 클래스 생성

package ddururi.bookbookclub.domain.like.exception;

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

/**
 * 좋아요 관련 비즈니스 예외
 */
@Getter
public class LikeException extends RuntimeException {

    private final ErrorCode errorCode;

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

 

📌  좋아요(Like) 관련 예외 핸들러 수정

package ddururi.bookbookclub.global.exception;

/**
 * 전역 예외 처리 핸들러
 * - 각 예외 상황에 맞는 HTTP 상태코드 및 응답 구조 반환
 * - ApiResponse.fail(...) 구조 사용
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
	//코드생략//
    
    @ExceptionHandler(LikeException.class)
    public ResponseEntity<ApiResponse<Void>> handleLikeException(LikeException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    //코드생략//
}

 

 


📌  좋아요(Like)  단위테스트

package ddururi.bookbookclub.domain.like.service;
class LikeServiceTest {

    @Mock
    private LikeRepository likeRepository;

    @Mock
    private FeedRepository feedRepository;

    @InjectMocks
    private LikeService likeService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    private User createUser(Long id) {
        User user = User.create("test@example.com", "encrypted", "nickname");
        ReflectionTestUtils.setField(user, "id", id);
        return user;
    }

    private Feed createFeed(Long id) {
        // 테스트용 유저와 책은 임시 더미 객체
        User dummyUser = User.create("dummy@email.com", "pw", "닉네임");
        Book dummyBook = mock(Book.class); // 실제 동작 필요 없음
        Feed feed = Feed.create(dummyUser, dummyBook, "내용");

        ReflectionTestUtils.setField(feed, "id", id);
        return feed;
    }

    @Nested
    @DisplayName("likeFeed() 테스트")
    class LikeFeedTest {

        @Test
        @DisplayName("정상적으로 좋아요를 누를 수 있다")
        void likeFeed_success() {
            // given
            User user = createUser(1L);
            Feed feed = createFeed(1L);

            given(likeRepository.existsByUserIdAndFeedId(user.getId(), feed.getId())).willReturn(false);
            given(feedRepository.findById(feed.getId())).willReturn(Optional.of(feed));
            given(likeRepository.save(any(Like.class))).willReturn(Like.create(user, feed));

            // when & then
            assertDoesNotThrow(() -> likeService.likeFeed(user, feed.getId()));
        }

        @Test
        @DisplayName("이미 좋아요를 누른 경우 예외 발생")
        void likeFeed_alreadyLiked() {
            User user = createUser(1L);
            Long feedId = 1L;

            given(likeRepository.existsByUserIdAndFeedId(user.getId(), feedId)).willReturn(true);

            assertThatThrownBy(() -> likeService.likeFeed(user, feedId))
                    .isInstanceOf(LikeException.class)
                    .hasMessage(ErrorCode.ALREADY_LIKED.getMessage());
        }

        @Test
        @DisplayName("존재하지 않는 피드에 좋아요 시 예외 발생")
        void likeFeed_feedNotFound() {
            User user = createUser(1L);
            Long feedId = 1L;

            given(likeRepository.existsByUserIdAndFeedId(user.getId(), feedId)).willReturn(false);
            given(feedRepository.findById(feedId)).willReturn(Optional.empty());

            assertThatThrownBy(() -> likeService.likeFeed(user, feedId))
                    .isInstanceOf(LikeException.class)
                    .hasMessage(ErrorCode.FEED_NOT_FOUND.getMessage());
        }
    }

    @Nested
    @DisplayName("unlikeFeed() 테스트")
    class UnlikeFeedTest {

        @Test
        @DisplayName("정상적으로 좋아요 취소 가능")
        void unlikeFeed_success() {
            User user = createUser(1L);
            Feed feed = createFeed(1L);
            Like like = Like.create(user, feed);

            given(likeRepository.findByUserIdAndFeedId(user.getId(), feed.getId()))
                    .willReturn(Optional.of(like));

            assertDoesNotThrow(() -> likeService.unlikeFeed(user, feed.getId()));
        }

        @Test
        @DisplayName("좋아요 기록 없을 경우 예외 발생")
        void unlikeFeed_notFound() {
            User user = createUser(1L);
            Long feedId = 1L;

            given(likeRepository.findByUserIdAndFeedId(user.getId(), feedId))
                    .willReturn(Optional.empty());

            assertThatThrownBy(() -> likeService.unlikeFeed(user, feedId))
                    .isInstanceOf(LikeException.class)
                    .hasMessage(ErrorCode.LIKE_NOT_FOUND.getMessage());
        }
    }

    @Nested
    @DisplayName("getLikeCount() 테스트")
    class GetLikeCountTest {

        @Test
        @DisplayName("피드의 좋아요 수 정상 조회")
        void getLikeCount_success() {
            Long feedId = 1L;
            given(likeRepository.countByFeedId(feedId)).willReturn(10L);

            long result = likeService.getLikeCount(feedId);

            assertThat(result).isEqualTo(10L);
        }
    }

    @Nested
    @DisplayName("hasUserLiked() 테스트")
    class HasUserLikedTest {

        @Test
        @DisplayName("사용자가 좋아요 눌렀는지 여부 조회")
        void hasUserLiked_success() {
            Long userId = 1L;
            Long feedId = 1L;

            given(likeRepository.existsByUserIdAndFeedId(userId, feedId)).willReturn(true);

            boolean result = likeService.hasUserLiked(userId, feedId);

            assertThat(result).isTrue();
        }
    }
}
728x90
320x100