2026. 7. 13. 16:57ㆍAI 기반 프론트엔드 웹개발자 양성과정

[React] React Router로 여러 페이지 만들기
+ 미니 프로젝트 「Culture Log」 — 문화생활 관람 기록장
2026.07.13(월) · React Router (STEP 40) · 미니 프로젝트: 06_react/mini
📋 오늘 한 일
지금까지 만든 컴포넌트들은 전부 한 화면(SPA) 안에서만 움직였다. 오늘은 React Router로 주소(URL)에 따라 다른 화면을 보여주는 법을 배우고, 배운 걸 바로 써먹어 Culture Log라는 미니 프로젝트를 만들었다. 영화·콘서트·페스티벌·연극/뮤지컬·스포츠 관람 기록을 남기고, 통계와 캘린더로 돌아보는 앱이다. 페이지 라우팅, 동적 주소(/record/:id), 페이지 간 데이터 전달, 재사용 가능한 차트 컴포넌트까지 — 오늘 하루가 유독 밀도 높았다.
1. React Router — 여러 페이지 만들기 (STEP 40)
React는 기본이 한 페이지(SPA)라, 주소별로 화면(홈·소개·상세)을 나누려면 라우터 라이브러리를 따로 설치해야 한다. 실무에서 거의 필수라는 설명을 듣고, my-app에 react-router-dom을 설치해 홈·소개 2페이지짜리 예제를 그대로 따라 만든 다음, 연습 삼아 Q&A 페이지를 하나 더 추가해 3페이지로 늘려봤다.
## ① 설치 (터미널, 이 프로젝트에 1번만)
npm install react-router-dom
src/pages/ 폴더에 모은다 — 컴포넌트(src/components/)와 페이지를 분리해두는 게 실무 정석이라고 배웠다. 페이지 파일 하나 = 화면 하나, App.jsx는 그 페이지들을 주소에 연결하는 역할만 한다.// pages/Home.jsx const Home = () => { return <h1>홈 화면입니다.</h1> } export default Home // pages/About.jsx const About = () => { return <h1>소개 화면입니다.</h1> } export default About // pages/Qna.jsx — 예제에는 없던 페이지, 연습 삼아 직접 추가 const Qna = () => { return <h1> Q&A 화면입니다.</h1> } export default Qna
// App.jsx — 주소 ↔ 화면 연결 (라우트 설정) import { BrowserRouter, Routes, Route, Link } from 'react-router-dom' import Home from './pages/Home.jsx' import About from './pages/About.jsx' import Qna from './pages/Qna.jsx' function App() { return ( <BrowserRouter> {/* 메뉴 - 어느 화면에서든 보임 */} <nav> <Link to="/">Home</Link> |{' '} <Link to="/about">About</Link> |{' '} <Link to="/Qna">Q&a</Link> </nav> {/* 화면 갈아끼우는 곳 */} <Routes> <Route path="/" element={<Home/>}/> <Route path="/About" element={<About />}/> <Route path="/Qna" element={<Qna/>}/> </Routes> </BrowserRouter> ) } export default App
| 구성 요소 | 역할 |
|---|---|
BrowserRouter | 라우팅 기능 전체를 켜는 최상위 래퍼 |
Routes / Route | path(주소) ↔ element(보여줄 화면) 짝짓기 |
Link | <a> 대신 쓰는 이동 버튼 — 새로고침 없이 화면만 전환 |
pages/Home.jsx)에서 컴포넌트를 부를 땐 ../components/MyButton.jsx처럼 ../로 한 칸 위로 나가야 한다(./=같은 폴더, ../=한 칸 위). 여기까지 오니 새로고침 없이 앱처럼 매끄럽게 전환되는 실제 다중 페이지 앱을 만들 수 있다는 게 실감났다.2. 미니 프로젝트 — Culture Log (문화생활 관람 기록장)
Router를 배운 김에 바로 규모 있는 미니 프로젝트를 만들었다. 영화·콘서트·페스티벌·연극/뮤지컬·스포츠 관람 기록을 남기고, 포스터 사진·별점·결제 정보·한줄평을 저장하고, 통계·캘린더·예매 알리미까지 갖춘 앱이다. React 19 + Vite로 만들고 react-router-dom으로 페이지 7개를 라우팅했다.
| 페이지 | 경로 | 내용 |
|---|---|---|
| 기록 목록 | / | 검색·카테고리·연도/월 필터 + 정렬 + 카드 그리드 |
| 상세 | /record/:id | 포스터·별점·결제 정보·한줄평, 수정·삭제 |
| 기록 추가 | /new | 포스터 업로드 + 예매 알리미에서 넘어온 값 프리필 |
| 기록 수정 | /edit/:id | 기존 값 불러와 폼 재사용 |
| 통계 | /stats | 관람수·평균 별점·총 지출 × 카테고리/연도/월 |
| 캘린더 | /calendar | 월간 달력에 관람일 포스터, 예매 오픈일 알림 |
| 예매 알리미 | /openings | 예매 오픈 예정 일정 등록·관리 |
/record/:id처럼 주소 뒤에 값이 붙는 패턴을 처음 실제로 써봤다. RecordCard가 <Link to={`/record/${record.id}`}>로 카드마다 다른 주소를 만들고, Detail 페이지가 그 id로 배열에서 딱 하나만 찾아 보여준다.① Splash — 진입할 때 뜨는 인트로 애니메이션
사이트에 처음 들어오면 티켓 아이콘이 그려지고 "Culture Log" 글자가 한 글자씩 나타났다 사라지는 스플래시 화면을 만들었다. useEffect 안에서 setTimeout 두 개로 "3초 뒤 페이드아웃 시작 → 0.6초 뒤 완전히 제거"를 예약했다.
function Splash({ onFinish }) { const [leaving, setLeaving] = useState(false) useEffect(() => { const fadeTimer = setTimeout(() => setLeaving(true), 3000) const doneTimer = setTimeout(() => onFinish(), 3600) return () => { // 클린업: 중간에 다시 실행되면 이전 타이머 정리 clearTimeout(fadeTimer) clearTimeout(doneTimer) } }, [onFinish]) return ( <div className={`splash ${leaving ? 'splash--leaving' : ''}`}> {/* 글자를 한 글자씩 순서대로 등장 (span마다 다른 애니메이션 지연시간) */} <h1> {text.split('').map((ch, i) => ( <span key={i} style={{ animationDelay: `${1.1 + i * 0.09}s` }}>{ch}</span ))} </h1> </div> ) }
useEffect의 클린업 함수(return () => {'{'} ... {'}'})를 실전에서 처음 써봤다. 컴포넌트가 사라지거나 onFinish가 바뀌기 전에 타이머를 꼭 지워줘야, 이미 없어진 화면에 대해 뒤늦게 setState가 호출되는 걸 막을 수 있다는 걸 배웠다.② 검색 · 필터 · 정렬 — useState 여러 개를 한 화면에서 조합
목록 페이지(Home.jsx) 하나에 검색어·카테고리·연도·월·정렬 기준까지 useState 다섯 개가 동시에 돌아간다. 매 렌더마다 원본 배열을 filter로 걸러내고 sort로 정렬한 새 배열을 만들어 보여주는 방식이라, 상태 하나만 바뀌어도 화면 전체가 그 조건에 맞게 다시 그려진다.
let list = records.filter((r) => { const okSearch = keyword === '' || r.title.toLowerCase().includes(keyword) const okCategory = filter === '전체' || r.category === filter const okYear = year === '전체' || yearOf(r.date) === year const okMonth = month === '전체' || monthOf(r.date) === month return okSearch && okCategory && okYear && okMonth }) // 원본을 안 건드리게 [...list]로 복사한 뒤 정렬 list = [...list].sort((a, b) => { if (sort === 'rating') return b.rating - a.rating if (sort === 'price') return b.price - a.price return b.date.localeCompare(a.date) // 최신순 })
.sort()는 배열을 제자리에서(원본을) 바꾸는 메서드라서, 꼭 [...list]로 복사본을 만든 뒤 정렬해야 한다는 걸 여기서 체감했다. 원본 배열을 그대로 정렬해버리면 다른 곳에서 참조하는 데이터 순서까지 몰래 바뀌어서 찾기 힘든 버그가 생긴다.③ D-day 자동 계산 — 관람 전/후를 데이터로 저장하지 않기
기록마다 "관람 전/후" 상태를 필드로 저장하는 대신, 관람일과 오늘 날짜를 비교해서 그때그때 계산하도록 만들었다(오늘 날짜는 계속 바뀌니까 저장해두면 금방 낡은 값이 된다). 미니 프로젝트 어제(트래블틱)의 tripStatus()와 같은 아이디어를 여기서도 그대로 썼다.
// data/utils.js export function daysUntil(dateStr) { const today = new Date() today.setHours(0, 0, 0, 0) const [y, m, d] = dateStr.split('-').map(Number) const target = new Date(y, m - 1, d) return Math.round((target - today) / (1000 * 60 * 60 * 24)) } // data/records.js — 뱃지 라벨은 여기서 문구만 결정 export function ddayLabel(record) { const d = daysUntil(record.date) if (d > 0) return `D-${d}` if (d === 0) return 'D-DAY' return null }
Date 객체끼리 바로 - 연산이 된다는 것도 오늘 처음 알았다(밀리초 단위 숫자가 나와서 하루=86,400,000으로 나눠줘야 한다). setHours(0,0,0,0)으로 시간을 0시로 맞춰두지 않으면 시:분:초 차이 때문에 날짜 계산이 한 끗 어긋난다.④ 통계 페이지 — 하나의 차트 컴포넌트를 여러 화면에 재사용
통계는 "지표(관람수/평균 별점/총 지출) × 기준(카테고리/연도/월)" 조합을 버튼 클릭만으로 바꿔가며 본다. 그래프는 직접 그리지 않고, BarChart·StackedBarChart 두 개의 재사용 컴포넌트에 계산된 데이터만 넘겨주는 방식으로 짰다.
// components/BarChart.jsx — items만 주면 막대 너비를 알아서 계산 function BarChart({ items, format, fixedMax }) { const max = fixedMax || Math.max(1, ...items.map((it) => it.value)) return ( <div className="bar-chart"> {items.map((it) => ( <div key={it.label} className="bar-row"> <span>{it.label}</span> <div style={{ width: `${(it.value / max) * 100}%` }} /> <span>{format ? format(it.value) : it.value}</span> </div> ))} </div> ) }
BarChart가 "카테고리별 관람수"에도, "연도별 총 지출"에도, "월별 평균 별점"에도 그대로 쓰인다. Stats.jsx가 어떤 기준이든 {'{ label, value, color }'} 모양으로만 데이터를 가공해서 넘기기 때문. "뭘 보여줄지"와 "어떻게 그릴지"를 분리하는 감각을 이번에 제대로 배웠다. 카테고리별로 색을 쌓아 보여주는 StackedBarChart도 같은 원리로 하나 더 만들었다.⑤ 예매 알리미 → 기록 등록 — useLocation으로 페이지 간 데이터 전달
예매 알리미(/openings)에서 "예매완료" 버튼을 누르면, 그 공연 정보(카테고리·제목·예매처 등)를 미리 채운 채로 기록 등록 페이지(/new)로 이동한다. localStorage가 아니라 navigate의 두 번째 인자로 state를 실어 보내고, 받는 쪽은 useLocation으로 꺼내 쓴다.
// Openings.jsx — 이동하면서 state로 값을 함께 보냄 const convertToRecord = (item) => { navigate('/new', { state: { fromOpening: { prefill: { category: item.category, title: item.title, bookedDate: item.openDate, } } } }) } // New.jsx — 받는 쪽에서 useLocation으로 꺼내기 const location = useLocation() const fromOpening = location.state?.fromOpening const initial = fromOpening ? { ...emptyRecord(), ...fromOpening.prefill } : undefined
?.(옵셔널 체이닝)를 여기서 실전에 처음 써봤다 — location.state가 없을 때(그냥 /new로 직접 들어온 경우) undefined.fromOpening에서 에러가 나는 걸 막아준다. 페이지끼리 데이터를 주고받는 방법이 localStorage(영구 저장) 말고도 navigate의 state(그 순간만 전달)로도 가능하다는 걸 배운 게 오늘의 수확.⑥ localStorage 자동 저장 — useEffect로 상태와 저장소 동기화
기록이 하나라도 바뀔 때마다 useEffect가 자동으로 localStorage에 저장한다. 의존성 배열에 [records]를 넣어 records가 바뀔 때만 다시 실행되게 했다.
useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(records)) } catch { // 저장 용량(대략 5MB) 초과 등은 무시 } }, [records])
localStorage가 React에서는 useEffect의 의존성 배열과 짝을 이뤄 "상태가 바뀔 때마다 자동으로 동기화"되는 패턴으로 발전했다. 포스터 이미지를 FileReader로 data URL(긴 문자열)로 바꿔 저장하다 보니 용량 초과 가능성도 있어 try/catch로 감쌌다.3. 배운 것을 어떻게 썼나
| 배운 것 | 이 프로젝트에서 쓴 곳 |
|---|---|
| React Router (STEP 40) | 7개 페이지 라우팅, /record/:id 동적 라우트 |
| useState (여러 개 조합) | 검색·필터·연도·월·정렬을 각각 독립된 상태로 관리 |
| useEffect + 클린업 | Splash 타이머 예약/정리, localStorage 자동 저장 |
| props로 함수 전달 | onAdd·onUpdate·onDelete를 App → 페이지로 내려보냄 |
| 컴포넌트 재사용 | Poster·StarRating·CategoryBadge·BarChart를 여러 페이지에서 공용 |
| useNavigate / useLocation | 페이지 이동 + 이동하며 데이터(state) 함께 전달 |
🔁 회고
BarChart 하나로 통계 페이지의 세 가지 지표를 전부 그렸고, Poster 하나가 카드·상세·캘린더 세 군데에서 크기만 다르게 재사용됐다. 이미지 로딩이 실패하면 이모지로 자동 대체되는 폴백(onError)도 작은 디테일이지만 마음에 든다.
App.jsx가 상태 관리를 전부 떠맡아 조금 비대해졌다. 지금은 useState + props 전달로만 해결했는데, 다음엔 Context나 상태 관리 라이브러리를 배우면 이 구조가 더 깔끔해질 것 같다.
✍️ 마치며
Router를 배우자마자 "여러 화면을 오가는 진짜 앱"을 만들 수 있게 됐다는 게 오늘 가장 크게 남았다. 특히 페이지끼리 데이터를 주고받는 방법이 localStorage(영구) 하나뿐인 줄 알았는데, navigate의 state(일회성)라는 또 다른 방법이 있다는 걸 예매 알리미 기능을 만들며 알게 됐다. 다음엔 Context API로 지금 App.jsx에 몰려있는 상태 관리를 분산시켜보고 싶다.
🏷️ React | React Router | 미니프로젝트 | useEffect | localStorage | 학습일지
'AI 기반 프론트엔드 웹개발자 양성과정' 카테고리의 다른 글
| [260715 13일차] Vercel, Claude Web (0) | 2026.07.15 |
|---|---|
| [260714 12일차] Node·Express, Groq API (0) | 2026.07.14 |
| [260710 10일차] React (0) | 2026.07.10 |
| [260709 09일차] React (0) | 2026.07.09 |
| [260708수 08일차] Mini-Project #2 (0) | 2026.07.08 |