지난 장에서 우리는 page.tsx와 layout.tsx를 이용해 '작은 기록 상점'의 첫 화면을 세웠습니다. 이제 상점 문은 열렸습니다. 그런데 아직은 살짝 아쉽습니다. 간판은 예쁜데, 안쪽 통로가 비어 있는 느낌이랄까요.
웹이 진짜 앱처럼 느껴지는 순간은, 화면과 화면이 자연스럽게 이어질 때입니다. 클릭했을 때 전체 페이지가 덜컥 새로고침되는 느낌이 아니라, 같은 공간 안에서 부드럽게 이동하는 느낌이 들어야 하죠.
이번 장에서는 그 감각을 만드는 네 가지를 익힙니다.
Link로 페이지 사이에 길 만들기useRouter로 코드에서 직접 이동시키기폴더를 중첩해 URL을 계층적으로 설계하기
세그먼트 개념으로 라우팅 구조를 읽는 눈 기르기
이제부터 우리의 '작은 기록 상점'은 정적인 소개 페이지가 아니라, 사용자가 이리저리 걸어 다닐 수 있는 작은 앱으로 자라기 시작합니다.
세그먼트부터 이해하면 길이 보입니다
Next.js의 App Router는 파일 시스템 기반 라우팅을 사용합니다. 쉽게 말하면, 폴더와 파일로 경로를 정의한다는 뜻입니다. 그리고 URL은 여러 조각으로 나뉘는데, 그 조각 하나하나를 세그먼트라고 부릅니다.1
예를 들어 이 URL을 봅시다.
/records/new이 주소는 이렇게 읽을 수 있습니다.
/는 루트 세그먼트records는 하나의 세그먼트new는 마지막 세그먼트
즉, URL은 하나의 긴 문자열이 아니라, 슬래시(/)로 나뉜 작은 조각들의 묶음입니다. 폴더 구조도 똑같이 생각하면 됩니다.
app/
records/
new/
page.tsx이 구조는 곧 /records/new를 뜻합니다.1
이 감각이 왜 중요할까요? 앞으로 앱이 커질수록 우리는 "어떤 화면이 어디에 속하는가?"를 계속 생각해야 하기 때문입니다. 기록 목록 아래에 새 기록 페이지가 있고, 나중에는 특정 기록 상세 페이지도 그 아래에 생길 것입니다. 세그먼트 개념을 이해하면 URL이 그냥 주소가 아니라 화면 구조도처럼 보이기 시작합니다.
Link는 가장 기본적이고 가장 자주 쓰는 이동 도구입니다
Next.js에서는 페이지 이동의 기본 수단으로 Link를 사용합니다. 공식 문서도 특별한 이유가 없다면 탐색에는 useRouter보다 Link를 먼저 쓰는 것을 권장합니다.2
왜 그럴까요?
Link는 단순히 클릭 가능한 글자 하나가 아닙니다. 내부 페이지로 이동할 때 클라이언트 사이드 내비게이션을 도와주고, 화면에 보이는 링크를 자동으로 미리 준비해 두는 프리패치도 수행합니다. 그래서 이동이 더 빠르고 부드럽습니다.12
말하자면 이런 느낌입니다.
일반 <a> 태그가 "문을 열고 밖에 나갔다가 다시 들어오는 이동"이라면, Link는 "건물 안 복도를 따라 옆방으로 이동하는 느낌"에 더 가깝습니다.
사용법은 아주 단순합니다.
import Link from 'next/link';
export default function HomeLinks() {
return (
<nav>
<Link href="/">홈</Link>
<Link href="/records">기록 둘러보기</Link>
<Link href="/records/new">새 기록 쓰기</Link>
</nav>
);
}코드가 단순한 이유는, 페이지 이동 자체를 프레임워크가 이미 잘 알고 있기 때문입니다. 우리는 "어디로 갈지"만 말하면 됩니다.
'작은 기록 상점'의 헤더를 진짜 내비게이션으로 바꾸기
지난 장에서 루트 레이아웃에 헤더와 메뉴를 넣어 두었습니다. 그때는 일단 화면 구조를 이해하는 게 중요해서 메뉴를 텍스트처럼 넣었죠. 이제 그 메뉴에 실제 길을 연결해 봅시다.
app/layout.tsx를 다음처럼 바꿔 볼 수 있습니다.
import Link from 'next/link';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header>
<div>
<h1>작은 기록 상점</h1>
<p>기억하고 싶은 순간을 한 줄씩 진열하는 공간</p>
</div>
<nav>
<Link href="/">홈</Link>
<Link href="/records">기록 둘러보기</Link>
<Link href="/records/new">새 기록 쓰기</Link>
</nav>
</header>
<main>{children}</main>
</body>
</html>
);
}이제 헤더는 장식이 아니라 실제 길잡이가 됩니다.
사용자 입장에서는 아주 작은 변화처럼 보일 수 있습니다. 하지만 개발자 입장에서는 꽤 중요한 첫걸음입니다. 앱 어디서든 공통 헤더가 있고, 그 헤더를 통해 주요 화면으로 이동할 수 있기 때문입니다. 이 순간부터 우리의 프로젝트는 "한 장짜리 페이지"에서 "연결된 화면들의 집합"으로 넘어갑니다.
중첩 라우팅은 폴더를 안에 넣는 것만으로 시작됩니다
중첩 라우팅은 이름만 들으면 조금 무겁게 들립니다. 하지만 실제로는 아주 직관적입니다.
폴더 안에 폴더를 넣으면, URL도 안에 안에 들어갑니다.
예를 들어 기록 영역을 이렇게 만들 수 있습니다.
app/
page.tsx -> /
records/
page.tsx -> /records
new/
page.tsx -> /records/new이 구조만 봐도 느낌이 옵니다.
records는 기록 목록 구역new는 그 기록 구역 안의 새 작성 화면
이것이 바로 중첩 라우팅입니다.1
중첩 라우팅이 좋은 이유는 주소만 예뻐져서가 아닙니다. 화면의 관계가 코드 구조에 그대로 드러나기 때문입니다.
예를 들어 /records/new는 "새 기록 쓰기"라는 기능이지만, 동시에 "기록 영역 안에 속한 기능"입니다. 이 관계가 폴더 구조에 자연스럽게 표현됩니다.
React Router를 처음 배울 때처럼 라우트 설정 파일을 길게 적지 않아도 되는 이유가 여기에 있습니다. 폴더 구조 자체가 이미 설계 문서 역할을 해 주는 것이죠.
기록 영역 전용 레이아웃을 하나 더 얹어 봅시다
이번 장의 제목은 중첩 라우팅이지만, 중첩 라우팅은 중첩 레이아웃과 만나면 훨씬 더 앱답게 느껴집니다.
예를 들어 /records 아래 화면들에만 공통 제목과 보조 메뉴를 넣고 싶다고 해 봅시다. 그럴 때는 app/records/layout.tsx를 만들면 됩니다.1
구조는 이렇게 됩니다.
app/
layout.tsx
page.tsx
records/
layout.tsx
page.tsx
new/
page.tsx그리고 app/records/layout.tsx는 이런 식으로 만들 수 있습니다.
import Link from 'next/link';
export default function RecordsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section>
<header>
<h2>기록 서가</h2>
<p>일상, 독서, 공부 메모를 모아 보는 공간</p>
<nav>
<Link href="/records">전체 기록</Link>
<Link href="/records/new">새 기록 쓰기</Link>
</nav>
</header>
<div>{children}</div>
</section>
);
}이제 어떤 일이 생길까요?
/records에 들어가도 이 레이아웃이 보이고, /records/new에 들어가도 같은 레이아웃이 보입니다. 즉, 사용자는 "아, 나는 지금 기록 구역 안에 있구나"를 자연스럽게 느끼게 됩니다.
이게 바로 앱 같은 느낌을 만드는 중요한 포인트입니다. 화면이 바뀌어도 완전히 다른 세계로 튀지 않고, 같은 구역 안에서 내용만 바뀌는 감각이 생기기 때문입니다.
목록 페이지와 새 기록 페이지를 연결해 봅시다
이제 기록 목록 페이지와 새 기록 페이지도 간단히 만들어 보겠습니다.
먼저 app/records/page.tsx입니다.
import Link from 'next/link';
const records = [
{
id: '1',
title: '비 오는 날의 메모',
summary: '우산보다 천천히 걷는 마음이 더 필요했다.',
},
{
id: '2',
title: '오늘 읽은 문장',
summary: '좋은 문장은 메모장보다 오래 남는다.',
},
];
export default function RecordsPage() {
return (
<section>
<h3>진열된 기록</h3>
{records.map((record) => (
<article key={record.id}>
<h4>{record.title}</h4>
<p>{record.summary}</p>
<Link href="/records/new">비슷한 기록 남기기</Link>
</article>
))}
</section>
);
}다음은 app/records/new/page.tsx입니다.
export default function NewRecordPage() {
return (
<section>
<h3>새 기록 쓰기</h3>
<p>여기에서 나중에 제목과 내용을 입력하는 폼을 만들 예정입니다.</p>
</section>
);
}아직 기능은 단순합니다. 하지만 흐름은 이미 살아 있습니다.
홈에서 기록 목록으로 이동하고, 기록 목록에서 새 기록 쓰기 화면으로 이동할 수 있습니다. 게다가 /records 아래에 들어가면 기록 전용 레이아웃이 유지되므로, 사용자는 같은 구역 안에서 움직이고 있다는 안정감을 느끼게 됩니다.
바로 이 지점에서 "웹사이트 페이지 몇 개"가 아니라 "연결된 앱 화면들"처럼 보이기 시작합니다.
useRouter는 버튼과 조건문에 잘 어울립니다
그렇다면 useRouter는 언제 쓸까요?
공식 문서는 분명하게 말합니다. 기본 탐색은 Link를 먼저 쓰고, useRouter는 프로그래밍 방식 이동이 필요할 때 사용하라고요.2
쉽게 구분하면 이렇습니다.
사용자가 클릭할 수 있는 일반 링크라면
Link어떤 로직이 끝난 뒤 이동해야 한다면
useRouter버튼 클릭, 저장 성공, 조건 분기 같은 상황이라면
useRouter
예를 들어 "첫 기록 쓰러 가기" 버튼은 단순 링크로도 만들 수 있습니다. 하지만 버튼 클릭과 함께 어떤 로직을 붙이고 싶다면 useRouter가 잘 맞습니다.
주의할 점도 하나 있습니다. useRouter는 Client Component에서만 사용할 수 있습니다. 그래서 파일 맨 위에 'use client'가 필요합니다.2
예제를 보겠습니다.
'use client';
import { useRouter } from 'next/navigation';
export default function EmptyStateActions() {
const router = useRouter();
return (
<div>
<button onClick={() => router.push('/records/new')}>
첫 기록 쓰러 가기
</button>
<button onClick={() => router.back()}>
이전 화면으로
</button>
</div>
);
}이 컴포넌트는 두 가지 이동을 합니다.
첫 번째 버튼은 /records/new로 보냅니다. 두 번째 버튼은 브라우저 뒤로 가기처럼 이전 화면으로 돌려보냅니다.2
즉, Link가 "정해진 길목에 붙이는 표지판"이라면, useRouter는 "상황에 따라 직접 문을 열어 주는 직원" 같은 느낌입니다.
router.push()와 router.replace()는 비슷하지만 성격이 다릅니다
useRouter를 배우다 보면 가장 먼저 만나는 고민이 있습니다.
"push랑 replace는 뭐가 다르지?"
둘 다 이동은 합니다. 하지만 브라우저 기록을 다루는 방식이 다릅니다.2
router.push('/records')는 새 기록을 히스토리에 추가합니다. 그래서 사용자는 뒤로 가기를 눌러 이전 화면으로 돌아갈 수 있습니다.
반면 router.replace('/records')는 현재 기록을 새 경로로 바꿉니다. 그래서 뒤로 가기를 눌렀을 때 방금 화면으로 다시 돌아오지 않게 만들 수 있습니다.2
예를 들어 새 기록 작성 화면에서 "그만두기" 버튼을 눌렀다고 해 봅시다.
'use client';
import { useRouter } from 'next/navigation';
export default function NewRecordActions() {
const router = useRouter();
return (
<div>
<button onClick={() => router.push('/records')}>
목록으로 이동
</button>
<button onClick={() => router.replace('/records')}>
작성 취소하고 돌아가기
</button>
</div>
);
}이름은 비슷하지만 의도는 꽤 다릅니다.
push는 "이동하되, 방금 왔던 길도 남겨 두자"에 가깝고, replace는 "현재 화면을 새 화면으로 갈아끼우자"에 가깝습니다.
초보자 기준으로는 이렇게 기억하면 충분합니다.
다시 돌아올 가능성이 있으면
push돌아오지 않는 편이 자연스러우면
replace
refresh()는 새로고침 같지만 완전히 똑같지는 않습니다
useRouter에는 refresh()도 있습니다. 현재 경로를 다시 불러오고, 서버 컴포넌트를 다시 렌더링하도록 요청합니다.2
router.refresh();나중에 데이터를 수정한 뒤 목록을 다시 보여 주고 싶을 때 꽤 유용합니다. 다만 이름만 보고 브라우저 전체 새로고침처럼 생각하면 조금 오해할 수 있습니다. refresh()는 Next.js가 현재 라우트를 기준으로 필요한 내용을 다시 가져오도록 돕는 동작에 가깝습니다.2
이 장에서는 "이런 도구가 있다" 정도만 기억해 두면 충분합니다. 실제로 진가가 드러나는 순간은 데이터 수정, Server Actions, 캐싱을 배운 뒤입니다.
스크롤 이동도 제어할 수 있습니다
기본적으로 Next.js는 새 경로로 이동하면 페이지 맨 위로 스크롤을 올립니다. 그런데 탭처럼 화면 일부만 바뀌는 느낌을 주고 싶을 때는 이 동작이 어색할 수 있습니다.
그럴 때는 scroll: false 옵션을 줄 수 있습니다.2
router.push('/records', { scroll: false });항상 필요한 옵션은 아니지만, 긴 목록 화면에서 "필터만 바뀌는 듯한 이동"을 만들 때 꽤 쓸모가 있습니다.
App Router에서는 next/router가 아니라 next/navigation입니다
이 부분은 아주 많이 헷갈립니다.
예전 예제나 오래된 블로그를 보면 next/router를 쓰는 코드가 자주 보입니다. 하지만 App Router에서는 useRouter를 next/navigation에서 가져와야 합니다.2
import { useRouter } from 'next/navigation';이 한 줄은 정말 자주 틀리는 부분이라서, 지금 확실히 기억해 두는 편이 좋습니다.
"최신 Next.js + App Router라면 next/navigation."
이렇게 외워 두면 됩니다.
Link와 useRouter를 어떻게 고르면 좋을까요?
처음에는 둘 다 페이지 이동이니까 아무거나 써도 될 것처럼 느껴집니다. 하지만 기준을 하나 잡아 두면 훨씬 편합니다.
세 가지 질문으로 고르면 됩니다.
화면에 보이는 일반 링크인가요? 그럼
Link가 먼저입니다.버튼 클릭 뒤 조건을 검사하고 이동하나요? 그럼
useRouter가 잘 맞습니다.브라우저 기록을 세밀하게 다뤄야 하나요? 그럼
useRouter가 필요합니다.
짧게 요약하면 이렇습니다.
Link는 선언적인 이동이고, useRouter는 명령형 이동입니다.
선언적이라는 말은 "여기로 가는 링크입니다"라고 미리 그려 두는 방식이라는 뜻이고, 명령형이라는 말은 "지금 이 조건이면 저기로 이동해"라고 코드가 직접 지시하는 방식이라는 뜻입니다.
이 구분만 잡혀도 라우팅 코드가 훨씬 덜 헷갈립니다.
세그먼트 감각으로 폴더 구조를 읽어 봅시다
이제 방금 만든 구조를 세그먼트 관점에서 다시 읽어 보겠습니다.
app/
page.tsx
records/
layout.tsx
page.tsx
new/
page.tsx이 구조는 단순히 파일 목록이 아닙니다. 이렇게 해석됩니다.
/는 홈/records는 기록 목록/records/new는 기록 작성
여기서 records는 부모 세그먼트이고, new는 그 아래 자식 세그먼트입니다. 그래서 /records/new는 새로운 독립 세계가 아니라, 기록 영역 안에 속한 한 장면처럼 느껴집니다.
이 감각은 다음 장에서 동적 라우트를 배울 때 더 중요해집니다.
예를 들어 앞으로 이런 구조가 생길 수 있습니다.
app/
records/
[id]/
page.tsx그러면 /records/1, /records/2, /records/hello 같은 경로가 모두 같은 틀 안에서 동작하게 됩니다.1
즉, 이번 장의 중첩 라우팅은 다음 장의 동적 라우팅으로 넘어가기 위한 아주 좋은 준비 운동입니다.
자주 헷갈리는 포인트를 미리 정리해 봅시다
여기서 초보자가 많이 부딪히는 지점을 한 번 정리해 두겠습니다.
첫째, 폴더만 만든다고 페이지가 생기지는 않습니다. 해당 경로를 실제로 열려면 그 폴더 안에 page.tsx가 있어야 합니다.1
둘째, Link는 내부 라우팅의 기본 도구입니다. "버튼인데 이동하니까 무조건 useRouter"라고 생각할 필요는 없습니다. 단순 이동이면 Link도 충분합니다.2
셋째, useRouter는 Client Component 전용입니다. 파일 맨 위의 'use client'를 잊으면 오류 메시지와 뜻밖의 눈싸움을 하게 됩니다.2
넷째, App Router에서는 next/router가 아니라 next/navigation입니다.2
다섯째, router.push()나 router.replace()에 믿을 수 없는 URL 문자열을 그대로 넣으면 안 됩니다. 잘못 다루면 보안 문제가 생길 수 있기 때문입니다.2
이 다섯 가지만 기억해도 라우팅에서 헤매는 시간이 꽤 줄어듭니다.
지금 우리의 앱이 왜 더 앱다워졌는지
이번 장에서 한 일은 단순해 보일 수 있습니다. 링크 몇 개 달고, 폴더 몇 개 만들고, 버튼 이동 하나 넣은 것뿐이니까요.
그런데 사용자 경험은 꽤 달라집니다.
홈에서 기록 목록으로 자연스럽게 이동하고, 기록 구역 안에서 다시 새 기록 화면으로 이어지고, 공통 레이아웃이 그 흐름을 감싸 주기 때문입니다. 화면이 바뀌어도 앱의 뼈대는 유지되고, 사용자는 길을 잃지 않습니다.1
바로 이 점이 중요합니다.
웹사이트는 보통 "페이지를 하나씩 방문한다"는 느낌이 강하고, 앱은 "같은 공간 안에서 장면이 바뀐다"는 느낌이 강합니다. Next.js의 Link, useRouter, 중첩 라우팅, 중첩 레이아웃은 그 두 번째 감각을 훨씬 쉽게 만들어 줍니다.
우리의 '작은 기록 상점'도 이제 간판만 있는 상점이 아닙니다. 입구가 있고, 서가가 있고, 새 메모를 적는 작은 작업대까지 생겼습니다.
마무리: 길이 생기면 앱이 살아납니다
이번 장에서는 화면을 연결하는 기본 기술을 배웠습니다.
우리는 이제 다음 사실을 손으로 확인했습니다.
Link는 Next.js에서 페이지 이동의 기본 도구다.useRouter는 버튼, 조건문, 로직 뒤 이동처럼 프로그래밍 방식 탐색에 어울린다.중첩 라우팅은 폴더를 중첩하면 자연스럽게 만들어진다.
세그먼트는 URL을 읽는 가장 작은 감각 단위다.
공통 레이아웃과 중첩 라우팅이 만나면 웹이 훨씬 앱처럼 느껴진다.
다음 장에서는 한 걸음 더 나아가겠습니다. 이제 /records/new처럼 고정된 주소를 넘어서, /records/1, /records/2처럼 값에 따라 달라지는 동적인 URL을 다뤄 볼 차례입니다.
진열장을 구경하는 단계는 끝났습니다. 이제는 각 기록마다 자기만의 주소를 붙여 줄 시간입니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.