본문 바로가기
Web Study/Next.js

Next.js로 SNS x.com 클론코딩하기 - 7

by 쿠리의일상 2024. 2. 2.

섹션 3의 막바지에 다다르고 있습니다. 내용이 알차서 그런가 강의 정리가 많네요. 사실 정리하는 것보다 직접 타이핑하면서 왜 이렇게 썼는지 생각하는 시간이 더 긴 것 같습니다. 그리고 새해를 맞이해 드디어 회사에서 반려노트북을 받게 됐는데 삼성 노트북... 생각보다 별론거 같습니다. 거금을 들였는데 아직 일주일도 안됐는데도 끊김이나 버벅임이 좀 있네요ㅜ 무게도 그램보다 무거워서 아쉽습니다만 전면 패널 터치 가능이니 그럴만도 싶습니다. (사실 이 가격이었으면 맥 프로를 사자고 했음 좋았을텐데 회사 분위기상 윈도우가 대부분이라... 슬프네요) 그래도 이번에 새로나온 인텔 cpu와 램 32기가라서 회사 다닐 동안은 열심히 써봐야지 싶습니다.

 

시작에 앞서 삽질했던 걸..

지금 생각해보면 정말 간단한건데 배열 형식의 정보를 리액트 상에서 뿌려줄 때 map 을 많이 사용합니다... 이때 map은 기본적으로 return이 되는 함수가 아니므로 { } 안에 내용을 구현했다면 꼭꼭.. return 을 해줍시다. 이걸 잠시 잊고 살다가 간만에 구현하려는데 안돼서 뭐지..? 싶었습니다ㅠㅋㅋ

 

useContext 를 사용한 탭 전환

x 홈페이지를보면 추천탭과 팔로우탭의 내용이 다른 것을 알 수 있습니다. 이 기능을 위해서 일전 포스팅에서 useContext 를 사용한 TabProvider 의 내용에서 Context 로 tab 내용을 가져와서 사용해줍니다.

 

탭을 바꾸어서 주소가 바뀌지 않는 것을 보아, 클라이언트 상에서 컴포넌트만 변경해주는 것을 확인해줄 수 있습니다. 이 경우엔 추천탭 전용 컴포넌트와 팔로우 전용 컴포넌트로 나누어 탭이 변하는 값을 가져와 내용을 변경해줄 수 있는 별도의 컴포넌트가 필요함을 알 수 있씁니다.

'use client';

import { createContext, useState } from 'react';

export const TabContext = createContext({
  tab: 'rec',
  setTab: (value: 'rec' | 'fol'): void => {},
});

type Props = { children: React.ReactNode };

export default function TabProvider({ children }: Props) {
  const [tab, setTab] = useState('rec');

  return (
    <TabContext.Provider value={{ tab, setTab }}>
      {children}
    </TabContext.Provider>
  );
}

 

"use client";

import { useContext } from "react";
import { TabContext } from "./TabProvider";
import PostRecommends from "./PostRecommends";
import FollowingPosts from "./FollowingPosts";

export default function TabDecider() {
  const { tab } = useContext(TabContext);

  if (tab === "rec") {
    return <PostRecommends />;
  }
  if (tab === "fol") {
    return <FollowingPosts />;
  }
}

저번에 지정해둔 Context로 간단하게 구현할 수 있습니다. /home 페이지는 서버 컴포넌트므로 훅을 사용할 수 없기에 따로 컴포넌트로 빼줘야 했습니다.

 

 

트렌드에 따른 검색 결과

나를 위한 트렌드 구역을 보시면 트렌드를 클릭하여 해당 내용을 확인할 수 있습니다. 이 내용은 쿼리스트링을 사용하여 값을 가져오게 되는데, 이 값과 추천탭 게시글과 팔로우탭 게시글에서 해준 대로 useQuery 를 사용하여 검색 결과 게시글을 구현해줍니다.

 

import { getSearchResult } from "../_lib/getSearchResult";
import { useQuery } from "@tanstack/react-query";
import Post from "../../_component/Post";
import { Post as IPost } from "@/model/Post";

type Props = {
  searchParams: { q: string; f?: string; pf?: string };
};

export default function SearchResult({ searchParams }: Props) {
  const { data } = useQuery<
    IPost[],
    Object,
    IPost[],
    [_1: string, _2: string, Props["searchParams"]]
  >({
    queryKey: ["posts", "search", searchParams],
    queryFn: getSearchResult,
    staleTime: 60 * 1000,
    gcTime: 5 * 60 * 1000,
  });

  return data?.map((post: IPost) => <Post key={post.postId} post={post} />);
}

여기서 알아둬야할 것은 queryFn에 들어가는 함수에는 기본적으로 queryKey가 들어간다고 합니다. 즉 따로 훅을 사용하여 주소창 값을 읽어오지 않아도 searchParams 부분을 통해 getSearchResult 함수에서 읽어들일 수 있는 것이죠.

그리고 useQuery의 타입을 보면 [ ] 안에 여러 타입들이 들어감을 알 수 있습니다. 이를 다이나믹 쿼리키라고 한다고 합니다. 타입스크립트가 약한 저로썬 처음에 좀 당황스러웠네요. 

 

import { Post } from "@/model/Post";
import { QueryFunction } from "@tanstack/query-core";

export const getSearchResult: QueryFunction<
  Post[],
  [_1: string, _2: string, searchParams: { q: string; pf?: string; f?: string }]
> = async ({ queryKey }) => {
  const [_1, _2, searchParams] = queryKey;

  const res = await fetch(
    `http://localhost:9090/api/search/${
      searchParams.q
    }?${searchParams.toString()}`,
    {
      next: {
        tags: ["posts", "search", searchParams.q],
      },
      cache: "no-store",
    }
  );

  if (!res.ok) {
    throw new Error("Failed to fetch data");
  }

  return res.json();
};

tags와 queryKey의 차이점 중 하나는 tags 는 객체 형태로 넣어줄 수 없어서 위처럼 searchParams.q 인 문자열로 넣어줬다는 점입니다.

그리고 알아두셔야할 점은 타입스크립트에서 쿼리함수의 타입을 지정하는 것인데,

import { QueryFunction } from "@tanstack/query-core";

 지정된 타입은 이러하며 <> 안에 구현해준 타입들이 들어가게 만들면 됩니다.

 

그리고 search form 에서 검색 할 때마다 주소를 변경해주는 로직을 짜줍니다.

"use client";

import style from "@/app/(afterLogin)/_component/rightSearch.module.css";
import { useRouter } from "next/navigation";
import { FormEventHandler } from "react";

type Props = {
  q?: string;
};

export default function SearchForm({ q }: Props) {
  const router = useRouter();

  const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    router.push(`/search?q=${e.currentTarget.search.value}`);
  };

  return (
    <form className={style.search} onSubmit={onSubmit}>
      <svg width={20} viewBox="0 0 24 24" aria-hidden="true">
        <g>
          <path d="M10.25 3.75c-3.59 0-6.5 2.91-6.5 6.5s2.91 6.5 6.5 6.5c1.795 0 3.419-.726 4.596-1.904 1.178-1.177 1.904-2.801 1.904-4.596 0-3.59-2.91-6.5-6.5-6.5zm-8.5 6.5c0-4.694 3.806-8.5 8.5-8.5s8.5 3.806 8.5 8.5c0 1.986-.682 3.815-1.824 5.262l4.781 4.781-1.414 1.414-4.781-4.781c-1.447 1.142-3.276 1.824-5.262 1.824-4.694 0-8.5-3.806-8.5-8.5z"></path>
        </g>
      </svg>
      <input name="search" type="search" />
    </form>
  );
}

currentTarget 을 활용하여 form 태그 안의 name에 지정된 키로 해당 값의 내용에 접근할 수 있습니다. 따로state 를 사용하지 않아도 되므로 간편하게 구현이 가능해집니다.

 

 

다만 검색할 때마다 관련 검색params의 결과로 아래와 같이 리액트 쿼리 캐싱에 쌓이게 됩니다. 그러므로 캐싱 시간을 짧게 하거나 특정 조건일 때 정리를 해주는 등의 메모리 관리가 별도로 필요합니다.

 

추가로 게시글 내용은 MSW 를 사용하여 만든 더미 데이터입니다. 이때 params 로 각 api마다 다이나믹하게 바뀌는 요청주소를 받아줄 수 있는데 넥스트는 폴더명에 [ ] 를 감싼 것에 비하여 MSW에선 변수명에 : 을 붙여서 나타냅니다.

http.get(
    "/api/users/:userID/posts/:postId/comments",
    async ({ request, params }) => {
      const { userID, postId } = params;
 //...
)

 

 

나머지는 거의 구현된 내용을 바탕으로 내용을 붙여넣기하거나 설정을 고쳐주는 수준으로 처리하면 된다고 합니다.

트렌드 목록

"use client";

import { usePathname } from "next/navigation";
import style from "./trendSection.module.css";
import Trend from "@/app/(afterLogin)/_component/Trend";
import { useSession } from "next-auth/react";
import { useQuery } from "@tanstack/react-query";
import { getTrends } from "../_lib/getTrends";
import { IHashtag } from "@/model/IHashtag";

export default function TrendSection() {
  const pathname = usePathname();
  const { data: session } = useSession();

  const { data } = useQuery<IHashtag[]>({
    queryKey: ["trends"],
    queryFn: getTrends,
    staleTime: 60 * 1000,
    gcTime: 5 * 60 * 1000,
    enabled: !!session?.user,
  });

  if (pathname === "/explore") return null;
  if (session?.user) {
    return (
      <div className={style.trendBg}>
        <div className={style.trend}>
          <h3>나를 위한 트렌드</h3>
          {data?.map((trend: IHashtag) => (
            <Trend trend={trend} key={trend.tagId} />
          ))}
        </div>
      </div>
    );
  } else {
    return (
      <div className={style.trendBg}>
        <div className={style.noTrend}>트랜드를 가져올 수 없습니다</div>
      </div>
    );
  }
}

트렌드 목록은 로그인 여부에 따라 보이거나 안보여야 합니다. 하지만 useQuery를 enabled 속성을 사용하지 않고 불러온다면 로그인을 하든 안하든 항상 데이터가 불러와지므로 최적화에 좋지 않습니다. 이를 위해 enabled 속성을 사용하여 session 에 user 로그인 정보가 있을 때만 useQuery 를 불러오도록 설정해줄 수 있습니다.

 

얼레벌레 트렌드 부분과 팔로우 추천 부분을 더미 데이터로 채우면 이렇게 됩니다.

자세한 코드는 아래 깃헙에서 확인 가능합니다.

https://github.com/ZeroCho/next-app-router-z/tree/master/ch3-2