Next.js
라우팅

Next.js 16 실무 프로젝트 구조 가이드

중년개발자
중년개발자

@loxo

약 15시간 전

2

계속 트렌드가 변화하고 있는 것 같습니다. 좀 더 좋은 방향으로 흘러가는 것 같아요.

Next.js 16 실무 프로젝트 구조 가이드

핵심 원칙: 라우트 전용 코드는 해당 라우트 가까이에 배치하고, 여러 화면에서 공유되는 업무 기능만 features로 분리한다.

1. 프로젝트 구성 원칙

Next.js App Router 프로젝트에서는 다음 세 가지 기준으로 코드를 구분한다.

text
app → URL, 페이지, 레이아웃, 로딩, 오류 처리 features → 고객, 계좌, 거래, 승인 등 업무 기능 components / lib → 공통 UI와 기술 기반 코드

실무에서는 features와 Colocation 중 하나만 선택하지 않는다.

text
라우트 전용 코드 → app 내부 Colocation 여러 라우트에서 공유되는 업무 기능 → features 전체 시스템에서 공통으로 사용하는 코드 → components 또는 lib

2. 업무 시스템 예시

다음과 같은 기업용 업무 시스템을 가정한다.

  • 사용자 로그인과 권한 관리
  • 고객 조회 및 고객 상세
  • 계좌 조회
  • 거래내역 조회
  • 이체 요청
  • 결재 및 승인
  • 알림
  • 관리자 기능
  • 외부 시스템 연계
  • 감사 로그

3. 권장 프로젝트 구조

text
business-portal/ ├── public/ │ ├── images/ │ ├── icons/ │ └── documents/ ├── src/ │ ├── app/ │ │ ├── (public)/ │ │ │ ├── login/ │ │ │ │ └── page.tsx │ │ │ ├── password-reset/ │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ │ │ │ ├── (service)/ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── dashboard/ │ │ │ │ ├── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── _components/ │ │ │ │ │ ├── approval-summary.tsx │ │ │ │ │ ├── recent-transactions.tsx │ │ │ │ │ └── work-status-card.tsx │ │ │ │ └── _queries/ │ │ │ │ └── get-dashboard-summary.ts │ │ │ │ │ │ │ ├── customers/ │ │ │ │ ├── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── _components/ │ │ │ │ │ ├── customer-search-form.tsx │ │ │ │ │ └── customer-search-result.tsx │ │ │ │ └── [customerId]/ │ │ │ │ ├── page.tsx │ │ │ │ └── _components/ │ │ │ │ ├── customer-profile.tsx │ │ │ │ └── customer-account-list.tsx │ │ │ │ │ │ │ ├── accounts/ │ │ │ │ └── [accountId]/ │ │ │ │ ├── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ └── _components/ │ │ │ │ ├── account-summary.tsx │ │ │ │ └── transaction-history.tsx │ │ │ │ │ │ │ ├── transfers/ │ │ │ │ ├── page.tsx │ │ │ │ ├── new/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── _components/ │ │ │ │ │ └── transfer-request-form.tsx │ │ │ │ └── [transferId]/ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── approvals/ │ │ │ │ ├── page.tsx │ │ │ │ ├── pending/ │ │ │ │ │ └── page.tsx │ │ │ │ └── [approvalId]/ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── notifications/ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── settings/ │ │ │ ├── profile/ │ │ │ │ └── page.tsx │ │ │ └── security/ │ │ │ └── page.tsx │ │ │ │ │ ├── (admin)/ │ │ │ ├── layout.tsx │ │ │ ├── users/ │ │ │ │ ├── page.tsx │ │ │ │ └── [userId]/ │ │ │ │ └── page.tsx │ │ │ ├── roles/ │ │ │ │ └── page.tsx │ │ │ ├── audit-logs/ │ │ │ │ └── page.tsx │ │ │ └── system-status/ │ │ │ └── page.tsx │ │ │ │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ └── session/ │ │ │ │ └── route.ts │ │ │ ├── webhooks/ │ │ │ │ └── external-system/ │ │ │ │ └── route.ts │ │ │ ├── files/ │ │ │ │ └── upload/ │ │ │ │ └── route.ts │ │ │ └── health/ │ │ │ └── route.ts │ │ │ │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── global-error.tsx │ │ └── globals.css │ │ │ ├── features/ │ │ ├── auth/ │ │ │ ├── actions/ │ │ │ │ ├── login.ts │ │ │ │ └── logout.ts │ │ │ ├── components/ │ │ │ │ ├── login-form.tsx │ │ │ │ └── permission-guard.tsx │ │ │ ├── queries/ │ │ │ │ └── get-current-user.ts │ │ │ ├── schemas/ │ │ │ │ └── login-schema.ts │ │ │ └── types/ │ │ │ └── auth.ts │ │ │ │ │ ├── customer/ │ │ │ ├── components/ │ │ │ │ ├── customer-card.tsx │ │ │ │ └── customer-status-badge.tsx │ │ │ ├── queries/ │ │ │ │ ├── get-customer.ts │ │ │ │ └── search-customers.ts │ │ │ ├── schemas/ │ │ │ │ └── customer-search-schema.ts │ │ │ └── types/ │ │ │ └── customer.ts │ │ │ │ │ ├── account/ │ │ │ ├── components/ │ │ │ │ ├── account-card.tsx │ │ │ │ └── balance-display.tsx │ │ │ ├── queries/ │ │ │ │ ├── get-account.ts │ │ │ │ └── get-account-transactions.ts │ │ │ └── types/ │ │ │ └── account.ts │ │ │ │ │ ├── transfer/ │ │ │ ├── actions/ │ │ │ │ ├── create-transfer.ts │ │ │ │ └── cancel-transfer.ts │ │ │ ├── components/ │ │ │ │ ├── transfer-form.tsx │ │ │ │ └── transfer-status-badge.tsx │ │ │ ├── queries/ │ │ │ │ ├── get-transfer.ts │ │ │ │ └── get-transfers.ts │ │ │ ├── schemas/ │ │ │ │ └── transfer-schema.ts │ │ │ └── types/ │ │ │ └── transfer.ts │ │ │ │ │ ├── approval/ │ │ │ ├── actions/ │ │ │ │ ├── approve-request.ts │ │ │ │ └── reject-request.ts │ │ │ ├── components/ │ │ │ │ ├── approval-list.tsx │ │ │ │ └── approval-status-badge.tsx │ │ │ ├── queries/ │ │ │ │ ├── get-approval.ts │ │ │ │ └── get-pending-approvals.ts │ │ │ └── types/ │ │ │ └── approval.ts │ │ │ │ │ └── notification/ │ │ ├── actions/ │ │ │ └── mark-as-read.ts │ │ ├── components/ │ │ │ └── notification-list.tsx │ │ └── queries/ │ │ └── get-notifications.ts │ │ │ ├── components/ │ │ ├── ui/ │ │ │ ├── button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── select.tsx │ │ │ ├── table.tsx │ │ │ └── badge.tsx │ │ ├── layout/ │ │ │ ├── app-header.tsx │ │ │ ├── app-sidebar.tsx │ │ │ └── breadcrumb.tsx │ │ └── common/ │ │ ├── data-table.tsx │ │ ├── empty-state.tsx │ │ ├── search-condition.tsx │ │ └── status-message.tsx │ │ │ ├── lib/ │ │ ├── api/ │ │ │ ├── api-client.ts │ │ │ ├── api-error.ts │ │ │ └── response.ts │ │ ├── auth/ │ │ │ ├── session.ts │ │ │ └── permission.ts │ │ ├── db/ │ │ │ ├── client.ts │ │ │ └── transaction.ts │ │ ├── env/ │ │ │ ├── client.ts │ │ │ └── server.ts │ │ ├── logging/ │ │ │ ├── audit-logger.ts │ │ │ └── application-logger.ts │ │ ├── integration/ │ │ │ ├── customer-system.ts │ │ │ └── account-system.ts │ │ └── utils/ │ │ ├── date.ts │ │ ├── number.ts │ │ └── masking.ts │ │ │ ├── config/ │ │ ├── menu.ts │ │ └── permissions.ts │ │ │ ├── constants/ │ │ ├── error-code.ts │ │ └── route.ts │ │ │ ├── types/ │ │ ├── api.ts │ │ └── common.ts │ │ │ ├── instrumentation.ts │ └── proxy.ts ├── tests/ │ ├── integration/ │ └── e2e/ ├── .env.local ├── eslint.config.mjs ├── next.config.ts ├── package.json ├── playwright.config.ts ├── tsconfig.json └── vitest.config.ts

4. 라우트 전용 코드와 공유 기능 구분

고객 조회 화면을 예로 들면 다음과 같이 나눈다.

text
app/(service)/customers/ ├── page.tsx ├── _components/ │ ├── customer-search-form.tsx │ └── customer-search-result.tsx

이 컴포넌트는 /customers 화면의 배치와 검색 화면 구성에만 사용되므로 라우트 내부에 둔다.

반면 다음 코드는 여러 화면에서 재사용될 수 있다.

text
features/customer/ ├── components/ │ ├── customer-card.tsx │ └── customer-status-badge.tsx ├── queries/ │ ├── get-customer.ts │ └── search-customers.ts └── types/ └── customer.ts

사용 예시는 다음과 같다.

tsx
// src/app/(service)/customers/[customerId]/page.tsx import { getCustomer } from '@/features/customer/queries/get-customer' import { CustomerCard } from '@/features/customer/components/customer-card' type CustomerDetailPageProps = { params: Promise<{ customerId: string }> } export default async function CustomerDetailPage({ params, }: CustomerDetailPageProps) { const { customerId } = await params const customer = await getCustomer(customerId) return <CustomerCard customer={customer} /> }

5. 업무 기능별 features 분리 기준

Feature 이름은 화면 이름보다 업무 용어를 기준으로 정한다.

text
좋은 예 auth customer account transfer approval notification 피해야 할 예 customer-page main-screen left-menu popup common-business

Feature 하나는 하나의 업무 책임을 가져야 한다.

text
transfer → 이체 조회, 등록, 취소, 상태 관리 approval → 결재 조회, 승인, 반려 customer → 고객 검색, 상세 조회, 상태 표시

6. 페이지와 Feature의 책임

page.tsx에는 화면 조립과 데이터 전달만 남긴다.

tsx
// 권장 export default async function TransferPage() { const transfers = await getTransfers() return <TransferList transfers={transfers} /> }

다음과 같이 모든 로직을 페이지에 몰아넣지 않는다.

tsx
// 피해야 할 구조 export default async function TransferPage() { // 권한 검사 // 요청값 검증 // 외부 API 호출 // 업무 규칙 처리 // 데이터 변환 // 감사 로그 // 화면 렌더링 }

권장 책임 분리는 다음과 같다.

text
page.tsx → 화면 진입점과 조립 features/*/queries → 데이터 조회 features/*/actions → 등록, 수정, 승인, 취소 features/*/schemas → 입력값 검증 features/*/components → 업무 기능 UI lib → DB, 인증, 로깅, 외부 시스템 연계

7. Server Action과 Route Handler 구분

사용자가 화면에서 수행하는 업무 변경은 Server Action을 우선 고려한다.

text
features/transfer/actions/create-transfer.ts features/approval/actions/approve-request.ts
tsx
'use server' import { revalidatePath } from 'next/cache' export async function approveRequest(approvalId: string) { await approvalService.approve(approvalId) revalidatePath('/approvals') }

외부 시스템이 호출하는 API나 Webhook은 Route Handler를 사용한다.

text
app/api/webhooks/external-system/route.ts app/api/files/upload/route.ts app/api/health/route.ts
text
화면 내부 업무 처리 → Server Action 외부 시스템 연계 → Route Handler Webhook, 파일 업로드, 공개 API → Route Handler

8. 외부 시스템 연계 구조

기업용 시스템은 내부 DB보다 외부 업무 시스템을 호출하는 경우가 많다.

text
lib/integration/ ├── customer-system.ts ├── account-system.ts └── approval-system.ts

Feature에서는 외부 시스템의 상세 호출 방식을 알지 않도록 한다.

text
features/customer/queries/get-customer.ts lib/integration/customer-system.ts 외부 고객 시스템 API

예:

tsx
// src/features/customer/queries/get-customer.ts import 'server-only' import { customerSystem } from '@/lib/integration/customer-system' export async function getCustomer(customerId: string) { return customerSystem.getCustomer(customerId) }

외부 API 응답을 화면에서 직접 사용하지 말고 내부 업무 타입으로 변환한다.

text
외부 시스템 DTO → Adapter 또는 Mapper → 내부 업무 모델 → 화면

9. 권한과 감사 로그

권한은 화면에서 버튼을 숨기는 것만으로 처리하면 안 된다.

text
화면 접근 권한 → layout 또는 page 업무 실행 권한 → Server Action 또는 서버 서비스 API 접근 권한 → Route Handler

예:

tsx
export async function approveRequest(approvalId: string) { const user = await requireCurrentUser() assertPermission(user, 'APPROVAL_WRITE') await approvalService.approve({ approvalId, approvedBy: user.id, }) await auditLogger.write({ action: 'APPROVAL_APPROVED', targetId: approvalId, userId: user.id, }) }

감사 로그는 개별 화면 컴포넌트가 아니라 서버의 업무 처리 계층에서 기록한다.


10. 의존성 방향

text
app features lib / components

허용하는 방향:

text
app → features app → components app → lib features → components features → lib

피해야 하는 방향:

text
lib → features components/ui → features features → app

Feature 간 직접 의존성이 많아지면 공통 업무 객체나 기반 로직을 별도 계층으로 분리한다.


11. 최종 판단 기준

text
특정 페이지에서만 사용하는 화면 구성 → app/.../_components 특정 페이지에서만 사용하는 조회 또는 가공 → app/.../_queries 또는 _lib 여러 페이지에서 공유하는 업무 기능 → features 공통 버튼, 입력창, 테이블 → components/ui 공통 레이아웃과 검색 조건 → components/common 또는 components/layout DB, 인증, 로깅, 환경변수 → lib 외부 시스템 API 호출 → lib/integration 외부 시스템이 호출하는 엔드포인트 → app/api

결론

실제 업무 프로젝트에서는 다음 구조가 가장 현실적이다.

text
app → 업무 화면과 URL features → 고객, 계좌, 이체, 승인 등 업무 기능 components → 공통 화면 요소 lib → 인증, DB, 로깅, 외부 연계

처음부터 모든 코드를 features로 분리하지 않는다.

text
처음에는 app 내부에 가깝게 배치 → 동일 기능이 여러 화면에서 사용되기 시작 → features로 이동

즉, Colocation으로 단순하게 시작하고, 공유 범위가 명확해질 때 Feature 구조로 확장하는 방식이 실무에서 가장 안정적이다.

이 구조는 은행·금융 시스템뿐 아니라 사내 관리자, ERP, CRM, 결재 시스템, 운영 포털에도 그대로 적용할 수 있습니다.

#Next.js#App Router#Frontend Architecture#Project Structure#React

댓글 0

Ctrl + Enter를 눌러 등록할 수 있습니다
※ AI 다듬기는 내용을 정제하는 보조 기능이며, 최종 내용은 사용자가 확인해야 합니다.