From f8833415acd16bc04bf3056474540d7c2656c22f Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:15:16 +0900 Subject: [PATCH 1/5] =?UTF-8?q?init=20:=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/clokey/domain/like/controller/LikeController.java | 1 + 1 file changed, 1 insertion(+) create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -0,0 +1 @@ + From 68aab2c8811687507238686aad42ea40fd1d1e64 Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Mon, 22 Dec 2025 00:32:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat=20:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9C=A0=EC=A0=80=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/controller/LikeController.java | 39 ++++++ .../dto/response/LikedMembersResponse.java | 18 +++ .../like/repository/MemberLikeRepository.java | 27 +++- .../domain/like/service/LikeService.java | 9 ++ .../domain/like/service/LikeServiceImpl.java | 72 ++++++++++ .../member/repository/FollowRepository.java | 11 ++ .../like/controller/LikeControllerTest.java | 122 +++++++++++++++++ .../domain/like/service/LikeServiceTest.java | 128 ++++++++++++++++++ 8 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java index 8b137891..24d765f5 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -1 +1,40 @@ +package org.clokey.domain.like.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.global.annotation.PageSize; +import org.clokey.response.BaseResponse; +import org.clokey.response.SliceResponse; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/likes") +@RequiredArgsConstructor +@Tag(name = "9. 좋아요 API", description = "좋아요 관련 API입니다.") +@Validated +public class LikeController { + + private final LikeService likeService; + + @GetMapping("/users") + @Operation(summary = "좋아요한 유저 조회", description = "내 기록을 좋아요한 유저를 조회합니다") + public BaseResponse> getLikedMembers( + @Parameter(description = "기록 ID") @RequestParam Long historyId, + @Parameter(description = "이전 페이지의 좋아요 ID (첫 요청 시 생략)") @RequestParam(required = false) + Long lastLikeId, + @Parameter(description = "페이지당 조회할 개수") @RequestParam @PageSize Integer size) { + SliceResponse response = + likeService.getLikedMembers(historyId, lastLikeId, size); + + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java new file mode 100644 index 00000000..4fdda289 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java @@ -0,0 +1,18 @@ +package org.clokey.domain.like.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "좋아요 유저 조회 결과") +public record LikedMembersResponse( + @Schema(description = "유저 미리보기 목록") List memberPreviews, + @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { + + @Schema(description = "유저 미리보기 DTO") + public record LikedMemberPreview( + @Schema(description = "유저 ID", example = "30") Long id, + @Schema(description = "클로키 ID", example = "@Clokey_USER1") String codiveId, + @Schema(description = "프로필 이미지 URL") String imageUrl, + @Schema(description = "닉네임") String nickname, + @Schema(description = "팔로우 여부") boolean followStatus) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java index a645167e..3ffe5bbc 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java @@ -1,6 +1,31 @@ package org.clokey.domain.like.repository; +import java.util.List; import org.clokey.like.entity.MemberLike; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface MemberLikeRepository extends JpaRepository {} +public interface MemberLikeRepository extends JpaRepository { + + @Query( + """ + SELECT ml + FROM MemberLike ml + WHERE ml.member.id = :memberId + AND (:lastLikeId IS NULL OR ml.id < :lastLikeId) + ORDER BY ml.id DESC + """) + List findLikedHistoriesByMemberId( + Long memberId, Long lastLikeId, Pageable pageable); + + @Query( + """ + SELECT ml + FROM MemberLike ml + WHERE ml.history.id = :historyId + AND (:lastLikeId IS NULL OR ml.id < :lastLikeId) + ORDER BY ml.id DESC + """) + List findLikeMembersByHistoryId(Long historyId, Long lastLikeId, Pageable pageable); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java new file mode 100644 index 00000000..0fe23bc3 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java @@ -0,0 +1,9 @@ +package org.clokey.domain.like.service; + +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.response.SliceResponse; + +public interface LikeService { + SliceResponse getLikedMembers( + Long historyId, Long lastLikedId, Integer size); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java new file mode 100644 index 00000000..15fd47d1 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -0,0 +1,72 @@ +package org.clokey.domain.like.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.FollowRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.clokey.response.SliceResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeServiceImpl implements LikeService { + + private final MemberUtil memberUtil; + private final MemberLikeRepository memberLikeRepository; + private final HistoryImageRepository historyImageRepository; + private final FollowRepository followRepository; + + @Override + public SliceResponse getLikedMembers( + Long historyId, Long lastLikeId, Integer size) { + + Member currentMember = memberUtil.getCurrentMember(); + Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); + + List likes = + memberLikeRepository.findLikeMembersByHistoryId(historyId, lastLikeId, pageable); + + boolean isLast = likes.size() <= size; + + if (!isLast) { + likes = likes.subList(0, size); + } + + if (likes.isEmpty()) { + return new SliceResponse<>(List.of(), true); + } + + List members = likes.stream().map(MemberLike::getMember).toList(); + List memberIds = members.stream().map(Member::getId).toList(); + + Set followedIdSet = + new HashSet<>( + followRepository.findFollowedMemberIds(currentMember.getId(), memberIds)); + + List previews = + members.stream() + .map( + member -> + new LikedMembersResponse.LikedMemberPreview( + member.getId(), + member.getClokeyId(), + member.getProfileImageUrl(), + member.getNickname(), + followedIdSet.contains(member.getId()))) + .toList(); + + return new SliceResponse<>(previews, isLast); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/member/repository/FollowRepository.java b/clokey-api/src/main/java/org/clokey/domain/member/repository/FollowRepository.java index 5ed45ba8..5f7858fc 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/repository/FollowRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/repository/FollowRepository.java @@ -1,12 +1,23 @@ package org.clokey.domain.member.repository; +import java.util.List; import java.util.Optional; import org.clokey.member.entity.Follow; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface FollowRepository extends JpaRepository, FollowRepositoryCustom { boolean existsByFollowFrom_IdAndFollowTo_Id(Long fromMemberId, Long toMemberId); + @Query( + """ + SELECT f.followTo.id + FROM Follow f + WHERE f.followFrom.id = :fromMemberId + AND f.followTo.id IN :toMemberIds +""") + List findFollowedMemberIds(Long fromMemberId, List toMemberIds); + Optional findByFollowFrom_IdAndFollowTo_Id(Long fromMemberId, Long toMemberId); } diff --git a/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java new file mode 100644 index 00000000..e31c3e22 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java @@ -0,0 +1,122 @@ +package org.clokey.domain.like.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(LikeController.class) +@AutoConfigureMockMvc(addFilters = false) +public class LikeControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private LikeService likeService; + + @Nested + class 좋아요한_유저_조회_시 { + @Test + void 유효한_요청이면_좋아요한_유저를_반환한다() throws Exception { + // given + List previews = + List.of( + new LikedMembersResponse.LikedMemberPreview( + 1L, "codive1", "https://img.com/img1.jpg", "nickname1", true), + new LikedMembersResponse.LikedMemberPreview( + 2L, "codive2", "https://img.com/img2.jpg", "nickname2", false)); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, true); + + given(likeService.getLikedMembers(any(), any(), anyInt())).willReturn(sliceResponse); + + ResultActions perform = + mockMvc.perform( + get("/likes/users") + .param("historyId", "1") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect(jsonPath("$.result.content[0].codiveId").value("codive1")) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[0].nickname").value("nickname1")) + .andExpect(jsonPath("$.result.content[0].followStatus").value(true)) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect(jsonPath("$.result.content[1].codiveId").value("codive2")) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.content[1].nickname").value("nickname2")) + .andExpect(jsonPath("$.result.content[1].followStatus").value(false)) + .andExpect(jsonPath("$.result.isLast").value(true)); + } + + @Test + void 마지막_페이지가_아닌_경우_isLast를_false로_응답한다() throws Exception { + // given + List previews = + List.of( + new LikedMembersResponse.LikedMemberPreview( + 1L, "codive1", "https://img.com/img1.jpg", "nickname1", true), + new LikedMembersResponse.LikedMemberPreview( + 2L, "codive2", "https://img.com/img2.jpg", "nickname2", false)); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, false); + + given(likeService.getLikedMembers(any(), any(), anyInt())).willReturn(sliceResponse); + + // when + ResultActions perform = + mockMvc.perform( + get("/likes/users") + .param("historyId", "1") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect(jsonPath("$.result.content[0].codiveId").value("codive1")) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[0].nickname").value("nickname1")) + .andExpect(jsonPath("$.result.content[0].followStatus").value(true)) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect(jsonPath("$.result.content[1].codiveId").value("codive2")) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.content[1].nickname").value("nickname2")) + .andExpect(jsonPath("$.result.content[1].followStatus").value(false)) + .andExpect(jsonPath("$.result.isLast").value(false)); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java new file mode 100644 index 00000000..113dc2f1 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java @@ -0,0 +1,128 @@ +package org.clokey.domain.like.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.TransactionUtil; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.FollowRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.history.entity.Situation; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Follow; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +public class LikeServiceTest extends IntegrationTest { + + @Autowired private LikeService likeService; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private HistoryImageRepository historyImageRepository; + @Autowired private FollowRepository followRepository; + + @Autowired private TransactionUtil transactionUtil; + @MockitoBean private MemberUtil memberUtil; + + @Nested + class 좋아요한_유저를_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + + Member member3 = + Member.createMember( + "testEmail3", + "testClokeyId3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + memberRepository.saveAll(List.of(member1, member2, member3)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history1 = + History.createHistory( + LocalDate.of(2024, 12, 25), + "content1", + memberRepository.findById(1L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + historyRepository.save(history1); + } + + @Test + void 좋아요한_유저가_있으면_유저를_반환한다() { + // given + memberLikeRepository.saveAll( + List.of( + MemberLike.createMemberLike( + memberRepository.findById(2L).orElseThrow(), + historyRepository.findById(1L).orElseThrow()), + MemberLike.createMemberLike( + memberRepository.findById(3L).orElseThrow(), + historyRepository.findById(1L).orElseThrow()))); + + followRepository.save( + Follow.createFollow( + memberRepository.findById(1L).orElseThrow(), + memberRepository.findById(2L).orElseThrow())); + + // when + SliceResponse response = + likeService.getLikedMembers(1L, null, 10); + + // then + assertThat(response.content()).hasSize(2); + assertThat(response.isLast()).isTrue(); + + assertThat(response.content()) + .extracting("id", "codiveId", "imageUrl", "nickname", "followStatus") + .containsExactly( + tuple(3L, "testClokeyId3", null, "testNickName3", false), + tuple(2L, "testClokeyId2", null, "testNickName2", true)); + } + + @Test + void 좋아요한_기록이_없으면_빈_리스트를_반환한다() { + // when + SliceResponse response = + likeService.getLikedMembers(1L, null, 10); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.isLast()).isTrue(); + } + } +} From 5c607ed490b2c6b04f819fd9cd9d19e069ba8721 Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:30:29 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix=20:=20projection=20=EC=A0=81=EC=9A=A9,?= =?UTF-8?q?=20n+1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/LikedHistoriesResponse.java | 43 +++++++-- .../dto/response/LikedMembersResponse.java | 47 ++++++++-- .../MemberLikeRepositoryCustom.java | 14 +++ .../repository/MemberLikeRepositoryImpl.java | 92 +++++++++++++++++++ .../domain/like/service/LikeServiceImpl.java | 73 +++++++-------- 5 files changed, 214 insertions(+), 55 deletions(-) create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryCustom.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryImpl.java diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java index b567d8b0..224cfc26 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -9,11 +9,40 @@ public record LikedHistoriesResponse( @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { @Schema(description = "히스토리 미리보기 DTO") - public record LikedHistoryPreview( - @Schema(description = "히스토리 ID", example = "30") Long id, - @Schema( - description = "히스토리 대표 이미지 URL", - example = - "https://clokeybucket.s3.ap-northeast-2.amazonaws.com/example.jpg") - String imageUrl) {} + public static class LikedHistoryPreview { + @Schema(description = "히스토리 ID", example = "30") + private final Long id; + + @Schema( + description = "히스토리 대표 이미지 URL", + example = "https://clokeybucket.s3.ap-northeast-2.amazonaws.com/example.jpg") + private String imageUrl; + + @Schema(description = "다음 페이지 조회를 위한 커서 ID (MemberLike ID)", example = "100") + private final Long lastLikeId; + + public LikedHistoryPreview(Long id, Long lastLikeId) { + this.id = id; + this.lastLikeId = lastLikeId; + this.imageUrl = null; + } + + public LikedHistoryPreview(Long id, String imageUrl) { + this.id = id; + this.imageUrl = imageUrl; + this.lastLikeId = null; + } + + public Long getId() { + return id; + } + + public String getImageUrl() { + return imageUrl; + } + + public Long getLastLikeId() { + return lastLikeId; + } + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java index 4fdda289..bfa8cd46 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import lombok.Getter; @Schema(description = "좋아요 유저 조회 결과") public record LikedMembersResponse( @@ -9,10 +10,44 @@ public record LikedMembersResponse( @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { @Schema(description = "유저 미리보기 DTO") - public record LikedMemberPreview( - @Schema(description = "유저 ID", example = "30") Long id, - @Schema(description = "클로키 ID", example = "@Clokey_USER1") String codiveId, - @Schema(description = "프로필 이미지 URL") String imageUrl, - @Schema(description = "닉네임") String nickname, - @Schema(description = "팔로우 여부") boolean followStatus) {} + @Getter + public static class LikedMemberPreview { + @Schema(description = "유저 ID", example = "30") + private final Long id; + + @Schema(description = "클로키 ID", example = "@Clokey_USER1") + private final String codiveId; + + @Schema(description = "프로필 이미지 URL") + private final String imageUrl; + + @Schema(description = "닉네임") + private final String nickname; + + @Schema(description = "팔로우 여부") + private final boolean followStatus; + + @Schema(description = "다음 페이지 조회를 위한 커서 ID (MemberLike ID)", example = "100") + private final Long lastLikeId; + + public LikedMemberPreview( + Long id, String codiveId, String imageUrl, String nickname, Long lastLikeId) { + this.id = id; + this.codiveId = codiveId; + this.imageUrl = imageUrl; + this.nickname = nickname; + this.lastLikeId = lastLikeId; + this.followStatus = false; + } + + public LikedMemberPreview( + Long id, String codiveId, String imageUrl, String nickname, boolean followStatus) { + this.id = id; + this.codiveId = codiveId; + this.imageUrl = imageUrl; + this.nickname = nickname; + this.followStatus = followStatus; + this.lastLikeId = null; + } + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryCustom.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryCustom.java new file mode 100644 index 00000000..c28a5fdb --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryCustom.java @@ -0,0 +1,14 @@ +package org.clokey.domain.like.repository; + +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.springframework.data.domain.Slice; + +public interface MemberLikeRepositoryCustom { + + Slice findLikedHistoriesSliceByMemberId( + Long memberId, Long lastLikeId, Integer size); + + Slice findLikedMembersSliceByHistoryId( + Long historyId, Long lastLikeId, Integer size); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryImpl.java new file mode 100644 index 00000000..8ddebd46 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepositoryImpl.java @@ -0,0 +1,92 @@ +package org.clokey.domain.like.repository; + +import static org.clokey.like.entity.QMemberLike.memberLike; +import static org.clokey.member.entity.QMember.member; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.global.paging.SortDirection; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberLikeRepositoryImpl implements MemberLikeRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + private final SortDirection DEFAULT_SORT = SortDirection.DESC; + + @Override + public Slice findLikedHistoriesSliceByMemberId( + Long memberId, Long lastLikeId, Integer size) { + + List results = + queryFactory + .select( + Projections.constructor( + LikedHistoriesResponse.LikedHistoryPreview.class, + memberLike.history.id, + memberLike.id)) + .from(memberLike) + .where( + memberLike.member.id.eq(memberId), + lastLikeIdCondition(lastLikeId, DEFAULT_SORT)) + .limit(size + 1) + .orderBy(memberLike.id.desc()) + .fetch(); + + return checkLastPage(size, results); + } + + @Override + public Slice findLikedMembersSliceByHistoryId( + Long historyId, Long lastLikeId, Integer size) { + + List results = + queryFactory + .select( + Projections.constructor( + LikedMembersResponse.LikedMemberPreview.class, + member.id, + member.clokeyId, + member.profileImageUrl, + member.nickname, + memberLike.id)) + .from(memberLike) + .join(memberLike.member, member) + .where( + memberLike.history.id.eq(historyId), + lastLikeIdCondition(lastLikeId, DEFAULT_SORT)) + .limit(size + 1) + .orderBy(memberLike.id.desc()) + .fetch(); + + return checkLastPage(size, results); + } + + private BooleanExpression lastLikeIdCondition(Long likeId, SortDirection direction) { + if (likeId == null) { + return null; + } + return memberLike.id.lt(likeId); + } + + private Slice checkLastPage(int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java index 3430ea30..5dc160aa 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -13,6 +13,7 @@ import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.dto.response.LikedMembersResponse; import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.like.repository.MemberLikeRepositoryCustom; import org.clokey.domain.member.repository.BlockRepository; import org.clokey.domain.member.repository.FollowRepository; import org.clokey.exception.BaseCustomException; @@ -21,9 +22,7 @@ import org.clokey.like.entity.MemberLike; import org.clokey.member.entity.Member; import org.clokey.response.SliceResponse; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,6 +37,7 @@ public class LikeServiceImpl implements LikeService { private final HistoryRepository historyRepository; private final BlockRepository blockRepository; private final FollowRepository followRepository; + private final MemberLikeRepositoryCustom memberLikeRepositoryCustom; @Override public SliceResponse getLikedHistories( @@ -45,37 +45,30 @@ public SliceResponse getLikedHistori Member currentMember = memberUtil.getCurrentMember(); - // limit + 1 조회 - Pageable pageable = PageRequest.of(0, size + 1); + Slice likedHistoriesSlice = + memberLikeRepositoryCustom.findLikedHistoriesSliceByMemberId( + currentMember.getId(), lastLikeId, size); - List likes = - memberLikeRepository.findLikedHistoriesByMemberId( - currentMember.getId(), lastLikeId, pageable); - - boolean isLast = likes.size() <= size; - - if (!isLast) { - likes = likes.subList(0, size); - } - - if (likes.isEmpty()) { + if (likedHistoriesSlice.isEmpty()) { return new SliceResponse<>(List.of(), true); } - List historyIds = likes.stream().map(like -> like.getHistory().getId()).toList(); + List historyIds = + likedHistoriesSlice.getContent().stream() + .map(LikedHistoriesResponse.LikedHistoryPreview::getId) + .toList(); Map imageMap = findFirstImagesByHistoryIds(historyIds); List previews = - likes.stream() + likedHistoriesSlice.getContent().stream() .map( - like -> + preview -> new LikedHistoriesResponse.LikedHistoryPreview( - like.getHistory().getId(), - imageMap.get(like.getHistory().getId()))) + preview.getId(), imageMap.get(preview.getId()))) .toList(); - return new SliceResponse<>(previews, isLast); + return new SliceResponse<>(previews, likedHistoriesSlice.isLast()); } private Map findFirstImagesByHistoryIds(List historyIds) { @@ -127,40 +120,36 @@ public SliceResponse getLikedMembers( Long historyId, Long lastLikeId, Integer size) { Member currentMember = memberUtil.getCurrentMember(); - Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); - List likes = - memberLikeRepository.findLikeMembersByHistoryId(historyId, lastLikeId, pageable); + Slice likedMembersSlice = + memberLikeRepositoryCustom.findLikedMembersSliceByHistoryId( + historyId, lastLikeId, size); - boolean isLast = likes.size() <= size; - - if (!isLast) { - likes = likes.subList(0, size); - } - - if (likes.isEmpty()) { + if (likedMembersSlice.isEmpty()) { return new SliceResponse<>(List.of(), true); } - List members = likes.stream().map(MemberLike::getMember).toList(); - List memberIds = members.stream().map(Member::getId).toList(); + List memberIds = + likedMembersSlice.getContent().stream() + .map(LikedMembersResponse.LikedMemberPreview::getId) + .toList(); Set followedIdSet = new HashSet<>( followRepository.findFollowedMemberIds(currentMember.getId(), memberIds)); List previews = - members.stream() + likedMembersSlice.getContent().stream() .map( - member -> + preview -> new LikedMembersResponse.LikedMemberPreview( - member.getId(), - member.getClokeyId(), - member.getProfileImageUrl(), - member.getNickname(), - followedIdSet.contains(member.getId()))) + preview.getId(), + preview.getCodiveId(), + preview.getImageUrl(), + preview.getNickname(), + followedIdSet.contains(preview.getId()))) .toList(); - return new SliceResponse<>(previews, isLast); + return new SliceResponse<>(previews, likedMembersSlice.isLast()); } } From c3270fde476c8dc97a3185a18404182fe0c0b207 Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:04:11 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore=20:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/dto/response/LikedHistoriesResponse.java | 6 ------ .../domain/like/dto/response/LikedMembersResponse.java | 10 ---------- 2 files changed, 16 deletions(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java index 224cfc26..8ffa79db 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -21,12 +21,6 @@ public static class LikedHistoryPreview { @Schema(description = "다음 페이지 조회를 위한 커서 ID (MemberLike ID)", example = "100") private final Long lastLikeId; - public LikedHistoryPreview(Long id, Long lastLikeId) { - this.id = id; - this.lastLikeId = lastLikeId; - this.imageUrl = null; - } - public LikedHistoryPreview(Long id, String imageUrl) { this.id = id; this.imageUrl = imageUrl; diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java index bfa8cd46..b84cbbe0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java @@ -30,16 +30,6 @@ public static class LikedMemberPreview { @Schema(description = "다음 페이지 조회를 위한 커서 ID (MemberLike ID)", example = "100") private final Long lastLikeId; - public LikedMemberPreview( - Long id, String codiveId, String imageUrl, String nickname, Long lastLikeId) { - this.id = id; - this.codiveId = codiveId; - this.imageUrl = imageUrl; - this.nickname = nickname; - this.lastLikeId = lastLikeId; - this.followStatus = false; - } - public LikedMemberPreview( Long id, String codiveId, String imageUrl, String nickname, boolean followStatus) { this.id = id; From ba1cafa47bfb6e565c654ae6cd12fa36db7309e8 Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:18:04 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/dto/response/LikedHistoriesResponse.java | 6 ++++++ .../domain/like/dto/response/LikedMembersResponse.java | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java index 8ffa79db..224cfc26 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -21,6 +21,12 @@ public static class LikedHistoryPreview { @Schema(description = "다음 페이지 조회를 위한 커서 ID (MemberLike ID)", example = "100") private final Long lastLikeId; + public LikedHistoryPreview(Long id, Long lastLikeId) { + this.id = id; + this.lastLikeId = lastLikeId; + this.imageUrl = null; + } + public LikedHistoryPreview(Long id, String imageUrl) { this.id = id; this.imageUrl = imageUrl; diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java index b84cbbe0..bfa8cd46 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedMembersResponse.java @@ -30,6 +30,16 @@ public static class LikedMemberPreview { @Schema(description = "다음 페이지 조회를 위한 커서 ID (MemberLike ID)", example = "100") private final Long lastLikeId; + public LikedMemberPreview( + Long id, String codiveId, String imageUrl, String nickname, Long lastLikeId) { + this.id = id; + this.codiveId = codiveId; + this.imageUrl = imageUrl; + this.nickname = nickname; + this.lastLikeId = lastLikeId; + this.followStatus = false; + } + public LikedMemberPreview( Long id, String codiveId, String imageUrl, String nickname, boolean followStatus) { this.id = id;