[회고] Wikied를 마치며...

junjeong·2025년 2월 13일

Wikied

목록 보기
1/5
post-thumbnail

👨‍💻Wikied는 어떤 프로젝트인가?


Wikied는 나무위키처럼 지인의 위키를 직접 작성하고 공유할 수 있는 재치 있는 플랫폼이다.
무한스크롤로 데이터를 불러오고, 에디터 외부 패키지를 사용하여 서버에 저장된 HTML을 화면에 마크다운 형식으로 렌더링하는 것이 주 과제였다.

🗓️ 작업 기간

2024.10.18 ~ 2024.11.05

👥 팀원

정준영
정준영(팀장 👑)
전상민
전상민
김진
김진
박상욱
박상욱
역할팀장으로 프로젝트 참여
모든 위키 페이지
무한 스크롤 기능
검색 페이지
마이 페이지
랜딩 페이지
자유 게시판 페이지
팝업 컴포넌트 구현
에디터
에디터
정보 수정 컴포넌트

⚙️ 내가 구현한 기능

"모든 위키" 페이지

첫번째로 맡은 역할은 "모든 위키" 페이지였다.

FCP 속도를 개선하고자 SSR을 활용해 초기의 데이터는 12개만 불러왔다. 이후에 데이터 패칭은 무한 스크롤 방식을 적용하여 화면에 LoadingSpinner 컴포넌트가 보일 때마다 클라이언트 사이드 렌더링으로 list를 업데이트하는 방식이다.

interface WikiListPageProps {
  initialList: Profile[];
}

export const getServerSideProps = async () => {
  const res = await getProfiles({ pageSize: 12 });
  return {
    props: {
      initialList: res,
    },
  };
};

const WikiListPage = ({ initialList }: WikiListPageProps) => {
  const { loadingRef, hasMore, list } = useInfiniteScroll(1, initialList);

  return (
    <div className="mx-auto px-[20px] Mobile:px-[100px] Tablet:px-[60px] w-full max-w-[840px] h-full">
      <WikiListTitle />
      <WikiCardList list={list} />
      {hasMore && (
        <div ref={loadingRef}>
          <LoadingSpinner />
        </div>
      )}
    </div>
  );
};

export default WikiListPage;

1. 무한 스크롤 기능


const useInfiniteScroll = (initialPage: number, initialList: Profile[]) => {
  const [list, setList] = useState<Profile[]>(initialList);
  const [page, setPage] = useState(initialPage);
  const [hasMore, setHasMore] = useState(true);
  const loadingRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const currentRef = loadingRef.current;

    const loadMoreProfiles = async () => {
      const newProfiles = await getProfiles({ page: page + 1, pageSize: 12 });

      if (newProfiles.length === 0) {
        setHasMore(false);
      } else {
        setList((prev) => [...prev, ...newProfiles]);
        setPage((prev) => prev + 1);
      }
    };

    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasMore) {
        loadMoreProfiles();
      }
    });

    if (currentRef) {
      observer.observe(currentRef);
    }

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, []);

  return { loadingRef, hasMore, list };
};

export default useInfiniteScroll;

링크 페이지에서만 사용하는 무한 스크롤 기능의 hook이다.

컴포넌트에서 필요한 상태는 list 하나 뿐이고, list가 업데이트 되는 조건은 무한스크롤이 걸려야 하기 때문에 useInfiniteScroll 훅에서 한꺼번에 관리해주는 로직으로 구현했다.

useInfiniteScroll 훅은 페이지 컴포넌트에서 필요한 리스트 상태값을 관리하고 동시에, loadingRef로 참조하는 LoadingSpinner 컴포넌트가 화면에 나타날 때마다 loadMoreProfiles() 함수를 호출하여 새로 받아온 프로필 목록을 setList로 업데이트하는 역할을 한다.

2. 검색 기능

두번째로 맡은 역할은 "검색 기능"이었다. 헤더에 보이는 SearchInput 컴포넌트에 검색어를 입력하면, 검색 페이지로 이동하게끔 구현했다.

처음 Figma의 요구사항에는, 위에 보이는 영상처럼 헤더에 있는 SearchInput을 검색 페이지에서도 재사용해 두 개가 보이게끔 해달라고 적혀있었지만 같은 페이지에 똑같은 기능을 하는 검색창이 중복으로 있는 것은 불필요한 UI/UX라고 판단하여 제거했다.

Header 컴포넌트와 Search 페이지의 페이지 컴포넌트의 코드는 다음과 같다.

// Header.jsx
export const Header = () => {
  const { searchedName, handleChange, handleSubmit } = useSearchName("");
  const { isMobile } = useViewport();
  const router = useRouter();
  const isSearchPage = router.pathname === "/search";

  return (
    <div className="top-0 z-[90] sticky flex justify-between items-center bg-background shadow-sm p-[24px] w-screen h-[80px]">
      <Logo />
      {!isSearchPage && (
        <div className="Mobile:hidden ml-auto">
          <SearchInput
            size="small"
            onChange={handleChange}
            onSubmit={handleSubmit}
            value={searchedName}
          />
        </div>
      )}
      <div className={isSearchPage ? "mx-10 ml-auto" : "mx-10"}>
        <Navigation />
      </div>
      <HeaderMenuContainer isMobile={isMobile} />
    </div>
  );
};

//SearchPage.jsx
export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  let res: GetProfilesResponse;
  const { page, q } = context.query;

  if (context.query) {
    res = await getProfilesByName({
      page: Number(page),
      name: String(q),
    });
  } else {
    res = await getProfilesByName();
  }

  return {
    props: {
      totalCount: res.totalCount || 0,
      list: res.list || [],
      q: q || "",
    },
  };
};

const SearchPage = ({ list, totalCount, q }: SearchPageProps) => {
  const router = useRouter();
  const { searchedName, handleChange, handleSubmit } = useSearchName(q);
  const { isMobile } = useViewport();

  const handlePageChange = (clickedPageNumber: number) => {
    router.push({
      pathname: router.pathname,
      query: { ...router.query, page: clickedPageNumber },
    });
  };

  return (
    <div className="relative flex flex-col items-center mx-auto mt-[80px] max-w-[860px] h-screen">
      <div className="relative">
        <SearchInput
          size={isMobile ? "small" : "large"}
          onChange={handleChange}
          onSubmit={handleSubmit}
          value={searchedName}
        />
        <div className="-bottom-[40px] left-0 absolute text-gray-400 text-lg">
          <TotalCountRender q={q} totalCount={totalCount} />
        </div>
      </div>
      <SearchedWikiList list={list} totalCount={totalCount} q={q} />
      <div className="bottom-[10%] absolute">
        <PaginationBar
          totalPage={Math.ceil(totalCount / 3)}
          currentPage={Number(router.query.page) || 1}
          handlePageChange={handlePageChange}
          isLoading={false}
        />
      </div>
    </div>
  );
};

export default SearchPage;

특이한 부분이 있다면 useSearchName 훅을 같이 사용해주고 있는 모습이다.

두개의 컴포넌트 모두 사용자에 입력에 따라 "검색어"라는 상태값을 추적해야 하고, 검색어에 해당하는 "/search" 페이지로 리다이렉션해야 하는 로직이 중복되기 때문에 하나의 hook으로 추상화해준 모습이다.

하나의 hook이 두개 이상의 컴포넌트에서 쓰이고 있기 떄문에 useSearchName의 로직을 변경할 경우, Header와 SearchPage 컴포넌트, 두개의 컴포넌트에게 영향을 준다. 중복되는 로직을 하나의 hook으로 추상화 해 가독성은 상승했지만 동시에 함수의 결합도는 상승한 꼴이다.

useSearchName을 고칠 때 Header와 SearchPage도 고쳐야하는... 함수의 응집도를 최대한 보완하고자 다음과 같은 주석을 달아주었다ㅎㅎ😂

3. 마이 페이지

마지막으로 맡은 페이지는 "마이 페이지"였다. 사실 마이 페이지라고 하기에는 너무 거창하고 나중 되서는 비밀번호 변경 페이지로 전락했다...ㅎㅎ 원래는 기존에 url을 조금 더 짧게 변경해주거나, 개성있는 다른 문자열로 바꾸어주는 기능을 넣어주고 싶었는데 학원에서 제공된 백엔드 api가 해당 기능까지는 지원해주지 못했던 탓에 도중에 무산되었다 ㅎㅎ

🚜 개선하고 싶은 부분

1. useInfiniteScroll에서 list에 대한 상태관리 책임 분리

지난 Frontend-Fundamentals 포스팅에서, "변경이 쉬운 클린 코드"를 작성하기 위해서는 함수의 예측 가능성을 고려하여 함수의 이름과 파라미터, 반환값만 보아도 어떤 기능을 하는지 쉽게 유추가 가능해야 한다고 했다.

const { loadingRef, hasMore, list } = useInfiniteScroll(1, initialList);

useInfiniteScroll 이라는 함수는, list라는 값을 반환해주고 있다. 때문에 '무한스크롤 기능"이 있고 list 상태값까지 관리해주고 있나보다...' 어느정도 유추는 가능하지만 함수의 이름을 봤을 때 성격이 너무 뚜렷하다 보니 무한스크롤 기능과 관련된 로직만 넣어주는 것이 더 바람직 해 보인다.

이처럼 1)list 상태관리2)무한스크롤로 list 업데이트 라는 다수의 책임을 가지고 있는 함수들이 있다면 각자 하나의 책임만 맡아서 할 수 있게끔 분리하는 작업이 필요해 보인다. 목적은 함수의 예측 가능성 향상도 있지만 개발의 유지보수성을 고려하는 작업이기도 하다.

2. 에러 핸들링이 정교하게 이루어지고 있는가?(+ 비슷한 기능의 함수 반환값 통일하기)

Wikied 프로젝트도 지난 Linkbrary와 비슷하게 많은 api 요청 수를 보유하고 있다.

그 중 인증이 필요한 요청은 쿠키에 저장한 accesToken을 열람하기 위해 API Route 엔드포인트에 접근하는 경우도 많았다.

서버에 요청을 보내는 비동기 요청은 에러 핸들링을 제대로 해주어야, 문제가 발생했을 때 디버깅이 용이해진다고 했다. 과연 WIkied에서는 많은 api 요청에서 에러 핸들링이 잘 이루어지고 있을까??

또한 API 함수들은 모두 "HTTP 요청"이라는 같은 임무를 수행한다. 고로 지난 시간에 배웠던 함수의 예측 가능성을 높이기 위해서는 반환값의 타입을 동일하게 해주어야 한다. 이 작업까지 같이 점검하면 좋을 것 같다.

// 프로픽 목록 불러와 list, hasmore, page 상태를 업데이트 하는 함수
const loadMoreProfiles = async () => {
    const newProfiles = await getProfiles({ page: page + 1, pageSize: 12 });

    if (newProfiles.length === 0) {
      setHasMore(false);
    } else {
      setList((prev) => [...prev, ...newProfiles]);
      setPage((prev) => prev + 1);
    }
  };

// 프로필 목록 조회
export const getProfiles = async (query: GetProfilesQuery = {}) => {
  const baseUrl = "/profiles";
  const page = query?.page || 1;
  const pageSize = query?.pageSize || 10;

  const queryString = `?page=${page}&pageSize=${pageSize}&name=${
    query?.name || ""
  }`;
  try {
    const res = await instance.get(`${baseUrl}${queryString}`);

    return res.data.list;
  } catch (err) {
    console.error("프로필 정보들을 불러오지 못했습니다.", err);
    return [];
  }
};

getProfiles를 호출하는 loadMoreProfiles 함수에서는 따로 try,catch 문이 없는 모습 + getProfiles의 return문이 에러가 생겼을 경우에는 빈 배열인 모습이다. 가능한 배열 안에 객체까지 통일시켜주면 좋을 것 같다.

3. 주 컨텐츠인 Editor UX 개선

Wikied의 핵심 컨텐츠는 사용자가 지인의 위키를 수정하는 Editor 부분이지 않을까 싶다.
우리는 몇 개의 Editor 라이브러리 중, 커스텀이 쉬운게 장점이라고 하는 React-Quill을 선택했다.

하지만 구현하다보니 여간 어려운 것이 한 두가지가 아니었고 ㅎㅎ 무엇보다 사용자가 썻을 때 만족감이 들만한 UX를 가진 에디터를 구현하기 위해서는 고쳐야할 것들이 너무 많이 보였다.

사용자가 만족하는 Editor를 만들기 위해서는 어떤 점들을 고려해야 할까??, 또한 이것들이 현재 Wikied에서는 잘 지켜지고 있을까??

4. XXS, CSRF 등 웹 보안 공격에 대비되어 있는가?

초보 프론트엔드 개발자가 신경써야 하는 보안 공격 중에는 XXS, CSRF 공격이 있다고 한다.

모두 브라우저에서 발생하는 클라이언트 측 공격이며, XXS는 공격자가 악성 스크립트를 웹 페이지에 삽입하여 사용자의 브라우저에서 실행되도록 하는 행위를 의미하며, CSRF는 사용자의 인증된 세션을 이용해 개발자가 의도하지 않은 요청을 서버에 보내는 행위를 의미한다.

Wikied 프로젝트는 사용자 정보가 담긴 로그인 기능도 있고, 사용자가 작성한 게시글을 HTML 형식으로 서버에 제출하는 기능도 있다.

이처럼 보안에 취약한 기능들이 많이 포함된 프로젝트이다. 그만큼 보안에 대한 이슈도 꼼꼼하게 점검해야 한다.

과연 현재 프로젝트에서는 이것들을 잘 고려해 코드가 작성되어 있을까?

🚪 마치며...

중급 프로젝트인 Wikied의 회고록을 마쳤다.
이번 프로젝트에서 크게 배울 수 있었던 부분은 아무래도 ""였던 것 같다.
다음 포스팅부터는 앞서 적었던 개선하고 싶은 부분에 대한 리팩토링 과정을 담을 예정이다.

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

0개의 댓글