본문 바로가기
Recap/bookshelf

bookshelf 프로젝트 회고

by yerin.dev 2024. 3. 9.

 

bookshelf 프로젝트

bookshelf 메인 페이지
책 검색 페이지
책 내 책장에 추가하기
해시태그 등록하기
해시태그별 책 보기
추가/제목/작가/별점 순으로 책장 보기

 

 

링크들

배포 링크: https://bookshelf-bay.vercel.app 

깃헙 레포지토리: https://github.com/yerinra/bookshelf

 

 

개요

 

나는 책 읽는 것을 좋아한다. 예전에는 무조건 종이책이 최고라고 생각했지만, 최근에는 부동산의 압박 때문에 전자책도 많이 읽는 편이다. 종이책과 전자책을 모두 읽게 되면서, 효율적인 독서기록의 필요성을 항상 느끼고 있었다. 기존의 온라인 서점들에는 책장 관리 기능이 있지만, 대부분 내가 구매한 전자책만을 관리할 수 있어서 제약이 있었다. 그렇다고 엑셀이나 수기로 관리할 수도 없고...


그래서 이 책장 앱을 기획하게 되었다. 해시태그 형식으로 책을 정리하면 좀더 읽은 책을 쉽게 파악할 수 있을 것이라는 생각에 해시태그 기능은 꼭 넣기로 했다. 그리고 별점 기능도 추가하여 내가 읽은 책에 대한 평가를 기록하고 나중에 다시 찾아볼 수 있게 하기로 결정했다.
이렇게 하여 새로운 프로젝트를 시작하게 되었다.

 

 

기능

  1. 책을 검색할 수 있다.
  2. 책을 책장에 추가할 수 있다.
  3. 책에 해시태그를 추가할 수 있다.
  4. 해시태그를 클릭하여 해당하는 책만 모아볼 수 있다.
  5. 책에 별점을 줄 수 있다.
  6. 책장을 여러 방식으로 정렬하여 볼 수 있다. (작가 순, 제목 순, 별점 순)

 

 

사용한 언어/라이브러리

사용한 기술 스택은 다음과 같다.

  • React
  • TypeScript
  • react-router-dom
  • axios
  • vite
    TypeScript를 사용한 프로젝트는 처음이었는데 확실히 프로젝트 구조가 조금 더 잡히는 듯한 느낌을 받았다. 자바스크립트만을 사용했을 때는 얼레벌레 어떻게든 돌아가는 느낌이었는데.. 아무튼 미리 타입을 선언 해 놓으면 vscode에서 자동완성으로 딱 뜨니까 에러가 발생할 확률도, 개발하는 내가 헤맬 일도 확실히 적어지는 것 같다. 이번 프로젝트의 목표는 타입스크립트에 익숙해지기였는데 확실히 성공한 듯하다. 타입 에러 빨간 줄이 뜨면 그것을 해결해나가면서 또 많이 배우기도 했다. 결론적으로 type safe한 프로젝트를 완성하였다.
  • styling
    • tailwindcss
      스타일링에 큰 시간을 들이고 싶지는 않았기 때문에 tailwind를 이용했다. 여러 파일을 왔다갔다 하지 않아도 되어서 좋고 유틸리티 클래스를 사용하니 타이핑할 것도 적어서 확실히 개발 속도가 빨라진다. 그리고 따로 클래스 이름을 짓지 않아도 되어서 좋고, 반응형 만들 때도 미디어 쿼리를 사용하지 않아도 되니 굉장히 간편하다는 장점이 있다.
      다만, 유틸리티 클래스들이 두줄 세줄로 길어지면 정신이 없기는 마찬가지다. 그리고 상태값에 따라 스타일링이 달라져야 할 때는 따로 clsx나 twMerge(혹은 둘 다)를 사용해야 한다는 것도 조금 귀찮은 점이긴 하다.
    • @radix-ui/icons
      이전에는 계속 react-icons만 썼었는데 다른 라이브러리도 써보고 싶어서 요즘 인기 많은 radix-ui/icons를 써봤다. 크게 다를 건 없고, 선택의 폭이 좁은 대신 훨씬 디자인이 깔끔한 느낌이다.
  • db / auth
    • firebase
      회원가입/로그인(+소셜로그인) 기능을 위해 firebase/auth를 이용했고, db는 firestore를 사용했다.
  • 상태관리
    • @tanstack/react-query
      API에서 비동기적으로 받아온 데이터의 상태 관리를 위해서 리액트 쿼리를 사용했다. 캐싱이나 로딩, 에러 상태 관리에도 큰 도움을 주었고 무한 스크롤 구현할 때 useInfiniteQuery를 사용하였다.
    • recoil
      atom을 기반으로 전역 상태 관리를 하는 라이브러리로 간편하고 유연하다는 장점이 있다. 기존에 useState 등의 훅을 사용하던 것처럼 사용하면 되어서 아주 편리했는데 더이상 업데이트 되고 있지 않은 것 같다. 기존에 이 프로젝트를 맡았던 분이 laid off 되었다나 뭐라나. 정확히는 알 수 없지만 아무래도 다음 프로젝트에는 zustand를 도입해봐야겠다.
  • 기타
    • aladin API
      • 책 데이터는 알라딘의 OpenAPI를 사용해서 받아왔다.
    • react-select
      그냥 select 태그를 이용해도 좋지만 조금 더 스타일링을 쉽게 해보고자 라이브러리를 사용했다.
    • react-hook-form
    • zod
      리액트 훅 폼만 사용해도 훅의 상태를 쉽게 관리할 수 있지만, 조금 더 나가서 zod와 함께 사용해보기로 했다. zod는 스키마 검증 라이브러리로, 미리 스키마를 정의하여 react-hook-form에 resolver를 연결하여 사용할 수 있었다.
    • sonner
      react-hot-toast를 사용할까 생각하기도 했는데 sonner의 디자인이 조금 더 미니멀한 점이 bookshelf의 디자인과 맞을 것 같아서 선택했다.
    • react-helmet-async
      SEO를 개선하기 위해 사용한 라이브러리
    • clsx
    • twMerge
      상태값에 따라 스타일링을 다르게 하기 위해서 clsx와 twMerge를 사용했다.
    • vercel

 

 

개발

기본적인 기능 구현

1일차 : 프로젝트 기획/시작
2일차 : 회원가입, 로그인
3일차 : 책 검색 - 무한 스크롤 1
4일차 : 책 검색 - 무한 스크롤 2
vite에서 CORS 에러 해결하기
Intersection Observer API로 무한 스크롤 구현
5일차 : 책 추가 기능
6일차 : 해시태그 기능
7일차 : skeleton UI
8일차 : 다크모드, 프로젝트 완성

 

 

vite를 이용해서 프로젝트를 시작했고, react-router-dom을 활용하여 경로들을 설정해줬다. 처음에는 루트들을 App.tsx에 다 넣어줬는데 생각보다 App 내에는 다른 Provider들도 들어가야 하기 때문에 너무 많은 루트들로 파일이 정신없어 보이는 걸 막고자 따로 <RotesPage> 컴포넌트를 만들어 분리해줬다.

 

로그인, 회원가입 페이지는 따로 빼주고 나머지 페이지들은 <BasicLayout>안에 들어가서 공통적인 컴포넌트(navBar, footer 등)이 적용되도록 했다.

<BasicLayout> 컴포넌트 안에 <Outlet>을 만들어서 경로에 따라 적절한 컴포넌트들이 보여지도록 했다.
책장 페이지는 로그인하지 않은 유저는 접속할 수 없도록 <ProtectedRoute>를 만들어 접근을 막았다.

 

 

// ProtectedRoute.tsx
useEffect(() => {
  if (user === null) {
    navigate("/", { replace: true });
  }
}, [navigate, user]);

 

로그인/회원가입은 구글의 firebase를 이용했다. 성공할 경우 sonner의 toast로 환영 메세지가 뜬다.
실패할 경우 에러 코드에 따라 다른 에러 메세지가 토스트로 나타나도록 했다.

 

회원가입 폼을 처음에는 그냥 일반적인 html form(required, minLength 등의 속성 이용)을 이용했었는데 리팩토링하는 과정에서 react-hook-form과 zod로 업그레이드 해주게 됐다. 기존의 form은 controlled 컴포넌트로 일일이 이메일과 비밀번호의 상태를 저장하고 있어야 했는데 리액트 훅 폼을 쓰면서 register만으로 validation을 쉽게 할 수 있게 되었다.
리액트 훅폼 만으로도 충분했지만, 더 단단한 validation을 하고 싶어서 zod도 사용했다. zod로 정의한 스키마를 useForm에 resolver로 연결해주면 된다.

 

 

useForm<TSignUpSchema>({ resolver: zodResolver(signUpSchema) });

 

관련 글 기존의 폼 요소를 react-hook-form과 zod로 리팩토링

 

 

그 다음은 책 검색 기능이다. 우선 알라딘 API를 이용해서 책 정보를 받아오기로 했는데 여기서부터 난관이 시작되었다. CORS 에러를 만나게 된 것이다. proxy를 사용해서 해결하면 된다고 해서 열심히 package.json도 수정하고 다른 방법도 써봤는데 안 되는 것이다. 이때 정말 하루종일 붙잡고 있었는데, 결론은 vite와 webpack이 다르기 때문에 다른 방법을 적용해야 했던 것이었다. vite 환경에서는 vite.config.js에 proxy를 설정해주면 된다.

 

 

export default defineConfig({
  server: {
    proxy: {
      "/keyword": {
        target: "http://www.aladin.co.kr/ttb/api/ItemSearch.aspx",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/keyword/, ""),
        secure: false,
        ws: true,
      },
      "/isbn": {
        target: "http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/isbn/, ""),
        secure: false,
        ws: true,
      },
    },
  },
});

 

 

검색결과는 /keyword, 하나의 책을 클릭했을 때는 /isbn으로 요청을 보내게 된다.

하나의 책을 검색하는 useBook이라는 훅을 만들었다. @tanstack/react-query를 사용했는데 이때 useQuery에 select를 넣으면 받을 데이터를 그냥 받는 게 아니라 데이터의 일부를 select하여 받을 수 있다는 것을 알게 되었다.

 

 

// useBook.ts

const useBook = (isbn13: string = "") => {
  const { data, isLoading, isError } = useQuery({
    queryKey: ["book", isbn13],
    queryFn: () => fetchBook(isbn13),
    select: (data) => data?.data.item,
  });
  return { data, isLoading, isError };
};

export default useBook;

 

 

리액트 쿼리를 사용하겠다고 결정은 했지만 리액트 쿼리가 뭐하는 라이브러리인지 제대로 이해를 못하고 있었던 것 같다. 리액트 쿼리도 결국 상태관리 라이브러리다. 난 그걸 놓치고 data를 또 state로 관리하려고 하는 희대의 바보같은 실수를 저질렀다. 에러가 나지 않았다면 끝까지 몰랐을 수도..어쨌든 에러 덕분에 리액트 쿼리가 어떤 역할을 하는 라이브러리인지에 대해서 제대로 배우게 됐다.

 

useBooks 같은 경우, 책을 검색한 결과를 받을 수 있는 훅인데 이때 무한 스크롤을 적용해서 데이터를 보여주기로 해서 @tanstack/react-query의 useInfiniteQuery를 사용했다.

 

 

// useBooks.ts

useInfiniteQuery({
  queryKey: ["books", keyword],
  queryFn: ({ pageParam }) => fetchBooksData(keyword, pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) => {
    const nextPage = allPages.length + 1;
    return lastPage?.data.length === 0 ? undefined : nextPage;
  },
  select: (data) => ({
    pages: data?.pages.flatMap((page) => page?.data),
    pageParams: data.pageParams,
  }),
});

 

 

useQuery와 다른 점이 있다면, initialPageParam을 설정해줘야 하고, getNextPageParam도 설정해줘야 한다는 점이다.

그리고 무한스크롤! 검색 결과를 보여줄 때, viewPort 안에 bookCard 가장 밑의 <div>를 만나게 되면 새롭게 데이터를 불러온다. useRef()를 사용해서 <div>에 ref를 설정했다. 다음페이지가 있고(hasNextpage), viewport 안에 들어온 상태라면 fetchNextPage()를 하도록 했다.

 

페이지가 로드될 때 적절한 ui를 보여주기 위해 skeleton ui 도 적용했다.

이때 책을 내 책장에 추가하면 collection(db, "users", currentUser, "books")에 책이 추가되도록 구성했다. firestore docs가 불친절하게 쓰여있어서 내용을 이해하는 데에 시간이 많이 걸렸던 것 같다.

 

책 디테일 페이지에서도 비슷한 로직이 이어진다. 책 소개를 확인할 수 있고, 책을 책장에 추가할 수 있다.

 

책장 페이지는 이 프로젝트의 가장 메인이라고 할 수 있는 페이지다. 우선 collection(db, "users", currentUser, "books")에서 해당하는 유저의 책장 데이터를 받아와서 전역으로 관리한다. Recoil을 사용해서 책들의 상태를 관리했다.
Recoil도 처음 써보는 것이었는데, useState 사용하듯이 사용하면 되어서 금방 적응할 수 있었다.

 

이때 책의 순서를 바꿀 수 있고, 해시태그를 선택하면 선택한 해시태그에 맞는 책만 보여줘야 한다. 처음에는 무작정 state를 만들었다. 기본적인 bookList가 있고, sortBy가 바뀌면 sortedBookList가 바뀌고 selectedTag가 있으면 또 다른 책들 state가 변경되는 식이었다. 지금 생각하니 이게 더 복잡한 것 같은데 😅.. 리팩토링하는 과정에서 이게 불필요한 상태들이라는 걸 깨닫게 됐다. 사실상 필요한 것은 원본 bookList 하나다. 그리고 sortBy나 selectedTag가 바뀔 때 따로 상태를 만들어 줄 필요 없이 derived state로 bookList를 filter하거나 sort 해서 사용하면 된다.

 

그래서 보여지는 책들은 sortedAndTaggedBooks(bookList를 sort하고 filter한 변수)를 map한 결과가 된다. 이때 컴포넌트가 렌더링될 때마다 filter 연산이 수행되므로 불필요한 연산을 막기 위해 useMemo를 이용해서 최적화했다.

 

관련 글: 2. 중복되는 코드 리팩토링하기(derived state, dumb component)

 

 

 

const sortedAndTaggedBooks = useMemo(
  () =>
    sortedBooks.filter((book) => book.hashtags?.includes(selectedTag!))
      .length == 0
      ? sortedBooks
      : sortedBooks.filter((book) => book.hashtags?.includes(selectedTag!)),
  [selectedTag, sortedBooks]
);

 

 

책을 정렬할 때 react-select 라이브러리를 사용했다. onChange 내에 선택된 옵션이 바뀔 때마다 무엇을 할 것인지를 넘겨주면 되어서 매우 간단했다. 스타일링은 생각보다 간단하지 않아서 조금 헤맸다. index.css에서 각각의 클래스 네임에 따라 스타일링 해줬다. 혹시라도 이 글을 react-select 스타일링으로 검색해서 보고 계신 분이 있으시다면 제 github를 참고하시면 될 것 같아요!

 

 

// BookShelfPage.tsx

<Select
  options={OPTIONS}
  onChange={(option) => {
    if (option) setSortBy(option.value as SortOptions);
  }}
  className="my-react-select-container"
  classNamePrefix="my-react-select"
/>;

 

그리고 모든 컴포넌트들은 아토믹 디자인 패턴을 적용해서 atoms, molecules, organisms, templates 등의 디렉토리에 각각 분류해서 넣어줬다.

 

 

리팩토링
1. 기존의 폼 요소를 react-hook-form과 zod로 리팩토링
2. 중복되는 코드 리팩토링하기(derived state, dumb component)
3. 아토믹 디자인 패턴 적용기
4. useMemo()로 불필요한 연산 줄이기

 

빌드/배포 후
1. 빌드 후 번들 사이즈 줄이기
2. vercel 배포 후 proxy 설정
3. lighthouse 점수 올리기(접근성+SEO 개선)

 

 

이렇게 모든 기능 구현을 마치고 마침내 빌드!
가장 처음에 빌드 했을 때 번들 사이즈가 1029 MB로 매우 컸다. 그래서 코드 스플리팅으로 번들 사이즈를 줄여주고, 이미지 프리로딩을 통해서 이미지가 빠르게 로드될 수 있도록 했다.

관련 글: 1. 빌드 후 번들 사이즈 줄이기

 

 

vercel로 배포 후 모든 게 순조로울 줄 알았는데, 다시 한 번 프록시를 설정해줘야 한다는 점을 잊고 있었다. dev 환경에서와 배포 후 production 에서의 환경은 별개라는 걸 다시금 깨닫고, CORS 에러를 다시 해결해야 했다. 프로젝트 루트에 vercel.json을 만들어서 설정을 해줬다.

관련 글: 2. vercel 배포 후 proxy 설정

 

 

이렇게 한 뒤 lighthouse를 돌려보았는데 접근성과 SEO에서 낮은 점수가 나왔다. 접근성을 위해서는 <img>의 alt 속성을 열심히 넣어줬다.

 

SEO를 위해서는 우선 robots.txt를 프로젝트의 root directory에 만들어주었다.

 

 

// robots.txt

User-agent: *
Disallow:

 

 

 

그리고 react-helmet-async를 이용해서 경로가 바뀔 때마다 헤드 내의 메타 태그들이 바뀔 수 있도록 설정해 주었다.

관련 글: 3. lighthouse 점수 올리기(접근성+SEO 개선)

 

 

느낀 점

이렇게 첫 번째 개인 프로젝트를 마무리했다..! 👏

 

프로젝트 기획부터 기능 구현, 배포 그리고 리팩토링과 버그 수정들까지. 무엇 하나 한 번에 쉽게 된 게 없는 것 같다. 하지만 또 그만큼 많이 배웠다. 이번 프로젝트의 목표는 1. 타입스크립트와 친해지는 것, 그리고 2. 배포까지 해보는 것이었는데 성공적으로 목표를 달성한 것 같아서 좋았다.

 

시작하기 전 예상으로는 기본적인 기능 구현에 시간이 가장 많이 걸릴 거라고 생각했는데 구현 자체는 오히려 금방 끝났고, 코드를 리팩토링하고 배포하는 데에 시간을 좀 더 많이 쏟게 됐다.

 

아쉬운 점이 있다면, 기획에 많은 시간을 쏟지 못했다보니 초기 기획과 개발 도중 달라지는 부분이 많이 생겼다는 것이다. 프로젝트 중간에 갑자기 아토믹 디자인 패턴을 적용해야겠다고 생각이 들어서 컴포넌트들을 다 분류해줬는데 생각해보면 이게 꼭 필요한 일이었을까 생각이 든다. 오히려 어느 컴포넌트를 어디로 분류해야 할지에 대해서 고민만 늘어갔던 작업이었다. 충분히 고민하지 않은 부분을 프로젝트에 섣불리 적용했다가 개발하는 내내 헤맸다. 다음에 프로젝트를 한다면 페이지를 기준으로 컴포넌트들을 분류하는 방식이 더 좋을 것 같다.