들어가며#
Next.js 15와 React 19로 웹 앱을 만들 때, App Router의 철학에 맞는 프로젝트 구조와 개발 규칙을 세우는 게 정말 중요한데요.
이번 글에서는 프로젝트 전체를 관통하는 기본 원칙과, 유지보수성과 확장성을 모두 잡는 디렉토리 구성에 대해 자세히 알아볼 것입니다.
기본 원칙#
Next.js 15와 React 19 환경에서 웹 앱을 개발할 때 반드시 지켜야 할 몇 가지 기본 원칙이 있는데요.
하나씩 차근차근 설명해 드리겠습니다.
App Router 철학에 충실한 구현#
Next.js 15에서는 App Router 사용이 권장되거든요.
이 App Router의 철학을 이해하고 그에 맞춰 구현하면, 프레임워크가 제공하는 기능을 100% 활용할 수 있게 됩니다.
게다가 이런 접근 방식은 앞으로 있을 업데이트에 따른 수정 작업을 최소화하는 가장 확실한 방법이기도 한데요.
각 장에서 더 자세히 다루겠지만, 특히 서버 컴포넌트(Server Component)와 클라이언트 컴포넌트(Client Component)의 올바른 사용법, 스트리밍, 캐시 전략, 상태 관리처럼 App Router가 제공하는 핵심 기능들을 제대로 이해하고 활용하는 것이 무엇보다 중요합니다.
코드 품질의 담보#
코드 품질을 보장하기 위해, 풀 리퀘스트(Pull Request)를 제출하기 전에는 반드시 몇 가지 체크를 거쳐야 하는데요.
물론, 풀 리퀘스트를 여러 개로 나눴을 때 이미 알고 있는 에러가 남아있는 경우는 예외입니다.
점진적인 개선은 허용하지만, 최종적으로는 모든 체크를 통과하는 것이 절대적인 원칙이거든요.
이런 과정을 통해 코드의 안정성을 확보할 수 있는 것입니다.
TypeScript 필수#
모든 코드는 타입스크립트(TypeScript)로 작성하는 것을 원칙으로 삼아야 하는데요.
타입 안정성을 확보함으로써 런타임 에러를 줄이고, 리팩토링의 안전성을 크게 높일 수 있기 때문입니다.
타입 정의는 type으로 통일#
타입을 정의할 때는 interface 대신 type으로 통일하는 것이 좋은데요.
물론 프로젝트 내부에서 규칙이 통일되어 있다면 꼭 따를 필요는 없지만, type으로 통일하면 몇 가지 장점이 있습니다.
주석은 최대한 꼼꼼하게#
코드의 의도나 배경을 설명하는 주석은 최대한 상세하게 작성하는 습관을 들이는 게 좋거든요.
특히 외부 라이브러리를 사용할 때는, 해당 함수나 훅, 프로퍼티가 어떤 의미를 가지는지 자세히 설명해 주는 것이 중요합니다.
export function UserSearchResult() {
const { data, isLoading, error } = useQuery({
queryKey: [users, searchConditions],
queryFn: () => fetchUser(userId),
staleTime: Infinity, // 자동 재요청을 막고, 사용자의 특정 행동을 통해서만 데이터를 다시 가져오도록 제어하기 위함
refetchOnMount: false, // SSR과 데이터 요청이 중복되는 것을 방지하기 위함
placeholderData: keepPreviousData, // 재검색 시 이전 검색 결과를 계속 보여주기 위함
...
...
});
}
Next.js 개발 환경에서는 여러 관점이 복잡하게 얽히는 경우가 많아, 코드나 파일 간의 가독성이 떨어질 위험이 있는데요.
또한, 최근 AI 기반 개발 환경에서 코드 리뷰의 부담을 줄이기 위해서라도 꼼꼼한 주석 작성은 강력히 권장됩니다.
요구사항과 사용자 경험(UX)을 최우선으로#
기술적인 구현 방식보다는, 실제 요구사항과 사용자 경험(UX)을 최우선으로 고려해야 하는데요.
언어나 프레임워크의 철학에 맞추기 위해 사용자 경험을 희생시키는 일은 없어야 합니다.
단순한 구현을 위해 요구사항이나 기획을 바꾸는 일은 기본적으로 지양해야 하는데요.
다만, 특정 기술 선택이 명백하게 더 큰 단점을 야기할 경우에만 재검토를 고려해 볼 수 있습니다.
물론 기술적인 아름다움이나 일관성도 중요하지만, 최종 목표는 사용자에게 최고의 가치를 제공하는 것이어야 합니다.
디렉토리 구성#
다음으로, 프로젝트의 뼈대가 되는 디렉토리 구성에 대해 알아볼 차례인데요.
Next.js의 파일 기반 라우팅과 콜로케이션(Colocation) 패턴에 기반한 디렉토리 구조를 채택하는 것을 추천합니다.
src/
├─ app/ // 화면・기능 단위
│ ├─ (private)/ // 로그인 필수 페이지 그룹
│ │ ├─ actions/ // 해당 그룹 전용 Server Actions
│ │ ├─ apis/ // 해당 그룹 전용 API 클라이언트
│ │ ├─ components/ // 해당 그룹 전용 컴포넌트
│ │ ├─ constants/ // 해당 그룹 전용 상수
│ │ ├─ hooks/ // 해당 그룹 전용 커스텀 훅
│ │ ├─ stores/ // 해당 그룹 전용 스토어 (단일 화면 내 깊은 계층 구조에서 데이터 공유)
│ │ ├─ providers/ // 해당 그룹 전용 프로바이더
│ │ ├─ schemas/ // 해당 그룹 전용 스키마
│ │ ├─ types/ // 해당 그룹 전용 타입 정의
│ │ ├─ utils/ // 해당 그룹 전용 유틸리티 함수
│ │ └─ layout.tsx // private 그룹용 레이아웃
│ │
│ ├─ (public)/ // 로그인 불필요 페이지 그룹
│ │ └─ layout.tsx // public 그룹용 레이아웃
│ │
│ └─ api/ // Route Handlers (클라이언트 데이터 요청을 위한 BFF용)
│
├─ actions/ // 공용 Server Actions
├─ apis/ // 공용 API 클라이언트
├─ components/ // 공용 컴포넌트
├─ constants/ // 공용 상수
├─ hooks/ // 공용 커스텀 훅
├─ stores/ // 전역 스토어
├─ providers/ // 공용 프로바이더
├─ lib/ // 라이브러리 관련 설정
├─ schemas/ // 공용 스키마
├─ types/ // 공용 타입 정의
└─ utils/ // 공용 유틸리티 함수
기본 방침#
디렉토리 구성의 핵심적인 기본 방침은 다음과 같은데요.
하나씩 살펴보겠습니다.
기능 기반 구성 (콜로케이션)
Next.js의 파일 기반 라우팅과 콜로케이션(Colocation) 패턴을 따라, 특정 화면이나 기능에서만 쓰이는 파일들은 해당page.tsx와 같은 위치에 모아두는 것이 좋은데요.반면 여러 곳에서 공통으로 사용하는 파일은
app디렉토리와 같은 레벨의 디렉토리에 배치하는 것이 기본 원칙입니다.이렇게 관련 코드를 가까운 곳에 배치하면 이 파일이 어디서 사용되는지 명확하게 파악할 수 있거든요.
결과적으로 불필요한 파일을 삭제하거나 영향 범위를 파악하기 쉬워져 유지보수성과 가독성이 크게 향상됩니다.
프라이빗 폴더 활용
화면 단위는 아니지만 기능적으로 독립된 모듈들은 프라이빗 폴더(
_xxxxx)를 사용해 관리하는 것이 효과적인데요.프라이빗 폴더는 URL 경로에 영향을 주지 않아 라우팅 대상에서 제외되므로, 기능별로 코드를 깔끔하게 정리하는 데 아주 유용합니다.
루트 그룹을 통한 인증 분리
루트 그룹(Route Group)은 디렉토리 이름을 URL 경로에 포함시키지 않으면서 디렉토리를 그룹화하는 기능인데요.
(private)과(public)같은 루트 그룹을 사용해 인증 필요 여부에 따라 디렉토리를 분리하는 것을 추천합니다.이렇게 하면 인증 상태에 따라 다른 레이아웃이나 헤더를 적용하는 로직을 명확하게 관리할 수 있거든요.
또한, 인증이 필요 없는 페이지는 정적 렌더링(Static Rendering) 대상이 되는 경우가 많으므로, 전체 경로 캐시(Full Route Cache, SSG와 유사)를 확실하게 적용하기 위해서라도 미리 분리해두는 것이 유리합니다.
세부 규칙#
디렉토리 구성과 관련된 몇 가지 구체적인 규칙들이 있는데요.
이 규칙들을 따르면 프로젝트의 안정성을 더욱 높일 수 있습니다.
루트 레이아웃의 제약
루트 레이아웃 파일(
app/layout.tsx)은 반드시 서버 컴포넌트로 유지해야 하는데요.또한, 이 파일 안에서
cookies()나headers()같은 동적 API를 직접 사용하는 것은 금지됩니다.API 클라이언트의 배치 규칙
apis디렉토리 바로 아래의 파일들은 리소스 단위로 배치하는 것이 좋은데요.이때 서버용인지, 클라이언트용(
GET요청 전용)인지에 따라 파일명에 점(.)을 사용해 구분합니다.apis/ ├─ users.server.ts // 서버용 API 클라이언트 └─ users.client.ts // 클라이언트용 API 클라이언트 (GET 전용)데이터 로딩 파트에서 더 자세히 설명하겠지만, 기본적으로 서버에서의 데이터 요청은 서버 컴포넌트에서 직접 처리하는데요.
반면, 클라이언트에서의 데이터 요청은
TanStack Query와Route Handler를 함께 사용하는 것이 일반적입니다.API클라이언트를 서버용과 클라이언트용으로 나누는 데에는 두 가지 중요한 이유가 있는데요.첫 번째는 바로 보안 때문입니다.
서버용
API클라이언트에서는 액세스 토큰을 부여하는 등 민감한 정보를 직접 다루는 로직이 포함될 수 있거든요.반면, 클라이언트용
API클라이언트는Route Handler의 엔드포인트를 호출하기 때문에, 이런 민감한 처리를 클라이언트 측 코드에 직접 담지 않게 됩니다.또한, 데이터 등록이나 수정은 반드시 서버 액션(Server Actions)으로 처리하기 때문에, 클라이언트용
API클라이언트에는GET요청만 정의하는 것이 원칙입니다.두 번째 이유는 에러 발생 시의 응답 방식 차이 때문인데요.
서버에서 데이터 요청 시 에러가 발생하면
Result타입으로 응답을 감싸서 반환합니다.하지만 클라이언트에서
useQuery를 사용하다 에러가 나면, 훅이 반환하는error객체에 에러를 위임하기 위해 에러를throw해야 하거든요.이처럼 두 환경의 에러 처리 방식이 다르기 때문에, 파일을 분리해서 관리하는 것이 훨씬 효율적입니다.
서버 액션(Server Actions)과의 연동
actions디렉토리 아래에 위치한 서버 액션 함수 안에서는fetch로직을 직접 작성하지 않는데요.대신,
apis디렉토리에 만들어 둔API클라이언트를import해서 사용합니다.// actions/users.ts use server; import { updateUser } from @/apis/users.server; import type { Result } from @/types/result; import type { User } from @/types/user; // Server Actions // apis 폴더의 API 클라이언트를 사용합니다. export async function updateUserAction(userId: string): Promise<Result<User>> { const result = await updateUser(userId); if(result.isSuccess) { // 업데이트 성공 시에만 데이터 캐시를 삭제합니다. revalidateTag(`users-${userId}`); } return result; }이렇게 API 통신 로직과 서버 액션의 비즈니스 로직을 분리하면 코드의 책임이 명확해져 유지보수성이 크게 향상됩니다.
파일 및 디렉토리 명명 규칙#
마지막으로, 파일과 디렉토리 이름에 대한 기본 규칙이 있는데요.
일관된 규칙을 통해 프로젝트의 가독성을 높일 수 있습니다.
| 대상 | 명명 규칙 | 예시 |
|---|---|---|
app 하위의 라우팅 대상 디렉토리 | 케밥 케이스(kebab-case) | user-profile/, search-results/ |
| 컴포넌트 파일 | 파스칼 케이스(PascalCase) | UserProfile.tsx, SearchForm.tsx |
| 그 외 디렉토리 및 파일 | 카멜 케이스(camelCase) | fetchUser.ts, userSchema.ts |
app 디렉토리 하위에서 URL 경로가 되는 것들은 구글 검색 엔진의 URL 구조 베스트 프랙티스에 따라 케밥 케이스를 사용하는데요.
그 외의 파일들은 자바스크립트(JavaScript)의 일반적인 관례를 따라 카멜 케이스를, 컴포넌트는 리액트(React)의 규칙에 맞춰 파스칼 케이스를 사용합니다.
마지막으로#
이번 글에서는 Next.js 15와 React 19로 웹 애플리케이션을 구축할 때의 기본 규칙과 디렉토리 구성에 대해 알아봤는데요.
여기에 소개된 규칙과 구조는 App Router의 철학을 따르면서도, 유지보수성, 확장성, 그리고 개발 경험을 극대화하는 것을 목표로 하고 있습니다.
물론 프로젝트의 규모나 요구사항에 따라 유연하게 조정하는 지혜도 필요한데요.
다음 글에서는 컴포넌트 설계, 데이터 로딩 및 갱신, 상태 관리, 캐시 전략, 에러 핸들링 등 더 구체적인 구현 방법에 대해 자세히 다뤄볼 예정입니다.
끝까지 읽어주셔서 정말 감사합니다.