개요
서버사이드 렌더링(SSR)은 그 구조상 비교적 이해하기 쉽지만, SPA(Single Page Application)에서는 코드만 봐서는 내부에 대해서 이해할 수 없었습니다.
SSR : 특정 경로 요청 -> 서버에서 해당 경로에 대한 HTML 생성 및 응답 -> Client에서 응답받은 그대로 render
SPA : 특정 경로 요청 -> JavaScript 파일 전달 -> Client에서 현재 path에 맞게 Render
React 진영에서 국룰처럼 사용되는 react-router-dom을 사용하면서도, 여태까지 그 동작 원리에 대해 깊이 생각해본 적은 없었던 것 같습니다. 이 포스팅을 통해서 SPA에서 Routing 처리를 어떻게 하는지에 대해서 알아보도록 하겠습니다.
기본 개념
기본적으로 알고 있어야 할 것들은 History API와 Location API 입니다.
History API
History API는 브라우저의 세션 기록을 관리하고 조작할 수 있는 API
https://developer.mozilla.org/ko/docs/Web/API/History_API
History API - Web API | MDN
History API는 history 전역 객체를 통해 브라우저 세션 히스토리(웹 익스텐션 히스토리와 혼동해서는 안 됩니다.)에 대한 접근을 제공합니다. 사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택
developer.mozilla.org
- history.pushState: 브라우저의 기록 스택에 새로운 상태를 추가합니다. 페이지를 새로고침하지 않고도 URL을 변경할 수 있습니다.
- history.replaceState: 현재의 상태를 새로운 상태로 대체합니다. 기록 스택을 업데이트하지만 새로운 기록을 추가하지 않습니다.
- popstateEvent: 브라우저 히스토리 변화 감지
Location API
Location API는 브라우저의 현재 URL에 대한 정보를 제공합니다. 현재 페이지의 경로, 쿼리 문자열, 해시 등을 가져올 수 있습니다.
https://developer.mozilla.org/ko/docs/Web/API/Location
Location - Web API | MDN
Location 인터페이스는 객체가 연결된 장소(URL)를 표현합니다. Location 인터페이스에 변경을 가하면 연결된 객체에도 반영되는데, Document와 Window 인터페이스가 이런 Location을 가지고 있습니다. 각각
developer.mozilla.org
예를 들어서 현재 경로가 https://example.com/page?id=123#section1 가정해봅시다.
- location.pathname: 현재 URL의 경로를 나타냅니다.(/page)
- location.search: URL의 쿼리 문자열을 반환합니다.(?id=123)
- location.hash: URL의 해시 부분을 반환합니다.(#section1)
이 포스팅을 이해하는데 이 정도면 충분하지만, 정말 중요한 API들이니 추가적인 설명은 MDN 및 다른분들의 포스팅을 보면 좋을 것 같습니다.
설계
위에서 설명한 두가지 핵심 API를 알았으니, 뭔가 번뜩 떠오르는 게 있을 겁니다.
- History API => path 변경 및 변경 감지
- Location API => 현재 path 가져오기
두가지 API의 역할을 토대로 아래와 같이 routing 기능을 설계해 구현해보도록 하겠습니다.
- 경로 변경
HIstory API - history.pushState(), history.replaceState() - 경로 변경 감지
HIstory API - popstateEvent - 현재 경로 가져오기
Location API - location.pathname() - Path 매칭
현재 path와 정의된 path 패턴을 매칭하여, 사용자가 보고 있는 페이지에 맞는 컴포넌트를 Render
구현
아래의 코드를 이해하기 위해서 먼저 선행지식으로 위의 내용과 React, React의 ContextAPI 필요합니다.
(아래의 예제 코드들을 제 Github Repository에 있습니다. 직접 Repository에 들어가서 보시는 것을 추천합니다.)
코드의 구조는 아래와 같습니다.
(모든 코드를 보여주는 것은 불필요한 것 같아, 주요 코드들만 이 포스팅에 추가하도록 하겠습니다.)
gumbor-router-dom/
│
├── components/
│ ├── index.tsx
│ ├── Link.tsx # Link 컴포넌트
│ ├── Route.tsx # Route 컴포넌트
│ └── Router.tsx # Router (Switch와 유사한 역할) 컴포넌트
│
├── hooks/
│ ├── index.tsx `
│ ├── useLocation.tsx # 현재 URL 경로를 반환하는 훅
│ ├── useParams.tsx # URL 경로 파라미터를 반환하는 훅
│ ├── useQueryString.tsx # 쿼리 스트링을 파싱하여 객체로 반환하는 훅
│
├── utils/
│ ├── index.ts
│ └── matchPath.ts # 경로 매칭 및 파라미터 추출을 위한 함수
│
├── RouterContext.ts # 라우팅 상태를 관리하는 컨텍스트
├── RouterProvider.tsx # RouterContext의 상태를 제공하는 프로바이더
├── useRouter.tsx # 라우팅 상태를 관리하는 훅
└── index.tsx # 애플리케이션 진입점
RouterContext.ts
import { createContext } from 'react';
// currentPath는 현재 브라우저의 경로를 나타냅니다.
// params는 현재 경로에서 추출된 파라미터를 담고 있는 객체입니다.
// setParams는 params 상태를 업데이트하는 함수입니다.
// navigate는 주어진 경로로 브라우저의 URL을 변경하는 함수입니다.
interface RouterContextProps {
currentPath: string;
params: { [key: string]: string };
setParams: (params: { [key: string]: string }) => void;
navigate: (path: string) => void;
}
// RouterContext는 라우팅 상태와 함수를 관리하고, 이를 컴포넌트 트리 내에서
// 사용할 수 있도록 제공하는 React의 Context 객체입니다.
// 기본값으로 undefined를 설정하여, 이 Context를 제공하지 않은 상태에서의 사용을 방지합니다.
export const RouterContext = createContext<RouterContextProps | undefined>(undefined);
RouterProvider.tsx
import { useState, useEffect, ReactNode } from 'react';
import { RouterContext } from './RouterContext';
import { matchPath } from './utils';
interface RouterProviderProps {
children: ReactNode;
}
// RouterProvider 컴포넌트는 라우팅 상태를 관리하고, 이를 하위 컴포넌트에 제공하는 역할을 합니다.
export const RouterProvider = ({ children }: RouterProviderProps) => {
// currentPath 상태는 현재 URL 경로를 나타내며, 초기 값으로 window.location.pathname을 사용합니다.
const [currentPath, setCurrentPath] = useState(window.location.pathname);
// 초기 params 값을 현재 경로를 바탕으로 계산합니다. matchPath를 사용하여 초기 경로의 파라미터를 추출합니다.
const initialParams = matchPath(currentPath, currentPath)?.params || {};
// params 상태는 현재 경로에서 추출된 파라미터들을 관리합니다.
const [params, setParams] = useState<{ [key: string]: string }>(initialParams);
// useEffect 훅을 사용하여 브라우저의 경로 변경(popstate 이벤트)을 감지합니다.
useEffect(() => {
// 브라우저 뒤로 가기, 앞으로 가기 등의 동작으로 경로가 변경되었을 때 실행될 함수입니다.
const onLocationChange = () => {
setCurrentPath(window.location.pathname); // 현재 경로를 업데이트합니다.
};
// popstate 이벤트에 onLocationChange 핸들러를 추가합니다.
window.addEventListener('popstate', onLocationChange);
// 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다.
return () => {
window.removeEventListener('popstate', onLocationChange);
};
}, []); // 빈 배열을 의존성으로 하여, 컴포넌트가 처음 마운트될 때 한 번만 실행되도록 합니다.
// navigate 함수는 주어진 경로로 이동하며, 브라우저의 history를 업데이트합니다.
const navigate = (path: string) => {
window.history.pushState({}, '', path); // 브라우저의 URL을 변경하지만 페이지를 새로고침하지 않습니다.
setCurrentPath(path); // currentPath 상태를 업데이트하여 UI를 갱신합니다.
};
// RouterContext.Provider를 통해 currentPath, params, setParams, navigate 함수를 하위 컴포넌트에 제공함으로써,
// 라우팅 상태를 전역적으로 관리하고 사용할 수 있도록 합니다.
return (
<RouterContext.Provider value={{ currentPath, params, setParams, navigate }}>
{children}
</RouterContext.Provider>
);
};
useRouter.tsx
import { useContext } from 'react';
import { RouterContext } from './RouterContext';
// useRouter 훅은 RouterContext의 값을 반환하는 커스텀 훅입니다.
// 이 훅을 사용하여 컴포넌트에서 라우팅 상태(currentPath, params 등)에 접근할 수 있습니다.
export const useRouter = () => {
// useContext 훅을 사용하여 RouterContext에 접근합니다.
const context = useContext(RouterContext);
// RouterContext가 존재하지 않는 경우, 즉 useRouter 훅이 RouterProvider 외부에서 호출된 경우
// 에러를 발생시킵니다. 이는 useRouter 훅이 올바른 컨텍스트 내에서 사용되도록 보장합니다.
if (!context) {
throw new Error('useRouter must be used within a RouterProvider');
}
// 정상적으로 RouterContext에 접근한 경우, context 값을 반환합니다.
// 이 반환값은 currentPath, params, setParams, navigate 등의 라우팅 관련 상태와 함수를 포함합니다.
return context;
};
matchPath.ts
// params는 경로에서 추출된 파라미터들을 담고 있는 객체입니다.
// path는 매칭된 경로 패턴을 나타냅니다.
// isExact는 현재 경로가 경로 패턴과 정확히 일치하는지 여부를 나타냅니다.
interface Match {
params: { [key: string]: string };
path: string;
isExact: boolean;
}
// matchPath 함수는 주어진 pathname과 path를 비교하여,
// 경로가 일치하는지 확인하고, 일치하는 경우 해당 경로 파라미터를 추출합니다.
export const matchPath = (pathname: string, path: string): Match | null => {
// keys 배열은 path에서 추출된 경로 파라미터 키들을 저장합니다.
const keys: string[] = [];
// path 패턴에서 파라미터 부분을 정규식 그룹으로 변환합니다.
// 예를 들어, "/user/:id"는 "/user/([^/]+)"로 변환됩니다.
const pattern = path
.replace(/\/:([^/]+)/g, (_, key) => {
keys.push(key); // 파라미터 이름을 keys 배열에 저장합니다.
return '/([^/]+)'; // 파라미터 부분을 정규식 그룹으로 변환합니다.
})
.replace(/\//g, '\\/'); // 슬래시를 이스케이프하여 정규식에서 올바르게 인식되도록 합니다.
// 변환된 패턴을 기반으로 정규식을 생성합니다.
const regex = new RegExp(`^${pattern}$`);
// pathname이 정규식 패턴과 일치하는지 확인합니다.
const match = pathname.match(regex);
// 경로가 일치하지 않으면 null을 반환합니다.
if (!match) return null;
// match 배열에서 URL 전체 매칭 결과와 각 파라미터 값을 추출합니다.
const [url, ...values] = match;
// isExact는 pathname이 완전히 일치하는지를 나타냅니다.
const isExact = pathname === url;
// keys 배열과 values 배열을 결합하여 params 객체를 생성합니다.
// 예를 들어, keys=["id"], values=["123"]인 경우, params={ id: "123" }가 됩니다.
const params = keys.reduce<{ [key: string]: string }>((acc, key, index) => {
acc[key] = values[index];
return acc;
}, {});
// 매칭된 결과를 객체 형태로 반환합니다. path, params, isExact를 포함합니다.
return { path, params, isExact };
};
Routes.tsx
import { ReactElement, ReactNode, Children, isValidElement } from 'react';
import { useRouter } from '../useRouter';
import { matchPath } from '../utils';
interface RoutesProps {
children: ReactNode;
}
// Routes 컴포넌트는 전달된 자식 컴포넌트 중 현재 경로와 매칭되는 첫 번째 Route 컴포넌트를 렌더링합니다.
export const Routes = ({ children }: RoutesProps) => {
// useRouter 훅을 사용하여 현재 라우팅 상태에서 현재 경로를 가져옵니다.
const { currentPath } = useRouter();
// match 변수는 매칭된 Route 컴포넌트를 저장합니다. 초기값은 null입니다.
let match: ReactElement | null = null;
// Children.forEach를 사용하여 전달된 자식 컴포넌트들을 순회합니다.
Children.forEach(children, child => {
// 아직 매칭된 컴포넌트가 없고, 현재 순회 중인 child가 유효한 React 엘리먼트일 경우에만 진행합니다.
if (!match && isValidElement(child)) {
// child의 props에서 path와 exact 값을 추출합니다.
const { path, exact } = child.props;
// 현재 경로와 child의 path를 비교하여 매칭 여부를 확인합니다.
const matched = matchPath(currentPath, path);
// 매칭이 성공하면, exact가 true인 경우 경로가 정확히 일치해야 하고,
// 그렇지 않은 경우 부분적으로 일치해도 매칭된 것으로 간주합니다.
if (matched && (!exact || matched.isExact)) {
// 매칭된 컴포넌트를 match 변수에 저장합니다.
match = child;
}
}
});
// 매칭된 Route 컴포넌트가 있다면 이를 렌더링하고, 그렇지 않으면 null을 반환합니다.
return match;
};
Route.tsx
import { useEffect, ComponentType } from 'react';
import { useRouter } from '../useRouter';
import { matchPath } from '../utils';
// path는 매칭할 경로 패턴을 나타냅니다.
// component는 매칭된 경로에 따라 렌더링할 React 컴포넌트입니다.
// exact는 경로가 정확히 일치해야만 매칭되도록 하는 선택적 속성입니다.
interface RouteProps {
path: string;
component: ComponentType;
exact?: boolean;
}
// Route 컴포넌트는 주어진 경로가 현재 경로와 매칭될 때, 해당 컴포넌트를 렌더링합니다.
export const Route = ({ path, component: Component, exact = false }: RouteProps) => {
// useRouter 훅을 사용하여 현재 라우팅 상태를 가져옵니다.
const { currentPath, params, setParams } = useRouter();
// matchPath 함수를 사용하여 현재 경로와 지정된 경로 패턴(path)을 매칭합니다.
const match = matchPath(currentPath, path);
// useEffect 훅을 사용하여 경로가 매칭될 때 params를 업데이트합니다.
useEffect(() => {
if (match && (!exact || match.isExact)) {
// 기존 params와 새로운 match.params를 비교하여 다를 경우에만 setParams를 호출하여 상태를 업데이트합니다.
if (JSON.stringify(params) !== JSON.stringify(match.params)) {
setParams(match.params);
}
}
}, [currentPath, path, exact, match, params, setParams]);
// 경로가 매칭되지 않거나, exact가 true이고 정확히 일치하지 않을 경우 컴포넌트를 렌더링하지 않습니다.
if (!match || (exact && !match.isExact)) return null;
// 경로가 매칭되면 해당 컴포넌트를 렌더링하며, 경로 파라미터를 props로 전달합니다.
return <Component />;
};
정리
History API와 Location API
- History API는 브라우저의 세션 기록을 관리하며, pushState와 replaceState를 통해 URL을 변경하고, popstate 이벤트를 통해 경로 변경을 감지할 수 있습니다.
- Location API는 현재 페이지의 경로, 쿼리 문자열, 해시 등을 가져오는 데 사용됩니다. 이를 통해 현재 URL의 세부 정보를 추출할 수 있습니다.
설계
- History API를 사용하여 경로를 변경하고 감지
- Location API를 통해 현재 경로를 확인
- 경로를 매칭하고 이에 맞는 컴포넌트를 렌더링
주요 컴포넌트
- RouterProvider: 라우팅 상태를 관리하고, 브라우저의 URL 변경을 감지하여 상태를 업데이트
- Route: 현재 경로와 정의된 경로 패턴을 비교하여 매칭되는 경우 해당 컴포넌트 렌더링
- Routes: 현재 경로와 매칭되는 첫 번째 Route 컴포넌트를 렌더링
작성된 코드들은 기본 Base 코드와 설계를 ChatGpt에 주고, 문제가 있는 부분들을 고쳐 만든 코드입니다. 문제가 있는 부분이나 수정이 필요한 부분은 댓글로 달아주세요!
추가적으로 포스팅했으면 하는 댓글로 달아주시면, 저도 열심히 공부해서 정리해서 올리도록 하겠습니다.
읽어주셔서 감사합니다! 🫡