대규모 Next.js 프로젝트 구조화: 뼈아프게 배운 것들
자체 무게에 무너지지 않는 Next.js 코드베이스 정리법에 대해 힘들게 얻은 교훈들. 기능 기반 아키텍처, 라우트 그룹, 서버/클라이언트 경계, 배럴 파일 함정, 그리고 바로 가져다 쓸 수 있는 실제 폴더 구조.
모든 Next.js 프로젝트는 깔끔하게 시작됩니다. 페이지 몇 개, components 폴더 하나, 어쩌면 lib/utils.ts 하나. 생산적으로 느껴집니다. 정돈된 느낌입니다.
그리고 6개월이 지납니다. 개발자 3명이 합류합니다. components 폴더에 140개의 파일이 있습니다. 누군가 utils.ts가 800줄이라서 utils2.ts를 만들었습니다. 어떤 레이아웃이 어떤 라우트를 감싸는지 아무도 모릅니다. 빌드가 4분이 걸리는데 이유를 모릅니다.
저는 이 사이클을 여러 번 겪었습니다. 다음에 나오는 내용은 제가 잘못된 것을 먼저 출시한 뒤 대부분의 교훈을 배우고 나서 실제로 하고 있는 방법입니다.
기본 구조는 금방 무너진다#
Next.js 문서에서는 프로젝트 구조를 제안합니다. 괜찮습니다. 개인 블로그나 작은 도구에는 잘 작동합니다. 하지만 두 명 이상의 개발자가 같은 코드베이스를 건드리는 순간, "타입 기반" 접근법은 금이 가기 시작합니다:
src/
components/
Button.tsx
Modal.tsx
UserCard.tsx
DashboardSidebar.tsx
InvoiceTable.tsx
InvoiceRow.tsx
InvoiceActions.tsx
...137 more files
hooks/
useAuth.ts
useInvoice.ts
useDebounce.ts
utils/
format.ts
validate.ts
misc.ts <- 끝의 시작
app/
page.tsx
dashboard/
page.tsx
문제는 이것이 "틀렸다"는 게 아닙니다. 기능과 함께 선형적으로 확장되지만 이해도와 함께 확장되지 않는다는 것입니다. 새로운 개발자가 인보이스 작업을 해야 할 때, components/, hooks/, utils/, types/, 그리고 app/을 동시에 열어야 합니다. 인보이스 기능이 5개 디렉토리에 흩어져 있습니다. "인보이스가 이런 모습이다"라고 알려주는 단일 장소가 없습니다.
4명의 개발자로 구성된 팀이 이 정확한 구조로 리팩토링하는 데 스프린트 하나를 통째로 쓰는 것을 봤습니다. "깔끔한" 방법이라고 확신하면서요. 3개월 안에 다시 혼란스러워졌습니다. 그냥 폴더가 더 많아졌을 뿐입니다.
기능 기반 코로케이션이 이긴다. 매번.#
실제 팀과의 접촉에서 살아남은 패턴은 기능 기반 코로케이션입니다. 아이디어는 간단합니다: 기능과 관련된 모든 것이 함께 위치합니다.
"모든 컴포넌트를 한 폴더에, 모든 훅을 다른 폴더에"가 아닙니다. 그 반대입니다. 인보이스를 위한 모든 컴포넌트, 훅, 타입, 유틸리티가 features/invoicing/ 안에 있습니다. 인증을 위한 모든 것이 features/auth/ 안에 있습니다.
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
export function useInvoiceActions(invoice: Invoice) {
const markAsPaid = useCallback(async () => {
await invoiceApi.updateStatus(invoice.id, "paid");
}, [invoice.id]);
const duplicate = useCallback(async () => {
const newNumber = formatInvoiceNumber(Date.now());
await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
}, [invoice]);
return { markAsPaid, duplicate };
}import문을 주목하세요. 모두 상대 경로이고, 모두 기능 내부에 있습니다. 이 훅은 전역 hooks/ 폴더나 공유 utils/format.ts에 접근하지 않습니다. 인보이스 기능 자체의 포맷 로직을 사용합니다. 인보이스 번호 포맷팅은 인보이스 관심사이지 전역 관심사가 아니기 때문입니다.
직관에 반하는 부분: 이는 약간의 중복이 생긴다는 의미입니다. 인보이스 기능에 format.ts가 있고 청구 기능에도 format.ts가 있을 수 있습니다. 괜찮습니다. 14개 기능이 의존하면서 아무도 안전하게 변경할 수 없는 공유 formatters.ts보다 사실 더 낫습니다.
저는 오랫동안 이것에 저항했습니다. DRY가 신조였거든요. 하지만 공유 유틸리티 파일이 코드베이스에서 가장 위험한 파일이 되는 것을 봤습니다. 한 기능을 위한 "작은 수정"이 다른 세 기능을 망가뜨리는 파일 말입니다. 통제된 중복과 함께하는 코로케이션이 강제 공유보다 더 유지보수하기 쉽습니다.
제가 실제로 사용하는 폴더 구조#
전체 트리입니다. 4-8명 팀의 두 개 프로덕션 애플리케이션에서 실전 검증되었습니다:
src/
├── app/
│ ├── (public)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── pricing/
│ │ │ └── page.tsx
│ │ └── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── invoices/
│ │ │ ├── page.tsx
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx
│ │ │ └── new/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── layout.tsx
│ └── not-found.tsx
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── login-form.tsx
│ │ │ ├── register-form.tsx
│ │ │ └── oauth-buttons.tsx
│ │ ├── actions/
│ │ │ ├── login.ts
│ │ │ └── register.ts
│ │ ├── hooks/
│ │ │ └── use-session.ts
│ │ ├── types.ts
│ │ └── constants.ts
│ ├── invoicing/
│ │ ├── components/
│ │ │ ├── invoice-table.tsx
│ │ │ ├── invoice-row.tsx
│ │ │ ├── invoice-form.tsx
│ │ │ └── invoice-pdf.tsx
│ │ ├── actions/
│ │ │ ├── create-invoice.ts
│ │ │ └── update-status.ts
│ │ ├── hooks/
│ │ │ └── use-invoice-actions.ts
│ │ ├── api/
│ │ │ └── client.ts
│ │ ├── utils/
│ │ │ └── format.ts
│ │ └── types.ts
│ └── billing/
│ ├── components/
│ ├── actions/
│ ├── hooks/
│ └── types.ts
├── shared/
│ ├── components/
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ ├── modal.tsx
│ │ │ └── toast.tsx
│ │ └── layout/
│ │ ├── header.tsx
│ │ ├── footer.tsx
│ │ └── sidebar.tsx
│ ├── hooks/
│ │ ├── use-debounce.ts
│ │ └── use-media-query.ts
│ ├── lib/
│ │ ├── db.ts
│ │ ├── auth.ts
│ │ ├── stripe.ts
│ │ └── email.ts
│ └── types/
│ └── global.ts
└── config/
├── site.ts
└── navigation.ts
주목할 점 몇 가지:
app/에는 라우팅 로직만 있습니다. 페이지 파일은 얇습니다.features/에서 가져와서 조합합니다. 페이지 파일은 최대 20-40줄이어야 합니다.features/가 실제 코드가 있는 곳입니다. 각 기능은 자체적으로 완결됩니다.features/invoicing/을 삭제해도 다른 것은 깨지지 않습니다 (그것을 import하는 페이지를 제외하고요. 이것이 바로 여러분이 원하는 커플링입니다).shared/는 쓰레기장이 아닙니다. 엄격한 하위 카테고리가 있습니다. 이에 대해서는 아래에서 더 다루겠습니다.
라우트 그룹: 단순한 정리 이상의 것#
라우트 그룹, 즉 (괄호로 묶인) 폴더는 App Router에서 가장 과소평가된 기능 중 하나입니다. URL에 전혀 영향을 미치지 않습니다. (dashboard)/invoices/page.tsx는 /dashboard/invoices가 아닌 /invoices에 렌더링됩니다.
하지만 진짜 강점은 레이아웃 격리입니다. 각 라우트 그룹은 자체 layout.tsx를 가집니다:
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen">
<Sidebar user={session.user} />
<main className="flex-1 p-6">{children}</main>
</div>
);
}// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
);
}인증 확인은 레이아웃 레벨에서 한 번만 일어납니다. 모든 페이지에서가 아닙니다. 퍼블릭 페이지는 완전히 다른 셸을 가집니다. 인증 페이지는 레이아웃이 전혀 없습니다 — 중앙에 배치된 카드만 있습니다. 깔끔하고, 명시적이며, 실수하기 어렵습니다.
팀들이 미들웨어 안에, 개별 페이지 안에, 컴포넌트 안에 인증 확인을 동시에 넣는 것을 봤습니다. 한 곳을 정하세요. 대부분의 앱에서 라우트 그룹 레이아웃이 그 장소입니다.
shared/ 폴더는 utils/가 아니다#
유지보수 악몽을 만드는 가장 빠른 방법은 lib/utils.ts 파일입니다. 작게 시작합니다. 그러다가 코드베이스의 잡동사니 서랍이 됩니다 — 분명한 소속이 없는 모든 함수가 결국 거기로 갑니다.
제 규칙은 이렇습니다: shared/ 폴더에는 정당한 이유가 필요합니다. 무언가가 shared/에 들어가려면:
- 3개 이상의 기능에서 사용되고,
- 진정으로 범용적이어야 합니다 (범용 가면을 쓴 비즈니스 로직이 아닌)
인보이스 만기일을 포맷하는 날짜 포맷 함수? features/invoicing/utils/에 넣습니다. Date를 받아 로케일 문자열을 반환하는 날짜 포맷 함수? shared/에 들어갈 수 있습니다.
shared/lib/ 하위 폴더는 특별히 서드파티 통합과 인프라를 위한 것입니다: 데이터베이스 클라이언트, 인증 라이브러리, 결제 제공자. 이것들은 전체 애플리케이션이 의존하지만 어떤 단일 기능도 소유하지 않는 것들입니다.
// shared/lib/db.ts — shared에 속합니다
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// features/invoicing/utils/format.ts — shared에 속하지 않습니다
export function formatInvoiceNumber(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const seq = Math.floor(Math.random() * 9000) + 1000;
return `INV-${year}-${seq}`;
}shared/utils/에 손을 뻗고 있으면서 helpers.ts보다 더 구체적인 이름을 지을 수 없다면, 멈추세요. 그 코드는 아마 기능 안에 속할 것입니다.
서버 vs. 클라이언트 컴포넌트: 경계를 의도적으로 그으세요#
성능 문제가 있는 코드베이스에서 끊임없이 보는 패턴이 있습니다:
// 합리적으로 보이지만 실수입니다
"use client";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
export default function InvoicesPage() {
const { data } = useInvoiceActions();
return (
<div>
<InvoiceFilters />
<InvoiceTable invoices={data} />
</div>
);
}페이지가 "use client"로 표시되었습니다. 이제 import하는 모든 것 — InvoiceTable, InvoiceFilters, 훅, 모든 의존성의 모든 의존성 — 이 클라이언트 번들에 들어갑니다. JavaScript 없이 서버에서 렌더링될 수 있었던 200행 테이블이 이제 브라우저로 전송됩니다.
해결법은 "use client"를 실제로 필요한 리프 컴포넌트로 밀어내리는 것입니다:
// app/(dashboard)/invoices/page.tsx — 서버 컴포넌트 (디렉티브 없음)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
export default async function InvoicesPage() {
const invoices = await db.query.invoices.findMany({
orderBy: (inv, { desc }) => [desc(inv.createdAt)],
});
return (
<div>
<InvoiceFilters /> {/* 클라이언트 컴포넌트 — 인터랙티브 필터 포함 */}
<InvoiceTable data={invoices} /> {/* 서버 컴포넌트 — 행만 렌더링 */}
</div>
);
}// features/invoicing/components/invoice-filters.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export function InvoiceFilters() {
const router = useRouter();
const params = useSearchParams();
const setStatus = (status: string) => {
const next = new URLSearchParams(params.toString());
next.set("status", status);
router.push(`?${next.toString()}`);
};
return (
<div className="flex gap-2">
{["all", "draft", "sent", "paid"].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={params.get("status") === s ? "font-bold" : ""}
>
{s}
</button>
))}
</div>
);
}제가 따르는 규칙: 모든 컴포넌트는 useState, useEffect, 이벤트 핸들러, 또는 브라우저 API가 필요할 때까지 서버 컴포넌트입니다. 그때, 그리고 그때만 "use client"를 추가하고, 그 컴포넌트를 최대한 작게 만드세요.
children 패턴은 여기서 최고의 친구입니다. 클라이언트 컴포넌트는 서버 컴포넌트를 children으로 받을 수 있습니다:
"use client";
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* children은 서버 컴포넌트가 될 수 있습니다 */}
</div>
);
}이것이 인터랙티브가 필요한 곳에서만 인터랙티비티를 유지하면서 컴포넌트 트리의 90%를 서버에 유지하는 방법입니다.
배럴 파일: 조용한 빌드 킬러#
저는 모든 폴더에 index.ts를 넣곤 했습니다. 깔끔한 import이잖아요, 그렇죠?
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";그러면 이렇게 import할 수 있었습니다:
import { InvoiceTable } from "@/features/invoicing/components";보기에 좋습니다. 문제는 이렇습니다: 번들러가 그 import를 볼 때, InvoiceTable이 무엇인지 알아내기 위해 전체 배럴 파일을 평가해야 합니다. 즉, 테이블만 필요했더라도 InvoiceRow, InvoiceForm, InvoicePdf도 함께 로드합니다.
한 프로젝트에서 내부 배럴 파일을 제거하자 개발 중 모듈 수가 11,000에서 3,500으로 떨어졌습니다. 개발 서버의 페이지 로드가 5-10초에서 2초 이하로 줄었습니다. 첫 로드 JavaScript가 1메가바이트 이상에서 약 200KB로 줄었습니다.
Vercel은 node_modules의 서드파티 패키지를 위해 next.config.ts에 optimizePackageImports를 만들었습니다. 하지만 여러분 자신의 배럴 파일은 고치지 않습니다. 자체 코드의 답은 더 간단합니다: 사용하지 마세요.
// 이렇게 하세요
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// 이렇게 하지 마세요
import { InvoiceTable } from "@/features/invoicing/components";네, import 경로가 더 깁니다. 빌드는 더 빠르고, 번들은 더 작으며, 개발 서버가 멈추지 않습니다. 매번 기꺼이 감수할 트레이드오프입니다.
유일한 예외: 기능의 공개 API. features/invoicing/이 기능 루트 레벨에 다른 기능이 import할 수 있는 2-3가지만 담긴 단일 index.ts를 노출한다면, 괜찮습니다. 편의 단축키가 아닌 경계 역할을 합니다. 하지만 components/index.ts나 hooks/index.ts 같은 하위 폴더 안의 배럴 파일? 없애세요.
얇은 페이지, 두꺼운 기능#
제가 따르는 가장 중요한 아키텍처 규칙은 이것입니다: 페이지 파일은 연결이지, 로직이 아닙니다.
app/의 페이지 파일은 세 가지를 해야 합니다:
- 데이터 가져오기 (서버 컴포넌트인 경우)
- 기능 컴포넌트 import
- 조합하기
그게 전부입니다. 비즈니스 로직 없음. 복잡한 조건부 렌더링 없음. 200줄짜리 JSX 트리 없음. 페이지 파일이 40줄을 넘어가고 있다면, 기능 코드를 잘못된 곳에 넣고 있는 것입니다.
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
interface Props {
params: Promise<{ id: string }>;
}
export default async function InvoicePage({ params }: Props) {
const { id } = await params;
const invoice = await db.query.invoices.findFirst({
where: (inv, { eq }) => eq(inv.id, id),
});
if (!invoice) notFound();
return <InvoiceDetail invoice={invoice} />;
}12줄입니다. 페이지는 필요한 데이터를 가져오고, not-found 케이스를 처리하고, 나머지 모든 것을 기능에 위임합니다. 인보이스 상세 UI가 변경되면 features/invoicing/을 편집합니다. app/이 아닙니다. 라우트가 변경되면 페이지 파일을 옮기면 되고, 기능 코드는 신경 쓰지 않습니다.
이것이 15명의 개발자가 서로 밟지 않고 동시에 작업할 수 있는 코드베이스를 만드는 방법입니다.
불편한 진실#
나쁜 코드를 방지하는 폴더 구조는 없습니다. 끔찍한 추상화로 가득한 아름답게 정리된 프로젝트도 봤고, 코드 자체가 명확해서 작업하기 쉬웠던 지저분한 플랫 구조도 봤습니다.
구조는 도구이지, 목표가 아닙니다. 제가 설명한 기능 기반 접근법이 작동하는 이유는 대규모에서 가장 중요한 것을 최적화하기 때문입니다: 이 코드베이스를 처음 보는 개발자가 필요한 것을 찾고, 다른 것을 망가뜨리지 않고 변경할 수 있는가?
현재 구조가 그 질문에 "예"라고 답한다면, 블로그 포스트가 시키니까 리팩토링하지 마세요 — 이 글이라 해도요.
하지만 개발자들이 코드를 작성하는 것보다 코드베이스를 탐색하는 데 더 많은 시간을 쓰고 있다면, "이건 어디에 넣어야 하나?"가 PR에서 반복되는 질문이라면, 청구 변경이 어째서인지 대시보드를 망가뜨린다면, 구조가 여러분과 싸우고 있는 것입니다. 그리고 위의 패턴이 제가 매번 그것을 해결한 방법입니다.