Next.js 길치

이현성·2026년 6월 1일
post-thumbnail

오늘 PR 확인 중 LinkuseRouter의 혼용 사용에 대한 대화를 나누는 것을 보았다.

어느 정도 알고 있긴 했는데 뭐랄까 딱 떨어지게 대답하기 힘들었다.

안 그래도 Next.js에서 페이지 이동 관련해서 조금 헷갈리는 이름들이 있기도 했고.

route, Link, router, navigate, redirect...

이번에는 App Router 기준으로 이동 관련 개념을 한 번 정리하려고 한다.


Next.js에서 이동을 볼 때는

크게 두 가지로 나눠서 보면 좋다.

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/qrAPI 요청 처리

여기서 헷갈린 점은 page.tsxroute.ts가 같은 "route"라는 말을 공유하지만 역할은 다르다는 것이다.

  • page.tsx: 화면을 보여주는 route
  • route.ts: API 요청을 처리하는 Route Handler

이 글 작성하기 전까지만 해도 route 하면 route.ts만 생각했는데 생각해보니 page.tsx도 route였구나 하고 생각이 났다.


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>도 충분

replace

기본적으로 Link는 브라우저 history에 새 기록을 추가한다.

<Link href="/dashboard">대시보드</Link>

이 말인 즉슨 이동 후 뒤로 가기를 누르면 이전 페이지로 돌아갈 수 있다는 의미다.

때때로 뒤로 가기 기록을 남기고 싶지 않을 때는 replace를 쓴다.

<Link href="/dashboard" replace>
  대시보드
</Link>

로그인 이후 진입 페이지처럼 "뒤로 가면 다시 로그인 페이지가 보이는 게 애매한" 경우에 생각해볼 수 있다.

예를 들어 /login에서 /dashboardreplace 이동하면, 뒤로 가기를 눌렀을 때 다시 /login으로 돌아오지 않고 /login 이전 페이지로 이동한다.

우리 프로젝트의 경우... 내 기억에 안 되어 있다 ㅎ;

scroll

페이지 이동 시 스크롤 위치를 제어하고 싶다면 scroll을 쓴다.

<Link href="/events" scroll={false}>
  행사 목록
</Link>

쓰면 어떻게 되냐, 스크롤 내린 상태에서 저 버튼을 누르면 이동하면서 보통 스크롤의 맨 위로 이동하게 되는데 이 옵션은 스크롤을 유지하게 해준다. 퍼센트 단위가 아니라 내린 px의 수치를 기억하는 거다!
쉽게 말해 현재 스크롤 offset 값을 유지한다고 생각하면 된다.

다만 이동한 페이지가 더 짧으면 그 px 위치까지 내려갈 수 없으니, 가능한 가장 아래쪽 위치로 맞춰질 수 있다.

prefetch

Link는 연결된 route를 미리 가져오는 prefetch를 지원한다.

<Link href="/admin/events" prefetch={false}>
  행사 목록
</Link>

링크가 너무 많거나, 들어갈 가능성이 낮은 무거운 페이지일 때 prefetch={false}를 고려할 수 있다.

최근에 TanStack Query의 prefetching에 대해 공부하려고 했는데, 여기서 이걸 보게 될 줄은 몰랐다.

공부하게 될 때 Link의 prefetch에 대해서도 자세히 알아봐야겠다.

참고로 이 prefetch는 프로덕션 환경에서 동작한다고 한다. 개발 서버에서 바로 체감이 안 될 수도 있다.


router

router는 코드로 직접 이동을 제어하고 싶을 때 쓰는 객체다.

App Router에서는 next/navigationuseRouter를 사용한다.

"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;

router.push

제일 많이 볼 수 있는 '그거'.

새 페이지로 이동하고 history에 기록을 남긴다.

router.push("/admin/events");

router.replace

이동은 하지만 history에 새 기록을 남기지 않는다.

router.replace("/login");

설명은 아까 말한 Link의 replace 옵션과 거의 비슷하다.

router.back, router.forward

브라우저의 뒤로 가기, 앞으로 가기와 비슷하다.

router.back();
router.forward();

모달 닫기, 이전 단계로 돌아가기 같은 UI에서 쓸 수 있다.

다만 사용자가 어떤 경로로 들어왔는지에 따라 결과가 달라질 수 있다.

그래서 서비스 흐름상 반드시 특정 페이지로 보내야 한다면 router.back()보다 router.push("/정해진-경로")가 더 안전하다.

실제로 우리 프로젝트에서 사용자가 바로 QR 페이지로 접근하는 경우를 고려하여 back 대신 push를 사용한 사례가 있다~

router.refresh

현재 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를 바꾼 경우에는 revalidatePathrevalidateTag 같은 흐름을 쓸 수 있다.

revalidatePath("/admin/events");

"이 경로에서 쓰는 서버 데이터가 바뀌었으니 다음에 다시 만들어"라고 Next.js 캐시에 알려주는 것이다.

정리하면 이렇다.

상황먼저 생각할 것
TanStack Query로 가져온 데이터 갱신invalidateQueries
서버 액션에서 DB 변경 후 서버 데이터 갱신revalidatePath, revalidateTag
현재 route의 서버 컴포넌트 데이터를 다시 받고 싶음router.refresh()

TanStack Query는 대략 이해가 되는데 저 서버 액션은 잘 이해가 안 되네. 그리고 revalidatePath, revalidateTag는 공부하면서 처음 알았는데 Next.js 내장 함수라고 한다.

사용법을 보니 TanStack Query랑 괴앵장히 유사해 보이는데, 말하고 싶은 내용은 거의 비슷한 것 같다.

차이점은 다음과 같다.

구분TanStack QueryNext.js revalidatePath / revalidateTag
캐시 위치브라우저, 클라이언트 메모리Next.js 서버 쪽 캐시
주 사용 위치Client ComponentServer Action, Route Handler
갱신 대상useQuery로 가져온 데이터서버 컴포넌트, fetch 캐시, route 캐시

기능은 비슷한데 생각보다 명확한 차이가 있네...;

우리 프로젝트의 경우 모든 백엔드 데이터를 TanStack Query로 관리하니 invalidateQueries 사용만 잘 고려해서 적용하면 될 듯싶다.


redirect

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

영구 이동이 필요하면 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

정해진 경로를 다른 경로로 보내야 한다면 next.config.jsredirects를 쓸 수 있다.

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 쪽을 봐야 한다.


NextResponse.redirect

요청이 실제 페이지나 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");

둘 다 이동은 되지만 의도가 다르다.

비교Linkrouter.push
방식선언적명령형
사용 위치JSX 안이벤트 핸들러, 함수 안
컴포넌트 종류서버/클라이언트 모두 가능클라이언트 컴포넌트
prefetch자동 지원직접 router.prefetch 고려
대표 상황메뉴, 목록, 카드 링크저장 후 이동, 확인 모달 후 이동

조금 더 나눠보면 이렇다.

<Link>를 쓰기 좋은 경우

  • 단순 페이지 이동
  • 클릭 가능한 텍스트, 이미지, 카드, 버튼처럼 보이는 링크 UI
  • 검색 엔진이나 브라우저가 "이건 이동 링크구나" 하고 알 수 있어야 하는 경우

반대로 useRouter를 쓰기 좋은 경우

  • 로그인 체크, 폼 유효성 검사 같은 조건부 이동
  • API 호출이나 저장 요청이 성공한 뒤 이동
  • 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 vs redirect

이 둘도 결과만 보면 비슷하다.

router.push("/login");
redirect("/login");

하지만 실행 위치가 다르다.

비교router.pushredirect
실행 위치브라우저서버
필요한 컴포넌트Client ComponentServer 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라는 이름 때문에 "라우팅할 때 쓰는 파일인가?" 싶을 수 있지만 정확히 말하면 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로 보는 게 이해하기 쉬울 것이다.


외부 URL 이동

외부 사이트로 이동할 때는 보통 <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가 제공하는 이동 최적화도 자연스럽게 받는다.

2. 서버 컴포넌트에서 useRouter 쓰기

const Page = () => {
  const router = useRouter();

  return <div />;
};

useRouter는 클라이언트 훅이다. 훅은 클라이언트에서 쓰는거다.

서버 컴포넌트에서는 redirect 같은 서버 쪽 API를 먼저 생각하자.


Stamply에선 어떻게 할 수 있나

화면/상황적당한 방법
랜딩에서 관리자 로그인 페이지로 이동<Link>
관리자 사이드바 메뉴 이동<Link>
행사 목록에서 행사 상세로 이동<Link href={...}>
행사 생성 폼 저장 후 상세로 이동서버 액션이면 redirect, 클라이언트 처리면 router.push
QR 스캔 후 미션 페이지로 이동처리 위치에 따라 redirect 또는 router.replace
비로그인 사용자가 /admin 접근proxy.tsNextResponse.redirect 또는 서버 레벨 redirect
/old-admin/admin으로 영구 변경next.config.js redirects

마무리

route는 목적지.

Link는 사용자가 누르는 이동.

router는 클라이언트 코드가 직접 시키는 이동.

redirect는 서버가 판단해서 보내는 이동.

NextResponse.redirect는 요청이 들어오는 입구에서 보내는 이동.


참고 자료


지금 36시간째 깨어 있어서 제정신이 아니다..
아니 5시부터 딱 3시간만 자려고 했는데, 잠 안 오게 해놓고 오피스 도착하자마자 쓰러지게 하는 거 있기 없기;
사실 오늘 prefetching 하고 싶었는데 너어어어어어어어어어어무 피곤하고 집중 안 돼서 오늘도 쩔 수 없이 라이트한 주제로 가져왔다 ㅠ
PR도 꼭 오늘 내로 해결하고 싶었는데 진짜 쥬글 것 같다.
좀 진득하게 보고 싶은데 여유가 안 되네. 이번에 MVP 발표만 끝나면 일정 여유롭게 잡아서 못해본 것들 다 해보고 싶다.

1개의 댓글

comment-user-thumbnail
2026년 6월 13일

@이현성님, 혹시 현 Agentic Programming 시대에 framework/library를 학습을 하고 계시는 입장에서, 여전히 next.js, react 등 framework/library에 대한 학습이 유용하다고 생각하시나요?

답글 달기