[260713 11일차] Mini Project #3

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

# React 학습일지

[React] React Router로 여러 페이지 만들기
+ 미니 프로젝트 「Culture Log」 — 문화생활 관람 기록장

2026.07.13(월) · React Router (STEP 40) · 미니 프로젝트: 06_react/mini

📋 오늘 한 일

지금까지 만든 컴포넌트들은 전부 한 화면(SPA) 안에서만 움직였다. 오늘은 React Router로 주소(URL)에 따라 다른 화면을 보여주는 법을 배우고, 배운 걸 바로 써먹어 Culture Log라는 미니 프로젝트를 만들었다. 영화·콘서트·페스티벌·연극/뮤지컬·스포츠 관람 기록을 남기고, 통계와 캘린더로 돌아보는 앱이다. 페이지 라우팅, 동적 주소(/record/:id), 페이지 간 데이터 전달, 재사용 가능한 차트 컴포넌트까지 — 오늘 하루가 유독 밀도 높았다.

React Router
BrowserRouterRoutes/RouteLink/NavLink
동적 라우트
:iduseParams
페이지 이동/전달
useNavigateuseLocation
인트로 애니메이션
useEffectsetTimeout
검색·필터·정렬
여러 useState 조합
재사용 차트
BarChartStackedBarChart
저장
localStorageuseEffect 동기화

1. React Router — 여러 페이지 만들기 (STEP 40)

React는 기본이 한 페이지(SPA)라, 주소별로 화면(홈·소개·상세)을 나누려면 라우터 라이브러리를 따로 설치해야 한다. 실무에서 거의 필수라는 설명을 듣고, my-appreact-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 / Routepath(주소) ↔ 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(영구 저장) 말고도 navigatestate(그 순간만 전달)로도 가능하다는 걸 배운 게 오늘의 수확.

⑥ localStorage 자동 저장 — useEffect로 상태와 저장소 동기화

기록이 하나라도 바뀔 때마다 useEffect가 자동으로 localStorage에 저장한다. 의존성 배열에 [records]를 넣어 records가 바뀔 때만 다시 실행되게 했다.

useEffect(() => {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(records))
  } catch {
    // 저장 용량(대략 5MB) 초과 등은 무시
  }
}, [records])
JS 학습 때 배운 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(영구) 하나뿐인 줄 알았는데, navigatestate(일회성)라는 또 다른 방법이 있다는 걸 예매 알리미 기능을 만들며 알게 됐다. 다음엔 Context API로 지금 App.jsx에 몰려있는 상태 관리를 분산시켜보고 싶다.

🏷️ React  |  React Router  |  미니프로젝트  |  useEffect  |  localStorage  |  학습일지