JSX 분리와 상태 추상화로 DX 개선하기(with Frontend Fundamentals)

junjeong·2025년 5월 26일

Linkbrary

목록 보기
6/6
post-thumbnail

소개

지난 시간 Frontend Fundamentals 정리 포스팅에서 다루었던 내용들을 바탕으로, Linkbrary 프로젝트에서 내가 맡았던 링크 페이지의 코드들을 개선해 보겠다.

👨‍💻 기존 코드

 (...중략)

interface LinkPageProps {
  linkList: LinkData[];
  folderList: FolderData[];
  totalCount: number;
}

// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달.
export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  const { req } = context;
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  // accessToken이 없으면 클라이언트에서 실행될 때 /login 페이지로 이동시킴.
  if (!accessToken) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  const fetchData = async (endpoint: string) => {
    const res = await axiosInstance.get(endpoint, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    return res.data;
  };

  const [links, folders] = await Promise.all([
    fetchData("/links"),
    fetchData("/folders"),
  ]);

  return {
    props: {
      linkList: links.list || [],
      folderList: folders || [],
      totalCount: links.totalCount || 0,
    },
  };
};

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const router = useRouter();
  const { query } = router;
  const { isMobile, isTablet } = useViewport();
  const { isOpen } = useModalStore();
  const [linkListData, setLinkListData] = useState({
    list: initialLinkList,
    totalCount: initialTotalCount,
  });
  const [folderList, setFolderList] = useState(initialFolderList);
  const [isLoading, setIsLoading] = useState(false);
  const [queryKeys, setQueryKeys] = useState({
    pathname: router.pathname,
    page: query.page,
    search: query.search,
    folderId: query.folder,
    isTablet: isTablet,
  });
  const [folderName] = useFolderName(queryKeys.folderId);

  useEffect(() => {
    setQueryKeys({
      pathname: router.pathname,
      page: query.page,
      search: query.search as string,
      folderId: query.folder as string,
      isTablet: isTablet,
    });
  }, [
    query.search,
    isTablet,
    query.folder,
    router.pathname,
    setQueryKeys,
    query.page,
  ]);

  // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅
  useFetchLinks(setLinkListData, setIsLoading, queryKeys);

  return (
    <>
      <div className="flex justify-center items-center bg-gray100 w-full h-[219px]">
        <AddLinkInput folderList={folderList} />
      </div>
      <Container>
        <main className="relative mt-[40px]">
          <SearchInput setQueryKeys={setQueryKeys} />
          {queryKeys.search && (
            <SearchResultMessage message={queryKeys.search} />
          )}
          <div className="flex justify-between mt-[40px]">
            {folderList && (
              <FolderTag
                folderList={folderList}
                queryKeys={queryKeys}
                setQueryKeys={setQueryKeys}
              />
            )}
            {!isMobile && <AddFolderButton setFolderList={setFolderList} />}
          </div>
             
          (...중략)
             
        </main>
      </Container>
      {isOpen && <Modal />}
      {isMobile && (
        <AddFolderButton setFolderList={setFolderList} isModal={true} />
      )}
    </>
  );
};

export default LinkPage;

자식 컴포넌트나, 관리하고 있는 상태가 너무 많아 가독성이 최악이다. 아무래도 링크 페이지가 주 컨텐츠이다 보니 상호작용 요소들이 많은 탓에, 조건부 렌더링에 필요한 상태와 분기 처리가 많을 수 밖에 없다.

이전에 배웠던 규칙들을 다시 생각해보며 차근히, 하나씩 적용해볼 수 있는 지점들이 어디 있을까 찾아 보자.

⚙️ getServerSideProps의 데이터 패칭 로직을 외부 함수, fetchInitialData로()로 추상화하자!

기존에 getServerSideProps에서 한가지 빠져도 되는 로직이 있다면, fetchData()의 내부 코드인 것 같다.

이 함수는 초기 데이터를 불러오는 용도로써, 예측가능성을 고려하여 함수의 이름을 더 명확히 fetchInitialData로 바꾸어주었다.

또한 따로 전체디렉토리에 utils에 뺄 수도 있었지만 만약 fetchInitialData 함수의 내부 로직을 수정하는 일이 발생한다고 가정했을 때 getServerSideProps 함수에도 영향을 주기 때문에 응집도를 고려하여 페이지 컴포넌트와 동일한 디렉토리에 두었다. 코드는 다음과 같이 바뀌었다.

export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  const { req } = context;
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  if (!accessToken) {
    return {
      redirect: {
        destination: "/login",
      },
    };
  }

  const [linkList, folderList]: any = await Promise.all([
    fetchInitialData(accessToken, "/links"),
    fetchInitialData(accessToken, "/folders"),
  ]);

  return {
    props: {
      linkList: linkList.list || [],
      folderList: folderList,
      totalCount: linkList.totalCount || 0,
    },
  };
};

Promimse.all()과 fetchInitialData 함수의 반환 타입을 호환하는 작업이 다소 난이도가 있어 우선 any로 선언해준 아쉬운 모습이다... 이번 포스팅에서는 이전에 배운 규칙들을 적용해보는 것에 초점을 맞추기 위해, 타입의 안정성에 대한 부분은 배제해야 할 것 같다...ㅜ(ts 숙련도 이슈)

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const router = useRouter();
  const { query } = router;
  const { isMobile, isTablet } = useViewport();
  const { isOpen } = useModalStore();
  const [linkListData, setLinkListData] = useState({
    list: initialLinkList,
    totalCount: initialTotalCount,
  });
  const [folderList, setFolderList] = useState(initialFolderList);
  const [isLoading, setIsLoading] = useState(false);
  const [queryKeys, setQueryKeys] = useState({
    pathname: router.pathname,
    page: query.page,
    search: query.search,
    folderId: query.folder,
    isTablet: isTablet,
  });
  const [folderName] = useFolderName(queryKeys.folderId);

웁스.. 상태를 정의하는 코드만 18줄이다....코드에서 지독한 냄새가 난다🥲

코드를 읽는 사람이 알고 싶은 것은 어떤 상태들이 있는지 정도이지, 어떤 함수로 어떻게 가져오는지는 굳이 알고 싶지 않다.

하여, 링크 페이지에서 필요한 상태들을 한꺼번에 관리하여 반환해주는useLinkPageState()라는 함수를 만들어주면 좋을 것 같다. 코드는 다음과 같이 변했다.

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const {
    linkListData,
    setLinkListData,
    folderList,
    setFolderList,
    folderName,
    isLoading,
    setIsLoading,
    queryKeys,
    setQueryKeys,
    isMobile,
    isOpen,
  } = useLinkPageState(initialLinkList, initialFolderList, initialTotalCount);

  // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅
  useFetchLinks(setLinkListData, setIsLoading, queryKeys);

훨씬 보기 편해졌다. 사용자가 읽는 맥락이 "이런 상태들이 있구나~" 정도에서 그치게 된다.

또한 link 페이지에서 필요로 하는 상태가 추가될 때에는 useLinkPageState 함수가 책임지면 된다. "link 페이지에서 필요한 상태를 정의하거나 변경"하는 역할을 담당하기에 이름은 다소 추상적이지만 useLinkPageState라는 이름으로 지정해주었다.

⚙️ 너무 많은 분기처리로 인해 복잡해진 JSX, 자식 컴포넌트로 분리해보자.

return (
    <>
      <div className="flex justify-center items-center bg-gray100 w-full h-[219px]">
        <AddLinkInput folderList={folderList} />
      </div>
      <Container>
        <main className="relative mt-[40px]">
          <SearchInput setQueryKeys={setQueryKeys} />
          {queryKeys.search && (
            <SearchResultMessage message={queryKeys.search} />
          )}
          <div className="flex justify-between mt-[40px]">
            {folderList && (
              <FolderTag
                folderList={folderList}
                queryKeys={queryKeys}
                setQueryKeys={setQueryKeys}
              />
            )}
            {!isMobile && <AddFolderButton setFolderList={setFolderList} />}
          </div>
             
          (...중략)
             
      </Container>
      {isOpen && <Modal />}
      {isMobile && (
        <AddFolderButton setFolderList={setFolderList} isModal={true} />
      )}
    </>
  );
};

링크 페이지가 리턴하는 JSX문이다. 앞서 설명했듯이 상호작용에 따른 조건부 렌더링 요소가 많아질 수록 코드는 분기처리할 것이 많아져 가독성이 떨어진다..ㅜ 어쩔 수 없는 상황이라고 생각은 들지만 복잡해도 너무 복잡하다 ㅎㅎ; 개선할 수 있는 방법이 뭐가 없을까??

전체 코드 중 특히 링크 목록을 isLoading와 length에 따라 삼항연산자를 중첩해서 사용해 렌더링하는 부분이 특히 복잡해 보인다. 이 부분만 컴포넌트로 따로 분리해도 return문을 읽는 맥락이 비교적 쉽게 개선될 수 있을 것 같다. 바뀐 코드는 다음과 같다.

return (
    <>
      <div className="flex justify-center items-center bg-gray100 w-full h-[219px]">
        <AddLinkInput folderList={folderList} />
      </div>
      <Container>
        <main className="relative mt-[40px]">
          <SearchInput setQueryKeys={setQueryKeys} />
          {queryKeys.search && (
            <SearchResultMessage message={queryKeys.search} />
          )}
          <div className="flex justify-between mt-[40px]">
            {folderList && (
              <FolderTag
                folderList={folderList}
                queryKeys={queryKeys}
                setQueryKeys={setQueryKeys}
              />
            )}
            {!isMobile && <AddFolderButton setFolderList={setFolderList} />}
          </div>
          <div className="flex justify-between items-center my-[24px]">
            {queryKeys.folderId && (
              <>
                <h1 className="text-2xl">{folderName as string}</h1>
                <FolderActionsMenu
                  setFolderList={setFolderList}
                  folderId={queryKeys.folderId}
                  linkCount={linkListData.totalCount}
                />
              </>
            )}
          </div>
          <LinkListRenderer isLoading={isLoading} linkListData={linkListData} />
        </main>
      </Container>
      {isOpen && <Modal />}
      {isMobile && (
        <AddFolderButton setFolderList={setFolderList} isModal={true} />
      )}
    </>
  );
};

복잡했던 isLoading과 linkListData.list.length에 따른 조건부 렌더링만 한 줄로 바뀌었는데 return문 전체가 읽기 수월해진 기분이 든다...🤔 이로써 지나친 삼항 연산자 사용은 오히려 가독성을 저해시킨다는 교훈을 얻었다.

😎 최종적으로 바뀐 코드

최종적으로 전체 코드는 다음과 같이 바뀌었다.

Before

(...중략)
interface LinkPageProps {
  linkList: LinkData[];
  folderList: FolderData[];
  totalCount: number;
}

// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달.
export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  const { req } = context;
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  // accessToken이 없으면 클라이언트에서 실행될 때 /login 페이지로 이동시킴.
  if (!accessToken) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  const fetchData = async (endpoint: string) => {
    const res = await axiosInstance.get(endpoint, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    return res.data;
  };

  const [links, folders] = await Promise.all([
    fetchData("/links"),
    fetchData("/folders"),
  ]);

  return {
    props: {
      linkList: links.list || [],
      folderList: folders || [],
      totalCount: links.totalCount || 0,
    },
  };
};

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const router = useRouter();
  const { query } = router;
  const { isMobile, isTablet } = useViewport();
  const { isOpen } = useModalStore();
  const [linkListData, setLinkListData] = useState({
    list: initialLinkList,
    totalCount: initialTotalCount,
  });
  const [folderList, setFolderList] = useState(initialFolderList);
  const [isLoading, setIsLoading] = useState(false);
  const [queryKeys, setQueryKeys] = useState({
    pathname: router.pathname,
    page: query.page,
    search: query.search,
    folderId: query.folder,
    isTablet: isTablet,
  });
  const [folderName] = useFolderName(queryKeys.folderId);

  useEffect(() => {
    setQueryKeys({
      pathname: router.pathname,
      page: query.page,
      search: query.search as string,
      folderId: query.folder as string,
      isTablet: isTablet,
    });
  }, [
    query.search,
    isTablet,
    query.folder,
    router.pathname,
    setQueryKeys,
    query.page,
  ]);

  // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅
  useFetchLinks(setLinkListData, setIsLoading, queryKeys);

  return (
    <>
      <div className="flex justify-center items-center bg-gray100 w-full h-[219px]">
        <AddLinkInput folderList={folderList} />
      </div>
      <Container>
        <main className="relative mt-[40px]">
          <SearchInput setQueryKeys={setQueryKeys} />
          {queryKeys.search && (
            <SearchResultMessage message={queryKeys.search} />
          )}
          <div className="flex justify-between mt-[40px]">
            {folderList && (
              <FolderTag
                folderList={folderList}
                queryKeys={queryKeys}
                setQueryKeys={setQueryKeys}
              />
            )}
            {!isMobile && <AddFolderButton setFolderList={setFolderList} />}
          </div>
          <div className="flex justify-between items-center my-[24px]">
            {queryKeys.folderId && (
              <>
                <h1 className="text-2xl">{folderName as string}</h1>
                <FolderActionsMenu
                  setFolderList={setFolderList}
                  folderId={queryKeys.folderId}
                  linkCount={linkListData.totalCount}
                />
              </>
            )}
          </div>
          {isLoading ? (
            <div className="gap-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
              {[...Array(3)].map((_, index) => (
                <LinkCardSkeleton key={index} />
              ))}
            </div>
          ) : linkListData.list.length !== 0 ? (
            <>
              <CardsLayout>
                {linkListData.list.map((link) => (
                  <LinkCard key={link.id} info={link} />
                ))}
              </CardsLayout>
              <Pagination totalCount={linkListData.totalCount} />
            </>
          ) : (
            <RenderEmptyLinkMessage />
          )}
        </main>
      </Container>
      {isOpen && <Modal />}
      {isMobile && (
        <AddFolderButton setFolderList={setFolderList} isModal={true} />
      )}
    </>
  );
};

export default LinkPage;

After

(...중략)

interface LinkPageProps {
  linkList: LinkData[];
  folderList: FolderData[];
  totalCount: number;
}

// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달.
export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  const { req } = context;
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  if (!accessToken) {
    return {
      redirect: {
        destination: "/login",
      },
    };
  }

  const [linkList, folderList]: any = await Promise.all([
    fetchInitialData(accessToken, "/links"),
    fetchInitialData(accessToken, "/folders"),
  ]);

  return {
    props: {
      linkList: linkList.list || [],
      folderList: folderList,
      totalCount: linkList.totalCount || 0,
    },
  };
};

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const {
    linkListData,
    setLinkListData,
    folderList,
    setFolderList,
    folderName,
    isLoading,
    setIsLoading,
    queryKeys,
    setQueryKeys,
    isMobile,
    isOpen,
  } = useLinkPageState(initialLinkList, initialFolderList, initialTotalCount);

  // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅
  useFetchLinks(setLinkListData, setIsLoading, queryKeys);

  return (
    <>
      <div className="flex justify-center items-center bg-gray100 w-full h-[219px]">
        <AddLinkInput folderList={folderList} />
      </div>
      <Container>
        <main className="relative mt-[40px]">
          <SearchInput setQueryKeys={setQueryKeys} />
          {queryKeys.search && (
            <SearchResultMessage message={queryKeys.search} />
          )}
          <div className="flex justify-between mt-[40px]">
            {folderList && (
              <FolderTag
                folderList={folderList}
                queryKeys={queryKeys}
                setQueryKeys={setQueryKeys}
              />
            )}
            {!isMobile && <AddFolderButton setFolderList={setFolderList} />}
          </div>
          <div className="flex justify-between items-center my-[24px]">
            {queryKeys.folderId && (
              <>
                <h1 className="text-2xl">{folderName as string}</h1>
                <FolderActionsMenu
                  setFolderList={setFolderList}
                  folderId={queryKeys.folderId}
                  linkCount={linkListData.totalCount}
                />
              </>
            )}
          </div>
          <LinkListRenderer isLoading={isLoading} linkListData={linkListData} />
        </main>
      </Container>
      {isOpen && <Modal />}
      {isMobile && (
        <AddFolderButton setFolderList={setFolderList} isModal={true} />
      )}
    </>
  );
};

export default LinkPage;

마치며...🚪

이번 시간은 Frontend Fundamentals에서 제시한 지침서를 바탕으로, 기존에 linkbrary의 문제점을 분석하고 최종적으로 클린코드를 적용하는 작업에 대해서 다루었다.

"과연 어떤 코드가 좋은 코드인가?"에 대한 주제를 다시 생각해 봤을 때. 배포된 프로젝트를 경험하는 사용자도 있지만, 배포 과정에서 직접 코드를 보고 만지는 개발자도 있다.

다른 개발자가 나의 코드를 더 빨리 해석하고, 더 빨리 고칠 수 있다면, 배포 기간도 그만큼 빨라진다. 배포 기간이 빨라진다는 뜻은 사용자에게 피드백을 드리는 속도 또한 빨라진다는 이야기이다.

고로, 개발자의 작업이 수월할수록 사용자 경험도 증가하는 것이다.

이처럼 클린 코드는 너무너무 중요한 영역이다. 당장은 넓은 시야에서 코드를 볼 줄 아는 능력이 부족 코드를 다 짜고 나서, 다시 리팩토링 작업을 하는데 큰 공수가 들어가지만 점차 시야가 트일수록 처음에 작성한 코드에 클린 코드를 적용하는 날이 왔으면 좋겠다😆

profile
Whether you're doing well or not, just keep going👨🏻‍💻🔥

0개의 댓글