First written: 23-01-04
Uploaded: 23-01-12
Last modified: 23-03-01
SPA는 Single Page Application의 줄임말이다. 반대 개념인 Multi Page Application(이하 MPA)을 생각해보는 게 이해해 빠르다. MPA는 clinet가 새로운 url로 요청을 보낼 때마다 해당 page에 맞는 html, css, js파일 등을 가져와서 page를 새로 render한다. 반면 SPA는 client와의 상호작용에 따라 필요한 부분만 update하여 보여주는 방식으로 작동한다.
누군가의 기술 블로그에 들어가 상단 navigation bar의 'CONTACT'를 클릭했다고 가정해보자. MPA에서는 url이 바뀌면서 server에 새로운 요청이 가고 server는 client에게 html, css, js파일 등이 담긴 response를 보내 화면을 보여준다. 하지만 그 기술블로그가 SPA라면 url이 바뀌지 않으며(물론, UX를 위해 바뀌는 것처럼 보이게 할 수도 있다) client가 보낸 요청에 server는 바뀔 내용만이 담긴 json 파일을 보내준다.
그러므로 SPA는 처음에 client에게 보내는 파일의 크기가 클 수밖에 없다. 구현되어 있는 모든 페이지의 기본 뼈대(html)와 인테리어(css) 및 기능(javascript)을 다 가지고 있어야 client의 요청에 일부만 수정하여 보여줄 수 있기 때문이다. 하여, 첫 로딩 시간이 오래 걸릴 수 있다. 물론 한 번 rendering이 끝나면 json 파일만이 오가기 때문에 server에 부하가 적게 걸리고 요청에 걸리는 시간이 비교적 빠르다. url이 없이 작동하거나 html에 상대적으로 적은 정보(meta tag 등)가 있다는 특징 때문에 SEO에 불리하다는 것은 큰 단점 중 하나다. url이 변하지 않으면 뒤로가기 등의 기능이 작동하지 않아 UX에도 치명적일 수 있다.
마지막 단점을 극복하고자 나온 것이 React Router라고 생각하면 된다. 내부적으로는 URL 기반으로 component들을 관리할 수 있게 되고, client 입장에서는 url이 변하면서(정확히는 browser의 History API를 사용한다) 웹사이트가 변하는 느낌을 받기 때문에 UX에도 긍정적이다.
(src/index.js)
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
위에서 "정확히는 browser의 History API를 사용한다"라고 언급했었다. 그걸 가능하게 해주는 친구가 BrowserRouter
다. 우리가 구현한 웹페이지가 실제로 새로운 페이지 자체를 rendering하는 것이 아님에도 불구하고 사용자는 페이지가 변함에 따라 뒤로가기나 앞으로 가기 등을 사용할 수 있게 된다. 개발자 또한 주소 경로에 관한 정보를 가지고 새로운 시도를 할 수 있다. App
을 BrowserRouter
로 감싸주자.
(src/App.js)
import { Routes, Route } from 'react-router-dom';
import Main from './Main';
import Ad from './Ad';
import Contact from './Contact';
import ArticlesList from './ArticlesList';
import Article from './Article';
import Login from './Login';
import MyPage from './MyPage';
import NotFound from './NotFound';
const App = () => {
return (
<Routes>
<Route element={<Ad />}>
<Route index element={<Main />} />
<Route path="/contact" element={<Contact />} />
</Route>
<Route path="/articles" element={<ArticlesList />}>
<Route path=":id" element={<Article />} />
</Route>
<Route path="/login" element={<Login />} />
<Route path="/mypage" element={<MyPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
}
export default App;
src/App.js부터 보면서, 전체적인 그림을 그려보자.
이 글에서 나는 세 챕터로 나누어서 설명을 이어갈 것이다. 첫 번째와 두 번째 챕터에서는 Route
tag로 Route
tag를 감싸는 문법을 사용하면서 Outlet
이라는 친구를 알아볼 거다. 개인적으로 처음에 접하고는 직관적이지 못하다는 느낌이 많았던 친구라서 두 번을 살펴볼 것이다(직관적이지 않다 뿐이지 원리를 이해하고 차근차근 쓰면 어렵지 않다). Outlet
친구 외에 어려운 파트는 없다고 보아도 무방하므로 너무 겁먹을 건 없다.
세 번째는 Route
tag 단독으로 존재하는 친구들을 살펴보며 몇 가지 부가적인 기능들을 살펴볼 것이다.
<Route element={<Ad />}>
<Route index element={<Main />} />
<Route path="/contact" element={<Contact />} />
</Route>
먼저 짚고 넘어가야하는 것은, 현재 상태에서 Route
tag 안쪽에 있는 Route
tag들은 그냥 바깥에 써주어도 상관이 없다는 점이다. 다만 여기서 중첩된 라우트를 사용한 것은 첫째로 Ad
component를 공유하고 있기 때문이다. 이 component는 (예시를 위해 임의로 정했는데) 광고주들이 건네준 광고를 보여주는 component이며, 당연히 Main과 Contact 페이지 모두에서 보여야 한다.
둘째로는, 사실 뭔가 알고 싶은 것이 있어서 들어온 사람들에게 매 챕터마다 조금씩 어려운 것(혹은 새로운 것)을 전달해주는 것이 좋겠다는 생각이 들어서 부득불 저렇게 구성한 것도 있다. 중첩된 라우트를 쓰지 않는 코드만 궁금한 분들은 3번으로 직행하셔도 될 것 같다.
참고로 두 번째 내부 Route
에 쓰여있는 path
attribute는 도메인 뒤에 붙었을 때 보여주게 될 component가 무엇인지를 알려준다. 지금은 local에서만 돌리고 있으므로 Contact component는 localhost:3000/contact
에서, Main component는 localhost:3000/
에서 보일 것임을(index라는 attribute는 도메인과 일치하는 url에서 보여줄 화면을 지칭한다. 굳이 path로 적으면 path="/"
가 될 것이다), 그리고 Ad component는 그 두 곳 모두에서 보여야 함을 알 수 있다.
(src/Ad.js)
const Ad = () => {
return (
<>
<nav>
<a href="www.google.com">google</a>
<a href="www.youtube.com">youtube</a>
</nav>
</>
)
}
export default Ad;
(src/Main.js)
const Main = () => {
return (
<>
<h1>Main Page</h1>
</>
)
}
export default Main;
(src/Contact.js)
const Contact = () => {
return (
<>
<h1>Contact Page</h1>
</>
)
}
export default Contact;
외부로부터 광고를 받은 페이지라고 가정하자. 우선 App.js도 그대로인 상태에서 실행을 해보자. 오류가 나는 분들은 App.js의 내용에서 구현되지 않은 component 부분을 주석처리하거나, 임시로 구현해주어야 한다.
실행이 정상적으로 되었다면, Ad.js만 보이고 같이 보여야 하는 Home.js의 내용이 보이지 않을 것이다. 한 번 더 얘기하자면 App.js 내의 Main component가 가진 index
attribute는 주어진 domain에서(기본 설정에서 바꾼 게 없다면 localhost:3000) 다른 url로 이동하지 않았을 때를 의미하기 때문에, React를 실행했을 때 맨 먼저 보여야 하는 화면을 정의한 component가 맞다. 그럼에도 왜 화면이 보이지 않는 걸까?
React에는 중첩된 라우팅을 우리가 예상한대로 구현하기 위해, Outlet
이라는 기능을 사용해야 하기 때문이다. Outlet
은 children component들이 보여질 곳을 설정해준다. 그러므로 지금 상태에서 잘 보여지고 있는 parent component인 Ad.js에 몇 가지만 추가하자.
(src/Ad.js)
import { Outlet } from 'react-router-dom';
const Ad = () => {
return (
<>
<nav>
<a href="www.google.com">google</a>
<a href="www.youtube.com">youtube</a>
</nav>
<section>
<Outlet />
</section>
</>
)
}
export default Ad;
Main page나 contact page의 내용들이 section tag에 들어가야 한다고 생각했으므로 위와 같이 적었다. 원하는 곳에 아무데나 적어도 된다. 광고주가 광고를 페이지 하단에 배치해달라고 했다면, 당연히 nav tag 위쪽에 Outlet
이 위치해야 할 것이다.
내가 처음에 배우면서 헷갈렸던 것은, Nav 자체를 다른 곳에 만들어두고 그걸 모듈마냥 불러와서 적용하는 게 익숙했기 때문인 것 같다. 그래서 자꾸 손가락은 습관적으로 children component에 가서 작업을 하려고 하는데, Outlet
은 전혀 다른 방식으로 작동하기 때문에 parent component에서 사용해야 한다는 걸 습득하는 데에 시간이 걸렸다.
이번엔 내부링크를 돌아다닐 수 있는 a tag를 만들어보자. App.js에서 아래의 부분을 다룬다. 아마 1번 내용이 어렵지 않았다면 2번은 누워서 떡먹기다.
<Route path="/articles" element={<ArticlesList />}>
<Route path=":id" element={<Article />} />
</Route>
Parent Route의 url을 이어받기 때문에 child(ren) Route에는 굳이 full url을 적어주지 않아도 된다.
해당되는 두 components는 아래와 같이 구현이 가능하다.
(src/ArticlesList.js)
import { Link, Outlet } from 'react-router-dom';
const ArticlesList = () => {
return (
<>
<Outlet />
<ul>
<li><Link to="/articles/1">article 1</Link></li>
<li><Link to="/articles/2">article 2</Link></li>
<li><Link to="/articles/3">article 3</Link></li>
</ul>
</>
)
}
export default ArticlesList;
(src/Article.js)
import { useParams } from 'react-router-dom';
const Article = () => {
const { id } = useParams();
return (
<>
<h1>Article-{id} Page!</h1>
</>
)
}
export default Article;
해당 /articles/1
, /articles/2
에서 1이나 2가 고정적인 url이 아닌 parameter라는 것은 App.js에 정의되어 있어 쉽게 알 수 있다. 해당 내용을 Article component에서 받기 위해서는 useParams
메서드를 사용하면 된다.
좀 더 나아가보자. 검색 페이지와 같은 경우를 생각해보자. 대부분의 검색 사이트는 url parameter가 아닌 querystring으로 검색을 한다. 이를테면 https://www.example.com/search?keyword=hi&category=greeting
와 같은 url을 생각해볼 수 있다. 여기서 querystring으로 keyword와 category가 전달되었으며, 이를 받아오고 싶다면 위의 방식과 매우 유사하게 만들어줄 수 있다.
import { useLocation } from 'react-router-dom';
const Search = () => {
const location = useLocation();
return (
<div>
<p>{location.keyword}</p>
<p>{location.category}</p>
</div>
)
}
React Router v6부터는 Hooks를 통해 useSearchParams
를 사용할 수 있다.
import { useSearchParams } from 'react-router-dom';
const Search = () => {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get('keyword');
const category = searchParams.get('category');
(...)
}
ArticlesList.js의 코드를 보자. 처음 보는 Link tag가 있다. url 이동을 생각하면 당연히 a tag가 떠오르겠지만, a tag는 페이지를 아예 새로고침해버리므로 이 프로젝트에서 쓸 수는 없다. Link tag도 a tag로 결국 변환되기는 하지만, a tag의 default 동작(페이지 자체를 새로고침하는 행위)을 막고 History API를 사용하게 하는 효과가 있다.
마지막으로 Link
대신 NavLink
를 사용하여 active 되었을 때 스타일링을 해보자.
import { NavLink, Outlet } from 'react-router-dom';
const ArticlesList = () => {
const activatedStyle = {
color: 'red'
}
return (
<>
<Outlet />
<ul>
<li><NavLink to="/articles/1" style={({ isActive }) => (isActive ? activatedStyle : undefined)}>article 1</NavLink></li>
<li><NavLink to="/articles/2" style={({ isActive }) => (isActive ? activatedStyle : undefined)}>article 2</NavLink></li>
<li><NavLink to="/articles/3" style={({ isActive }) => (isActive ? activatedStyle : undefined)}>article 3</NavLink></li>
</ul>
</>
)
}
export default ArticlesList;
NavLink의 style, className의 parameter로 { isActive }를 전달해주고 이에 따른 반환값이 있는 함수를 설정해주면, 그에 맞춰서 style이나 className을 부여해준다. 여기서는 예시로 간단한 클릭을 했을 경우 list tag가 빨갛게 보이는 코드를 작성했다.
마지막 내용은 navigation 파트에 관련된 내용들이다.
<Route path="/login" element={<Login />} />
<Route path="/mypage" element={<MyPage />} />
<Route path="*" element={<NotFound />} />
아래와 같이 코드를 작성하면 된다.
(src/NotFound.js)
const NotFound = () => {
return (
<div>
404 PAGE NOT FOUND
</div>
)
}
export default NotFound;
(src/Mypage.js)
import { Navigate } from "react-router-dom";
const Mypage = () => {
const isLoggedIn = false;
if(!isLoggedIn){
return <Navigate to="/login" replace={true} />;
}
return (
<>
<h1>Mypage Page</h1>
<p>Only logged-in user can access this page.</p>
<p>if not, one should go to login page to validate oneself.</p>
</>
)
}
export default Mypage;
(src/Login.js)
import { useNavigate } from "react-router-dom";
const Login = () => {
const navigate = useNavigate();
const goBackTwice = () => {
navigate(-2);
}
const goArticlesList = () => {
navigate('/articles')
}
return (
<>
<h1>Login Page</h1>
<button onClick={goBackTwice}>go back twice</button>
<button onClick={goArticlesList}>go List</button>
</>
)
}
export default Login;
크게 설명이 어렵진 않을 것이다. My page는 로그인하지 않으면 볼 수 없다는 가정 하에 짰다. Mypage component를 보면 return <Navigate to="/login" replace={true} />;
라고 되어 있다. replace={true}
의 의미는 이동한 페이지를 페이지 기록에 남기지 말라는 뜻이다.
Login page 코드로 이동하면 useNavigate
를 사용해 여러 번 뒤로 가거나 (Nav)Link와 같은 효과를 줄 수 있음을 확인하게 된다. 역시 여기에도 추가 인자로 { replace: true }
를 주면 페이지에 기록을 남기지 않는 효과를 볼 수 있다.
참고 서적 :
김민준, 2019, 길벗, 『리액트를 다루는 기술, 개정판』