7월 원티드 프리온보딩을 듣게 되었다. next.js 에 관한 내용이었고, 회차를 거듭할 수록 이해하기 힘들었으나 모르더라도 들었다. 회차마다 주는 과제를 시간상 할 수 없었지만, 노션에 기록이 되어 있으니 뒤늦게라도 해본다.
즉! 이건 뒷북을 치면서 react-router를 만들어보는 게시물이다!
모르는 것은 다른 참여자 분의 코드를 살펴보며 차근차근 이해하는 것이 목표로 최대한 react-router를 파먹어 보겠다. 추후에는 react-router의 실제 코드를 까보며 어떤 점이 다르고 어떻게 예외처리를 하는 지 파먹기로 하자. let's go!
React와 History API 사용하여 SPA Router 기능 구현하기
- 해당 주소로 진입했을 때 아래 주소에 맞는 페이지가 렌더링 되어야 한다.
- 버튼을 클릭하면 해당 페이지로, 뒤로 가기 버튼을 눌렀을 때 이전 페이지로 이동해야 한다.
- Router, Route 컴포넌트를 구현해야 하며, 형태는 아래와 같아야 한다.
ReactDOM.createRoot(container).render( <Router> <Route path="/" component={<Root />} /> <Route path="/about" component={<About />} /> </Router> );
- 최소한의 push 기능을 가진 useRouter Hook을 작성한다.
react-router 를 사용한다고 하면 무엇이 떠오를까? 여러가지가 있을 수도 있지만, 나는 <Router>
, <Routes>
, <Route>
가 떠오른다. 구조는 순차적으로 Router 내부에 Routes 가 있고, 그 내부에 Route가 들어간다.
그리고 Route 에는 path
, component
가 props로 들어간다. 여기서의 path는 url 주소를 의미하고 component는 react 컴포넌트를 의미한다.
즉,
path='/' component={<Main />}
라면...
url 이/
경로일 때, Main 컴포넌트를 렌더링한다.
여기까지 생각해보면, Route를 구현할 수 있으니 바로 넘어가보겠다.
위에서 설명했듯이, path 는 url 경로, component는 리액트 컴포넌트이다. 이걸 props로 넘겨받으니 아래와 같은 구조로 만들어볼 수 있겠다.
interface RouteProps {
path:string;
component:JSX.Element
}
export default function({path, component}:RouteProps){
return ()
}
위와 같은 구조로 만들었다면, 다음으로 넘어가자. 우리가 브라우저 주소 창에 치는 url 과 Route로 들어오는 path가 맞는지 확인해 줄 것이다. 동일한 path 라면 component를 렌더링해준다.
*설명을 잊었는데, 나는 여기서 typescript를 사용한다.
❓그런데 여기서 의문이 든다. react router 를 사용할 때, 항상
<Router>
내부에<Route>
를 넣었었는데,<Route>
단독으로도 가능할까? 한 번 해보면 되지!?
// Route.tsx
interface RouteProps {
path:string;
component:JSX.Element
}
export default function({path, component}:RouteProps){
return <>{path === window.location.pathname ? component:null}</>
}
그런데 이렇게 <Route>
만 사용하게 되면, 다른 페이지로 이동하는 데에 문제가 생긴다. 이를테면, 새로고침 없이 화면을 렌더링할 수 없는 문제. 새로고침이 있다는 것은 프론트에 있는 데이터가 날아가는 데다가 사용자 경험도 좋지 않다. 그렇다고 새로고침을 막게 되면, 화면 렌더링이 일어나지 않아 곤란하다.
리액트에서 렌더링을 일으키는 트리거 중 하나가 state 변경이다. 정확히 말하면 state라기 보단 setState를 사용하면 렌더링이 다시 일어나게 된다. (함수형 기준)
그렇다면, state에 보관할만한 재료는 무엇이 있을까? 어떤 것이 바뀌고, 보관할 필요가 있을지에 대해 접근하면 path 라는 적절한 재료가 떠오른다. path가 바뀔 때마다 렌더링을 새로 해주면 되는 일이다.
그럼, <Route>
에서 path를 보관하고 바뀔 때마다 렌더링을 다시 해주면 되는 걸까? <Route>
는 props로 path와 component를 받는다. 여기에서 들어오는 props의 path는 지정한 url 인 pathname이다. 그리고 현재 url을 알기 위해 window.location.pathname
을 이용한다.
useEffect
를 사용해 path가 바뀐 것을 감지하고 변경되었다면 state를 다시 set해주는 게 맞는 걸까? 그렇게 한다면 무한 렌더링이 될 것 같다. 이건 좋지 않은 방법이다. 그렇다면 이걸 Router
에 위임한다면 어떨까? Router
는 path를 가지고 변경에 대한 감지만 한다. 변경되었다면 state를 변경하여 자식 컴포넌트를 재렌더링해주면 되는 일이다.
interface RouterProps {
children: React.ReactNode | null;
basename?: string;
}
export default function Router({ children }: RouterProps) {
const [path, setPath] = useState(window.location.pathname);
console.log(children);
const onChangePath = useCallback((path: string) => {
window.history.pushState("", "", path);
setPath(path);
}, []);
return (
<routerContext.Provider value={{ path, onChangePath }}>
{children}
</routerContext.Provider>
);
}
그렇다면 Router는 자식 컴포넌트를 어떻게 알 수 있을까? 모른다 자식 컴포넌트는 children으로 props를 통해 들어오기 때문에 어떤 컴포넌트가 들어오는 지 알 수 없다. 또 데이터를 전달해주고 싶어도 할 수 없다. 그럴 때, useContext를 사용할 수 있다.
우리가 사용하는 전역 상태 관리 툴인 리덕스나 리코일도 useContext를 사용해서 만든 것이니 대충 감은 올 것이다. 컴포넌트 사이 사이를 뛰어넘을 때 도와주는 훅이다.
우선, useState를 사용해 현재 path를 초기값으로 저장한다. []
에 들어가는 값이 변경되면 useCallback을 재실행을 하게 되는데, 내부에 값은 담지 않았다. 이렇게 하면 컴포넌트가 렌더링될 때마다 한 번씩만 실행하게 된다. 즉, 주소가 바뀔 때마다 한 번만 실행한다.
useCallback은 함수를 재활용하는 훅인데, 컴포넌트가 생성될 때마다 함수도 새롭게 생성된다. 이렇게 되면 비효율적으로 재생성되는 일이 생기니 useCallback을 통해 함수를 재생성하지 않고 사용할 수 있다. useCallback을 사용하지 않고도 써봤는데, 안해도 무방할 거 같으나 url이 움직이는 경우가 많으니 그럴 때마다 함수가 재생성되는 건 비효율적이라 사용해줬다.
그러나 useCallback(useMemo)을 굳이 사용할 필요는 없다는 견해도 있다.
여기까지 했다면, 무사히 라우터는 구현해낸 것 같다. 주소창에 /about
을 쳤을 때, 해당 페이지가 나오게 된다. 다만, 문제가 하나 있는데, 뒤로 가기를 눌렀을 때, 화면의 갱신이 일어나지 않는 점이다. 즉, 리렌더링이 일어나지 않는다는 것인데, url은 멀쩡히 변경되고 있다.
그렇다는 건, path state가 변경되지 않는다는 말이다. 이것을 다시 고쳐보겠다.
pushState를 하게 되면,
popstate
라는 이벤트가 발생하게 된다. 앞으로 가기, 뒤로 가기를 눌렀을 때 발생하는 이벤트인데, 이걸로 화면을 리렌더링 시켜줄 것이다.
popstate
이벤트가 발생했다는 것은 뒤로 혹은 앞으로 가기 버튼을 눌렀다는 게 된다. 그럴 때, setPath를 호출해 값을 변경해주면 화면이 갱신될 것이다. 이벤트를 잡아보자!
useEffect(() => {
const onPopState = (event: PopStateEvent) => {
setPath(event.state?.path ?? "/");
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
컴포넌트가 최초 실행될 때, 이벤트 등록을 해주면, 그 뒤로도 잘 감지한다. 그리고 컴포넌트가 언마운트 될 때 이벤트를 제거해줘야 성능 저하의 문제가 생기지 않는다.
이벤트를 삭제하지 않으면 계속 감지한다.
앞으로 가기, 뒤로 가기를 눌렀을 때, event를 받아와서 path를 넣어주는데 이때, pushState에 해줘야 할 것이 있다. event.state란 데이터를 넣을 수 있는 곳인데, pushState를 할 때, 첫 인자에 값을 넣을 수 있다. 그러면 popstate 이벤트가 발생했을 때, 데이터를 받을 수 있고, 그 데이터는 pushState의 첫 인자로 넣은 값이 오게 된다.
window.history.pushState({ path }, "", path);
따라서 pushState의 첫 인자에 path를 넣어 저장해주자. 첫 인자에 넣지 않으면 event.state는 빈값으로 나온다. 만약, state.path 값이 없다면, 사이트에 처음 들어와 메인 화면만 본 상태라는 의미이니 대체주소로 /
메인홈 주소를 넣어준다.
이렇게 코드를 짜준다면, 뒤로 가기 앞으로 가기를 눌러도 화면이 갱신되는 걸 확인할 수 있다.
다음은 버튼을 눌렀을 때, 해당 페이지로 가게 만들어보자. 정말 단순하게 a 태그만 사용하면 할 수 있지만 그렇게 하면 SPA라고 할 수 없으니, react에서 사용하는 Link 태그를 만들어보겠다.
<Link to='/'>Home</Link>
Link 태그를 사용하면, a 태그의 href 처럼 이동할 페이지의 url 과 텍스트를 적는다. 그럼, 적어도 Link 컴포넌트는 to 라는 props를 필수로 받아야 한다는 소리이다. 또한 SPA 답게 새로고침을 막아야 한다는 제한이 있으니 주의하자.
interface ILinkProps {
text: string;
to: string;
}
export default function Link({ text, to }: ILinkProps) {
const { onChangePath } = useContext(routerContext);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
onChangePath(to);
};
return <a onClick={handleClick}>{text}</a>;
}
나는 위와 같이 구현해주었다. 우선, a 태그를 이용하여 버튼을 만들어주고, 클릭 이벤트가 발생했을 때, 새로고침을 막아둔다. 그 뒤는 routerContext에 저장해둔 changePath에 to prop 을 넣어주면 자동으로 화면이 갱신되며 이동되는 걸 볼 수 있다.
Home
이라는 버튼은 단순한 a 태그이다. 그래서 버튼을 눌렀을 때, 새로고침이 작용돼 console 창이 날아가는 걸 확인할 수 있다.
이렇게 하면, 제시한 요구사항은 대체적으로 구현한 것 같다. Link 태그도 hook 에 속하는 지는 잘 모르겠으나, hook을 만든다고 해도 Link 태그와 유사하게 만들면 될 거 같다.
Router, Route의 컴포넌트는 아래와 같이 구현했다.
<Router>
<Route path="/" components={<App />}></Route>
<Route path="/about" components={<About />}></Route>
</Router>
과제를 하기 전에는 어려울 거라고 생각을 했는데, 막상 하면서 보니 생각보다 어렵지 않았던 거 같다. 물론, 하기 전에는 막막해서 다른 분들의 코드를 보며 참고하고 왜 이런 코드를 사용하셨을까 고민하며 블로그를 적었다.
꽤 공부가 된 것 같다!
...
이게 정말 끝인 걸까? 그렇다면, react-router는 왜 Router
와 Route
사이에 Routes
를 컴포넌트를 넣어 사용하는 걸까? 단순히 동작은 Router
와 Route
만으로 가능하다면 Routes
가 하는 역할은 무엇일까.
이거에 대한 해답은 강의를 들으며 알 수 있었다.
react 개발 툴을 사용하여 components를 보면, 두 개의 Route가 렌더링되고 있음을 알 수 있다. 지금 있는 페이지는 App 컴포넌트를 렌더링하는 홈 /
에 있다. 따라서 /About
페이지의 About 컴포넌트를 렌더링하고 있지는 않으나 Route는 분명하게 렌더링되고 있음을 확인할 수 있다.
지금 당장은 Route가 두 개밖에 없어 큰 문제가 되지 않지만, 추후 Route가 100개가 되고 200개가 된다면, 성능 저하의 원인이 될 수도 있을 것이다.
결국,
Routes
가 하는 일은 현재 필요한 단 하나의 Route를 렌더링하는 것이다.
Router
와 마찬가지로 children
을 받을 것이다. Route
가 여러 개일 수도, 하나일 수도, 아니면 아예 없을 수도 있다. 여러 개일 때는 배열로 들어올테니 기준점을 배열로 지정하자. 하나가 들어올 때도, 배열로 변환하여 사용하면 편리할 것이다.
interface RoutesProps {
children: React.ReactNode | null;
}
export default function Routes({ children = null }: RoutesProps) {
const { path } = useContext(routerContext);
if(!children) return;
// Route가 없다면, 그대로 return 하여 종료
const childArray = Array.isArray(children) ? children : [children];
const currentRenderComponent = () => {
const current = childArray.find((el) => el.props.path === path);
if (!current) return <ErrorPage />;
return current;
};
return currentRenderComponent();
}
Route가 없을 때, return 시키며 예외 처리를 하면 좋겠다. 지금은 공부하는 단계이니 따로 다른 처리를 하지는 않았다. Route를 사용하여 컴포넌트를 렌더링하세요. 라는 문구를 출력해도 좋을 거 같다!
childArray
를 사용하여 들어온 children
이 배열인지 확인하고, 배열이라면 그대로 할당한다. 아니라면 배열 안에 넣어준다.
(배열이라면 여러 개의 Route를 가지고 있다고 가정함.)
그리고 현재 페이지의 path와 똑같은 path를 가지고 있는 컴포넌트가 렌더링되어야 할 컴포넌트가 된다. find
를 사용하면, 제일 처음 조건에 맞는 녀석을 return 해주니 사용하였다.
context 에 들어와있는 현재 path와 컴포넌트에 들어있는 path를 비교하여 current
에 할당한다.
만약, current
가 없다면, 맞는 path를 가진 컴포넌트가 없다는 의미가 되므로 return 시켜 예외 처리를 해주면 된다.
나 같은 경우에는 에러 페이지를 보여주게 했는데, 다른 경고 문구를 써도 괜찮을 거 같다.
(맞는 경로가 없을 때, 에러 페이지를 보여줄 수 있을까? 에 대한 공부 목적으로 넣어봤다.)
react 개발 툴에서 하나의 Route
만 렌더링하는 Routes
를 볼 수 있다!
막연히, 과제에는 Router와 Route를 구현하세요. 라고만 되어 있어서 그렇게 생각했는데, 이런 함정이 숨어있을 줄은 몰랐다. 다시 생각해보면 Routes가 있는데...
뒤늦게라도 과제 구현을 해서 다행이다. 공부가 되었고, 완벽히는 아니지만 react-router가 어떻게 돌아가는 지는 알 수 있을 것 같다.
최대한 배운 것을 자세히 풀어썼는데, 다음에 까먹게 된다면 또 와서 공부할 수 있을 것 같다. 더불어 나처럼 모르는 사람에게도 도움이 되었으면 하면서 글을 마친다.
끝!
react-router... 정말 이런 생각 어떻게 하는 거지? 대단하다...
많은 도움이 되었습니다, 감사합니다.