Next.js 16 실무 프로젝트 구조 가이드
중년개발자
@loxo
약 15시간 전
계속 트렌드가 변화하고 있는 것 같습니다. 좀 더 좋은 방향으로 흘러가는 것 같아요.
Next.js 16 실무 프로젝트 구조 가이드
핵심 원칙: 라우트 전용 코드는 해당 라우트 가까이에 배치하고, 여러 화면에서 공유되는 업무 기능만
features로 분리한다.
1. 프로젝트 구성 원칙
Next.js App Router 프로젝트에서는 다음 세 가지 기준으로 코드를 구분한다.
app
→ URL, 페이지, 레이아웃, 로딩, 오류 처리
features
→ 고객, 계좌, 거래, 승인 등 업무 기능
components / lib
→ 공통 UI와 기술 기반 코드실무에서는 features와 Colocation 중 하나만 선택하지 않는다.
라우트 전용 코드
→ app 내부 Colocation
여러 라우트에서 공유되는 업무 기능
→ features
전체 시스템에서 공통으로 사용하는 코드
→ components 또는 lib2. 업무 시스템 예시
다음과 같은 기업용 업무 시스템을 가정한다.
- 사용자 로그인과 권한 관리
- 고객 조회 및 고객 상세
- 계좌 조회
- 거래내역 조회
- 이체 요청
- 결재 및 승인
- 알림
- 관리자 기능
- 외부 시스템 연계
- 감사 로그
3. 권장 프로젝트 구조
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.ts4. 라우트 전용 코드와 공유 기능 구분
고객 조회 화면을 예로 들면 다음과 같이 나눈다.
app/(service)/customers/
├── page.tsx
├── _components/
│ ├── customer-search-form.tsx
│ └── customer-search-result.tsx이 컴포넌트는 /customers 화면의 배치와 검색 화면 구성에만 사용되므로 라우트 내부에 둔다.
반면 다음 코드는 여러 화면에서 재사용될 수 있다.
features/customer/
├── components/
│ ├── customer-card.tsx
│ └── customer-status-badge.tsx
├── queries/
│ ├── get-customer.ts
│ └── search-customers.ts
└── types/
└── customer.ts사용 예시는 다음과 같다.
// 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 이름은 화면 이름보다 업무 용어를 기준으로 정한다.
좋은 예
auth
customer
account
transfer
approval
notification
피해야 할 예
customer-page
main-screen
left-menu
popup
common-businessFeature 하나는 하나의 업무 책임을 가져야 한다.
transfer
→ 이체 조회, 등록, 취소, 상태 관리
approval
→ 결재 조회, 승인, 반려
customer
→ 고객 검색, 상세 조회, 상태 표시6. 페이지와 Feature의 책임
page.tsx에는 화면 조립과 데이터 전달만 남긴다.
// 권장
export default async function TransferPage() {
const transfers = await getTransfers()
return <TransferList transfers={transfers} />
}다음과 같이 모든 로직을 페이지에 몰아넣지 않는다.
// 피해야 할 구조
export default async function TransferPage() {
// 권한 검사
// 요청값 검증
// 외부 API 호출
// 업무 규칙 처리
// 데이터 변환
// 감사 로그
// 화면 렌더링
}권장 책임 분리는 다음과 같다.
page.tsx
→ 화면 진입점과 조립
features/*/queries
→ 데이터 조회
features/*/actions
→ 등록, 수정, 승인, 취소
features/*/schemas
→ 입력값 검증
features/*/components
→ 업무 기능 UI
lib
→ DB, 인증, 로깅, 외부 시스템 연계7. Server Action과 Route Handler 구분
사용자가 화면에서 수행하는 업무 변경은 Server Action을 우선 고려한다.
features/transfer/actions/create-transfer.ts
features/approval/actions/approve-request.ts'use server'
import { revalidatePath } from 'next/cache'
export async function approveRequest(approvalId: string) {
await approvalService.approve(approvalId)
revalidatePath('/approvals')
}외부 시스템이 호출하는 API나 Webhook은 Route Handler를 사용한다.
app/api/webhooks/external-system/route.ts
app/api/files/upload/route.ts
app/api/health/route.ts화면 내부 업무 처리
→ Server Action
외부 시스템 연계
→ Route Handler
Webhook, 파일 업로드, 공개 API
→ Route Handler8. 외부 시스템 연계 구조
기업용 시스템은 내부 DB보다 외부 업무 시스템을 호출하는 경우가 많다.
lib/integration/
├── customer-system.ts
├── account-system.ts
└── approval-system.tsFeature에서는 외부 시스템의 상세 호출 방식을 알지 않도록 한다.
features/customer/queries/get-customer.ts
↓
lib/integration/customer-system.ts
↓
외부 고객 시스템 API예:
// 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 응답을 화면에서 직접 사용하지 말고 내부 업무 타입으로 변환한다.
외부 시스템 DTO
→ Adapter 또는 Mapper
→ 내부 업무 모델
→ 화면9. 권한과 감사 로그
권한은 화면에서 버튼을 숨기는 것만으로 처리하면 안 된다.
화면 접근 권한
→ layout 또는 page
업무 실행 권한
→ Server Action 또는 서버 서비스
API 접근 권한
→ Route Handler예:
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. 의존성 방향
app
↓
features
↓
lib / components허용하는 방향:
app → features
app → components
app → lib
features → components
features → lib피해야 하는 방향:
lib → features
components/ui → features
features → appFeature 간 직접 의존성이 많아지면 공통 업무 객체나 기반 로직을 별도 계층으로 분리한다.
11. 최종 판단 기준
특정 페이지에서만 사용하는 화면 구성
→ app/.../_components
특정 페이지에서만 사용하는 조회 또는 가공
→ app/.../_queries 또는 _lib
여러 페이지에서 공유하는 업무 기능
→ features
공통 버튼, 입력창, 테이블
→ components/ui
공통 레이아웃과 검색 조건
→ components/common 또는 components/layout
DB, 인증, 로깅, 환경변수
→ lib
외부 시스템 API 호출
→ lib/integration
외부 시스템이 호출하는 엔드포인트
→ app/api결론
실제 업무 프로젝트에서는 다음 구조가 가장 현실적이다.
app
→ 업무 화면과 URL
features
→ 고객, 계좌, 이체, 승인 등 업무 기능
components
→ 공통 화면 요소
lib
→ 인증, DB, 로깅, 외부 연계처음부터 모든 코드를 features로 분리하지 않는다.
처음에는 app 내부에 가깝게 배치
→ 동일 기능이 여러 화면에서 사용되기 시작
→ features로 이동즉, Colocation으로 단순하게 시작하고, 공유 범위가 명확해질 때 Feature 구조로 확장하는 방식이 실무에서 가장 안정적이다.
이 구조는 은행·금융 시스템뿐 아니라 사내 관리자, ERP, CRM, 결재 시스템, 운영 포털에도 그대로 적용할 수 있습니다.