
오늘 PR 확인 중 Link와 useRouter의 혼용 사용에 대한 대화를 나누는 것을 보았다.
어느 정도 알고 있긴 했는데 뭐랄까 딱 떨어지게 대답하기 힘들었다.
안 그래도 Next.js에서 페이지 이동 관련해서 조금 헷갈리는 이름들이 있기도 했고.
route, Link, router, navigate, redirect...
이번에는 App Router 기준으로 이동 관련 개념을 한 번 정리하려고 한다.
크게 두 가지로 나눠서 보면 좋다.
1. route
어디로 갈 수 있는지 정하는 구조
2. navigation
그 route로 실제 이동시키는 방법
route는 길 자체고, Link, router.push, redirect는 그 길로 보내는 방법에 가깝다.
저 3개를 사용할 때 추가로 URL을 넣어주곤 하잖아.
예를 들어 이런 구조가 있다고 하자.
app
├─ page.tsx
├─ events
│ ├─ page.tsx
│ └─ [eventId]
│ └─ page.tsx
└─ api
└─ qr
└─ route.ts
그러면 대략 이런 route가 생긴다.
| 파일 | 만들어지는 경로 | 역할 |
|---|---|---|
app/page.tsx | / | 메인 페이지 |
app/events/page.tsx | /events | 행사 목록 페이지 |
app/events/[eventId]/page.tsx | /events/1 | 행사 상세 페이지 |
app/api/qr/route.ts | /api/qr | API 요청 처리 |
여기서 헷갈린 점은 page.tsx와 route.ts가 같은 "route"라는 말을 공유하지만 역할은 다르다는 것이다.
page.tsx: 화면을 보여주는 routeroute.ts: API 요청을 처리하는 Route Handler이 글 작성하기 전까지만 해도 route 하면 route.ts만 생각했는데 생각해보니 page.tsx도 route였구나 하고 생각이 났다.
route는 사용자가 접근할 수 있는 URL 경로다.
App Router에서는 폴더와 파일로 route를 만든다.
app/admin/events/page.tsx
이 파일은 아래 주소에 해당한다.
/admin/events
동적 경로는 폴더 이름을 대괄호로 감싼다.
app/admin/events/[eventId]/page.tsx
그러면 이런 주소들을 처리할 수 있다.
/admin/events/1
/admin/events/42
/admin/events/stamply
그리고 페이지 컴포넌트에서는 params로 값을 받을 수 있다.
type Props = {
params: Promise<{
eventId: string;
}>;
};
const EventDetailPage = async ({ params }: Props) => {
const { eventId } = await params;
return <div>eventId: {eventId}</div>;
};
export default EventDetailPage;
route는 이동 방법이 아니라 목적지다.
Link는 Next.js에서 페이지 사이를 이동할 때 가장 기본으로 쓰는 컴포넌트다.
import Link from "next/link";
const Header = () => {
return <Link href="/admin/events">행사 목록</Link>;
};
export default Header;
href를 사용해서 겉으로는 <a> 태그처럼 보이지만, Next.js의 클라이언트 사이드 이동과 prefetch 최적화가 붙어 있다.
그래서 화면에 보이는 이동 UI라면 보통 Link를 생각하면 된다.
<Link href={`/events/${event.id}`}>{event.title}</Link>
이런 상황에 잘 맞는다.
| 상황 | Link 사용 |
|---|---|
| 헤더 메뉴 | 좋음 |
| 탭, 사이드바 | 좋음 |
| 카드 클릭으로 상세 이동 | 좋음 |
| 글 목록에서 상세 페이지 이동 | 좋음 |
| 단순 외부 링크 | 보통 <a>도 충분 |
기본적으로 Link는 브라우저 history에 새 기록을 추가한다.
<Link href="/dashboard">대시보드</Link>
이 말인 즉슨 이동 후 뒤로 가기를 누르면 이전 페이지로 돌아갈 수 있다는 의미다.
때때로 뒤로 가기 기록을 남기고 싶지 않을 때는 replace를 쓴다.
<Link href="/dashboard" replace>
대시보드
</Link>
로그인 이후 진입 페이지처럼 "뒤로 가면 다시 로그인 페이지가 보이는 게 애매한" 경우에 생각해볼 수 있다.
예를 들어 /login에서 /dashboard로 replace 이동하면, 뒤로 가기를 눌렀을 때 다시 /login으로 돌아오지 않고 /login 이전 페이지로 이동한다.
우리 프로젝트의 경우... 내 기억에 안 되어 있다 ㅎ;
페이지 이동 시 스크롤 위치를 제어하고 싶다면 scroll을 쓴다.
<Link href="/events" scroll={false}>
행사 목록
</Link>
쓰면 어떻게 되냐, 스크롤 내린 상태에서 저 버튼을 누르면 이동하면서 보통 스크롤의 맨 위로 이동하게 되는데 이 옵션은 스크롤을 유지하게 해준다. 퍼센트 단위가 아니라 내린 px의 수치를 기억하는 거다!
쉽게 말해 현재 스크롤 offset 값을 유지한다고 생각하면 된다.
다만 이동한 페이지가 더 짧으면 그 px 위치까지 내려갈 수 없으니, 가능한 가장 아래쪽 위치로 맞춰질 수 있다.
Link는 연결된 route를 미리 가져오는 prefetch를 지원한다.
<Link href="/admin/events" prefetch={false}>
행사 목록
</Link>
링크가 너무 많거나, 들어갈 가능성이 낮은 무거운 페이지일 때 prefetch={false}를 고려할 수 있다.
최근에 TanStack Query의 prefetching에 대해 공부하려고 했는데, 여기서 이걸 보게 될 줄은 몰랐다.
공부하게 될 때 Link의 prefetch에 대해서도 자세히 알아봐야겠다.
참고로 이 prefetch는 프로덕션 환경에서 동작한다고 한다. 개발 서버에서 바로 체감이 안 될 수도 있다.
router는 코드로 직접 이동을 제어하고 싶을 때 쓰는 객체다.
App Router에서는 next/navigation의 useRouter를 사용한다.
"use client";
import { useRouter } from "next/navigation";
const EventCreateButton = () => {
const router = useRouter();
const handleClick = () => {
router.push("/admin/events/new");
};
return <button onClick={handleClick}>행사 만들기</button>;
};
export default EventCreateButton;
제일 많이 볼 수 있는 '그거'.
새 페이지로 이동하고 history에 기록을 남긴다.
router.push("/admin/events");
이동은 하지만 history에 새 기록을 남기지 않는다.
router.replace("/login");
설명은 아까 말한 Link의 replace 옵션과 거의 비슷하다.
브라우저의 뒤로 가기, 앞으로 가기와 비슷하다.
router.back();
router.forward();
모달 닫기, 이전 단계로 돌아가기 같은 UI에서 쓸 수 있다.
다만 사용자가 어떤 경로로 들어왔는지에 따라 결과가 달라질 수 있다.
그래서 서비스 흐름상 반드시 특정 페이지로 보내야 한다면 router.back()보다 router.push("/정해진-경로")가 더 안전하다.
실제로 우리 프로젝트에서 사용자가 바로 QR 페이지로 접근하는 경우를 고려하여 back 대신 push를 사용한 사례가 있다~
현재 route를 새로고침한다.
router.refresh();
브라우저 전체 새로고침처럼 모든 상태를 날리는 느낌이라기보다는, 현재 route의 서버 컴포넌트 데이터를 다시 가져오는 쪽에 가깝다.
예를 들어 클라이언트에서 어떤 작업을 끝낸 뒤 현재 화면의 서버 데이터만 다시 반영하고 싶을 때 대략 다음과 같이 사용한다.
const handleSave = async () => {
await updateEvent();
router.refresh();
};
그런데 데이터 갱신 목적이라면 무조건 refresh부터 쓰기보다는, 데이터가 어디에서 관리되는지 먼저 보는 게 좋다고 한다.
쉽게 말하면 router.refresh()는 현재 route를 다시 불러오는 쪽에 가깝다. 범위가 넓은거지.
반면 TanStack Query로 가져온 데이터라면 invalidate를 쓴다.
queryClient.invalidateQueries({ queryKey: ["events"] });
"events 데이터가 바뀌었으니 다시 가져와줘"라는 내용이다.
즉, 화면 전체를 다시 흔드는 게 아니라 특정 query만 다시 가져오게 하는 방식이다.
서버 액션에서 DB를 바꾼 경우에는 revalidatePath나 revalidateTag 같은 흐름을 쓸 수 있다.
revalidatePath("/admin/events");
"이 경로에서 쓰는 서버 데이터가 바뀌었으니 다음에 다시 만들어"라고 Next.js 캐시에 알려주는 것이다.
정리하면 이렇다.
| 상황 | 먼저 생각할 것 |
|---|---|
| TanStack Query로 가져온 데이터 갱신 | invalidateQueries |
| 서버 액션에서 DB 변경 후 서버 데이터 갱신 | revalidatePath, revalidateTag |
| 현재 route의 서버 컴포넌트 데이터를 다시 받고 싶음 | router.refresh() |
TanStack Query는 대략 이해가 되는데 저 서버 액션은 잘 이해가 안 되네. 그리고 revalidatePath, revalidateTag는 공부하면서 처음 알았는데 Next.js 내장 함수라고 한다.
사용법을 보니 TanStack Query랑 괴앵장히 유사해 보이는데, 말하고 싶은 내용은 거의 비슷한 것 같다.
차이점은 다음과 같다.
| 구분 | TanStack Query | Next.js revalidatePath / revalidateTag |
|---|---|---|
| 캐시 위치 | 브라우저, 클라이언트 메모리 | Next.js 서버 쪽 캐시 |
| 주 사용 위치 | Client Component | Server Action, Route Handler |
| 갱신 대상 | useQuery로 가져온 데이터 | 서버 컴포넌트, fetch 캐시, route 캐시 |
기능은 비슷한데 생각보다 명확한 차이가 있네...;
우리 프로젝트의 경우 모든 백엔드 데이터를 TanStack Query로 관리하니 invalidateQueries 사용만 잘 고려해서 적용하면 될 듯싶다.
redirect는 서버 쪽에서 사용자를 다른 URL로 보내는 함수다.
import { redirect } from "next/navigation";
const AdminPage = async () => {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return <div>관리자 페이지</div>;
};
export default AdminPage;
클라이언트 버튼 클릭 이벤트에서 쓰는 router.push와 다르게, redirect는 서버 컴포넌트, 서버 액션, Route Handler 같은 곳에서 자연스럽다.
예를 들어 서버 액션에서 저장 후 상세 페이지로 보내는 경우다.
"use server";
import { redirect } from "next/navigation";
export const createEvent = async (formData: FormData) => {
const event = await insertEvent(formData);
redirect(`/admin/events/${event.id}`);
};
여기서 redirect는 단순히 다음 줄로 계속 진행되는 함수처럼 생각하면 안 된다.
호출되면 그 시점에서 이동 응답을 던진다. 던지면 마치 return처럼 끝이기 때문에 보통 조건문 안이나 함수 마지막에 둔다.
영구 이동이 필요하면 permanentRedirect를 쓴다.
import { permanentRedirect } from "next/navigation";
const Page = async () => {
permanentRedirect("/new-url");
};
export default Page;
예를 들어 사용자 닉네임이 바뀌어서 canonical URL이 완전히 바뀐 경우처럼, "앞으로도 계속 새 주소가 맞다"는 의미가 있을 때 어울린다.
음 쉽게 말하면 canonical URL은 그냥 공식 주소라고 생각하면 된다.
일반 redirect가 "이번 요청은 일단 여기로 보내자"에 가깝다면, permanentRedirect는 "이 주소는 앞으로도 계속 저 주소가 맞다"에 가깝다.
예를 들어 예전 주소가 /events/1이었는데 앞으로는 /events/stamply-popup을 공식 주소로 쓰기로 했다면, 예전 주소로 들어온 사용자를 새 주소로 영구 이동시키는 식이다.
정해진 경로를 다른 경로로 보내야 한다면 next.config.js의 redirects를 쓸 수 있다.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: "/old-events",
destination: "/events",
permanent: true,
},
];
},
};
export default nextConfig;
이건 컴포넌트 내부 로직이라기보다는, URL 규칙에 가까운 리다이렉트다.
예를 들어 이런 상황에 적용한다.
| 상황 | 사용 |
|---|---|
/old를 /new로 옮김 | 굿 ☺️ |
| 서비스 URL 구조 개편 | 굿 ☺️ |
| 특정 버튼 클릭 후 이동 | 낫 굿 😵 |
| 로그인 여부에 따른 이동 | 보통 다른 방법이 나음 |
사용자 상태나 DB 조회 결과에 따라 매번 판단해야 한다면 next.config.js보다는 서버 로직이나 Proxy 쪽을 봐야 한다.
요청이 실제 페이지나 API에 도착하기 전에 가로채서 보내고 싶다면 NextResponse.redirect를 쓴다.
App Router 기준으로는 proxy.ts에서 많이 보게 된다.
이 방식은 요청 초입에서 처리한다는 점이 다르기 때문에 인증, 세션, 지역, A/B 테스트처럼 페이지 렌더링 전에 판단해야 하는 일에 어울린다.
다만 모든 요청 앞단에 걸릴 수 있으니 너무 무거운 일을 넣으면 부담이 된다.
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
pathname.match(/^\/event\/[^/]+\/brochure/) &&
!pathname.startsWith("/admin")
) {
const participantCookie = request.cookies.get(PARTICIPANT_COOKIE_NAME);
if (!participantCookie) {
return NextResponse.redirect(new URL("/qr-required", request.url));
}
}
return NextResponse.next();
}
우리 프로젝트의 proxy.ts의 일부를 한 번 가져와봤다.
이 코드는 /event/어쩌구/brochure 형태의 페이지에 접근했을 때 참여자 쿠키가 없으면 /qr-required로 보내는 흐름이다.
즉, 페이지가 렌더링되기 전에 "이 사용자가 바로 브로슈어 페이지를 봐도 되는 상태인가?"를 먼저 확인하는 것이다.
| 상황 | 추천 |
|---|---|
| 화면에 보이는 링크로 이동 | <Link> |
| 버튼 클릭 후 이동 | useRouter().push() |
| 뒤로 가기 기록을 남기지 않는 이동 | replace |
| 서버 컴포넌트에서 조건에 따라 이동 | redirect() |
| 서버 액션 완료 후 이동 | redirect() |
| 영구 URL 변경 | permanentRedirect() 또는 next.config.js redirects |
| 오래된 URL을 새 URL로 매핑 | next.config.js redirects |
| 요청이 페이지에 닿기 전에 인증 체크 | NextResponse.redirect() in proxy.ts |
| API 응답을 직접 만들기 | route.ts |
이 글의 시작.. 제일 헷갈리는 경우다.
<Link href="/events">행사</Link>
router.push("/events");
둘 다 이동은 되지만 의도가 다르다.
| 비교 | Link | router.push |
|---|---|---|
| 방식 | 선언적 | 명령형 |
| 사용 위치 | JSX 안 | 이벤트 핸들러, 함수 안 |
| 컴포넌트 종류 | 서버/클라이언트 모두 가능 | 클라이언트 컴포넌트 |
| prefetch | 자동 지원 | 직접 router.prefetch 고려 |
| 대표 상황 | 메뉴, 목록, 카드 링크 | 저장 후 이동, 확인 모달 후 이동 |
조금 더 나눠보면 이렇다.
<Link>를 쓰기 좋은 경우
반대로 useRouter를 쓰기 좋은 경우
onClick 안에서 여러 로직을 처리한 다음 코드로 이동시켜야 하는 경우여기서 헷갈릴 수 있는 건 동적 URL이다.
<Link href={`/events/${event.id}`}>{event.title}</Link>
이렇게 URL에 변수가 들어가도 JSX만 봤을 때 어디로 이동하는지 보이면 Link가 자연스럽다.
반대로 클릭했을 때 조건을 검사하거나 비동기 작업을 기다린 뒤 이동해야 하면 router.push가 자연스럽다.
예를 들어 이동 전에 확인창을 띄워야 한다면 이런 식이다.
const handleClick = () => {
if (!confirm("작성 중인 내용이 사라질 수 있습니다. 이동할까요?")) {
return;
}
router.push("/events");
};
이 둘도 결과만 보면 비슷하다.
router.push("/login");
redirect("/login");
하지만 실행 위치가 다르다.
| 비교 | router.push | redirect |
|---|---|---|
| 실행 위치 | 브라우저 | 서버 |
| 필요한 컴포넌트 | Client Component | Server Component, Server Action, Route Handler |
| 사용 방식 | 이벤트 이후 이동 | 렌더링/요청 처리 중 이동 |
| 예시 | 버튼 클릭 후 이동 | 로그인 안 된 사용자를 서버에서 차단 |
클라이언트에서 사용자가 뭔가 누른 상황이라면..?
"use client";
const Button = () => {
const router = useRouter();
return <button onClick={() => router.push("/login")}>로그인</button>;
};
이땐 router.push를 사용한다.
서버에서 사용자 정보를 확인했는데 없다면..?
const AdminPage = async () => {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return <div>관리자 페이지</div>;
};
이건 redirect를 사용한다.
route.ts라는 이름 때문에 "라우팅할 때 쓰는 파일인가?" 싶을 수 있지만 정확히 말하면 route.ts는 화면 이동용 파일이 아니라 요청 핸들러다.
export const GET = async () => {
return Response.json({
message: "hello",
});
};
이런 식으로 API를 만든다.
GET /api/qr
물론 Route Handler 안에서도 이전 코드 예시에서 볼 수 있듯 리다이렉트 응답을 만들 수는 있다.
import { redirect } from "next/navigation";
export const GET = async () => {
redirect("/events");
};
하지만 일반적인 페이지 이동은 Link, router, redirect로 보는 게 이해하기 쉬울 것이다.
외부 사이트로 이동할 때는 보통 <a> 태그가 충분하다.
<a href="https://example.com" target="_blank" rel="noreferrer">
외부 링크
</a>
Link는 Next.js 앱 내부 route 이동에 초점이 있다.
외부 링크까지 무조건 Link로 감싸려고 하면 오히려 의도가 흐려진다.
코드로 외부 URL을 열어야 한다면 브라우저 API를 쓸 수 있다.
window.location.href = "https://example.com";
다만 router.push에 사용자 입력으로 만든 URL을 그대로 넣는 건 피해야 한다.
검증되지 않은 URL이 들어가면 보안 문제가 생길 수 있다.
이 내용은 처음 안건데 오.. 확인 확인.
<button onClick={() => router.push("/events")}>행사</button>
버튼이어야 할 이유가 없다면 보통은 Link가 더 낫다.
<Link href="/events">행사</Link>
의미도 더 명확하고, Next.js가 제공하는 이동 최적화도 자연스럽게 받는다.
const Page = () => {
const router = useRouter();
return <div />;
};
useRouter는 클라이언트 훅이다. 훅은 클라이언트에서 쓰는거다.
서버 컴포넌트에서는 redirect 같은 서버 쪽 API를 먼저 생각하자.
| 화면/상황 | 적당한 방법 |
|---|---|
| 랜딩에서 관리자 로그인 페이지로 이동 | <Link> |
| 관리자 사이드바 메뉴 이동 | <Link> |
| 행사 목록에서 행사 상세로 이동 | <Link href={...}> |
| 행사 생성 폼 저장 후 상세로 이동 | 서버 액션이면 redirect, 클라이언트 처리면 router.push |
| QR 스캔 후 미션 페이지로 이동 | 처리 위치에 따라 redirect 또는 router.replace |
비로그인 사용자가 /admin 접근 | proxy.ts의 NextResponse.redirect 또는 서버 레벨 redirect |
/old-admin을 /admin으로 영구 변경 | next.config.js redirects |
route는 목적지.
Link는 사용자가 누르는 이동.
router는 클라이언트 코드가 직접 시키는 이동.
redirect는 서버가 판단해서 보내는 이동.
NextResponse.redirect는 요청이 들어오는 입구에서 보내는 이동.
지금 36시간째 깨어 있어서 제정신이 아니다..
아니 5시부터 딱 3시간만 자려고 했는데, 잠 안 오게 해놓고 오피스 도착하자마자 쓰러지게 하는 거 있기 없기;
사실 오늘 prefetching 하고 싶었는데 너어어어어어어어어어어무 피곤하고 집중 안 돼서 오늘도 쩔 수 없이 라이트한 주제로 가져왔다 ㅠ
PR도 꼭 오늘 내로 해결하고 싶었는데 진짜 쥬글 것 같다.
좀 진득하게 보고 싶은데 여유가 안 되네. 이번에 MVP 발표만 끝나면 일정 여유롭게 잡아서 못해본 것들 다 해보고 싶다.
@이현성님, 혹시 현 Agentic Programming 시대에 framework/library를 학습을 하고 계시는 입장에서, 여전히 next.js, react 등 framework/library에 대한 학습이 유용하다고 생각하시나요?