Next.js Route Group Private Folder template tsx

··설명 약 8분·작성: 해비·블로그 소개

Next.js Route Group, Private Folder, layout과 template은 어떻게 구분하나요?

괄호로 묶은 Route Group은 URL을 바꾸지 않고 경로를 정리하며, _로 시작하는 Private Folder는 라우팅 대상에서 빠집니다. 공통 UI와 상태를 유지할 때는 layout, 이동 경계에서 새로 시작할 때는 template을 검토합니다.

적용 조건·범위: React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

본문 근거: Next.js Route Group Private Folder template tsx 관련 문서 · Project Structure

독립 실습: 그룹 URL과 template state 관찰하기

React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

별도 빈 프로젝트에서 /practice/a를 열고 버튼을 증가시킨 뒤 Link B로 이동합니다.

app/(lab)/practice/template.tsx 전체 코드

"use client";
import { useState, type ReactNode } from 'react';
import Link from 'next/link';
export default function Template({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  return <main>
    <button onClick={() => setCount(value => value + 1)}>템플릿 {count}</button>
    <Link href="/practice/a">A</Link> <Link href="/practice/b">B</Link>
    {children}
  </main>;
}

app/(lab)/practice/a/page.tsx 전체 코드

export default function Page() { return <h1>A 페이지</h1>; }

app/(lab)/practice/b/page.tsx 전체 코드

export default function Page() { return <h1>B 페이지</h1>; }

app/_practice/page.tsx 전체 코드

export default function PrivateExample() { return <p>이 파일은 공개 경로가 아닙니다.</p>; }

기대 결과: (lab)은 URL에 없고, A→B 전환에서 template의 카운터가 0으로 초기화되는 것이 기대 결과입니다. /_practice는 라우팅에서 제외됩니다.

자가 확인: template을 layout으로 바꾼 별도 복사본에서 같은 이동을 비교해 상태 보존 차이를 기록합니다. 같은 URL을 만드는 다른 practice page를 함께 두지 않습니다.

Route Group

app/
├─ (public)/
│  └─ page.tsx
└─ (app)/
   └─ tasks/
      └─ page.tsx

괄호 폴더 이름은 URL에 포함되지 않습니다.

Private Folder

app/
└─ _components/
   └─ Header.tsx

_components는 라우팅에서 제외되는 보조 폴더로 사용할 수 있습니다.

layout과 template

layout은 같은 route tree 안에서 유지되는 공통 UI에 적합합니다.
template은 route 이동 시 새 인스턴스로 렌더링되는 경계가 필요할 때 사용합니다.

자주 발생하는 오류

  • Route Group 이름이 URL에 포함된다고 생각
  • private folder를 실제 route로 기대
  • layout과 template의 상태 보존 차이 혼동
  • 서로 같은 URL을 만드는 Route Group 충돌

Private Folder는 라우팅에서 제외하는 파일 구성 규칙이며 로그인·자료 권한 정책을 대신하지 않습니다. template의 새 인스턴스와 그 안의 Client state 초기화를 화면에 표시된 숫자로 관찰하고, 단순히 children만 반환하는 파일에서 새 인스턴스 여부를 화면 모양만으로 단정하지 않습니다.

공식 원리: Next.js Route Group Private Folder template tsx 관련 문서 (2026-10-12 확인). 아래 결과는 실행 전 기대 기준입니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.

app/page.tsx는 app/(public)/page.tsx로, tasks·settings는 app/(app) 아래로 옮깁니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js next 폴더와 빌드 산출물 이해의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: app/(app)/layout.tsx, app/(app)/settings/page.tsx, app/(app)/tasks/page.tsx, app/(app)/template.tsx, app/(public)/page.tsx, app/layout.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

직접 확인: 폴더를 옮긴 뒤 URL

독자 질문: app/(app)/tasks/page.tsx로 옮기면 주소도 /(app)/tasks가 될까요? 괄호 이름은 URL에서 빠지므로 주소는 /tasks입니다.

  1. 5회차와 6회차의 파일 목록을 비교합니다. 6회차에는 app/(public)/page.tsx, app/(app)/tasks/page.tsx, app/(app)/settings/page.tsx가 있습니다. 이전 app/page.tsx, app/tasks/page.tsx, app/settings/page.tsx가 남아 동일 URL을 만들지 않는지 확인합니다.
  2. npm run dev 뒤 /의 ‘TaskFlow Next.js’와 작업 목록 링크, /tasks의 ‘작업 목록’·검색 입력·상태, /settings의 ‘설정’ 제목을 확인합니다. 각 주소를 직접 입력하고 새로고침합니다. /(public)과 /(app)/tasks는 기대 URL이 아닙니다.
  3. app/_components/SiteHeader.tsx는 보조 컴포넌트이며 /_components 라우트가 아닙니다. app/(app)/layout.tsx는 작업·설정의 공통 헤더를 렌더링합니다. 현재 template.tsx는 children을 그대로 반환하므로 화면 모양만 보고 인스턴스 재생성을 입증할 수 없습니다.
  4. npm run typecheck와 npm run build의 종료 코드와 오류를 따로 기록합니다.

오류 점검

/tasks가 404면 파일 경로와 이름을 확인합니다. 빌드에 같은 경로 충돌이 나오면 이전 위치나 다른 그룹의 중복 page.tsx를 찾습니다. /_components의 404는 해당 폴더가 라우트가 아니므로 예상 결과입니다.

검증 상태: 코드와 문서는 대조했습니다. 이번 콘텐츠 편집에서 개발 서버·타입 검사·빌드·브라우저를 실행하지 않아 실제 화면과 경로 결과는 미확인입니다.

스스로 확인할 문제와 다음 학습

app/(public)/page.tsx는 어떤 주소인가요? 답: /입니다. 다음 7회차에서는 Parallel Routes의 slot과 default.tsx를 살펴봅니다.

완료 코드 ZIP 다운로드 — next-step-06-route-groups

이전 체크포인트: next-step-05-next-output

이번 회차의 변경

app/page.tsx는 app/(public)/page.tsx로, tasks·settings는 app/(app) 아래로 옮깁니다. 기존 위치의 page.tsx를 남기면 같은 URL 충돌이 발생합니다. app/_components/SiteHeader.tsx는 URL에 노출되지 않는 공통 UI입니다.

변경·추가 파일:

  • app/layout.tsx
  • app/(public)/page.tsx
  • app/(app)/tasks/page.tsx
  • app/(app)/settings/page.tsx
  • app/(app)/layout.tsx
  • app/(app)/template.tsx

기존 위치에서 제거할 파일:

  • app/page.tsx
  • app/settings/page.tsx
  • app/tasks/page.tsx
전체 파일·코드 보기

실행과 검증

Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.

npm install
npm run dev
npm run build
npm run start

전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.

완료 체크포인트: next-step-06-route-groups

실습 프로젝트 전체 코드

.gitignore

node_modules
.next
.env*
!.env.example
*.tsbuildinfo

app/(app)/layout.tsx

import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <SiteHeader />
      {children}
    </>
  );
}

app/(app)/settings/page.tsx

export default function SettingsPage() {
  return (
    <main>
      <h1>설정</h1>
      <p>TaskFlow 설정 화면입니다.</p>
    </main>
  );
}

app/(app)/tasks/page.tsx

import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
export default async function TasksPage() {
  const tasks = await getTasks();
  return (
    <main>
      <h1>작업 목록</h1>
      <TaskSearch />
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            {task.title}
            <span> · {task.completed ? "완료" : "진행 중"}</span>
          </li>
        ))}
      </ul>
    </main>
  );
}

app/(app)/template.tsx

import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

app/(public)/page.tsx

import Link from "next/link";
export default function HomePage() {
  return (
    <main>
      <h1>TaskFlow Next.js</h1>
      <p>App Router 기반 작업 관리 서비스입니다.</p>
      <Link href="/tasks">작업 목록 보기</Link>
    </main>
  );
}

app/_components/SiteHeader.tsx

import Link from "next/link";
export default function SiteHeader() {
  return (
    <header>
      <nav aria-label="주 메뉴">
        <Link href="/">홈</Link>
        <Link href="/tasks">작업</Link>
        <Link href="/settings">설정</Link>
      </nav>
    </header>
  );
}

app/globals.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #171717;
  background: #fafafa;
  font-family: Arial, sans-serif;
  line-height: 1.6;
}
header,
main {
  width: min(880px, calc(100% - 32px));
  margin: 24px auto;
}
nav {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
}
a {
  color: inherit;
  text-underline-offset: 3px;
}
button,
input,
select {
  min-height: 44px;
  padding: 8px 12px;
  font: inherit;
  border: 1px solid #aaa;
  background: #fff;
  color: #171717;
  max-width: 100%;
}
button {
  cursor: pointer;
}
button:disabled {
  opacity: 0.6;
  cursor: wait;
}
input {
  display: block;
  width: 100%;
}
label {
  display: block;
}
form,
section,
aside {
  margin: 20px 0;
}
ul {
  padding: 0;
  list-style: none;
}
li {
  padding: 12px;
  margin: 12px 0;
  border: 1px solid #ccc;
  overflow-wrap: anywhere;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.controls form {
  margin: 0;
}
.modal {
  width: min(640px, calc(100% - 32px));
  border: 1px solid #777;
  padding: 24px;
  background: #fff;
  color: #171717;
}
.modal::backdrop {
  background: rgb(0 0 0 / 55%);
}
:focus-visible {
  outline: 3px solid #555;
  outline-offset: 3px;
}
[data-theme="dark"] {
  background: #202020;
  color: #fafafa;
  padding: 16px;
}
[data-density="compact"] li {
  padding: 8px;
  margin: 8px 0;
}
img {
  max-width: 100%;
  height: auto;
}

app/layout.tsx

import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
  title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
  description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}

app/lib/data.ts

import type { Task } from "./types";
const tasks: Task[] = [
  {
    id: "task-1",
    title: "App Router 공부",
    description: "서버와 브라우저의 역할을 구분합니다.",
    completed: false,
    ownerId: "demo",
  },
  {
    id: "task-2",
    title: "TaskFlow 만들기",
    description: "누적 실습을 완성합니다.",
    completed: true,
    ownerId: "demo",
  },
];
export async function getTasks() {
  return tasks;
}
export async function getTask(id: string) {
  return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
  const ts = await getTasks();
  return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}

app/lib/types.ts

export type Task = {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  ownerId: string;
};

app/ui/task-search.tsx

"use client";
import { useState } from "react";
export default function TaskSearch() {
  const [keyword, setKeyword] = useState("");
  return (
    <label>
      작업 검색
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
        placeholder="검색어 입력"
      />
    </label>
  );
}

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";


// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

next.config.ts

import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;

package.json

{
  "name": "taskflow-next",
  "version": "1.0.6",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.13.6",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./*"
      ]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

글 소개·학습 준비·전체 목차

Next.js Route Group Private Folder template tsx 학습 표지

Next.js · 6/38 · TaskFlow 누적 실습

Next.js Route Group Private Folder template tsx

Next.js · 6 / 38 · 필수 학습 · Route Group·Private Folder·template.tsx

URL을 바꾸지 않고 폴더를 정리하고 이동 시 상태를 다시 시작하려면? 괄호 Route Group은 URL에서 빠지고 _로 시작하는 Private Folder는 라우팅 대상에서 제외됩니다. 공통 UI를 유지할 때는 layout을, 이동 경계에서 새 인스턴스가 필요할 때는 template을 사용합니다. 별도 A·B 페이지의 카운터로 차이를 확인합니다.

이번 글에서 배우는 내용

  • Route Group (group)
  • Private Folder _folder
  • layout.tsx와 template.tsx 차이
  • URL과 디렉터리 구조 분리
  • 공개 영역과 앱 영역 분리

목차

다음 학습

다음 글에서는 같은 layout 안에 여러 독립 UI 영역을 렌더링하는 Parallel Routes와 default.tsx를 다룹니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 39개 · 필수 38개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기