Feat: 맞팔 탐지 페이지 구현 - #39
Conversation
9615138 to
dd862c3
Compare
| return useMutation({ | ||
| mutationFn: createFollowRefresh, | ||
| onSuccess: () => { | ||
| queryClient.resetQueries({ queryKey: followQueryKeys.all() }); |
There was a problem hiding this comment.
invalidateQueries는 캐시된걸 보여주면서 새로 패치해오는데
강제 갱신버튼에 의해 해당 mutation이 호출되는 경우, 캐시된 것도 날리고 다시 패치해와야하므로 resetQueries를 사용합니다
| <Flex ref={observerRef} justify="center" align="center" css={{ width: "100%", height: rem(6) }}> | ||
| {isFetchingNextPage && <Spinner css={{ height: rem(2.5) }} />} | ||
| </Flex> |
There was a problem hiding this comment.
무한스크롤로 다음 페이지를 패칭해올 때 스피너를 표시합니다
| fetchNextPage: () => void; | ||
| hasNextPage: boolean; | ||
| isFetchingNextPage: boolean; | ||
| options?: IntersectionObserverInit; |
There was a problem hiding this comment.
options로 root, threshold, rootMargin을 외부에서 설정할 수 있습니다
| {Array.from({ length: 10 }, (_, idx) => idx).map((idx) => ( | ||
| <Skeleton key={idx}> | ||
| <UserListItemCard> | ||
| <UserProfileAvatar /> | ||
| </UserListItemCard> | ||
| </Skeleton> | ||
| ))} |
There was a problem hiding this comment.
로딩 상태를 스켈레톤 UI를 이용해 처리합니다
| const updateCanRefresh = () => { | ||
| const enabled = checkRefreshEnabled(lastSyncAt); | ||
| setIsRefreshEnabled(enabled); | ||
|
|
||
| if (enabled) { | ||
| clearInterval(intervalId); | ||
| } | ||
| }; | ||
|
|
||
| const intervalId = setInterval(updateCanRefresh, 30000); | ||
|
|
||
| return () => clearInterval(intervalId); |
There was a problem hiding this comment.
5분 이후부터 강제 갱신 가능하므로, 30초마다 갱신 버튼을 활성화 해야 하는지 체크합니다
| }, | ||
| mutations: { | ||
| retry: 0, | ||
| throwOnError: true, |
There was a problem hiding this comment.
mutation은 사용처에서 각각 맞는 에러 처리 방식 사용하기 위해 throwOnError 제거합니다
| const poll = async () => { | ||
| const response = await apiClient.get<FollowDetectResponse>(`api/v1/users/me/followings/${detectType}`, { | ||
| searchParams: lastGithubUserId ? { lastGithubUserId } : undefined, | ||
| }); | ||
|
|
||
| if (response.status === 202) { | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| return poll(); | ||
| } | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| return poll(); |
There was a problem hiding this comment.
맞팔 리스트 조회시 status가 202이면 202가 아닐때까지 polling합니다
There was a problem hiding this comment.
서버에서 status가 지속적으로 202를 반환한다면 무제한 polling이 발생할것 같은데, 최대 횟수나 타임아웃 등의 제한을 두어서 처리해주어도 좋을것 같아요
There was a problem hiding this comment.
너무 좋은데요 ?! 👍 이 부분 백엔드 담당자분과 횟수 제한 어떻게 둘지 논의 먼저 해보겠습니다!
| <> | ||
| <Header | ||
| renderCenter={() => <>팔로우 관리</>} | ||
| renderRight={data ? () => <RefreshButton lastSyncAt={data.pages[0].data.lastSyncAt} /> : undefined} |
There was a problem hiding this comment.
lastSyncAt은 페이지 마다 모두 값이 동일하므로 pages[0]의 값을 고정으로 사용합니다. totalCount도 마찬가지
Yeonseo-Jo
left a comment
There was a problem hiding this comment.
poliing도 들어가고 생각보다 복잡한 부분이 많았군용 ..
수고하셨습니다 ~~
| *.local | ||
|
|
||
| .env | ||
| .env.development |
There was a problem hiding this comment.
환경별 env 공유해서 사용하기로 했던것 같은데, 깃에 안올라가게 처리한 이유가 있나요 -?!
There was a problem hiding this comment.
요거 제가 긴가민가한데.. ㅎㅎ 지금은 BASE_URL만 있어서 노출되어도 되지만, 생각해보니 노출되면 안되는 시크릿 키 같은걸 들고 있게되면 파일이 올라가면 안되겠더라구요! 그래서 깃에 안올라가도록 처리해줬는데 요 부분 어떻게 생각하시나요 ~?
| const poll = async () => { | ||
| const response = await apiClient.get<FollowDetectResponse>(`api/v1/users/me/followings/${detectType}`, { | ||
| searchParams: lastGithubUserId ? { lastGithubUserId } : undefined, | ||
| }); | ||
|
|
||
| if (response.status === 202) { | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| return poll(); | ||
| } | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| return poll(); |
There was a problem hiding this comment.
서버에서 status가 지속적으로 202를 반환한다면 무제한 polling이 발생할것 같은데, 최대 횟수나 타임아웃 등의 제한을 두어서 처리해주어도 좋을것 같아요
| onClick={async () => { | ||
| try { | ||
| await follow(user.githubUserId); | ||
| toast.success("팔로우에 성공했습니다."); | ||
| } catch (e) { | ||
| toast.error("팔로우에 실패했습니다. 다시 시도해주세요"); |
There was a problem hiding this comment.
팔로우/언팔로우시 버튼 onClick 내부 로직이 동일하므로 핸들러로 추상화하고, mutate function이랑 toast 텍스트만 각 버튼에 따라 적용되도록 수정해줘도 좋을것 같아요~
There was a problem hiding this comment.
고것도 좋은 것 같네용 👀 그런데 아직은 추상화하지 않아도 되는 정도의 로직이라고 생각해요!
핸들러를 만들게 되면 type에 따라 api 요청 함수 / success / error 토스트 텍스트 분기가 다 들어가야 하다보니
결국 현재 담고 있는 모든 로직에 분기가 들어가는 것과 같아서 추상화하면 불필요하게 복잡도가 높아질 것 같아요 🤔
그래서 지금은 이대로 쓰는게 더 직관적이지 않을까 하는 의견입니다!!
나중에 좀 더 공통 처리 로직이나 부가적인 로직이 들어갔을 때 추상화 고려해봐도 좋을 것 같은데 어떠세요 ~?!
There was a problem hiding this comment.
저두 지금은 옵션이 2개라 괜찮다고 보긴 하는데 나중에 옵션들이 추가된다면 핸들러로 추상화가 필요하긴 할 것 같아요. 그러면 옵션 개수만큼 분기가 필요해질거라 옵션이 많아지면 그만큼 핸들러 로직이 비대해질수도 있을 것 같아요.
이럴 때 핸들러 내부를 추상화시키고 액션값들은 map으로 빼는 방법도 있을 것 같네요~~ 모든 옵션 케이스의 액션 데이터를 map 한 곳 에서 관리하기 때문에 응집성 측면에서도 이점이 있을 것 같아용
const map = {
"followed-only": {
mutate: follow,
success: "팔로우에 성공했습니다.",
error: "팔로우에 실패했습니다. 다시 시도해주세요",
buttonText: "Follow",
},
"following-only": {
mutate: unfollow,
success: "언팔로우에 성공했습니다.",
error: "언팔로우에 실패했습니다. 다시 시도해주세요",
buttonText: "Unfollow",
},
} as const;
const currentAction = map[type];
const handleClick = async (userId: string) => {
try {
await currentAction.mutate(userId);
toast.success(currentAction.success);
} catch {
toast.error(currentAction.error);
}
};| options?: IntersectionObserverInit; | ||
| } | ||
|
|
||
| export function useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage, options }: Params) { |
| export function DetectTypeSelector({ value, onChange }: Props) { | ||
| return ( | ||
| <SegmentedControl.Root | ||
| defaultValue="mutual" |
There was a problem hiding this comment.
defaultValue는 현 value에 상관없이 항상 고정으로 넣어주나요?!
There was a problem hiding this comment.
value에 state값 넣어주면서 지워도 되는데 놓쳤네요! ㅋ-ㅋ 30ebef1
| if (lastPage) return undefined; | ||
|
|
||
| const userList = prevPage.data.userList; | ||
| return userList[userList.length - 1].githubUserId; |
There was a problem hiding this comment.
userList가 빈 배열일 경우 undefined 접근 가능성이 있을것 같은데, 이 경우의 방어로직 넣어주면 좋을것 같아요~
| options?: IntersectionObserverInit; | ||
| } | ||
|
|
||
| export function useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage, options }: Params) { |
| onClick={async () => { | ||
| try { | ||
| await follow(user.githubUserId); | ||
| toast.success("팔로우에 성공했습니다."); | ||
| } catch (e) { | ||
| toast.error("팔로우에 실패했습니다. 다시 시도해주세요"); |
There was a problem hiding this comment.
저두 지금은 옵션이 2개라 괜찮다고 보긴 하는데 나중에 옵션들이 추가된다면 핸들러로 추상화가 필요하긴 할 것 같아요. 그러면 옵션 개수만큼 분기가 필요해질거라 옵션이 많아지면 그만큼 핸들러 로직이 비대해질수도 있을 것 같아요.
이럴 때 핸들러 내부를 추상화시키고 액션값들은 map으로 빼는 방법도 있을 것 같네요~~ 모든 옵션 케이스의 액션 데이터를 map 한 곳 에서 관리하기 때문에 응집성 측면에서도 이점이 있을 것 같아용
const map = {
"followed-only": {
mutate: follow,
success: "팔로우에 성공했습니다.",
error: "팔로우에 실패했습니다. 다시 시도해주세요",
buttonText: "Follow",
},
"following-only": {
mutate: unfollow,
success: "언팔로우에 성공했습니다.",
error: "언팔로우에 실패했습니다. 다시 시도해주세요",
buttonText: "Unfollow",
},
} as const;
const currentAction = map[type];
const handleClick = async (userId: string) => {
try {
await currentAction.mutate(userId);
toast.success(currentAction.success);
} catch {
toast.error(currentAction.error);
}
};|
중요 사항은 모두 반영해서 일단 머지해볼게요 ~! 혹시 반영할 것 더 생기면 후속 PR로 열겠습니당 |
close #18
☑️ 완료 태스크
🔎 PR 내용
☑️ 팔로우 조회 무한스크롤 구현
팔로우 조회 기능의 경우
모요이 클라이언트-모요이 서버-깃허브 서버의 구조로, 모요이 서버가 깃허브 서버에서 정보를 받아와 맞팔로우/나만 팔로우/상대만 팔로우를 계산해야합니다.때문에 정보 요청시 아직 처리하고 있는 상태일 수 있는데요,
이를 백엔드와의 논의 하에 정보 처리 중일 경우
202상태 코드를 반환하고, 프론트에서 200이 올때까지 정보를 요청하는 polling 방식을 사용합니다.미리 말씀 나눈 대로 무한스크롤 사용할 수 있도록 커스텀 훅
useInfiniteScroll만들었습니다!저희가 RQ를 사용하기 때문에, 보다 이해가 쉽도록 인터페이스 네이밍을 RQ를 기준으로 작성했습니다.
☑️ 팔로우/언팔로우 구현
mutateAsync 사용했습니당. 개인적으로 저는 mutate 사용해서 콜백 넘기는 것보다 await/async로 작성하는게 더 가독성이 좋더라구요!
성공/실패했을 때 toast 띄워주었습니다
☑️ 강제 갱신 구현
갱신 = 깃허브에서 정보 받아와서 계산하기
서버 시간 기준마지막 갱신 시점으로부터 5분 이후부터 갱신 가능함.👉 갱신이 불가능할 때 버튼 disabled 처리해주어야 함.
참고 링크
https://discord.com/channels/1319925803663888505/1410601230895681536/1410602228649037845
때문에 백엔드 담당자와의 논의 끝에, 아직 갱신 가능 상태가 아닌경우 갱신 가능할 때까지 30초마다 상태를 확인하여 버튼 disabled를 관리하기로 했습니다.
✚ 더불어 n분전으로 표기되는 부분 클릭시 툴팁으로 마지막 갱신 시간을 보여줌
☑️ 로딩 처리
스켈레톤 UI로 로딩처리하였습니다
radix에 Skeleton 컴포넌트가 있어서 그것 이용해봤습니다
📷 스크린샷
2025-09-21.2.30.04.mov
2025-09-21.2.34.37.mov