[원티드] Pre-onboarding Frontend challenge_첫번째 과제

이번주 화요일 첫번째 세션이 있었고 그에 대한 과제가 있었다.

첫번째 과제는 지난 번 사전 과제의 3번 문제를 보강해서 블로그에 작성하는 과제였고,

두번째 과제는 [ React와 History API 사용하여 SPA Router 기능 구현하기 ] 였다.

 

첫번째 과제는 지난 블로그에 내용을 작성했기에, 이번 글에서는 두번째 과제에 대한 이야기를 해보겠다.

 


저보고 라이브러리를 만들라구요..?

 

CRA를 통해 구현한 React 프로젝트는 SPA기 때문에 Routing을 위한 라이브러리를 사용했었다.

가장 많이 사용하는게  react-router-dom  이라는 라이브러리인데, 이 라이브러리가 하는 기능을 구현하는 것이 과제였다.

 

- root 주소로 진입했을 때는 root 페이지가 렌더링 / about 주소로 진입했을 때는 about 페이지가 렌더링 되도록 구현
- 각 페이지에서 버튼을 클릭하면 다른 페이지로 이동하도록 구현
- 뒤로가기 버튼을 누르면 이전 페이지로 이동해야 하도록 구현
- push 기능을 가진 useRouter Hook을 작성
- Router, Route 컴포넌트는 아래의 구조 형태처럼 구현
ReactDOM.createRoot(container).render(
  <Router>
    <Route path="/" component={<Root />} />
    <Route path="/about" component={<About />} />
  </Router>
);

 

결국 react-router-dom 의 기능을 구현하는것이 이번 과제의 목적이었다.

페이지를 이동하는 useRouter는 아래 힌트를 참고해서 구현하면 된다고 한다.

window.onpopstate / window.location.pathname / History API(pushState)

 


초기 세팅 (Feat. Vite)

 

이번 과제의 초기세팅은 Vite를 통해 진행했다.

Vite의 특징과 장점에 대해서 짧게 알아보자면

- 프랑스어로 ‘빠르다’는 뜻을 가진 자바스크립트 빌드 툴
- 프로젝트 스캐폴딩 템플릿 지원하고, 설정이 매우 간단함(거의 불필요함)
- CRA에 비해 프로젝트에 담긴 의존성 규모가 작아서 인스톨 시간에 대한 부담이 없음
- HMR 및 빌드 속도가 매우 빠름

 

추천해 주셔서 진행해 봤는데, 진짜 CRA에 비해서 설치 속도가 미친듯이 빠르긴 했다..ㅎㅎ

물론 설치 후 npm install 을 해주긴 해야했는데, 그래도 그에 비하면 진짜 엄청난 속도로 초기 세팅이 진행 됐다 ㅎㅎ

여기에 추가 된 건, typescript와 sass를 사용했다.

 


내가 만든 react-router-dom~

 

이제 중요한건 어떻게 하면 Routing을 할 수 있는 라이브러리를 만들어내느냐다.

// main.tsx

ReactDOM.createRoot(container).render(
  <Router>
    <Route path="/" component={<Root />} />
    <Route path="/about" component={<About />} />
  </Router>
);

 

일단 돼야 하는건 main.tsx가 렌더링 됐을 때, 경로에 따라서 매칭 된 컴포넌트가 보여져야 한다는 것이다.

우선 지금 내가 만들어야 하는 것들에 대해서 나열해 본다.

Router 컴포넌트, Route 컴포넌트, useRouter Hook

Router 컴포넌트 / Route 컴포넌트 / useRouter Hook

 

이 세가지를 만들어서 페이지를 이동 할 수 있는 간단한 라이브러리를 만들어야 한다.

우선 Router 컴포넌트와 Route 컴포넌트를 반환하는 react-router 라이브러리를 만들었다.

interface RouteType {
  path: string;
  element: React.ReactNode;
}

interface Props {
  children: React.ReactElement<RouteType>[];
}

const Router = ({ children }: Props) => {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    setCurrentPath(window.location.pathname)
  }, [window.location.pathname]);

  return <>{children}</>
};

 

우선 Router 컴포넌트에서는 현재 경로를 알 수 있도록 state로 관리를 했고, 

경로의 변화를 감지해서 경로가 변경 될 때 마다 해당 Router 컴포넌트를 다시 그려줄 수 있도록 구현했고,

결과적으로 해당 컴포넌트에 반환값은 Router 컴포넌트 안에 들어가는 자식 요소가 반환이 되도록 해줬다.

const Route = ({ path, element }: RouteType) => {
  const currentPath = window.location.pathname;
  
  if (path !== currentPath) return null;
  return <>{element}</>;
};

 

Route 컴포넌트에서는 path, element를 props로 받아서 

현재 경로와 지정해둔 path가 일치하는 element만 반환하도록 구현해줬다.

const useRouter = () => {
  const push = (path: string) => {
    window.history.pushState({}, "", path);
  };

  return { push };
};

export default useRouter;

 

그리고 마지막으로 페이지를 이동할 수 있게 해주는 hook인 uesRouter를 위와 같이 구현했다.

해당 hook에서는 힌트로 알려준 History.pushState를 사용했는데, 사용 방법에 대해서는 아래 MDN 문서를 참고했다.

어쨋든 경로를 설정해 주고, 각 페이지 버튼에 각각의 경로를 입력해서 구현해준 뒤, 실제 동작을 진행해봤다.

 

 

분명 Root Page에서 페이지 이동 버튼을 눌렀을 때, 도메인이 /about 으로 변경되는 것을 확인했는데,

화면에 렌더링이 다시 일어나지 않았다....

 


내가 놓친 부분

 

1. window.history.pushState

 

내가 useRouter에서 사용한 window.history.pushState() 는

현재 페이지를 다시 로드하거나 이동하지 않고도 브라우저의 URL을 변경하는 메서드였다.

다시 말해, push 함수에서 인자로 받은 path를 통해 URL은 변경은 됐지만, 내가 원한대로 렌더링에 변화는 일어나지 않았었다.

 

2. window.addEventListener("popstate", ()=>{})

 

addEventListener를 사용하면서 가장 빈번하게 사용했던 것이 "click" 아니면 "input"이었는데,

이번 기회를 통해 "popstate"에 대해서 알아 볼 수 있게 되었다.

popstate 이벤트는 브라우저의 백 버튼이나 history.back() 호출 등을 통해서만 발생되는 이벤트라고 한다. (출처 : MDN)

const Router = ({ children }: Props) => {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handleSetPathName = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener("popstate", handleSetPathName);
    return () => {
      window.removeEventListener("popstate", handleSetPathName);
    };
  }, [window.location.pathname]);

  return <>{children}</>
};

 

해당 부분을 이용해서 Router 컴포넌트의 useEffect 부분에 popstate를 감지하면 해당 현재 URL state에 담아

화면이 리렌더링이 일어날 수 있도록 구현했다.

 

그러나 여전히 URL만 변경되고 화면은 변하지 않는 상황이 계속 발생했다.

 

3. window.dispatchEvent()

 

계속해서 해결책을 찾지 못하던 와중 어떤 분의 블로그를 보고 참고한 내용인데,

dispatchEvent는 EventTarget 객체로 Event를 발송해서, 해당 이벤트에 대해 등록된 이벤트를 순서대로 호출한다고 되어있는데

내가 이해한대로 풀이해 보면, 해당 메서드 안에 어떤 이벤트 객체를 전달하면 해당 이벤트가 등록된 순서대로

이벤트를 호출한다고 이해를 했다.

 

이게 무슨말인지 지금까지 작성된 코드로 설명해 보면 push 메서드를 사용할 때, 그 안에 이 디스패치 이벤트를 함께 실행 시키고

거기에 popstate를 이벤트를 호출하게 되면 자동적으로 Router 컴포넌트에 있는 popstate가 실행 되면서

Router 컴포넌트의 state를 변경시키게 되는 거라고 이해했다.

const useRouter = () => {
  const push = (path: string) => {
    window.history.pushState({}, "", path);
    window.dispatchEvent(new PopStateEvent("popstate"));
  };

  return { push };
};

 

push 메서드 안에 window.dispatchEvent 메서드를 호출하고 그 안에

popstate 이벤트 객체를 전달하면서 window에서 popstate 이벤트를 동기적으로 호출하도록 설정했다.

이제는 될거라고 생각하고 시도했지만 여전히 동일한 에러를 보여주고 있었다...

 

4. children..!

 

뭐가 문제인지 계속 못 찾고 있다가, 렌더링이 어떻게 이루어 지고 있는지 문득 궁금해서

Router 컴포넌트와 Route 컴포넌트 내부에 각각 console.log를 찍어봤다.

그런데?

 

URL이 변경 됐을 때, Router 컴포넌트에 있는 console은 찍혔는데, Route 컴포넌트에 있는 console은 나오지 않았다..!

내가 생각했을 때는 Router 컴포넌트가 부모기 때문에 자식인 Route 컴포넌트도 렌더링 돼서

조건에 따라 렌더링이 될거라 생각했는데, Router 컴포넌트에서 렌더링이 멈춰버렸다.

이유에 대해서 검색해 보았고, 가장 아래있는 글에서 그 해답을 찾을 수 있었다.

 

해당 글을 읽고 내 나름대로 해석을 해보자면, react에서 컴포넌트는 state나 props의 변화가 있을 때 리렌더링이 된다.

Router 컴포넌트에서 props로 받은 children은 첫 마운트 됐을 때 받아온 값이다.

이미 받아온 값이고, Router 컴포넌트의 state의 변화가 일어나서 리렌더링이 일어난다고 하더라도

props로 전달받은 값은 변하지 않기 때문에 이전 값과 다르지 않아 렌더링이 발생하지 않게 되는 것이다.

 

그렇다면 Route 컴포넌트는 제 기능을 할 수 없기에, Router 컴포넌트에서 리렌더링이 일어났을 때,

경로를 비교해서 조건부 렌더링을 해줘야 했다!

 

5. 해결!!!

const Router = ({ children }: Props) => {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handleSetPathName = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener("popstate", handleSetPathName);
    return () => {
      window.removeEventListener("popstate", handleSetPathName);
    };
  }, [window.location.pathname]);

  return children.filter((ele) => ele.props.path === currentPath);
};

 

children을 console.log로 보니까 배열 형태로 데이터를 확인할 수 있었다.

그래서 해당 배열을 현재 경로와 비교해 맞는 데이터만 반환해서 버튼을 누를 때 마다 페이지를 변하게 할 수 있었다.

 

 

결과적으로 페이지 이동도 잘 되고, 뒤로가기, 앞으로 가기 까지 잘 되는 Router를 만들어버리고 말았다..!

잘한건지는 모르겠지만 그래도 뿌듯한 순간이 아닐수 없다..!


레퍼런스

- History.pushState MDN [ 이동

- popstate MDN [ 이동 ]

- dispatchEvent() MDN [ 이동 ]

- children이 리렌더링 되지 않는 이유 [ 이동 ]