문서 읽는 데 69분 · C1

C-1: React Router 8 — 주소로 화면을 나눈다

목차 57
전체 59강 중 13강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구 튜터입니다. 지난 시간에 우리는 인스타그램 클론의 비주얼을 마감했어요. 아이콘이 문자를 대신하고, 누르면 반응하고, 색과 크기가 기준을 넘습니다. 기능도 다 됩니다. 좋아요를 누르면 숫자가 오르고, 회원가입 폼은 검증까지 하고, 모달도 열려요.

그런데 우리 앱에는 아직 화면이 하나뿐입니다.

회원가입 폼과 피드가 같은 화면에 세로로 쌓여 있어요. 브라우저 주소창은 앱을 처음 연 뒤로 한 번도 바뀐 적이 없습니다. 친구에게 "우리 앱 회원가입 여기서 해" 하고 링크를 보낼 방법이 없어요. 보낼 주소가 하나뿐이니까요.

지난 과목에서는 이런 고민을 할 필요가 없었죠. HTML 파일을 여러 개 만들면 그만이었으니까요. index.htmlprofile.html을 만들고 <a href="profile.html">로 이으면, 브라우저가 알아서 새 문서를 받아 와서 그려줬습니다. 파일이 곧 주소였어요.

React로 넘어오면서 그 구조가 사라졌습니다. 우리 앱의 HTML 파일은 index.html 하나뿐이고, 그 안에는 빈 <div id="root">만 있어요. 나머지는 전부 JavaScript가 그립니다. 문서가 하나인데 화면을 어떻게 나눌까요.

오늘 그 답을 만듭니다.

텍스트
 오늘의 여정

 1. 화면이 하나뿐인 앱          지금 무엇이 안 되는지 직접 해본다
        
 2. 세 모드와 우리의 선택        Declarative / Data / Framework
        
 3. 라우터를 세운다              createBrowserRouter + RouterProvider
        
 4. / 와 /signup 으로 가른다     App.tsx 를 헐어 두 화면으로
        
 5. 머리를 한 곳으로             중첩 라우트와 Outlet
        
 6. 눌러서 오가기                <a> 와 <Link> 의 진짜 차이
        
 7. 지금 어디 있는지             NavLink 와 aria-current

💡 오늘 수업의 핵심 — "화면을 나누는 일은 주소를 나누는 일이다"

컴포넌트를 나누는 것과 화면을 나누는 것은 다릅니다. 우리는 이미 FeedSignUpForm을 따로 만들어 뒀지만, 둘은 여전히 한 화면에 함께 있어요. 화면이 갈리려면 주소가 갈려야 합니다.


🎯 학습 목표

  • React Router 8의 Data Mode로 라우터를 세우고, 하나뿐이던 화면을 주소별로 나눌 수 있다
  • <Link><a>와 무엇이 다른지 설명하고, 그 차이를 개발자 도구에서 직접 확인할 수 있다
  • 중첩 라우트와 <Outlet/>으로 주소가 바뀌어도 살아남는 공통 껍데기를 만들 수 있다

Step 1: "화면이 하나뿐인 앱"

코드를 고치기 전에, 지금 우리 앱이 무엇을 못 하는지부터 눈으로 봅시다. 개발 서버를 띄우고 브라우저를 여세요.

네 가지를 직접 해보세요

하나 — 주소창을 보세요. 페이지를 스크롤해서 회원가입 폼으로 갔다가 피드로 돌아와 보세요. 주소창이 바뀌나요? 안 바뀝니다. 앱을 처음 연 주소 그대로예요.

둘 — 회원가입 화면만 공유해 보세요. 친구에게 보낼 링크를 만들어 보려고 하면 막힙니다. 우리 앱에는 보낼 수 있는 주소가 하나뿐이라, 링크를 받은 친구는 항상 맨 위부터 보게 돼요.

셋 — 뒤로 가기를 눌러 보세요. 앱 안에서 이것저것 누르다가 뒤로 가기를 누르면 어디로 갈까요. 앱의 이전 화면이 아니라 앱을 열기 전에 보던 페이지로 나가버립니다. 브라우저 입장에서 우리 앱은 통째로 한 페이지거든요.

넷 — 새로고침을 눌러 보세요. 좋아요를 몇 개 눌러 두고 새로고침하면 전부 처음으로 돌아옵니다. 이건 오늘 고칠 문제가 아니에요. 뒤에 데이터와 상태를 다루는 시간에 다시 만나니 기억만 해두세요.

지난 과목에서는 이게 왜 문제가 아니었나

순수 HTML로 만들 때는 이 넷이 전부 공짜였습니다. 파일을 나누면 주소가 나뉘고, 주소가 나뉘면 나머지는 브라우저가 알아서 해줬어요.

텍스트
 순수 HTML 시절                        지금 우리 React 앱

 index.html      /                    index.html    /
 signup.html     /signup                 └ <div id="root"></div>
 profile.html    /jaehoon                    └ JS 가 전부 그린다

 파일이 곧 주소                        파일은 하나, 주소도 하나
 브라우저가 문서를 새로 받아 그림      JS 가 안에서만 화면을 바꿈

React로 넘어오면서 얻은 것이 많았죠. 화면이 바뀌어도 문서를 새로 받지 않으니 빠르고, 상태가 유지되고, 화면 전환이 매끄럽지만, 그 대가로 주소라는 이름표를 잃었어요.

잃어버린 것을 정리하면

우리가 잃은 것 왜 필요한가
화면마다 다른 주소 링크로 공유하고, 즐겨찾기에 넣을 수 있어야 한다
뒤로 가기 사용자가 가장 많이 쓰는 이동 방법이다
주소를 치고 들어오기 검색 결과나 알림에서 특정 화면으로 바로 와야 한다

라우터는 이 셋을 되찾아 주는 도구입니다. 문서는 하나로 두면서, 주소는 여러 개인 것처럼 보이게 만들어요.

💡 한 줄 정리

지금 우리 앱은 화면이 하나라서 주소도 하나이고, 그래서 공유·뒤로 가기·직접 접근이 전부 막혀 있습니다.

🙋 학생 질문 — "튜터님, 그럼 그냥 HTML 파일을 여러 개 만들면 안 되나요?"

만들 수는 있지만, 그러면 React를 쓰는 이유가 대부분 사라집니다.

파일을 나누면 화면을 옮길 때마다 브라우저가 문서를 새로 받아 옵니다. 그 말은 JavaScript도 처음부터 다시 읽고, React도 처음부터 다시 시작하고, 우리가 들고 있던 상태도 전부 날아간다는 뜻이에요. 좋아요를 누른 기록도, 입력하던 폼 내용도 사라집니다.

라우터가 하는 일은 이겁니다. 주소는 나누되 문서는 하나로 유지하기. 사용자에게는 여러 페이지처럼 보이는데, 실제로는 JavaScript가 화면 안쪽만 갈아끼워요. 오늘 Step 6에서 이 차이를 개발자 도구로 직접 재봅니다.


Step 2: "세 모드와 우리의 선택"

React Router를 설치하기 전에 한 가지 정하고 가야 합니다. 이 라이브러리는 쓰는 방식이 세 가지예요. 문서에서도 이걸 모드(mode)라고 부릅니다.

세 가지 모드

텍스트
 Declarative  주소를 컴포넌트에 이어주기만 한다
              <BrowserRouter> · <Routes> · <Route> · useNavigate
              ─ 가장 단순. 데이터는 각자 알아서.

 Data         주소 표를 React 바깥으로 꺼낸다
              createBrowserRouter · loader · action · useFetcher
              ─ 화면이 그려지기 전에 데이터를 부를 수 있다.

 Framework    Data 모드에 Vite 플러그인을 씌운다
              타입 안전한 href · 자동 코드 분할 · 서버 렌더링
              ─ 사실상 프레임워크. Next.js 와 같은 자리를 노린다.

Framework 모드는 원래 Remix라는 별도 프레임워크였는데, React Router 7에서 합쳐지면서 지금은 같은 패키지 안에 셋이 다 들어 있습니다.

공식 문서는 Framework를 권합니다. 우리는 Data를 고릅니다

솔직하게 말씀드릴게요. React Router 공식 문서에서 "무엇을 골라야 할지 모르겠으면" 항목에 적혀 있는 답은 Framework 모드입니다. 새로 시작하는 프로젝트라면 그쪽을 권하는데, 우리는 Data 모드로 갑니다. 이유가 둘 있어요.

첫째, 서버 렌더링은 다음 카테고리에서 제대로 다룹니다. Framework 모드의 큰 장점은 서버 렌더링과 자동 코드 분할인데, 우리는 그 주제를 Next.js로 깊게 배울 예정이에요. 여기서 어설프게 맛만 보면 두 번 배우는 셈이 됩니다.

둘째, 지금은 SPA를 SPA로 이해할 시간입니다. 프레임워크가 알아서 해주는 것을 먼저 만나면, 나중에 그게 무엇을 대신해 준 건지 모르게 돼요. 라우터가 주소를 어떻게 맡아보는지, 데이터를 언제 부르는지를 우리 손으로 한 번 조립해 두면, 다음에 프레임워크를 만났을 때 "아, 이걸 대신해 주는구나"가 됩니다.

정리하면 이렇습니다. Data 모드는 번들과 서버를 우리가 쥔 채로 라우팅의 이점만 가져오는 선택이에요.

⚠️ 검색하면 나오는 옛 자료 이야기

React Router를 검색하면 십중팔구 npm install react-router-dom으로 시작하는 글이 나옵니다. 이름이 비슷해서 그냥 따라 하기 쉬운데, 그 패키지는 지금 없습니다.

원래 React Router는 웹용과 네이티브용으로 패키지가 갈려 있었어요. 웹에서 쓰려면 -dom이 붙은 쪽을 깔아야 했죠. 버전 7에서 하나로 합쳐지면서 그 패키지는 껍데기만 남았고, 버전 8에서는 아예 없어졌습니다.

그러니 검색 결과에 그 이름이 보이면 "이 글은 버전 7 이전이구나" 하고 걸러내면 됩니다. 우리가 깔 것은 react-router 하나예요.

💡 한 줄 정리

React Router는 세 가지 방식으로 쓸 수 있고, 우리는 SPA 로서 번들과 서버를 직접 쥐기 위해 Data 모드를 고릅니다.

🙋 학생 질문 — "튜터님, Framework 모드가 더 좋은 거면 나중에 갈아타야 하나요?"

갈아탈 수 있고, 실제로 그렇게 하는 팀도 있어요. Framework 모드는 Data 모드를 감싼 것이라 라우트 표를 다시 짤 필요가 거의 없습니다.

다만 "더 좋다"는 표현은 조금 조심해야 해요. Framework 모드는 더 많은 것을 대신해 주는 선택입니다. 서버 렌더링이 필요 없는 앱, 예를 들어 로그인한 사람만 쓰는 사내 관리 화면 같은 것은 Data 모드가 더 잘 맞아요. 검색 엔진에 걸릴 필요가 없으니 서버 렌더링의 이득이 거의 없고, 대신 배포는 정적 파일 올리기로 끝나니까요.

도구를 고를 때는 "무엇이 최신인가"보다 "우리 앱이 무엇을 필요로 하는가"를 먼저 보세요. 오늘 우리가 Data 모드를 고른 것도 유행이 아니라 커리큘럼 전체를 놓고 본 판단입니다.


Step 3: "라우터를 세운다"

이제 설치합니다. 패키지는 하나예요.

Bash
npm install react-router

라우터를 만들고 앱을 감싼다

우리 앱의 시작점은 main.tsx입니다. 지금은 <App />을 그냥 그리고 있는데, 그 자리를 라우터가 대신하게 만들 거예요.

tsx
// apps/web-spa/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import { App } from './App';
import './styles/globals.css';

const rootElement = document.getElementById('root');

if (!rootElement) {
  throw new Error('#root 를 찾지 못했습니다');
}

// 라우터는 React 바깥에서 딱 한 번 만든다.
// 컴포넌트 안에서 만들면 다시 그려질 때마다 새 라우터가 생겨 주소 기록이 끊긴다.
const router = createBrowserRouter([{ path: '/', Component: App }]);

createRoot(rootElement).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
);

createBrowserRouter에 넘긴 것은 주소와 화면을 짝지은 배열입니다. 지금은 한 줄뿐이에요. "주소가 /App을 그려라." 아직 화면이 하나라 나뉜 게 없지만, 라우터는 세워졌습니다.

RouterProvider는 그 라우터를 React 트리에 연결하는 컴포넌트예요. 이제부터 트리 안 어디서든 "지금 주소가 뭐냐"를 물어볼 수 있습니다.

⚠️ import가 두 갈래입니다

위 코드에서 눈여겨볼 것이 있어요. 두 줄이 서로 다른 곳에서 옵니다.

텍스트
 createBrowserRouter    'react-router'
 RouterProvider         'react-router/dom'

처음 보면 오타처럼 보이지만, 일부러 그런 겁니다. 왜 그런지 보려면 react-router/dom이 내놓는 RouterProvider의 소스를 열어보면 됩니다. 하는 일이 한 줄뿐이에요.

JavaScript
// node_modules/react-router/dist/development/lib/dom-export/dom-router-provider.js
function RouterProvider$1(props) {
	return React$1.createElement(RouterProvider, {
		flushSync: ReactDOM.flushSync,
		...props
	});
}

이게 전부입니다. react-router 쪽의 RouterProvider를 그대로 가져와서 flushSync라는 함수 하나를 얹어 넘길 뿐이에요. flushSyncreact-dom이 주는 도구라 브라우저 밖에서는 쓸 수 없고, 그래서 브라우저 전용 통로인 /dom으로 갈라놓은 겁니다.

그런데 여기가 함정입니다. react-router에도 RouterProvider라는 이름이 있어서, 잘못 가져와도 아무 일이 안 일어납니다.

확인 방법 잘못 가져왔을 때
타입 검사 통과합니다
화면 정상적으로 그려집니다
콘솔 경고 안 뜹니다

경고문이 라이브러리 안에 있긴 한데, flushSync를 실제로 쓰는 기능을 건드릴 때만 뜹니다. 우리가 오늘 배울 범위에서는 영영 안 보여요. 조용히 잘못된 채로 굴러가다가 나중에 화면 전환 효과를 넣을 때쯤 이상하게 동작하니, 이 두 줄은 외우기보다 왜 갈라졌는지를 기억해 두세요. 브라우저에서만 되는 것은 /dom 쪽입니다.

라우터를 컴포넌트 밖에 두는 이유

코드에서 createBrowserRoutercreateRoot보다 위, 즉 어떤 컴포넌트 안도 아닌 곳에서 불립니다. 이건 취향이 아니라 규칙이에요.

라우터는 "지금 어느 주소에 있고, 여기까지 어떻게 왔는지"를 기억하는 물건입니다. 컴포넌트 안에서 만들면 그 컴포넌트가 다시 그려질 때마다 기억을 잃은 새 라우터가 생겨요. 뒤로 가기가 망가지고, 화면 전환이 이상해집니다.

설치가 남긴 자국

라이브러리를 하나 들이면 앱이 얼마나 무거워지는지 재봅시다. 프로덕션 빌드 결과입니다.

지난 시간 라우터 설치 후 차이
CSS 42,114 B 42,114 B 0
JS 408,739 B 501,218 B +92,479

CSS가 한 바이트도 안 늘었습니다. 당연한 결과예요. Tailwind는 우리 소스에 적힌 클래스 이름을 훑어서 CSS를 만드는데, 라우터는 클래스 이름을 하나도 안 내놓거든요. Shadcn 컴포넌트를 들여오던 시간에 "CSS는 그대로인데 JS만 십만 바이트 늘었다"를 봤죠. 같은 일이 여기서도 벌어집니다.

JS는 92KB 늘어 1.23배가 됐고, 빌드가 이런 경고를 내놓기 시작해요.

텍스트
(!) Some chunks are larger than 500 kB after minification. Consider:

500KB 를 넘겼다는 뜻입니다. 지금 당장 문제가 되진 않지만, 이 경고는 나중에 필요한 화면의 코드만 나중에 받아오는 기법으로 해결하게 됩니다. 라우터가 그 기법의 출발점이에요. 화면이 주소로 갈려 있어야 "이 주소의 코드는 나중에"라고 말할 수 있으니까요.

💡 한 줄 정리

라우터는 React 바깥에서 한 번 만들어 RouterProvider로 앱을 감싸고, RouterProviderreact-router/dom에서 가져옵니다.

🙋 학생 질문 — "튜터님, 이름이 같은 게 둘이면 잘못 가져왔는지 어떻게 알아채나요?"

오늘처럼 소스를 열어보는 것이 가장 확실한 방법이고, 실무에서는 두 가지를 더 씁니다.

하나는 자동 검사예요. "이 파일에서 RouterProvider는 반드시 react-router/dom에서 와야 한다"를 검사로 만들어 두면, 누군가 실수로 고쳤을 때 바로 걸립니다. 우리 코드베이스에도 그 검사가 들어가 있어요.

다른 하나는 팀의 약속입니다. 이런 갈래는 프로젝트 문서에 한 줄 적어두면 대부분 해결돼요. 새로 온 사람이 검색해서 옛날 글을 따라 하는 게 가장 흔한 사고 경로거든요.

그리고 습관 하나만 들이세요. 라이브러리에서 무언가를 가져올 때, 그게 어느 경로에서 오는지 한 번은 확인하는 겁니다. 오늘 본 것처럼 네 줄짜리 껍데기가 왜 따로 있는지 알면, 그 라이브러리의 설계 의도까지 함께 읽히거든요.


Step 4: "/ 와 /signup으로 가른다"

라우터는 세웠지만 주소는 여전히 하나이니, 이제 진짜로 화면을 나눠 봅시다.

무엇을 어디로 나눌까

지금 App.tsx 안에는 두 덩어리가 세로로 쌓여 있어요. 회원가입 폼과 피드입니다. 이 둘을 각자 자기 주소를 가진 화면으로 떼어냅니다.

텍스트
 App.tsx  ─┬─  회원가입 폼     /signup   SignUpPage
           └─  피드            /         HomePage

src/routes/라는 새 디렉토리를 만들고 그 안에 화면을 하나씩 둘게요. 앞으로 주소를 가진 것들은 전부 여기에 모입니다.

홈 화면

tsx
// apps/web-spa/src/routes/HomePage.tsx
export function HomePage() {
  const { posts, likedCount, toast, toggleLike, reachBottom, dismissToast } = useFeed(feedPosts);

  useScrollRestore(reachBottom);

  // ... 탭 제목과 알림 정리는 지난 시간 그대로 ...

  return (
    <main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
      <header className="mb-4 flex items-baseline justify-between">
        <h1 className="mb-4 text-2xl font-bold">인스타그램</h1>
        <span className="text-sm text-faint">좋아요 누른 게시물 {likedCount}개</span>
        <ThemeToggle />
      </header>
      <Section title="피드">
        <Feed posts={posts} onToggleLike={toggleLike} />
      </Section>
      {toast !== null && <Toast message={toast.message} />}
    </main>
  );
}

App.tsx에 있던 것에서 회원가입 부분만 빠졌습니다. 나머지는 그대로예요.

회원가입 화면

tsx
// apps/web-spa/src/routes/SignUpPage.tsx
import { Section } from '../components/Section';
import { SignUpForm } from '../components/SignUpForm';
import { ThemeToggle } from '../components/ThemeToggle';

export function SignUpPage() {
  return (
    <main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
      <header className="mb-4 flex items-baseline justify-between">
        <h1 className="mb-4 text-2xl font-bold">인스타그램</h1>
        <ThemeToggle />
      </header>
      <Section title="회원가입">
        <SignUpForm onSubmit={(values) => console.log('가입 요청', values.username)} />
      </Section>
    </main>
  );
}

주소 표를 따로 뺀다

주소가 둘이 되니 main.tsx 안에 배열을 그대로 두기가 답답해집니다. 표를 따로 빼요.

TypeScript
// apps/web-spa/src/routes/routes.ts
import type { RouteObject } from 'react-router';
import { HomePage } from './HomePage';
import { SignUpPage } from './SignUpPage';

// 주소와 화면을 짝지은 표. 라우터를 만드는 곳(main.tsx)과 떼어두면
// 어떤 주소가 있는지 이 파일 하나만 보면 된다.
export const routes: RouteObject[] = [
  { path: '/', Component: HomePage },
  { path: '/signup', Component: SignUpPage },
];

RouteObject는 라우터가 받아들이는 한 줄의 생김새예요. 타입을 달아두면 pathpathname으로 잘못 적는 실수를 바로 잡아줍니다.

main.tsx는 이제 표를 받아 라우터만 만듭니다.

tsx
// apps/web-spa/src/main.tsx
import { routes } from './routes/routes';

const router = createBrowserRouter(routes);

App.tsx는 사라집니다

두 화면이 각자 자기 껍데기를 갖게 됐으니 App.tsx는 할 일이 없어졌어요. 지웁니다.

처음엔 좀 허전할 수 있는데, 이게 라우팅이 들어온 앱의 자연스러운 생김새입니다. 최상위에 "앱 전체"라는 컴포넌트가 있는 게 아니라, 주소마다 화면이 있고 라우터가 그중 하나를 고르는 구조예요.

확인해 봅시다

브라우저에서 주소를 직접 쳐보세요.

  • localhost:5173/ → 피드가 뜹니다
  • localhost:5173/signup → 회원가입 폼이 뜹니다

주소창에 주소를 치고 들어가는 것이 됩니다. 새로고침해도 그 화면이 그대로 있어요. Step 1에서 못 했던 것 중 하나가 해결됐습니다.

⚠️ 그런데 머리가 두 번 적혀 있습니다

두 파일을 다시 보세요. <main>도, <header>도, <h1>인스타그램</h1>도, <ThemeToggle />도 양쪽에 똑같이 들어가 있습니다.

지금은 화면이 둘이라 견딜 만하지만, 화면이 다섯 개가 되면 다섯 군데를 똑같이 고쳐야 해요. 제목 하나 바꾸는 데 다섯 파일을 열어야 합니다.

이건 다음 Step에서 바로 해결합니다.

💡 한 줄 정리

주소마다 화면 파일을 두고 표로 묶으면 화면이 갈리지만, 두 화면이 공통으로 쓰는 껍데기는 아직 중복으로 남습니다.

🙋 학생 질문 — "튜터님, 라우트 표를 왜 굳이 파일로 빼나요? main.tsx에 두면 안 되나요?"

둬도 동작은 똑같아요. 빼는 이유는 사람 때문입니다.

프로젝트에 새로 들어온 사람이 가장 먼저 묻는 것이 "이 앱에 화면이 뭐뭐 있어요?"예요. 표가 한 파일에 모여 있으면 그 파일 하나만 열면 답이 나옵니다. main.tsx에 섞여 있으면 라우터 만드는 코드, 루트 엘리먼트 찾는 코드, 스타일 불러오는 코드 사이에서 표를 찾아 읽어야 하고, 실무에서는 이 표가 생각보다 빨리 커집니다. 오늘은 두 줄이지만 다음 시간에 게시물 상세와 프로필이 붙고, 없는 주소를 받아낼 화면도 붙어요. 나중에 나눌 것을 미리 나눠두는 것은 과하지만, 이건 이미 나눌 이유가 생긴 상태입니다.


Step 5: "머리를 한 곳으로"

두 화면이 똑같이 들고 있는 껍데기를 하나로 합칩니다. React Router에는 이걸 위한 구조가 따로 있어요. 중첩 라우트입니다.

부모가 껍데기를 맡는다

발상은 이렇습니다. 주소 표를 평평한 목록이 아니라 나무 모양으로 만들어요. 부모가 공통 껍데기를 그리고, 자식이 그 안에 들어갑니다.

텍스트
 /            Layout      main · header · 제목 · 화면 밝기
  │
  ├ (index)   HomePage    좋아요 개수 · 피드 · 알림
  └ signup    SignUpPage  회원가입 폼

 주소가 / 든 /signup 이든 Layout 은 늘 그려진다.
 갈리는 것은 그 안쪽 한 군데뿐이다.

껍데기를 만든다

tsx
// apps/web-spa/src/routes/Layout.tsx
import { Outlet } from 'react-router';
import { ThemeToggle } from '../components/ThemeToggle';

// 주소가 바뀌어도 이 바깥은 살아남는다.
// 갈리는 것은 Outlet 자리 하나뿐이다.
export function Layout() {
  return (
    <main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
      <header className="mb-4 flex items-baseline justify-between">
        <h1 className="mb-4 text-2xl font-bold">인스타그램</h1>
        <ThemeToggle />
      </header>
      <Outlet />
    </main>
  );
}

핵심은 <Outlet />입니다. "여기에 자식 화면을 그려라"라고 표시해 두는 것이에요. children을 받아 그리는 것과 비슷한데, 무엇이 들어올지를 부모가 아니라 주소가 정한다는 점이 다릅니다.

표를 나무로 바꾼다

TypeScript
// apps/web-spa/src/routes/routes.ts
import type { RouteObject } from 'react-router';
import { Layout } from './Layout';
import { HomePage } from './HomePage';
import { SignUpPage } from './SignUpPage';

// 부모가 껍데기(Layout)를 맡고, 자식이 Outlet 자리에 들어간다.
// 자식 주소는 앞에 빗금을 안 붙인다 — 부모 주소에 이어 붙기 때문이다.
export const routes: RouteObject[] = [
  {
    path: '/',
    Component: Layout,
    children: [
      { index: true, Component: HomePage },
      { path: 'signup', Component: SignUpPage },
    ],
  },
];

두 가지를 짚고 갈게요.

index: true는 "부모 주소 그 자체"를 뜻합니다. 부모가 /를 맡고 있으니, 주소가 정확히 / 일 때 그려질 자식이 필요해요. 그게 index 라우트입니다. path를 안 적는 대신 index: true를 적어요.

자식의 path에는 빗금을 안 붙였습니다. 'signup' 이지 '/signup'이 아니에요. 부모 주소에 이어 붙는다는 뜻입니다.

그런데 여기서 한 가지 짚고 갈 게 있어요. 빗금을 붙여서 '/signup'이라고 적으면 어떻게 될까요. 아무 일도 안 일어납니다. 똑같이 동작해요.

이유는 라우터가 절대 경로 자식을 이렇게 판정하기 때문입니다.

텍스트
 자식이 빗금으로 시작하면
    부모들의 주소를 전부 이어 붙인 것으로 시작해야 한다
    그렇지 않으면 에러

우리 부모는 /예요. 모든 절대 경로는 /로 시작하니 언제나 통과하므로, 우리 앱에서는 둘 중 무엇을 써도 같아요.

규칙이 실제로 무는 것은 부모 주소가 /가 아닐 때입니다. 부모가 /app인데 자식에 /other를 적으면 이런 에러가 납니다.

텍스트
Absolute route path "/other" nested under path "/app" is not valid.
An absolute child route path must start with the combined path of all its parent routes.

그래도 자식에는 빗금을 안 붙이는 쪽을 권합니다. 부모 주소가 바뀌었을 때 자식을 안 고쳐도 되거든요. 빗금을 붙여두면 부모를 /account로 옮기는 순간 자식마다 전부 따라 고쳐야 하고, 안 고치면 위 에러가 납니다.

두 화면은 홀가분해집니다

껍데기를 부모에게 넘겼으니 자식은 자기 내용만 그리면 됩니다.

tsx
// apps/web-spa/src/routes/SignUpPage.tsx
import { Section } from '../components/Section';
import { SignUpForm } from '../components/SignUpForm';

export function SignUpPage() {
  return (
    <Section title="회원가입">
      <SignUpForm onSubmit={(values) => console.log('가입 요청', values.username)} />
    </Section>
  );
}

<main><header>ThemeToggle도 전부 빠졌어요. 회원가입 화면은 이제 정말 회원가입만 담당합니다.

홈도 마찬가지인데, 하나 달라진 것이 있습니다. 좋아요 개수는 홈에만 있는 정보라 Layout으로 못 올려요. 머리말에서 빠져나와 피드 위로 내려왔습니다.

tsx
// apps/web-spa/src/routes/HomePage.tsx
  return (
    <>
      <p className="mb-4 text-sm text-faint">좋아요 누른 게시물 {likedCount}개</p>
      <Section title="피드">
        <Feed posts={posts} onToggleLike={toggleLike} />
      </Section>
      {toast !== null && <Toast message={toast.message} />}
    </>
  );

이건 사소해 보이지만 중요한 결정입니다. 공통 껍데기에는 공통인 것만 올릴 수 있어요. 특정 화면에만 있는 상태를 억지로 올리면, 그 상태를 안 쓰는 화면까지 그 상태를 알아야 하는 이상한 구조가 됩니다. 상태를 어디에 둘지는 곧 상태를 다루는 시간에 본격적으로 파고듭니다.

정말 살아남는지 확인해 봅시다

"주소가 바뀌어도 껍데기가 안 사라진다"는 말을 눈으로 확인하는 방법이 있어요. 개발자 도구를 열고 요소 탭에서 <header>를 찾은 다음, 메뉴를 눌러 다른 주소로 이동해 보세요.

React가 화면을 다시 그렸다면 그 <header>새로 만들어진 다른 요소여야 하는데, 실제로는 선택이 유지된 채 그대로 있어요. 같은 요소가 살아남은 겁니다.

이게 중첩 라우트의 실제 이득입니다. 껍데기 안에 스크롤 위치나 열려 있는 메뉴 같은 상태가 있어도, 주소가 바뀔 때 안 날아가요.

💡 한 줄 정리

부모 라우트가 <Outlet />으로 자리를 내주면 공통 껍데기는 한 번만 적히고, 주소가 바뀌어도 그 껍데기는 살아남습니다.

🙋 학생 질문 — "튜터님, index 라우트를 안 만들고 부모에 그냥 피드를 그리면 안 되나요?"

안 됩니다. 정확히는, 그렇게 하면 /signup에서도 피드가 보여요.

부모 라우트의 컴포넌트는 자식 주소에서도 항상 그려집니다. 그게 껍데기의 존재 이유니까요. 그러니 부모에 피드를 직접 그리면 회원가입 화면에서도 피드가 함께 뜹니다.

index: true는 이 문제를 푸는 장치예요. "부모 주소 딱 그것일 때만 그릴 자식"이라는 뜻이라, /에서는 나오고 /signup에서는 안 나옵니다.

이름이 좀 낯설죠. 웹 서버가 디렉토리 주소를 받으면 index.html을 내주던 관습에서 온 이름이에요. /photos/로 들어오면 /photos/index.html을 주는 그 규칙과 같은 발상입니다.


Step 6: "눌러서 오가기"

지금까지는 주소를 손으로 쳐서 화면을 옮겼으니, 이제 누를 수 있게 만들어요. 머리말에 메뉴를 답니다.

먼저 아는 방법으로 해봅시다

주소를 알고 있으니 링크를 만들면 되겠죠. 지난 과목에서 쓰던 방법 그대로요.

tsx
// apps/web-spa/src/routes/Layout.tsx (이렇게 해보면)
<nav aria-label="주요 메뉴" className="flex gap-3 text-sm">
  <a href="/">홈</a>
  <a href="/signup">회원가입</a>
</nav>

화면에는 잘 나옵니다. 눌러보면 화면도 잘 바뀌어요. 주소창도 바뀌고요. 겉으로는 아무 문제가 없습니다.

그런데 이건 우리가 원한 게 아닙니다.

무슨 일이 벌어지는지 재봅시다

개발자 도구의 네트워크 탭을 열고 기록을 지운 다음, 메뉴를 눌러 보세요. 그리고 두 방식을 나란히 비교합니다.

<Link>로 눌렀을 때 <a>로 눌렀을 때
네트워크 요청 0건 70여 건
심어둔 전역 값 살아 있음 사라짐
개발자 도구로 넣은 요소 (해당 없음) 사라짐

<a>를 눌렀을 때 수십 건이 다시 날아갑니다. 문서부터 시작해서 우리가 쓰는 모든 코드를 처음부터 다시 받아온 거예요.

이 숫자는 개발 서버 기준이고, 잴 때마다 조금씩 달라집니다. 개발 중에는 코드를 하나로 묶지 않고 파일 단위로 내주기 때문이에요. 실제 배포본에서는 문서와 묶인 JS, CSS 정도라 서너 건입니다. 중요한 것은 숫자가 아니라 0 이냐 아니냐입니다.

전역 값이 사라진 것이 더 결정적입니다. 콘솔에서 window.something = '살아있음'처럼 값을 하나 심어두고 눌러 보세요. <Link>로 옮기면 그 값이 그대로 있고, <a>로 옮기면 사라집니다. JavaScript 가 통째로 다시 시작됐다는 뜻이에요.

그 말은 우리가 들고 있던 것도 전부 날아갔다는 뜻입니다. 좋아요 기록도, 입력하던 폼 내용도, 다크모드로 갈아둔 표시도요.

<Link>로 바꿉니다

tsx
// apps/web-spa/src/routes/Layout.tsx
import { Link, Outlet } from 'react-router';
import { ThemeToggle } from '../components/ThemeToggle';

export function Layout() {
  return (
    <main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
      <header className="mb-4 flex items-baseline justify-between">
        <h1 className="mb-4 text-2xl font-bold">인스타그램</h1>
        <nav aria-label="주요 메뉴" className="flex gap-3 text-sm">
          <Link to="/">홈</Link>
          <Link to="/signup">회원가입</Link>
        </nav>
        <ThemeToggle />
      </header>
      <Outlet />
    </main>
  );
}

hrefto로 바뀐 것 말고는 똑같아 보이죠. 하는 일은 완전히 다릅니다.

<Link>는 클릭을 가로채요. 브라우저가 원래 하려던 일(새 문서 받아오기)을 막고, 대신 주소창의 주소만 바꾼 다음 라우터에게 "주소 바뀌었어"라고 알립니다. 라우터는 표를 보고 <Outlet /> 안쪽만 갈아끼워요. 문서는 그대로입니다.

그런데 <Link>도 결국 <a>를 그립니다

개발자 도구에서 메뉴를 살펴보면 이렇게 나옵니다.

HTML
<a href="/signup" data-discover="true">회원가입</a>

<Link>라는 태그가 따로 있는 게 아니라, 진짜 앵커를 그리고 거기에 클릭 가로채기를 붙인 것이에요. data-discover는 라우터가 나중에 화면별로 코드를 나눠 받을 때 쓰려고 표시해 두는 것이라 지금은 신경 안 써도 됩니다.

이건 대충 만든 게 아니라 일부러 그렇게 만든 것입니다. href가 진짜로 있어야 되는 일들이 있거든요.

  • 마우스 가운데 버튼으로 누르거나 Ctrl을 누른 채 클릭하면 새 탭에서 열립니다
  • 우클릭해서 "링크 주소 복사"가 됩니다
  • 마우스를 올리면 브라우저 왼쪽 아래에 갈 주소가 뜹니다
  • 화면 낭독기가 "링크"라고 읽어줍니다

만약 <button onClick={...}>으로 만들었다면 이 넷이 전부 안 됐을 거예요. 버튼처럼 동작하되 링크로 남는 것, 그게 <Link>의 설계입니다.

💡 한 줄 정리

<Link>는 진짜 앵커를 그리되 클릭을 가로채서, 주소만 바꾸고 문서는 그대로 두고 화면 안쪽만 갈아끼웁니다.

🙋 학생 질문 — "튜터님, 그럼 앱 안에서는 <a>를 절대 쓰면 안 되나요?"

앱 안쪽 주소로 갈 때는 쓰지 말아야 하지만, <a>를 써야 하는 자리도 분명히 있습니다.

바깥 사이트로 나갈 때<a>가 맞아요. <Link to="https://github.com">이라고 써도 터지지는 않습니다. 라우터가 "이건 우리 주소가 아니네"를 알아채고 클릭 가로채기를 아예 안 붙인 채 평범한 앵커를 그려줘요. 그래도 처음부터 <a>로 적는 편이 낫습니다. 읽는 사람에게 "여기서부터는 앱 밖"이라는 게 분명하게 보이니까요.

파일 내려받기<a href="..." download>가 맞고요.

그리고 하나 더. 우리 앱 안이지만 일부러 새로 시작하고 싶을 때가 있어요. 로그아웃이 대표적입니다. 메모리에 남아 있는 사용자 정보를 확실히 지우고 싶으면 통째로 새로 받는 게 오히려 안전하죠.

정리하면, <Link>는 "우리 앱 안에서 상태를 유지한 채 옮길 때" 쓰는 도구입니다. 그 조건에 안 맞으면 <a>가 맞아요.


Step 7: "지금 어디 있는지"

메뉴가 생겼는데 아쉬운 것이 하나 있어요. 지금 어느 화면에 있는지 메뉴만 봐서는 모릅니다. 두 링크가 똑같이 생겼거든요.

React Router에는 이걸 위한 링크가 따로 있습니다. NavLink예요.

켜진 것만 다르게 그린다

tsx
// apps/web-spa/src/routes/Layout.tsx
import { NavLink, Outlet } from 'react-router';
import { ThemeToggle } from '../components/ThemeToggle';

// NavLink 는 자기가 가리키는 주소에 와 있는지를 알려준다.
// 켜진 링크에는 aria-current="page" 도 알아서 붙는다.
function navLinkClass({ isActive }: { isActive: boolean }) {
  return isActive ? 'font-semibold text-ink' : 'text-faint';
}

export function Layout() {
  return (
    <main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
      <header className="mb-4 flex items-baseline justify-between">
        <h1 className="mb-4 text-2xl font-bold">인스타그램</h1>
        <nav aria-label="주요 메뉴" className="flex gap-3 text-sm">
          <NavLink to="/" className={navLinkClass}>
            홈
          </NavLink>
          <NavLink to="/signup" className={navLinkClass}>
            회원가입
          </NavLink>
        </nav>
        <ThemeToggle />
      </header>
      <Outlet />
    </main>
  );
}

className에 문자열 대신 함수를 넘긴 것이 새롭죠. NavLink는 그 함수를 부르면서 "지금 네가 가리키는 주소에 와 있냐"를 isActive로 알려줍니다. 우리는 그 답을 보고 클래스를 정하면 되니, 홈에 있으면 홈이 진하고 회원가입에 있으면 회원가입이 진합니다.

낭독기에도 같은 사실이 전해집니다

개발자 도구에서 켜진 링크를 살펴보세요. 우리가 안 적은 속성이 붙어 있습니다.

HTML
<a aria-current="page" class="font-semibold text-ink" href="/signup" data-discover="true">회원가입</a>

aria-current="page"는 화면 낭독기에게 "이게 지금 페이지"라고 알려주는 표시예요. 눈으로 보는 사람은 글자가 진해진 것으로 알지만, 낭독기를 쓰는 사람에게는 굵기가 전해지지 않습니다. 이 속성이 그 몫을 합니다.

NavLink는 이걸 자동으로 붙여줘요. 우리가 신경 쓸 게 하나 줄어든 겁니다. 지난 시간에 좋아요 버튼의 눌림 상태를 직접 챙겨야 했던 것과 대조되죠. 도구가 챙겨주는 것과 우리가 챙겨야 하는 것을 구분해 두는 게 중요합니다.

⚠️ 검색하면 나오는 조언 하나를 재봤습니다

NavLink를 검색하면 거의 반드시 나오는 조언이 있어요.

루트 링크(to="/")에는 end를 붙여라. 안 그러면 모든 주소에서 켜진다.

endNavLink가 받는 옵션입니다. 그런데 이 조언이 지금도 맞는지 직접 재봤어요. 결과입니다.

지금 주소 to="/" to="/" + end to="/signup" to="/signup" + end
/ 켜짐 켜짐 꺼짐 꺼짐
/signup 꺼짐 꺼짐 켜짐 켜짐
/signup/done 꺼짐 꺼짐 켜짐 꺼짐

to="/" 쪽 두 칸을 보세요. end를 붙이나 안 붙이나 결과가 똑같습니다. 위 조언은 이 버전에서 근거가 없어요.

왜 그런지는 라이브러리가 켜짐을 판정하는 규칙을 보면 알 수 있습니다. 규칙은 이렇습니다.

텍스트
 켜진다 =  주소가 링크와 완전히 같다
        또는
           (end 가 아니고) 주소가 링크로 시작하는데
            그 바로 다음 글자가 '/' 이다

to="/"이고 지금 주소가 /signup인 경우를 넣어 봅시다. 링크는 /이고 그 길이는 1 이니, 확인할 글자는 /signup의 두 번째 글자예요. s입니다. /가 아니죠. 그래서 두 번째 갈래가 애초에 안 걸립니다.

그럼 end는 언제 쓰나요. 표의 마지막 줄이 답입니다. 자식을 거느린 주소에서 갈려요. /signup/done에 있을 때 /signup 링크를 켤지 말지가 그겁니다. 붙이면 "정확히 그 주소일 때만", 안 붙이면 "그 아래 어디에 있어도" 켜져요.

우리 앱에는 아직 자식을 거느린 주소가 없어서 end가 아무 일도 안 합니다. 그래서 안 붙였어요. 하는 일이 없는 옵션을 습관처럼 붙여두면, 나중에 그게 왜 있는지 아무도 모르게 됩니다.

이 이야기의 교훈은 end 자체가 아니에요. 널리 퍼진 조언도 버전이 바뀌면 근거를 잃는다는 것, 그리고 그걸 확인하는 방법은 직접 재보는 것뿐이라는 겁니다.

💡 한 줄 정리

NavLinkisActive로 켜짐을 알려주고 aria-current까지 붙여주며, end는 자식을 거느린 주소에서만 의미가 있습니다.

🙋 학생 질문 — "튜터님, 검색해서 나온 조언이 맞는지 매번 어떻게 확인하나요?"

전부 확인할 수는 없어요. 그래서 언제 의심할지를 정해두는 게 현실적입니다.

의심할 만한 신호가 몇 개 있어요. 글에 적힌 버전이 지금보다 두 단계 이상 낮을 때, 설치 명령에 없어진 패키지 이름이 있을 때, "이건 무조건 붙여야 한다"처럼 이유 없이 단정할 때요. 오늘 본 end 조언은 세 번째였습니다. 왜 그런지가 안 적혀 있었어요.

확인하는 방법은 오늘 한 그대로입니다. 가장 작은 판을 만들어서 옵션을 붙인 것과 뗀 것을 나란히 놓고 결과를 보는 거예요. 5분이면 됩니다.

한 가지 덧붙이면, 공식 문서도 만능은 아니에요. 문서가 옛 동작을 그대로 설명하고 있는 경우도 종종 있습니다. 가장 확실한 것은 지금 내 프로젝트에 깔린 그 버전으로 재보는 겁니다.


마무리

오늘 한 일을 한 줄로 줄이면 이렇습니다. 하나뿐이던 화면에 주소라는 이름표를 붙였습니다.

시작할 때 우리 앱은 주소가 하나였어요. 지금은 //signup이 있고, 각각 링크로 공유할 수 있고, 뒤로 가기가 앱 안에서 동작하고, 주소를 직접 쳐서 들어올 수 있습니다. 껍데기는 한 번만 적혀 있고요.

오늘 배운 핵심 세 가지

💡 하나 — 주소를 나누는 것과 컴포넌트를 나누는 것은 다릅니다. FeedSignUpForm은 진작에 따로 있었지만 화면은 하나였어요. 갈린 것은 오늘 주소 표를 만들면서부터입니다. 컴포넌트는 화면을 조립하는 단위이고, 주소는 화면을 부르는 이름이에요.

💡 둘 — 같은 이름이 둘일 수 있고, 틀려도 안 터질 수 있습니다. RouterProvider는 두 곳에서 가져올 수 있는데 잘못 골라도 타입 검사가 통과하고 화면도 잘 나옵니다. 조용히 잘못되는 것이 시끄럽게 터지는 것보다 위험해요. 어디서 오는 것인지 한 번은 확인하는 습관이 필요합니다.

💡 셋 — 널리 알려진 조언도 재봐야 합니다. 루트 링크에 end를 붙이라는 조언은 이 버전에서 아무 일도 안 했어요. 규칙을 직접 읽고 표를 만들어보고 나서야 end가 실제로 갈리는 곳을 알 수 있었습니다. 5분이면 확인할 수 있는 것을 안 하고 외우면, 이유를 모르는 코드가 쌓입니다.

시작할 때 남아 있던 것

지난 시간에 남겨둔 약속들이 어떻게 닫혔는지 봅시다.

약속했던 것 닫힌 곳
피드는 /, 회원가입은 따로 Step 4 — 주소 표로 갈랐다
뒤로 가기를 누르면 어디로 Step 1 · 6 — 앱 안에서 오가게 됐다
그때 필요한 데이터는 누가 언제 아직 — 다음 시간에
모달은 주소를 가져야 하나 아직 — 다음 시간에

다음 시간 예고

오늘 우리가 만든 주소는 둘 다 고정된 주소입니다. //signup은 미리 정해져 있어요. 그런데 진짜 인스타그램의 주소는 이렇게 생겼습니다.

텍스트
 /p/3021847265        게시물 하나
 /jaehoon             누군가의 프로필

게시물이 백만 개면 주소 표에 백만 줄을 적을 수는 없죠. 다음 시간에는 주소의 일부를 값으로 받는 법을 배웁니다.

그리고 더 큰 문제가 하나 남아 있어요. 오늘은 화면에 그릴 데이터가 이미 코드 안에 있었습니다. 그런데 /p/3021847265로 누가 주소를 치고 들어오면, 그 게시물 데이터는 누가 언제 불러올까요. 화면을 먼저 그리고 나서 부르면 빈 화면이 잠깐 보입니다. 다음 시간에는 화면이 그려지기 전에 데이터를 부르는 방법을 만듭니다.

지난 시간에 만든 모달 이야기도 아직 안 끝났어요. 열어두고 새로고침하면 사라지는 그 모달이요. 오늘은 주소를 나누는 데까지 왔으니, 다음 시간에 모달이 주소를 가져야 하는지를 그때 배운 것들로 답할 수 있게 됩니다.


과제

[구현] 세 번째 주소 만들기

/about 주소를 만들고 우리 앱을 소개하는 화면을 붙여 보세요.

  • routes/AboutPage.tsx를 만들고 주소 표에 자식으로 추가합니다. 내용은 제목 하나와 문단 하나면 충분해요.
  • 이 화면에 <main>이나 <header>를 넣어야 할까요? 넣지 말아야 할까요? 결정하고 이유를 한 줄로 적어 주세요.
  • 머리말 메뉴에 /about 링크를 추가하세요.
  • 주소 표에 자식으로 추가할 때 path를 어떻게 적었는지, 빗금을 붙였는지 안 붙였는지 적어 주세요. 붙여서도 한번 해보고 무슨 일이 생기는지 확인해 보세요.

[구현] 자식을 거느린 주소로 end 재현하기

Step 7의 표 마지막 줄을 직접 재현해 보세요.

  • /signup 아래에 /signup/done이라는 자식 주소를 만듭니다. 가입이 끝났다는 화면이라고 생각하면 돼요.
  • /signup/done으로 들어간 상태에서 머리말의 회원가입 링크가 켜지는지 확인하고 적어 주세요.
  • 그다음 그 링크에 end를 붙이고 다시 확인해 주세요. 무엇이 달라졌나요.
  • 둘 중 어느 쪽이 사용자에게 더 자연스러울까요? 정답이 하나가 아닙니다. 여러분의 판단과 이유를 적어 주세요.

[탐구] 재보고 숫자를 적기

각 항목의 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.

  • main.tsx에서 RouterProviderreact-router에서 가져오도록 바꿔 보세요. 타입 검사와 화면과 콘솔 중 무엇이 문제를 알려주나요? 아무것도 안 알려준다면 그건 무엇을 뜻할까요.
  • 라우터를 만드는 줄을 컴포넌트 안으로 옮겨 보세요. 어떤 동작이 망가지는지 직접 눌러가며 찾아 적어 주세요.
  • 주소 표에서 index: true를 지우고 대신 path: ''로 바꿔 보세요. 동작이 같나요, 다른가요?
  • 네트워크 탭을 열고 기록을 지운 다음 메뉴를 눌러 요청 수를 세어 주세요. 그다음 주소창에 같은 주소를 직접 치고 들어가 다시 세어 주세요. 두 숫자가 왜 다른지 적어 주세요.

생각해볼 주제

1. 주소는 누구를 위한 것일까요

오늘 우리는 주소를 나누면서 "링크로 공유할 수 있다", "뒤로 가기가 된다"를 이득으로 꼽았습니다. 전부 사용자를 위한 것이었죠.

그런데 주소는 개발자에게도 쓰입니다. 오류 보고에 주소가 찍히면 어느 화면에서 터졌는지 바로 알 수 있고, 방문 기록을 모으면 어느 화면을 많이 보는지 알 수 있어요.

그렇다면 화면을 나눌 때 기준이 무엇이어야 할까요. 사용자가 "여기 링크 보내줘"라고 말할 만한 단위일까요, 아니면 개발자가 구분해서 보고 싶은 단위일까요. 둘이 어긋나는 경우를 하나 떠올려 보고, 그때 무엇을 우선할지 생각해 보세요.

2. 도구가 챙겨주는 것을 어디까지 믿어야 할까요

NavLinkaria-current를 알아서 붙여줬습니다. 편했죠. 그런데 지난 시간에 우리는 아이콘 꾸러미가 낭독기 처리를 챙겨준 반면, 움직임 꾸러미는 동작 줄이기를 전혀 안 본다는 것을 봤습니다.

같은 생태계의 도구인데 챙겨주는 범위가 제각각이에요. 그렇다면 새 라이브러리를 들일 때 "무엇을 안 챙겨주는지"를 어떻게 알아낼 수 있을까요.

문서를 읽는 것으로 충분할까요? 오늘 end 조언처럼 문서 바깥의 통설이 틀린 경우도 있었는데, 그럼 무엇을 근거로 삼아야 할지 생각해 보세요.

3. 상태를 어디에 둘지는 누가 정할까요

Step 5에서 좋아요 개수를 공통 껍데기로 못 올렸습니다. 홈에만 있는 정보였으니까요. 그래서 머리말에서 빠져나와 피드 위로 내려왔어요.

만약 디자이너가 "좋아요 개수는 모든 화면의 머리말에 있어야 한다" 고 요구하면 어떻게 될까요. 그 값은 홈이 들고 있는데 머리말은 Layout이 그립니다.

이럴 때 선택지가 몇 가지 떠오르나요. 각각의 대가는 무엇일까요. 화면 구조와 상태 구조가 어긋날 때 무엇을 먼저 바꿔야 할지 생각해 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 세 번째 주소 만들기

채점 포인트

항목 확인할 것 배점
파일 위치 routes/AboutPage.tsx로 만들었는가 15
표 등록 children 배열에 자식으로 넣었는가 (최상위에 형제로 두지 않았는가) 25
껍데기 판단 <main>·<header>를 안 넣고 이유를 설명했는가 25
메뉴 추가 Layout의 nav에 링크를 더했는가 15
빗금 실험 붙여도 동작이 같다는 것을 확인하고 이유를 적었는가 20

풀이 예시

화면은 자기 내용만 그립니다. 껍데기는 Layout 소관이에요.

tsx
// apps/web-spa/src/routes/AboutPage.tsx
import { Section } from '../components/Section';

export function AboutPage() {
  return (
    <Section title="소개">
      <p className="text-sm text-faint">
        지난 과목에서 손으로 지은 인스타그램 클론을 React 로 옮겨 짓고 있습니다.
      </p>
    </Section>
  );
}

표에는 자식으로 넣습니다.

TypeScript
// apps/web-spa/src/routes/routes.ts
export const routes: RouteObject[] = [
  {
    path: '/',
    Component: Layout,
    children: [
      { index: true, Component: HomePage },
      { path: 'signup', Component: SignUpPage },
      { path: 'about', Component: AboutPage },
    ],
  },
];

메뉴는 Layout 한 곳만 고치면 세 화면 모두에 반영됩니다.

tsx
// apps/web-spa/src/routes/Layout.tsx
<NavLink to="/about" className={navLinkClass}>
  소개
</NavLink>

껍데기를 안 넣는 이유: 넣으면 <main> 안에 <main>이 생깁니다. Layout이 이미 그리고 있으니까요. 화면이 두 겹으로 감싸이면서 여백이 두 배가 되고, 문서 구조상으로도 페이지의 본문 영역이 둘이 되어 낭독기가 혼란스러워집니다.

빗금 실험 결과: { path: '/about', Component: AboutPage }로 바꿔도 똑같이 동작합니다. 라우터는 자식의 절대 경로가 부모들의 주소를 이어 붙인 것으로 시작하는지만 봅니다. 우리 부모는 /라서 모든 절대 경로가 통과해요.

규칙이 실제로 무는 것은 부모가 /가 아닐 때입니다. 부모가 /app인데 자식에 /other를 적으면 이런 에러가 납니다.

텍스트
Absolute route path "/other" nested under path "/app" is not valid.
An absolute child route path must start with the combined path of all its parent routes.

자주 나오는 실수

하나 — 최상위에 형제로 넣기. 이렇게 넣으면 Layout이 안 그려져서 제목도 메뉴도 사라진 맨몸 화면이 나옵니다.

TypeScript
export const routes: RouteObject[] = [
  { path: '/', Component: Layout, children: [ /* ... */ ] },
  { path: '/about', Component: AboutPage },   // 이러면 껍데기가 없다
];

둘 — 껍데기를 복사해 넣기. Step 5에서 걷어낸 중복을 다시 만드는 셈입니다. 화면이 하나 늘 때마다 고칠 곳도 하나 늘어요.

💡 튜터의 한마디 — 화면을 하나 추가할 때 고치는 파일이 몇 개인지 세어 보세요. 오늘 구조에서는 새 파일 하나 + 표 한 줄 + 메뉴 한 줄, 이렇게 셋입니다. Step 4 구조였다면 여기에 껍데기 복사가 더 붙었을 거예요. 좋은 구조는 기능을 더할 때 드는 비용이 일정합니다.

🎯 [과제 2 예시답안] 자식을 거느린 주소로 end 재현하기

채점 포인트

항목 확인할 것 배점
중첩 구성 signup을 부모로 만들고 done을 자식으로 넣었는가 25
Outlet 부모가 된 signup에 자식 그릴 자리를 마련했는가 20
end 없을 때 /signup/done에서 회원가입 링크가 켜진다고 적었는가 20
end 붙였을 때 꺼진다고 적었는가 20
판단 어느 쪽이 자연스러운지 이유와 함께 적었는가 (정답 없음) 15

풀이 예시

signup이 자식을 갖게 되면 자기도 껍데기 역할을 해야 합니다.

TypeScript
// apps/web-spa/src/routes/routes.ts
{
  path: 'signup',
  Component: SignUpLayout,
  children: [
    { index: true, Component: SignUpPage },
    { path: 'done', Component: SignUpDonePage },
  ],
},
tsx
// apps/web-spa/src/routes/SignUpLayout.tsx
import { Outlet } from 'react-router';

export function SignUpLayout() {
  return <Outlet />;
}

가입 절차가 여러 단계로 늘어나면 이 껍데기가 진행 표시줄 같은 것을 맡게 됩니다. 지금은 자리만 내주면 돼요.

측정 결과

지금 주소 to="/signup" to="/signup" + end
/signup 켜짐 켜짐
/signup/done 켜짐 꺼짐

end를 안 붙이면 /signup/done에서도 회원가입 링크가 진하게 남습니다. 붙이면 꺼져요.

어느 쪽이 자연스러운가 — 둘 다 성립합니다. 판단 근거는 그 링크가 무엇을 가리키느냐예요.

메뉴의 링크가 구역을 가리킨다면 end를 빼는 쪽이 맞습니다. 사용자는 "나는 지금 회원가입 절차 안에 있다" 고 느끼니까요. 상단 메뉴가 보통 이렇습니다.

반대로 링크가 정확히 그 페이지 하나를 가리킨다면 end를 붙이는 쪽이 맞아요. 하위 페이지에 있는데 부모 링크가 켜져 있으면 "지금 그 페이지인가?" 하고 헷갈립니다. 부모와 자식이 메뉴에 나란히 있을 때 특히 그래요.

우리 메뉴는 구역을 가리키는 쪽에 가까우니 end 없이 두는 편이 자연스럽습니다.

자주 나오는 실수

signup에 Outlet을 안 두는 것. SignUpPage를 그대로 부모로 두고 자식만 추가하면, /signup/done으로 갔을 때 회원가입 폼이 그대로 뜬 채 아무것도 안 바뀝니다. 자식을 그릴 자리를 안 만들었으니까요.

💡 튜터의 한마디end를 붙일지 말지에 정답이 없다는 점이 중요합니다. 이런 옵션은 "권장 설정"을 외우는 게 아니라 우리 메뉴가 무엇을 가리키는지를 먼저 정하고 거기서 답을 끌어내야 해요. 교안에서 널리 퍼진 조언 하나가 근거를 잃은 것을 봤죠. 근거 없이 외운 설정은 버전이 바뀌면 그대로 낡습니다.

🎯 [과제 3 예시답안] 재보고 숫자를 적기

채점 포인트

항목 확인할 것 배점
import 실험 타입 검사·화면·콘솔 셋 다 조용하다는 것을 확인했는가 25
라우터 위치 무엇이 망가지는지 직접 눌러보고 적었는가 25
index 실험 결과가 같다는 것을 확인하고, 차이 나는 곳을 찾았는가 25
요청 수 두 숫자와 그 차이의 이유를 적었는가 25

풀이 예시

하나 — RouterProviderreact-router에서 가져오기

확인 방법 결과
타입 검사 통과
화면 정상
콘솔 경고 없음

셋 다 조용합니다. 이게 뜻하는 것은 이 실수는 도구가 안 잡아준다는 겁니다. 두 곳에 같은 이름이 있고 둘 다 진짜 컴포넌트라, 타입 검사기 입장에서는 잘못된 게 없어요.

이런 종류의 실수를 막는 방법은 도구를 믿는 게 아니라 규칙을 명시적으로 적어두는 것입니다. 프로젝트 문서에 한 줄 남기거나, 자동 검사로 못 박아두는 식으로요.

둘 — 라우터를 컴포넌트 안에서 만들기

tsx
// 이렇게 하면 (일부러 망가뜨리는 예)
function Root() {
  const router = createBrowserRouter(routes);   // 그릴 때마다 새로 생긴다
  return <RouterProvider router={router} />;
}

첫 화면은 멀쩡히 뜹니다. 그래서 잘못된 줄 모르고 넘어가기 쉬워요. 망가지는 것은 이동한 다음입니다.

  • 메뉴를 눌러도 화면이 안 바뀌거나, 바뀌었다가 되돌아옵니다
  • 뒤로 가기가 동작하지 않습니다
  • 주소창은 바뀌었는데 화면이 옛날 것 그대로인 경우도 생깁니다

라우터는 "여기까지 어떻게 왔는지"를 기억하는 물건인데, 다시 그려질 때마다 기억을 잃은 새 라우터로 갈아치우기 때문입니다.

셋 — index: true 대신 path: ''

화면 결과는 같습니다. /에서 똑같이 홈이 뜹니다.

차이는 다른 곳에 있어요. index 라우트는 자식을 가질 수 없습니다.

텍스트
Cannot specify children on an index route

path: ''는 자식을 가질 수 있고요. 그래서 "이 자리는 끝이다"를 분명히 하고 싶으면 index: true, 나중에 아래로 더 뻗을 수도 있으면 path: ''가 됩니다. 대부분의 경우 앞쪽이 의도를 더 잘 드러내요.

넷 — 요청 수 비교

이동 방법 네트워크 요청
메뉴를 눌러서 0건
주소창에 직접 치고 들어가서 수십 건 (개발 서버 기준)

메뉴를 누르면 요청이 아예 없습니다. 화면을 그릴 코드가 이미 브라우저 안에 있으니 새로 받아올 게 없어요. 라우터는 주소창의 글자만 바꾸고 화면 안쪽을 갈아끼웁니다.

주소창에 직접 치는 것은 브라우저에게 "이 주소의 문서를 새로 받아와"라고 말하는 것입니다. 그래서 문서부터 시작해 모든 코드를 다시 받습니다. <a>를 눌렀을 때와 같은 일이에요.

개발 서버는 코드를 묶지 않고 파일 단위로 내주기 때문에 숫자가 큽니다. 배포본에서는 문서·JS·CSS 정도라 훨씬 적어요. 중요한 것은 숫자가 아니라 0 이냐 아니냐입니다.

자주 나오는 실수

요청 수를 잴 때 기록을 안 지우는 것. 네트워크 탭에는 앱을 처음 열 때의 요청이 이미 쌓여 있습니다. 지우고 나서 눌러야 그 클릭이 만든 요청만 보여요.

💡 튜터의 한마디 — 네 실험 중 셋이 "아무 일도 안 일어난다"를 확인하는 것이었습니다. 잘못 가져와도 조용하고, path: ''로 바꿔도 같고, 링크를 눌러도 요청이 0 이죠. 아무 일도 안 일어나는 것을 확인하는 것도 측정입니다. 오히려 이런 확인이 더 중요해요. 무언가 터지는 실수는 알아서 드러나지만, 조용한 실수는 찾아 나서지 않으면 안 보이거든요.


🤔 [생각해볼 주제 1] 주소는 누구를 위한 것일까요

문제 상황 요약

화면을 주소로 나눌 때 그 단위를 무엇으로 정할지 묻는 문제입니다. 사용자가 공유하고 싶어 하는 단위와 개발자가 구분해서 보고 싶은 단위가 어긋날 때 무엇을 우선할지가 핵심이에요.

튜터의 가이드 및 해설

먼저 어긋나는 예를 하나 잡아 봅시다. 회원가입이 세 단계라고 해볼게요. 계정 정보, 프로필 사진, 관심사 선택입니다.

개발자 입장에서는 세 단계에 각각 주소를 주고 싶어요. 어느 단계에서 사람들이 이탈하는지 알고 싶고, 오류가 어느 단계에서 났는지도 알고 싶으니까요.

사용자 입장에서는 두 번째 단계 링크를 공유할 일이 없습니다. 오히려 그 주소를 남에게 받아서 들어가면 첫 단계를 건너뛴 이상한 상태가 돼요.

이럴 때 판단 기준은 주소가 만드는 약속입니다. 주소를 준다는 것은 "이 주소로 들어오면 이 화면을 보여주겠다"는 약속이에요. 그 약속을 지킬 수 없는 화면에는 주소를 주면 안 됩니다.

가입 2단계는 1단계 없이는 성립하지 않으니 약속을 지킬 수 없어요. 이런 화면은 주소를 안 주는 쪽이 맞습니다. 그럼 개발자가 원하던 측정은 어떻게 할까요. 주소 말고 다른 수단으로 합니다. 화면 전환을 이벤트로 기록하면 돼요.

반대로 게시물 상세는 약속을 지킬 수 있습니다. 그 주소만 있으면 화면이 완성되니까요. 이런 것은 반드시 주소를 줘야 합니다.

정리하면 이렇습니다. 주소는 측정 도구가 아니라 약속입니다. 측정하고 싶다는 이유로 주소를 늘리면, 지킬 수 없는 약속이 생겨요.

🎯 면접관을 홀리는 핵심 멘트

"화면에 주소를 줄지 말지는 '그 주소로 낯선 사람이 들어와도 화면이 성립하는가'로 정합니다. 성립하면 주소를 주고, 성립하지 않으면 주소 대신 이벤트로 기록해요. 주소는 측정 수단이 아니라 사용자와 맺는 약속이니까요."

🤔 [생각해볼 주제 2] 도구가 챙겨주는 것을 어디까지 믿어야 할까요

문제 상황 요약

NavLinkaria-current를 알아서 붙여줬는데, 지난 시간에 만난 움직임 관련 도구는 동작 줄이기 설정을 전혀 안 봤습니다. 같은 생태계인데 챙겨주는 범위가 제각각일 때, 새 라이브러리의 빈틈을 어떻게 알아낼지 묻는 문제입니다.

튜터의 가이드 및 해설

문서를 읽는 것만으로는 부족합니다. 이유가 분명해요. 문서는 그 도구가 하는 일을 적지, 안 하는 일을 적지 않습니다. 안 하는 일의 목록은 무한하니까요.

그래서 실무에서는 반대로 접근합니다. 도구를 믿는 대신 결과를 검사해요. 접근성이라면 "우리 화면의 모든 조작 요소에 읽어줄 이름이 있는가"를 우리 쪽에서 검사합니다. 그 이름이 라이브러리가 붙여준 것인지 우리가 붙인 것인지는 검사 입장에서 상관없어요.

이 방식이 좋은 이유는 라이브러리를 바꿔도 검사가 그대로 살아남는다는 겁니다. 도구 A가 챙겨주던 것을 도구 B가 안 챙겨주면, 도구를 바꾼 순간 검사가 걸려요.

그리고 딸려온 것에는 빠진 것도 함께 딸려온다는 것을 기본값으로 두세요. 새 라이브러리를 들일 때 "무엇을 해주나"만 보지 말고, 우리가 이미 지키고 있던 기준을 그 라이브러리가 지키는지 한 번 훑어보는 겁니다. 오늘 NavLink는 지켰고, 지난 시간의 움직임 도구는 안 지켰어요. 미리 알 방법은 없었지만, 훑어봤기 때문에 알았습니다.

마지막으로 통설에 대해서요. 오늘 end 조언이 근거를 잃은 것을 봤죠. 통설을 의심할 신호가 몇 개 있습니다. 이유 없이 단정하는 조언, 버전이 안 적힌 조언, 없어진 패키지 이름이 나오는 조언이요. 이런 신호가 보이면 가장 작은 판을 만들어 5분만 재보면 됩니다.

🎯 면접관을 홀리는 핵심 멘트

"라이브러리가 무엇을 챙겨주는지는 문서로 알 수 있지만, 무엇을 안 챙겨주는지는 문서에 안 나옵니다. 그래서 저는 도구가 아니라 결과를 검사합니다. 우리 화면이 기준을 지키는지 우리 쪽 검사로 확인해 두면, 도구를 갈아끼워도 그 검사가 빈틈을 대신 찾아줍니다."

🤔 [생각해볼 주제 3] 상태를 어디에 둘지는 누가 정할까요

문제 상황 요약

좋아요 개수는 홈이 들고 있는데, 디자이너가 그 숫자를 모든 화면의 머리말에 요구하는 상황입니다. 머리말은 Layout이 그리므로 화면 구조와 상태 구조가 어긋납니다.

튜터의 가이드 및 해설

선택지는 크게 셋입니다. 각각의 대가를 보죠.

하나 — 상태를 Layout으로 올린다. 가장 먼저 떠오르는 방법이고, B-2에서 배운 상태 끌어올리기 그대로예요. 대가는 Layout이 피드 데이터를 알게 된다는 겁니다. 회원가입 화면만 보는 사람도 피드를 불러오게 되고, Layout은 "껍데기"가 아니라 "피드를 아는 껍데기"가 됩니다.

둘 — 값을 위로 전달할 통로를 만든다. 자식이 부모에게 값을 올려주는 방법이 라우터에도 있어요. 다만 이건 데이터가 아래에서 위로 흐르는 구조라 추적이 어려워집니다. 화면이 늘수록 누가 그 값을 넣었는지 찾기 힘들어져요.

셋 — 상태를 화면 바깥에 둔다. 컴포넌트 나무 어디에도 속하지 않는 곳에 값을 두고, 필요한 쪽이 각자 꺼내 쓰는 방법입니다. Layout도 꺼내 쓰고 홈도 꺼내 쓰면 되니 어긋남 자체가 사라져요. 대가는 도구가 하나 늘고, "이 값이 어디서 바뀌는지"가 코드에서 덜 드러난다는 점입니다.

여기서 진짜 물어야 할 질문은 따로 있습니다. 그 숫자는 애초에 어디에 속하는 값인가요.

"내가 좋아요를 누른 게시물 수"는 사실 홈 화면의 값이 아니라 사용자의 값입니다. 홈에 있던 것은 마침 홈이 피드를 들고 있었기 때문이지, 그게 제자리여서가 아니에요. 그렇게 보면 디자이너의 요구는 무리한 게 아니라 값의 진짜 소속을 드러낸 것입니다.

그래서 저는 셋째를 고릅니다. 화면 구조와 상태 구조가 어긋날 때, 먼저 의심할 것은 상태가 있는 위치예요. 값이 화면 하나에 매여 있는데 여러 화면이 그 값을 필요로 한다면, 그건 처음부터 그 화면의 값이 아니었을 가능성이 큽니다.

곧 상태를 다루는 시간에 이 이야기를 본격적으로 파고듭니다. 상태를 어디에 두어야 하는지, 그리고 주소 자체도 상태를 담을 수 있다는 것까지요.

🎯 면접관을 홀리는 핵심 멘트

"화면 구조와 상태 구조가 어긋나면 저는 화면부터 고치지 않습니다. 그 값이 원래 어디 소속인지부터 다시 봐요. 여러 화면이 같은 값을 필요로 한다는 것은 대개 그 값이 처음부터 특정 화면의 것이 아니었다는 신호입니다."

전체 목록 리액트