-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 내 팔로잉 리스트 조회 api 개발 #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
321c48d
cd3407b
1a9f428
12dfed7
2a6c860
b6b5b22
38d5002
cbcf0dc
652f636
83e97e2
4917236
596c918
b61097a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package konkuk.thip.common.util; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record CursorBasedList<T>( | ||
| List<T> contents, | ||
| String nextCursor, | ||
| boolean hasNext | ||
| ) { | ||
| public static <T> CursorBasedList<T> of(List<T> queryList, int size, CursorExtractor<T> extractor) { | ||
| boolean hasNext = queryList.size() > size; | ||
| List<T> contents = hasNext ? queryList.subList(0, size) : queryList; | ||
| String nextCursor = hasNext ? extractor.extractCursor(contents.get(size - 1)) : null; | ||
| return new CursorBasedList<>(contents, nextCursor, hasNext); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package konkuk.thip.common.util; | ||
|
|
||
| @FunctionalInterface | ||
| public interface CursorExtractor<T> { | ||
| String extractCursor(T lastElement); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,13 @@ | ||
| package konkuk.thip.user.adapter.in.web; | ||
|
|
||
| import jakarta.validation.constraints.Max; | ||
| import jakarta.validation.constraints.Min; | ||
| import konkuk.thip.common.dto.BaseResponse; | ||
| import konkuk.thip.common.security.annotation.UserId; | ||
| import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; | ||
| import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; | ||
| import konkuk.thip.user.adapter.in.web.response.UserViewAliasChoiceResponse; | ||
| import konkuk.thip.user.application.port.in.UserGetFollowersUsecase; | ||
| import konkuk.thip.user.application.port.in.UserGetFollowUsecase; | ||
| import konkuk.thip.user.application.port.in.UserViewAliasChoiceUseCase; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
|
|
@@ -16,18 +20,35 @@ | |
| public class UserQueryController { | ||
|
|
||
| private final UserViewAliasChoiceUseCase userViewAliasChoiceUseCase; | ||
| private final UserGetFollowersUsecase userGetFollowersUsecase; | ||
| private final UserGetFollowUsecase userGetFollowUsecase; | ||
|
|
||
| /** | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM |
||
| * 사용자 별칭 선택 화면 조회 | ||
| */ | ||
| @GetMapping("/users/alias") | ||
| public BaseResponse<UserViewAliasChoiceResponse> showAliasChoiceView() { | ||
| return BaseResponse.ok(UserViewAliasChoiceResponse.of( | ||
| userViewAliasChoiceUseCase.getAllAliasesAndCategories() | ||
| )); | ||
| } | ||
|
|
||
| /** | ||
| * 사용자 팔로워 조회 | ||
| */ | ||
| @GetMapping("/users/{userId}/followers") | ||
| public BaseResponse<UserFollowersResponse> showFollowers(@PathVariable final Long userId, | ||
| @RequestParam(required = false) final String cursor) { | ||
| return BaseResponse.ok(userGetFollowersUsecase.getUserFollowers(userId, cursor)); | ||
| @RequestParam(required = false) final String cursor, | ||
| @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { | ||
| return BaseResponse.ok(userGetFollowUsecase.getUserFollowers(userId, cursor, size)); | ||
| } | ||
|
|
||
| /** | ||
| * 내 팔로잉 리스트 조회 | ||
| */ | ||
| @GetMapping("/users/my/following") | ||
| public BaseResponse<UserFollowingResponse> showMyFollowing(@UserId final Long userId, | ||
| @RequestParam(required = false) final String cursor, | ||
| @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { | ||
|
Comment on lines
+40
to
+51
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p3 : 저희 페이지 사이즈 BE 내부에서 정의하는게 아니라 요청 파라미터로 받는 건가요??
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 넵, 프론트 쪽에서 단일 페이지 크기를 요청 파라미터로 전달할 수도 있고, 전달하지 않는 경우에는 최대 페이지 수 10을 기본값(default) 으로 설정해두었습니다 |
||
| return BaseResponse.ok(userGetFollowUsecase.getMyFollowing(userId, cursor, size)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package konkuk.thip.user.adapter.in.web.response; | ||
|
|
||
| import lombok.Builder; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Builder | ||
| public record UserFollowingResponse( | ||
| List<Following> followings, | ||
| String nextCursor, | ||
| boolean isLast | ||
| ) { | ||
| @Builder | ||
| public record Following( | ||
| Long userId, | ||
| String nickname, | ||
| String profileImageUrl, | ||
| String aliasName | ||
| ){ | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,9 +21,9 @@ public class FollowingJpaEntity extends BaseJpaEntity { | |
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "user_id") | ||
| private UserJpaEntity userJpaEntity; | ||
| private UserJpaEntity userJpaEntity; // 팔로잉 하는 유저 | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "following_user_id") | ||
| private UserJpaEntity followingUserJpaEntity; | ||
| private UserJpaEntity followingUserJpaEntity; // 팔로우 당하는 유저 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ㅋㅋㅋ 팔로우당하는유저(비웃은거아님웃겨서웃은거임)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ㅋㅋㅋㅋ 팔로잉 팔로워 헷갈리긴 하네여
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 필드 이름이 너무 애매해서 그냥 주석 남겼습니다 ㅎ |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,8 @@ | ||
| package konkuk.thip.user.adapter.out.persistence; | ||
|
|
||
| import konkuk.thip.common.util.CursorBasedList; | ||
| import konkuk.thip.common.util.DateUtil; | ||
| import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; | ||
| import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; | ||
| import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; | ||
| import konkuk.thip.user.application.port.out.dto.FollowQueryDto; | ||
| import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; | ||
| import konkuk.thip.user.application.port.out.FollowingQueryPort; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
@@ -19,39 +18,26 @@ public class FollowingQueryPersistenceAdapter implements FollowingQueryPort { | |
| private final FollowingJpaRepository followingJpaRepository; | ||
|
|
||
| @Override | ||
| public UserFollowersResponse getFollowersByUserId(Long userId, String cursor, int size) { | ||
| LocalDateTime nextCursor = null; | ||
| if (cursor != null && !cursor.isBlank()) { | ||
| nextCursor = DateUtil.parseDateTime(cursor); | ||
| } | ||
|
|
||
| List<FollowingJpaEntity> followerEntities = | ||
| followingJpaRepository.findFollowersByUserIdBeforeCreatedAt(userId, nextCursor, size); | ||
|
|
||
| List<UserJpaEntity> followers = followerEntities.stream() | ||
| .map(FollowingJpaEntity::getUserJpaEntity) // 팔로워 사용자 | ||
| .toList(); | ||
|
|
||
| List<UserFollowersResponse.Follower> followerList = followers.stream() | ||
| .map(follower -> UserFollowersResponse.Follower.builder() | ||
| .userId(follower.getUserId()) | ||
| .nickname(follower.getNickname()) | ||
| .profileImageUrl(follower.getAliasForUserJpaEntity().getImageUrl()) | ||
| .aliasName(follower.getAliasForUserJpaEntity().getValue()) | ||
| .followerCount(follower.getFollowerCount()) | ||
| .build()) | ||
| .toList(); | ||
|
|
||
| boolean isLast = followerEntities.size() < size; | ||
| nextCursor = isLast ? null : | ||
| followerEntities.get(followerEntities.size() - 1).getCreatedAt(); | ||
|
|
||
| return UserFollowersResponse.builder() | ||
| .followerList(followerList) | ||
| .size(followerList.size()) | ||
| .nextCursor(nextCursor) | ||
| .isFirst(cursor == null) // cursor가 null이면 첫 페이지 | ||
| .isLast(isLast) | ||
| .build(); | ||
| public CursorBasedList<FollowQueryDto> getFollowersByUserId(Long userId, String cursor, int size) { | ||
| LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; | ||
| List<FollowQueryDto> followerDtos = followingJpaRepository.findFollowerDtosByUserIdBeforeCreatedAt( | ||
| userId, | ||
| cursorVal, | ||
| size | ||
| ); | ||
|
Comment on lines
+23
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain커서 기반 페이지네이션 구현 확인 메서드명 🏁 Script executed: #!/bin/bash
# Repository 구현에서 실제 쿼리 조건 확인
rg -B 5 -A 10 "findFollowerDtosByUserIdBeforeCreatedAt.*cursor.*size" src/main/java/konkuk/thip/user/adapter/out/persistence/repository/Length of output: 3627 🏁 Script executed: #!/bin/bash
# find the implementation of findFollowDtos to check cursor comparison operator
rg -n "private List<FollowQueryDto> findFollowDtos" -A 20 src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.javaLength of output: 1269 커서 이상(inclusive) 조회로 조건 수정 필요 THIP 프로젝트의 커서 페이지네이션 컨벤션에 따라, 전달받은 커서 이상의 데이터를 조회해야 합니다. 현재
🤖 Prompt for AI Agents |
||
|
|
||
| return CursorBasedList.of(followerDtos, size, followerDto -> followerDto.createdAt().toString()); | ||
| } | ||
|
|
||
| @Override | ||
| public CursorBasedList<FollowQueryDto> getFollowingByUserId(Long userId, String cursor, int size) { | ||
| LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; | ||
| List<FollowQueryDto> followingDtos = followingJpaRepository.findFollowingDtosByUserIdBeforeCreatedAt( | ||
| userId, | ||
| cursorVal, | ||
| size | ||
| ); | ||
|
|
||
| return CursorBasedList.of(followingDtos, size, followingDto -> followingDto.createdAt().toString()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM 함수형 인터페이스 좋네요 |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ | |
| import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; | ||
| import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; | ||
| import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; | ||
| import konkuk.thip.user.application.port.out.dto.FollowQueryDto; | ||
| import konkuk.thip.user.application.port.out.dto.QFollowQueryDto; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
|
|
@@ -34,26 +36,55 @@ public Optional<FollowingJpaEntity> findByUserAndTargetUser(Long userId, Long ta | |
| } | ||
|
|
||
| @Override | ||
| public List<FollowingJpaEntity> findFollowersByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { | ||
| public List<FollowQueryDto> findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { | ||
| return findFollowDtos( | ||
| userId, | ||
| cursor, | ||
| size, | ||
| true // isFollowerQuery | ||
| ); | ||
| } | ||
|
|
||
| @Override | ||
| public List<FollowQueryDto> findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { | ||
| return findFollowDtos( | ||
| userId, | ||
| cursor, | ||
| size, | ||
| false // isFollowingQuery | ||
| ); | ||
| } | ||
|
|
||
| private List<FollowQueryDto> findFollowDtos(Long userId, LocalDateTime cursor, int size, boolean isFollowerQuery) { | ||
| QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; | ||
| QUserJpaEntity user = QUserJpaEntity.userJpaEntity; | ||
| QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; | ||
|
|
||
| BooleanBuilder condition = new BooleanBuilder() | ||
| .and(following.followingUserJpaEntity.userId.eq(userId)) | ||
| .and((isFollowerQuery ? following.followingUserJpaEntity.userId.eq(userId) : following.userJpaEntity.userId.eq(userId))) | ||
| .and(following.status.eq(StatusType.ACTIVE)); | ||
|
|
||
| if (cursor != null) { | ||
| condition.and(following.createdAt.lt(cursor)); | ||
| } | ||
|
Comment on lines
67
to
69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 커서 조건이 프로젝트 컨벤션과 불일치 학습된 내용에 따르면 THIP 프로젝트의 커서 페이지네이션은 "커서 이상(inclusive)"의 데이터를 조회해야 하므로 if (cursor != null) {
- condition.and(following.createdAt.lt(cursor));
+ condition.and(following.createdAt.loe(cursor));
}🤖 Prompt for AI Agents |
||
|
|
||
| QUserJpaEntity targetUser = isFollowerQuery ? following.userJpaEntity : following.followingUserJpaEntity; | ||
|
|
||
| return jpaQueryFactory | ||
| .selectFrom(following) | ||
| .leftJoin(following.userJpaEntity, user).fetchJoin() // N+1 문제 방지를 위해 fetchJoin | ||
| .leftJoin(user.aliasForUserJpaEntity, alias).fetchJoin() | ||
| .select(new QFollowQueryDto( | ||
| targetUser.userId, | ||
| targetUser.nickname, | ||
| alias.imageUrl, | ||
| alias.value, | ||
| targetUser.followerCount, | ||
| following.createdAt | ||
| )) | ||
| .from(following) | ||
| .leftJoin(targetUser, user) | ||
| .leftJoin(user.aliasForUserJpaEntity, alias) | ||
| .where(condition) | ||
| .orderBy(following.createdAt.desc()) | ||
| .limit(size) | ||
| .limit(size + 1) | ||
| .fetch(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package konkuk.thip.user.application.mapper; | ||
|
|
||
| import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; | ||
| import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; | ||
| import konkuk.thip.user.application.port.out.dto.FollowQueryDto; | ||
| import org.mapstruct.Mapper; | ||
|
|
||
| @Mapper(componentModel = "spring") | ||
| public interface FollowDtoMapper { | ||
|
|
||
| UserFollowersResponse.Follower toFollowerList(FollowQueryDto dto); | ||
|
|
||
| UserFollowingResponse.Following toFollowingList(FollowQueryDto dto); | ||
| } | ||
|
Comment on lines
+8
to
+14
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM 새로운 기술 도입 좋습니다! |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package konkuk.thip.user.application.port.in; | ||
|
|
||
| import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; | ||
| import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; | ||
|
|
||
| public interface UserGetFollowUsecase { | ||
| UserFollowersResponse getUserFollowers(Long userId, String cursor, int size); | ||
|
|
||
| UserFollowingResponse getMyFollowing(Long userId, String cursor, int size); | ||
|
Comment on lines
+7
to
+9
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM |
||
| } | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| package konkuk.thip.user.application.port.out; | ||
|
|
||
| import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; | ||
| import konkuk.thip.common.util.CursorBasedList; | ||
| import konkuk.thip.user.application.port.out.dto.FollowQueryDto; | ||
|
|
||
| public interface FollowingQueryPort { | ||
| UserFollowersResponse getFollowersByUserId(Long userId, String cursor, int size); | ||
| CursorBasedList<FollowQueryDto> getFollowersByUserId(Long userId, String cursor, int size); | ||
| CursorBasedList<FollowQueryDto> getFollowingByUserId(Long userId, String cursor, int size); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
클라이언트와 주고받는 cursor 값을 String 타입으로 명시하고, 영속성 adapter 내부에서 클라이언트에게 전달받은 커서의 타입을 LocalDateTime 으로 수정하셨군요!!
전 "내 모임방 목록 조회 api" 에서
위와 같은 플로우를 생각하여 controller 에서 받는 cursor request parameter, response 로 전달하는 nextCursor 의 타입을 모두 저희가 페이징 처리시 사용하는 타입 그대로 정의하였습니다.
@buzz0331 @hd0rable 혹시 이 부분에 대해서 어떻게 생각하시나요??
위 플로우처럼 스프링부트가 제공해주는 Jackson 라이브러리를 활용하면 클라이언트와 주고받는 커서의 타입을 신경쓰지 않아도 되고, 명시한 커서 타입이 아니라 다른 타입이 요청 파라미터로 들어올 경우 스프링이 내부적으로 "파라미터 바인딩 실패 -> 400 error throw" 해주므로 저희가 추가로 예외처리를 하지 않아도 되는 장점이 있을 것 같습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저는 개인적으로 커서를 하나의 문자열로 받아 처리하는 방식이 응답 구조의 일관성이나 확장성 측면에서 더 적합하다고 생각하고 있습니다. 앞으로 저희가 커서 페이징에서 공통적으로 사용할 CursorBasedList 구조에서도 nextCursor는 단일 String 타입으로 정의되어 있기 때문에, 커서 역시 하나의 문자열로 통합하여 사용하는 것이 다양한 조회 API에 일관되게 적용할 수 있고, 응답 구조 또한 통일된 형태로 유지할 수 있다는 점에서 장점이 있다고 생각합니다.
반대로, 커서를 여러 파라미터로 나누게 되면 각 API마다 커서 필드를 개별적으로 정의해야 하며, 공통 응답 객체를 사용하는 데에도 제한이 생기게 됩니다. 또한 이 경우 응답 DTO뿐만 아니라 요청 파라미터도 각각의 이름을 따로 지정해야 하기 때문에, 예를 들어 cursorDate, cursorRoomId처럼 네이밍이 API마다 달라질 가능성이 높고, 이는 클라이언트 측에서도 사용 시 혼란을 줄 수 있다고 생각합니다.
반면 커서를 cursor={String} 형태로 일관되게 주고받게 되면, 클라이언트는 커서 값을 단순히 저장하고 그대로 다음 요청에 넘겨주는 방식으로 처리할 수 있기 때문에, 구현 부담도 줄고 인터페이스도 단순해진다는 장점이 있습니다.
무엇보다 커서 구조를 외부에 드러내지 않고, 서버 내부 로직에 따라 파싱하여 처리하는 방식은 캡슐화 측면에서도 바람직하며, 추후 커서 구조가 변경되거나 인코딩/보안 처리 등을 추가해야 할 때도 서버 단에서만 수정하면 되므로 유지보수에도 유리하다고 생각합니다.
이런 이유로 성준님 PR에서 사용하신 LocalDate와 roomId 두 개의 커서 값을 "2024-07-22|102"와 같은 형식으로 하나의 문자열 커서로 묶어서 전달하고, 이를 서버 내부에서 분리·파싱하여 사용하는 방식도 한번 고려해보면 좋을 것 같습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아아 CursorBasedList에 정의해두신 String nextCursor 가 커서 하나의 String 형변환값이 아니라, 그냥 저희가 페이징처리시에 사용할 커서 자체를 의미하신 거군요!
코멘트로 남겨주신 내용처럼 복합 커서가 필요할 경우, 저희가 내부적으로 복합 커서를 String 으로 변환하고, 받은 cursor 값을 다시 복합 커서로 파싱하는 방식 좋은 것 같습니다!!
현준님 pr 머지되면 복합 커서로 작성된 코드 수정해보겠습니다!!