치춘짱베리굿나이스

[프리온보딩] 220514 개인과제 #1 완성 및 배포 본문

프로젝트/원티드 프리온보딩

[프리온보딩] 220514 개인과제 #1 완성 및 배포

치춘 2022. 5. 15. 02:11

개인과제 #1 완성, 배포

공식적인 내생각

Suspense로 로딩창 구현해보겠다고 온갖 삽질을 하다가 오늘 드디어 해결했다

겸사겸사 localStorage 값이 뜬금없이 초기화되는 문제도 해결하고, 드래그앤 드롭도 구현했다

Suspense + Promise 로 로딩 구현하는 게 제일 힘들었다... 하나 잘 막으면 다른 하나에서 터지고

이게 다 비동기 때문이다 setState가 비동기라서 그렇다 진짜 상태값 바뀌는 타이밍을 모르겠어서...

 

배포도 마치긴 했는데 깃허브 페이지 배포는 언제 해도 헷갈린다

배포 후에 데이터 받아오는 함수가 동작을 안해서 환경변수가 제대로 적용이 안 된줄 알고 한참을 해멨는데 CRA는 컴파일할 때 env 파일 변수를 알아서 잘 컴파일해준다는 걸 뒤늦게 알았고 사실 https 이슈였다

지금은 404 에러 뜨는거랑 싸우고 있다... 이것만 해결해주고 잘라했는데 넘 졸리다 일찍 자야지

삽질의 기록들

Suspense로 로딩창 구현하기 + 상태값 초기화 타이밍

분명 Suspense와 Promise에 대해서 열심히 공부했으니 구현만 하면 금방이겠다 했는데 생각보다 꼬이는 부분이 많았다

우선 데이터를 받아오는 지점이 Suspense로 감싸진 컴포넌트가 렌더링되기 전에 위치해야 한다는 점 때문에 불가피하게 컴포넌트를 두 개로 쪼개야 했고, 그 과정에서 상태값의 업데이트 타이밍이 전부 꼬여서 + 무한스크롤을 위해 intersect 감지당하는 요소가 생각보다 빨리 렌더링돼서 스크롤을 내릴 때 로딩되어야 할 데이터가 자꾸 처음에 로딩되었다

결국 useEffect를 가지고 컴포넌트 업데이트를 위한 온갖 쇼를 벌인 끝에 해결했다...

export const MovieListContainer = (): JSX.Element => {
  const [searchValue, setSearchValue] = useRecoilState(searchValueState);

  const res = fetchWrappedMovieData(searchValue, 1);

  useUnmount(() => {
    setSearchValue('');
  });

  return (
    <div className={styles.movieContainer}>
      {searchValue === '' ? (
        <ContainerMessage isLoading={false} message='Movie not found!' />
      ) : (
        <Suspense fallback={<ContainerMessage isLoading />}>
          <MovieList resource={res} />
        </Suspense>
      )}
    </div>
  );
};

이 컴포넌트는 검색 화면 하단의 검색결과가 나오는 컴포넌트이고, searchValue는 검색 바 (input 태그) 로부터 받아오기 때문에 부모를 통해서 값을 넘겨주지 않고 Recoil을 통해 전역으로 관리하였다

이 컴포넌트가 화면에서 사라질 때 (/Favorite 페이지로 넘어갈 때) 검색 결과를 없애기 위해 Unmount 시에 searchValue를 빈 문자열로 만들어 주었다

 

jsx 요소로는 우선 div로 전체 컴포넌트를 감싸주고, 이 부분이 흰 배경이 된다

searchValue가 빈 문자열일 땐 굳이 검색을 수행할 필요가 없기 때문에 ‘검색 결과가 없습니다' 메시지를 띄워주는 컴포넌트를 반환하고, 그 외의 경우엔 검색 결과를 리스트로 출력하되 Suspense로 감싸주었다

Suspense 부분이 정말 힘들었다 그냥 컴포넌트를 Suspense로 감싸기만 하면 되는줄 알았던 것은 젊은 날의 과오였을 뿐이다

import axios, { AxiosPromise, AxiosResponse } from 'axios';

const wrapPromise = (promise: AxiosPromise) => {
  let status = 'pending';
  let result: AxiosResponse<any, any>;
  const suspender = promise.then(
    (response) => {
      status = 'success';
      result = response;
    },
    (error) => {
      status = 'error';
      result = error;
    }
  );
  const read = () => {
    if (status === 'pending') throw suspender;
    else if (status === 'error') throw result;
    else if (status === 'success') return result;
    return null;
  };

  return { read };
};

export const fetchWrappedMovieData = (searchValue: string, page: number) => {
  const promise = axios({
    method: 'GET',
    params: {
      apikey: process.env.REACT_APP_MOVIE_API_ID,
      s: searchValue,
      page,
    },
    url: process.env.REACT_APP_MOVIE_API_URL,
  });
  return wrapPromise(promise);
};

wrapPromise 함수로 감싸줘야 pending / success / error 값을 보고 Suspense가 적절한 컴포넌트를 반환해주거나 Error Boundary로 에러를 위임하는 것이다

axios로 받아온 데이터를 Suspense에 적합한 형태로 Wrapping한 건 좋은데, 문제는 추가 데이터를 받아올 땐 이러한 형태로 데이터를 감쌌다간 스크롤을 할 때마다 로딩 창이 나와버린다... 결국엔 영화 데이터를 받아오는 함수를 2개로 분할했다

하나는 wrapPromise 함수로 감싸 Suspense에 적합한 형태로 만든 것이고, 나머지 하나는 axiosthen / catch 만 수행하여 별도의 로딩 매커니즘을 적용하지 않았다

export const MovieList = ({ resource }: IMovieListData): JSX.Element => {
  const searchResult = resource.read();
  const totalResults = searchResult?.data.totalResults;
  const response = searchResult?.data.Response;
  const searchValue = useRecoilValue(searchValueState);
  const [movieArray, setMovieArray] = useState<IMovie[]>([]);
  const [isNextPage, setIsNextPage] = useState(true);
  const [pages, setPages] = useState(1);

  const getMoreMovie = async () => {
    if (totalResults && totalResults < pages * 10) {
      setIsNextPage(false);
      return;
    }
    const responseData = await fetchMovieData(searchValue, pages + 1);
    if (responseData.Response === 'True') {
      setMovieArray((prevState) => _.uniqBy(prevState.concat(responseData?.Search), 'imdbID'));
      setPages((prevState) => prevState + 1);
    }
  };

  useEffect(() => {
    const arrTemp = _.uniqBy(searchResult?.data.Search as IMovie[], 'imdbID');
    setMovieArray(arrTemp);
    setPages(1);
  }, [searchValue, searchResult]);

...

Suspense로 감싸진 컴포넌트의 일부분이다 (searchValueinput이 submit될 때마다 변한다)

searchResults가 아까 wrap한 Promise를 읽어오는 변수이고, 저 값이 없으면 어떠한 요소도 렌더링할 수 없기 때문에 아직 searchResults가 대기 상태일 때 Suspense가 로딩 컴포넌트로 해당 부분을 대체해준다 (fallback)

totalResults, response는 컴포넌트 내에서 지속적으로 변하는 값이 아니기 때문에 굳이 상태값을 사용하지 않았고, searchValue가 변경될 때마다 영화 배열을 초기화시키고 컴포넌트를 리렌더링해야 하기 때문에 값만 useRecoilValue 훅으로 받아왔다

 

무한스크롤을 통해 영화를 누적해서 받아올 것이기 때문에 IMovie 타입의 배열과 무한 렌더링 가능 여부를 알려주는 boolean 값, 무한 스크롤 시에 특정 페이지의 값을 받아오기 위한 number 값을 상태값으로 선언해주었다

검색 값이 바뀔 때마다 페이지는 새로고침되지 않고 컴포넌트만 리렌더링되기 때문에, pages를 그때그때 초기화해 주지 않으면 이전에 무한스크롤으로 증가시켰던 페이지 수가 남아있으므로 searchValue가 변할 때마다 페이지 번호도 초기화해 준다

무한스크롤을 구현할 때 교차 이벤트가 발생할 때마다 실행시킬 콜백 함수를 넘겨주어야 하는데, 그 함수를 getMoreMovie로 하여 movieArray에 값이 누적될 수 있도록 설정하였다

 

useEffect 부분이 가장 까다로웠는데, 도대체 어느 시점에서 어떤 값을 초기화해야 이상한 동작을 방지할 수 있을지 헷갈렸기 때문이다

가장 이상적인 건 searchValue (검색어) 가 바뀔 때마다 지금까지 값이 누적되던 배열 movieArray와 페이지 번호를 초기화해 주는 것이었다

searchValue가 바뀔 때마다 처음에는 빈 배열로 초기화했더니 아예 영화가 한 개도 안 받아지기도 하고, pages를 컴포넌트 마운트 시에 바꾸려고 하니 최초에 한번 바뀌고 끝끝내 안 바뀌고, 아예 부모 컴포넌트에서 pagesmovieArray 상태값을 초기화하여 자식으로 넘겨주는 방법을 시도해보니 pages가 중첩되어 증가하여 (???) 지금 받아오면 안 되는 영화들이 들어오거나 이전 영화들이 남아있기도 했다

결국 searchValuesearchResult의 결과로 배열을 초기화해주니 해결되어서 속이 시원하다

localStorage 값 받아오기 / 초기화 시점

두 번째는 로컬 스토리지에서 값을 받아오는 시점을 Favorite 페이지가 처음 렌더링될 때로 잡아두니 툭하면 로컬 스토리지 내부 값들이 초기화되는 현상이 일어났다

분명 검색 페이지에선 좋아요를 눌렀는데, Favorite 페이지만 진입하면 값들이 다 날아갔다

이유인즉슨 로컬 스토리지 내부에 전역 상태값인 favoriteData를 초기화하는 코드 때문이었다

로컬 스토리지에서 좋아요 누른 영화 목록을 불러와 favoriteData에 저장하고, favoriteData가 바뀔 때마다 로컬 스토리지에 새로 저장하는 방식을 채택하였는데, favorite 페이지가 렌더링될 때 favoriteData를 초기화하는 코드를 심었기 때문이었다 (....)

export const FetchFavoriteData = () => {
  const setFavoriteData = useSetRecoilState(favoriteDataState);

  useMount(() => {
    const storageData = store.get('storageData');
    if (!storageData) {
      store.set('storageData', INITIAL_FAVORITE);
      setFavoriteData(INITIAL_FAVORITE);
    } else setFavoriteData(storageData);
  });

  return null;
};

두 페이지를 오갈 때마다 데이터를 날릴 순 없었기 때문에, 내비게이션 바와 마찬가지로 최상위에 favoriteData랑 로컬 스토리지만 관리하는 컴포넌트를 따로 빼 주었다

이 컴포넌트가 마운트될 때마다 로컬 스토리지에서 값을 가져와 favoriteData를 초기화해 주고, 어떠한 jsx 태그도 렌더링하지 않는다

<BrowserRouter basename={process.env.PUBLIC_URL}>
    <FetchFavoriteData />
    <NavigationBar />
    <Routes>
        <Route path='/' element={<MainPage />} />
        <Route path='/favorites' element={<FavoritePage />} />
    </Routes>
</BrowserRouter>

라우팅 시에 Routes 위에 최상위 컴포넌트로 분리하면 하위 페이지가 바뀌어도 모습만 안 보인다 뿐이지 최상단에서 위치를 지키고 있기 때문에 새로고침을 할 때마다만 favoriteData를 로컬에서 불러오거나 초기화한다

보이지 않는 곳에서 묵묵히 일하는 컴포넌트 덕에 앱을 열었을 때나 새로고침을 할 때 로컬 스토리지에서 데이터를 잘 불러오는 앱이 되었다

오늘 정리한 내용

lodash

lodash

 

lodash

lodash 설치 $> npm i lodash $> yarn add lodash npm 링크 Contributors lodash 용례 이름이 lodash인 이유는 라이브러리를 사용할 때 언더바 ( _ = low dash) 를 쓰기 때문이다 객체, 배열 등의 자바스크립트 /..

blog.chichoon.com

 

Comments