Skip to content

Feat: 맞팔 탐지 페이지 구현 - #39

Merged
seobbang merged 33 commits into
mainfrom
feat/#18-follow
Sep 23, 2025
Merged

Feat: 맞팔 탐지 페이지 구현#39
seobbang merged 33 commits into
mainfrom
feat/#18-follow

Conversation

@seobbang

@seobbang seobbang commented Aug 31, 2025

Copy link
Copy Markdown
Member

close #18

☑️ 완료 태스크

  • ui 구현
  • 맞팔로우/나만 팔로우/상대만 팔로우 정보 가져오기 구현
  • 무한스크롤 구현
  • 팔로우/언팔로우 구현
  • 강제 갱신 구현

🔎 PR 내용

☑️ 팔로우 조회 무한스크롤 구현

팔로우 조회 기능의 경우 모요이 클라이언트 - 모요이 서버 - 깃허브 서버 의 구조로, 모요이 서버가 깃허브 서버에서 정보를 받아와 맞팔로우/나만 팔로우/상대만 팔로우를 계산해야합니다.

때문에 정보 요청시 아직 처리하고 있는 상태일 수 있는데요,
이를 백엔드와의 논의 하에 정보 처리 중일 경우 202 상태 코드를 반환하고, 프론트에서 200이 올때까지 정보를 요청하는 polling 방식을 사용합니다.

미리 말씀 나눈 대로 무한스크롤 사용할 수 있도록 커스텀 훅 useInfiniteScroll 만들었습니다!
저희가 RQ를 사용하기 때문에, 보다 이해가 쉽도록 인터페이스 네이밍을 RQ를 기준으로 작성했습니다.

interface Params {
  fetchNextPage: () => void;
  hasNextPage: boolean;
  isFetchingNextPage: boolean;
  options?: IntersectionObserverInit;
}

☑️ 팔로우/언팔로우 구현

mutateAsync 사용했습니당. 개인적으로 저는 mutate 사용해서 콜백 넘기는 것보다 await/async로 작성하는게 더 가독성이 좋더라구요!
성공/실패했을 때 toast 띄워주었습니다

  const { mutateAsync: unfollow } = useDeleteUnfollowUser();
  const { mutateAsync: follow } = useCreateFollowUser();
<Button
    color="indigo"
    variant="soft"
    size="1"
    css={{ cursor: "pointer" }}
    onClick={async () => {
        try {
              await follow(user.githubUserId);
              toast.success("팔로우에 성공했습니다.");
          } catch (e) {
             toast.error("팔로우에 실패했습니다. 다시 시도해주세요");
          }
      }}
 >

☑️ 강제 갱신 구현

갱신 = 깃허브에서 정보 받아와서 계산하기
서버 시간 기준 마지막 갱신 시점으로부터 5분 이후부터 갱신 가능함.
👉 갱신이 불가능할 때 버튼 disabled 처리해주어야 함.

참고 링크
https://discord.com/channels/1319925803663888505/1410601230895681536/1410602228649037845

  1. 서버 시간 기준으로 정확히 갱신 가능이 되는 시점을 완전히 똑같이 맞추는 것이 어려움
  2. 서비스 특성상 실제로 갱신 가능 시점이 아주 정확히 일치할 필요 없음. 서버에서 갱신 가능한 시간 이후에만 버튼이 클릭가능해지면 됨.
  3. 때문에 매 초마다 시간을 계산(업데이트)할 필요도 없음.

때문에 백엔드 담당자와의 논의 끝에, 아직 갱신 가능 상태가 아닌경우 갱신 가능할 때까지 30초마다 상태를 확인하여 버튼 disabled를 관리하기로 했습니다.

✚ 더불어 n분전으로 표기되는 부분 클릭시 툴팁으로 마지막 갱신 시간을 보여줌

☑️ 로딩 처리

스켈레톤 UI로 로딩처리하였습니다
radix에 Skeleton 컴포넌트가 있어서 그것 이용해봤습니다

<Flex direction="column" gap="2" css={{ padding: `0 ${rem(2)}` }}>
            {Array.from({ length: 10 }, (_, idx) => idx).map((idx) => (
              <Skeleton key={idx}>
                <UserListItemCard>
                  <UserProfileAvatar />
                </UserListItemCard>
              </Skeleton>
            ))}
</Flex>

📷 스크린샷

2025-09-21.2.30.04.mov
2025-09-21.2.34.37.mov

return useMutation({
mutationFn: createFollowRefresh,
onSuccess: () => {
queryClient.resetQueries({ queryKey: followQueryKeys.all() });

@seobbang seobbang Aug 31, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalidateQueries는 캐시된걸 보여주면서 새로 패치해오는데
강제 갱신버튼에 의해 해당 mutation이 호출되는 경우, 캐시된 것도 날리고 다시 패치해와야하므로 resetQueries를 사용합니다

Comment thread src/Follow/page.tsx
Comment on lines +66 to +68
<Flex ref={observerRef} justify="center" align="center" css={{ width: "100%", height: rem(6) }}>
{isFetchingNextPage && <Spinner css={{ height: rem(2.5) }} />}
</Flex>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

무한스크롤로 다음 페이지를 패칭해올 때 스피너를 표시합니다

fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
options?: IntersectionObserverInit;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options로 root, threshold, rootMargin을 외부에서 설정할 수 있습니다

Comment thread src/Follow/page.tsx
Comment on lines +72 to +78
{Array.from({ length: 10 }, (_, idx) => idx).map((idx) => (
<Skeleton key={idx}>
<UserListItemCard>
<UserProfileAvatar />
</UserListItemCard>
</Skeleton>
))}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로딩 상태를 스켈레톤 UI를 이용해 처리합니다

Comment on lines +24 to +35
const updateCanRefresh = () => {
const enabled = checkRefreshEnabled(lastSyncAt);
setIsRefreshEnabled(enabled);

if (enabled) {
clearInterval(intervalId);
}
};

const intervalId = setInterval(updateCanRefresh, 30000);

return () => clearInterval(intervalId);

@seobbang seobbang Aug 31, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5분 이후부터 강제 갱신 가능하므로, 30초마다 갱신 버튼을 활성화 해야 하는지 체크합니다

Comment thread src/App.tsx
},
mutations: {
retry: 0,
throwOnError: true,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mutation은 사용처에서 각각 맞는 에러 처리 방식 사용하기 위해 throwOnError 제거합니다

Comment thread src/Follow/apis/follow.ts
Comment on lines +22 to +35
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();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞팔 리스트 조회시 status가 202이면 202가 아닐때까지 polling합니다

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버에서 status가 지속적으로 202를 반환한다면 무제한 polling이 발생할것 같은데, 최대 횟수나 타임아웃 등의 제한을 두어서 처리해주어도 좋을것 같아요

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 좋은데요 ?! 👍 이 부분 백엔드 담당자분과 횟수 제한 어떻게 둘지 논의 먼저 해보겠습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영 완료 ~ 51ef6b3
논의해서 40번으로 결정했습니다!

Comment thread src/Follow/page.tsx
<>
<Header
renderCenter={() => <>팔로우 관리</>}
renderRight={data ? () => <RefreshButton lastSyncAt={data.pages[0].data.lastSyncAt} /> : undefined}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastSyncAt은 페이지 마다 모두 값이 동일하므로 pages[0]의 값을 고정으로 사용합니다. totalCount도 마찬가지

@seobbang seobbang changed the title Feat/#18 follow 맞팔 탐지 페이지 구현 Sep 20, 2025
@seobbang
seobbang requested review from Yeonseo-Jo and jungwoo3490 and removed request for jungwoo3490 September 20, 2025 17:36
@seobbang seobbang self-assigned this Sep 20, 2025
@seobbang
seobbang marked this pull request as ready for review September 20, 2025 17:36

@Yeonseo-Jo Yeonseo-Jo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

poliing도 들어가고 생각보다 복잡한 부분이 많았군용 ..
수고하셨습니다 ~~

Comment thread .gitignore
*.local

.env
.env.development

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

환경별 env 공유해서 사용하기로 했던것 같은데, 깃에 안올라가게 처리한 이유가 있나요 -?!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 제가 긴가민가한데.. ㅎㅎ 지금은 BASE_URL만 있어서 노출되어도 되지만, 생각해보니 노출되면 안되는 시크릿 키 같은걸 들고 있게되면 파일이 올라가면 안되겠더라구요! 그래서 깃에 안올라가도록 처리해줬는데 요 부분 어떻게 생각하시나요 ~?

Comment thread src/Follow/apis/follow.ts
Comment on lines +22 to +35
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버에서 status가 지속적으로 202를 반환한다면 무제한 polling이 발생할것 같은데, 최대 횟수나 타임아웃 등의 제한을 두어서 처리해주어도 좋을것 같아요

Comment on lines +39 to +44
onClick={async () => {
try {
await follow(user.githubUserId);
toast.success("팔로우에 성공했습니다.");
} catch (e) {
toast.error("팔로우에 실패했습니다. 다시 시도해주세요");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

팔로우/언팔로우시 버튼 onClick 내부 로직이 동일하므로 핸들러로 추상화하고, mutate function이랑 toast 텍스트만 각 버튼에 따라 적용되도록 수정해줘도 좋을것 같아요~

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고것도 좋은 것 같네용 👀 그런데 아직은 추상화하지 않아도 되는 정도의 로직이라고 생각해요!

핸들러를 만들게 되면 type에 따라 api 요청 함수 / success / error 토스트 텍스트 분기가 다 들어가야 하다보니
결국 현재 담고 있는 모든 로직에 분기가 들어가는 것과 같아서 추상화하면 불필요하게 복잡도가 높아질 것 같아요 🤔

그래서 지금은 이대로 쓰는게 더 직관적이지 않을까 하는 의견입니다!!

나중에 좀 더 공통 처리 로직이나 부가적인 로직이 들어갔을 때 추상화 고려해봐도 좋을 것 같은데 어떠세요 ~?!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저두 지금은 옵션이 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

최고최고 잘쓰겠습니다 ㅎㅎ

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다~~~~!! 👍🏻👍🏻

export function DetectTypeSelector({ value, onChange }: Props) {
return (
<SegmentedControl.Root
defaultValue="mutual"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultValue는 현 value에 상관없이 항상 고정으로 넣어주나요?!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value에 state값 넣어주면서 지워도 되는데 놓쳤네요! ㅋ-ㅋ 30ebef1

if (lastPage) return undefined;

const userList = prevPage.data.userList;
return userList[userList.length - 1].githubUserId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userList가 빈 배열일 경우 undefined 접근 가능성이 있을것 같은데, 이 경우의 방어로직 넣어주면 좋을것 같아요~

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

허거덩 그렇네요 반영했슴당 !_! 964fa64

@jungwoo3490 jungwoo3490 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻👍🏻👍🏻

options?: IntersectionObserverInit;
}

export function useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage, options }: Params) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다~~~~!! 👍🏻👍🏻

Comment on lines +39 to +44
onClick={async () => {
try {
await follow(user.githubUserId);
toast.success("팔로우에 성공했습니다.");
} catch (e) {
toast.error("팔로우에 실패했습니다. 다시 시도해주세요");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저두 지금은 옵션이 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);
    }
  };

@seobbang

Copy link
Copy Markdown
Member Author

중요 사항은 모두 반영해서 일단 머지해볼게요 ~! 혹시 반영할 것 더 생기면 후속 PR로 열겠습니당

@seobbang
seobbang merged commit a3b3b5e into main Sep 23, 2025
1 check passed
@seobbang seobbang changed the title 맞팔 탐지 페이지 구현 Feat: 맞팔 탐지 페이지 구현 Sep 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: 맞팔 탐지 페이지 구현

3 participants