템플릿 컴포넌트 활용
공유 레이아웃의 상태 유지와 template.tsx의 세그먼트별 재마운트 범위를 비교해 적용 시점을 판단합니다.
Next.js App Router에서 UI를 함께 쓰고 재사용하는 가장 보편적인 방법은 layout.tsx로 정의하는 레이아웃 컴포넌트입니다. 하지만 때로는 레이아웃처럼 공통 UI를 제공하면서도, 페이지를 이동할 때 컴포넌트 인스턴스를 새로 만들고 상태를 초기화해야 할 때가 있습니다. 이때 쓰는 것이 템플릿 컴포넌트(template.tsx)이며, 이 글에서는 템플릿이 레이아웃과 어떻게 다른지, 어떤 상황에서 템플릿을 써야 하는지 예시와 함께 알아봅니다.
템플릿 컴포넌트란 무엇인가요?
템플릿 컴포넌트는 app 디렉터리의 특정 라우트 세그먼트 폴더 안에 있는 template.tsx 파일로, 레이아웃처럼 children prop을 받아 그 라우트의 내용을 감쌉니다. 차이는 수명에 있습니다. 공유 layout.tsx는 클라이언트 측 이동에서 유지할 헤더와 사이드바, 하위 UI를 감싸며, 레이아웃이 재사용된다고 아무 데이터 함수에나 캐시가 생기는 것은 아닙니다. 반면 template.tsx는 자기 세그먼트의 키가 바뀔 때 새 인스턴스와 DOM을 만들고 안의 클라이언트 상태를 초기화하므로, 그 지점에서 애니메이션이나 로거를 다시 시작할 수 있습니다.
두 파일 모두 기본으로 서버 컴포넌트이며 children으로 받은 하위 UI를 감쌉니다. 템플릿은 레이아웃과 페이지 사이에 놓여, 레이아웃이 자식들을 감싸고 템플릿이 다시 그 레이아웃의 자식(곧 페이지)을 감싸는 형태로 동작합니다. 다른 특수 파일을 빼고 포함 관계만 보면 layout.tsx(루트) → template.tsx(루트) → layout.tsx(세그먼트) → template.tsx(세그먼트) → page.tsx 순서입니다(Next.js 문서의 template).
템플릿 컴포넌트를 쓰는 경우
템플릿은 새로 마운트될 때 처음의 애니메이션을 실행하는 라이브러리(예: Motion)와 이어 페이지 전환 애니메이션을 만들 때 쓸모가 있으며, 템플릿이 다시 마운트되는 이동에서 그 효과가 다시 시작됩니다. 템플릿의 키가 바뀌는 이동에서 안쪽 클라이언트 컴포넌트의 로컬 상태를 초기화할 때도 쓰므로, 예를 들어 그 범위를 떠나 다른 화면으로 이동할 때 폼의 입력값을 새로 시작할 수 있습니다. 다시 마운트되는 지점마다 로거의 상태나 측정 구간을 새로 시작할 때도 쓸 수 있지만, 이는 모든 페이지 뷰를 세는 기능과 범위가 다르며, 개발 중 Strict Mode의 Effect 재실행도 실제 이동 횟수와 구별해야 합니다.
NEXT · CH5-3
template의 키가 바뀌는 범위만 초기화합니다
dashboard/template.tsx를 기준으로 비교합니다. 더 깊은 이동과 검색값 변경을 이 수준의 재마운트로 세지 않습니다.
세 경우는 매번 카운터2에서 시작하는 별도 실험입니다. 대시보드 템플릿 바깥의 공유 layout과, 바뀐 더 낮은 페이지의 수명을 따로 구별합니다.
| 이동 종류 | dashboard/template.tsx의 동작 |
|---|---|
| 형제 화면 | overview → analytics처럼 이 레벨의 키가 바뀌면 재마운트합니다. 내부 Client Component의 로컬 상태와 effect가 새로 시작합니다. |
| 하위 화면 | profile/edit → profile/security는 더 깊은 이동입니다. 대시보드 template은 유지되고, 바뀐 하위 페이지나 더 낮은 template은 별도로 처리됩니다. |
| 검색값 변경 | overview?tab=a → overview?tab=b처럼 search params만 바뀌면 template 재마운트를 일으키지 않습니다. |
- 이동 종류: 형제 화면
dashboard/template.tsx의 동작:overview → analytics처럼 이 레벨의 키가 바뀌면 재마운트합니다. 내부 Client Component의 로컬 상태와 effect가 새로 시작합니다.- 이동 종류: 하위 화면
dashboard/template.tsx의 동작:profile/edit → profile/security는 더 깊은 이동입니다. 대시보드 template은 유지되고, 바뀐 하위 페이지나 더 낮은 template은 별도로 처리됩니다.- 이동 종류: 검색값 변경
dashboard/template.tsx의 동작:overview?tab=a → overview?tab=b처럼 search params만 바뀌면 template 재마운트를 일으키지 않습니다.
본문의 motion.div는 새 마운트 때 initial → animate를 시작하도록 작성했습니다. 바깥 공유 layout과 그 Client 상태는 재사용 조건에서 유지됩니다. Strict Mode Effect 재실행은 실제 탐색 횟수와 구별합니다.
위 그림은 app/dashboard/template.tsx를 기준으로 템플릿이 다시 마운트되는 범위를 비교합니다. 그림의 요약처럼 템플릿은 자기 키가 바뀌는 범위만 초기화하며, 더 깊은 이동이나 검색값(검색 매개변수의 값)의 변경은 이 수준의 재마운트로 세지 않습니다. 세 경우는 모두 카운터가 2인 상태에서 시작하는 독립된 Link 이동이며, 공유 DashboardLayout은 그대로 유지됩니다. overview에서 analytics처럼 바로 아래의 키가 바뀌면 템플릿이 새로 마운트되어 안의 카운터가 2에서 0이 되고, profile/edit에서 profile/security로의 더 깊은 이동이나 overview?tab=a에서 tab=b로의 검색값 변경에서는 이 수준의 템플릿이 유지되어 카운터가 2로 남습니다.
그림 아래의 표는 이동의 종류마다 동작을 정리합니다. 형제 화면으로 가면 이 수준의 키가 바뀌어 다시 마운트되므로 안쪽 클라이언트 컴포넌트의 로컬 상태와 Effect가 새로 시작합니다. 하위 화면으로의 이동은 더 깊은 이동이라 대시보드 템플릿은 유지되고, 바뀐 하위 페이지나 더 낮은 템플릿은 따로 처리됩니다. 검색값만 바뀌면 템플릿의 재마운트를 일으키지 않습니다. 그림의 마지막 설명처럼 본문의 motion.div는 새로 마운트될 때 initial에서 animate로 시작하도록 작성했고, 바깥의 공유 레이아웃과 그 Client 상태는 재사용 조건에서 유지되며, Strict Mode의 Effect 재실행은 실제 이동 횟수와 구별합니다.
템플릿 컴포넌트 구현 실습
간단한 페이지 전환 효과로 템플릿의 동작을 이해해 봅시다. 이 예제는 Motion 라이브러리로 페이지 전환 애니메이션을 구현합니다. Motion은 예전의 Framer Motion이 이름을 바꾼 라이브러리로, 지금은 motion 패키지를 설치하고 motion/react에서 가져옵니다(framer-motion 패키지도 같은 버전으로 계속 배포됩니다). 먼저 프로젝트에 설치합니다.
npm install motion
# 또는
yarn add motionsrc/app/dashboard 폴더 안에 template.tsx를 만듭니다.
템플릿 안에서 Motion의 motion 컴포넌트로 애니메이션을 적용합니다. 이 예제는 motion을 직접 쓰는 컴포넌트에 "use client"를 붙이며, 템플릿 자체는 서버 컴포넌트로 두고 별도의 클라이언트 애니메이션 컴포넌트를 감싸는 구성도 가능합니다.
"use client"; // 클라이언트 컴포넌트임을 명시
import { motion } from 'motion/react';
export default function DashboardTemplate({ children }: { children: React.ReactNode }) {
return (
// motion.div는 Motion의 애니메이션 가능한 div 컴포넌트입니다.
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
{children} {/* 여기에 대시보드 페이지 콘텐츠가 렌더링됩니다 */}
</motion.div>
);
}"use client"는 이 예제의 애니메이션 컴포넌트를 클라이언트 쪽으로 선언합니다. motion.div는 Motion이 제공하는 컴포넌트로, initial과 animate prop으로 애니메이션의 시작점과 끝점을 정합니다. 이 템플릿의 키가 바뀌면 motion.div도 새로 마운트되어 처음의 애니메이션을 다시 시작하므로, 유지할 공통 UI는 바깥의 레이아웃에 둡니다.
원한다면 애니메이션을 더 잘 보려고 DashboardLayout에 최소 높이를 지정할 수 있습니다. 다음은 스타일만 보여 주는 일부 코드이며, 생략한 영역의 기존 사이드바와 {children} 렌더링은 그대로 둡니다.
// ...
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const userInfo = await getUserInfo(); // 기존 예시 코드 유지
return (
<div style={{ display: 'flex', minHeight: 'calc(100vh - 180px)', border: '1px solid #ccc', borderRadius: '8px', overflow: 'hidden' }}>
{/* ... */}
</div>
);
}개발 서버(npm run dev)를 실행해 http://localhost:3000/dashboard에 들어간 뒤, 대시보드 메뉴의 개요와 분석, 설정 같은 다른 링크를 눌러 보세요. /dashboard/overview, /dashboard/analytics, /dashboard/settings처럼 이 템플릿의 키를 바꾸는 형제 화면으로 이동하면 내용의 처음 애니메이션이 다시 시작됩니다. 새로 마운트될 때 initial에서 animate로 바뀌도록 작성한 코드 때문입니다. 같은 첫 마운트 애니메이션을 공유 레이아웃에 두면, 그 레이아웃을 재사용하는 이동만으로는 다시 시작하지 않으며, 상태나 props로 애니메이션을 따로 제어하는 경우와도 구별합니다.
애니메이션 대신 상태로 범위 확인하기
짧은 애니메이션을 눈으로 구별하기 어렵다면 다음 카운터를 별도의 파일로 만듭니다. useState를 쓰는 작은 클라이언트 컴포넌트이므로 바깥 레이아웃 전체를 클라이언트 컴포넌트로 바꿀 필요는 없습니다.
"use client";
import { useState } from 'react';
export default function ScopeCounter() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
카운터 클릭 수: {count}
</button>
);
}먼저 dashboard/template.tsx에 import ScopeCounter from './ScopeCounter';를 더하고, 기존 motion.div 안의 {children} 앞에 <ScopeCounter />를 넣습니다. 기존의 {children}과 애니메이션 코드는 그대로 둡니다. 클릭 수를 올린 뒤 메뉴의 Link로 개요에서 분석으로 이동해, 새로 마운트되어 0부터 시작하는지 확인합니다.
비교하려면 같은 카운터를 템플릿에서 빼고 dashboard/layout.tsx의 JSX 안, {children}보다 바깥에 한 번만 둡니다. 그 레이아웃에도 같은 상대 경로의 import를 더하며, 두 배치를 동시에 남기지 말고 같은 공유 레이아웃 아래의 Link 이동에서 상태가 유지되는지 비교합니다. 이 실습의 대상은 브라우저의 새로고침이 아닙니다.
템플릿 컴포넌트의 장단점
템플릿을 쓰면 다시 마운트해야 하는 화면의 범위를 파일의 위치로 정하고, 그 안의 처음 애니메이션과 클라이언트 상태, 측정 구간을 함께 새로 시작할 수 있습니다.
다만 다시 마운트할 때마다 DOM을 만들고 클라이언트 Effect를 설정하는 일이 되풀이되며, 그 비용이 얼마인지는 실제 컴포넌트에서 측정해야 합니다. 유지하려던 폼의 입력값이나 선택 상태도 같은 범위 안에 있으면 초기화되므로, UI를 함께 쓰기만 하면 될 때는 레이아웃으로 충분하고, 새 인스턴스가 꼭 필요한 구간에만 템플릿을 둡니다.
정리
template.tsx는 레이아웃처럼children을 감싸지만, 자기 세그먼트의 키가 바뀔 때마다 새로 마운트되어 안의 상태를 초기화합니다.- 포함 관계는 레이아웃 → 템플릿 → 하위 레이아웃 → 하위 템플릿 → 페이지 순서입니다.
- 형제 화면으로의 이동은 템플릿을 다시 마운트하지만, 더 깊은 하위 이동이나 검색값의 변경은 그 수준의 템플릿을 다시 마운트하지 않습니다.
- 페이지 전환 애니메이션이나 상태 초기화처럼 새 인스턴스가 필요한 곳에만 템플릿을 쓰고, UI를 함께 쓰기만 할 때는 레이아웃을 씁니다.
연습 문제
1. 세 이동에서의 카운터
카운터가 다시 dashboard/template.tsx 안에 있고, 각 실험을 시작할 때 클릭 수를 2로 올렸다고 가정합니다. 다음 세 이동에서 이 카운터가 초기화되는지와 그 이유를 설명해 보세요. 첫째는 /dashboard/overview → /dashboard/analytics, 둘째는 /dashboard/profile/edit → /dashboard/profile/security, 셋째는 /dashboard/overview?tab=a → /dashboard/overview?tab=b입니다.
해설 보기
첫 이동은 dashboard 바로 아래의 세그먼트 키가 overview에서 analytics로 바뀌므로, 대시보드 템플릿 안의 카운터가 새로 마운트되어 0이 됩니다. 두 번째 이동에서 이 수준의 키는 계속 profile이므로, 바뀐 하위 페이지는 따로 교체되지만 대시보드 템플릿 안의 카운터는 2를 유지합니다. 세 번째 이동은 검색값만 바뀌므로 이 템플릿을 다시 마운트하지 않아 2를 유지합니다.
이는 주어진 배치와 클라이언트 측 이동이라는 조건의 결과입니다. 카운터를 바뀌는 페이지 안에 두거나 다른 key를 직접 주면 수명이 달라지며, 템플릿을 더 낮은 폴더에 두었다면 그 폴더의 세그먼트를 기준으로 다시 판단합니다. Strict Mode의 개발용 Effect 재실행이나 페이지 데이터를 다시 조회한 횟수로 이 상태 문제의 답을 대신하지 않습니다.
2. 레이아웃과 템플릿 고르기
대시보드에서 사용자가 사이드바의 검색창에 입력한 값은 페이지를 옮겨도 남아 있어야 하고, 본문의 “작성 중인 메모” 입력란은 다른 페이지로 가면 비워져야 합니다. 각 입력란을 레이아웃과 템플릿 가운데 어디에 두어야 할까요?
해설 보기
사이드바의 검색창은 dashboard/layout.tsx에 둡니다. 공유 레이아웃은 그 아래의 클라이언트 측 이동에서 재사용되므로 입력값이 남습니다. 본문의 메모 입력란은 dashboard/template.tsx 안에 두면, 형제 페이지로 이동할 때 템플릿이 새로 마운트되어 입력값이 비워집니다.
다만 같은 세그먼트 안에서 검색값만 바뀌는 이동이나 더 깊은 하위 이동에서는 템플릿이 다시 마운트되지 않으므로, 그때도 비워야 한다면 페이지 안에 두거나 key를 직접 정하는 방법을 검토합니다.
3. 루트 템플릿의 재마운트 범위
모든 페이지 이동에서 전환 애니메이션을 다시 시작하려고 app/template.tsx에 motion.div를 두고, 그 안에 장바구니 미리보기의 로컬 상태도 넣었습니다. /about에서 /blog로, /blog/a에서 /blog/b로 이동할 때 각각 애니메이션이 다시 시작될까요? 장바구니 미리보기의 상태는 어떻게 될까요?
해설 보기
/about에서 /blog로 갈 때는 루트 바로 아래의 세그먼트 키가 about에서 blog로 바뀌므로, 루트 템플릿이 새로 마운트되어 애니메이션이 다시 시작되고 장바구니 미리보기의 상태도 초기화됩니다. /blog/a에서 /blog/b로 갈 때는 이 수준의 키가 계속 blog이므로 루트 템플릿은 유지되어 애니메이션이 다시 시작되지 않습니다. 그래서 루트 템플릿 하나로 모든 페이지 이동의 애니메이션을 다시 시작한다는 기대는 맞지 않습니다.
이동해도 남아야 하는 장바구니 미리보기는 루트 레이아웃처럼 재사용되는 위치로 옮기고, 더 깊은 이동에서도 애니메이션이 필요하다면 그 세그먼트의 폴더에 템플릿을 두는 방법을 검토합니다. 다시 마운트할 때마다 DOM과 Effect를 새로 만드는 비용도 실제 화면에서 측정합니다.
템플릿의 동작과 키는 template.js API 참조에서 확인할 수 있습니다.
다음 글 로딩 UI 구현에서는 loading.tsx와 Suspense로 데이터를 기다리는 동안의 로딩 UI를 구현하는 방법을 알아봅니다.