
Next.js 애플리케이션 설계 - 폴더 구조, React Query, Zustand 최적 조합 가이드
React
2025.06.17.
데이터 플랫폼 구축 프로젝트를 진행하면서 프론트엔드 기술 스택 선택의 기회가 주어졌다.
그동안 React를 공부하며 Gatsby로 블로그를 만들고, 몇 가지 개인 프로젝트에만 React를 사용해왔던 터라 이번 기회에 Next.js + React Query + Zustand 조합을 꼭 써보고 싶다는 점을 적극 어필했다.
불행인지 다행인지, 내 제안이 채택되어 제안한 스택으로 프론트엔드를 직접 구현해보라는 지시와 함께 초기 프로젝트를 구축하는 임무를 맡게 되었다.
초기 셋업은 비교적 무난하게 끝이나고, 정신없이 기능 구현을 하다 고민한 부분을 한번 정리해두면 좋을 것 같아 정리한다.
1. 폴더 구조
root
├── .vscode
├── .next : Next.js 빌드 아웃풋 (자동 생성, 배포 전 빌드 결과물)
├── node_modules : 설치된 외부 라이브러리
├── public : 정적 리소스 (images, favicon, robots.txt 등)
├── src/app
│ ├── (page)
│ │ ├── _fragments : 해당 page 내부에서만 사용하는 재사용 컴포넌트들 (ex: Card, Section 등)
│ │ ├── page.tsx : 해당 route의 최상위 컴포넌트 (React page entry point) / Server Component
│ │ ├── page.module.css : 해당 page 전용 CSS 모듈 스타일 정의
│ │ ├── types.ts : 해당 page 전용 타입, 인터페이스 정의 (지역 한정)
│ │ └── queries.tsx : 해당 page 전용 API 호출 로직 (React Query 커스텀 훅)
│ ├── api : 서버 API 래퍼 정의 (REST, GraphQL 등)
| └── shared
│ ├── component : 공통(여러 페이지에서 사용되는) 컴포넌트
│ ├── constants : 전역 상수 (ROUTES, API URL, 정규식, ENUM 등)
│ ├── hooks : 공통적으로 반복 사용하는 custom hooks (ex: useDebounce, useClickOutside)
│ ├── store : 전역 상태 관리 (Zustand)
│ ├── styles : 전역 테마, 변수, reset 등
│ ├── types : 전역 타입 정의 (ex: User, Token, Pagination)
│ ├── lib : 외부 라이브러리, 설정 래퍼, 초기화 함수 등 (ex: axiosInstance, i18n, analytics) (비즈니스 로직 X)
│ └── util : 범용 유틸 함수 (로직 중심, 재사용 가능) (ex: formatDate, generateUUID)
├── .prettier
├── eslint.config.mjs
└── package.json : 프로젝트 메타정보 및 종속성 목록
- 라우트 그룹 (page): Next.js의 파일 기반 라우팅 시스템을 따르면서도, 관련 파일들을 논리적인 단위로 묶기 위해 라우트 그룹을 적극적으로 활용했다. 이를 통해 URL 경로에 영향을 주지 않으면서 프로젝트 구조를 깔끔하게 유지할 수 있다.
- 지역적 컴포넌트 : _공통 컴포넌트 폴더(shared/components)가 무분별하게 커지는 것을 방지하고, 컴포넌트의 사용 범위를 명확히 하기 위해 각 페이지 내부에서만 사용되는 컴포넌트는 _fragments 폴더에 배치했다.
- 데이터 로직의 분리 : queries.tsx에 데이터 패칭 로직(React Query)을 모아 페이지는 데이터 로딩 상태나 API 호출 방식에 대해 전혀 알 필요 없이 오직 데이터만 가져다 쓸 수 있게 했다. (UI 컴포넌트로 부터 분리)
2. 비동기 요청
안정적인 비동기 처리를 위해 fetch API를 한번 감싼 공통 래퍼(Wrapper) 함수를 사용하게 했다.
이를 통해 에러 처리, URL 조합, 헤더 설정 등 반복적인 로직을 중앙에서 관리할 수 있게된다.
export async function serverGet<T>(
url: string,
queryParams?: Record<string, any>,
cacheOption: RequestCache = "default"
): Promise<FetchResult<T>> {
try {
const fullUrl = new URL(buildFullUrl(url));
if (queryParams) {
Object.keys(queryParams).forEach((key) =>
fullUrl.searchParams.append(key, queryParams[key])
);
}
const res = await fetch(fullUrl.toString(), { cache: cacheOption });
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const json = await res.json();
return { data: json, error: null };
} catch (err: any) {
return { data: null, error: err.message };
}
}
export async function serverPostJson<T>(
url: string,
body: any
): Promise<FetchResult<T>> {
try {
const fullUrl = buildFullUrl(url);
const res = await fetch(fullUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const json = await res.json();
return { data: json, error: null };
} catch (err: any) {
return { data: null, error: err.message };
}
}
export async function serverPostForm<T>(
url: string,
formData: FormData
): Promise<FetchResult<T>> {
try {
const fullUrl = buildFullUrl(url);
const res = await fetch(fullUrl, {
method: "POST",
body: formData,
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const json = await res.json();
return { data: json, error: null };
} catch (err: any) {
return { data: null, error: err.message };
}
}
// 예시
// 1. get
// const { data, error } = await serverGet<any>("/api/hello", { search: "test" });
// 2. form data
// const formData = new FormData();
// formData.append("username", "CodeGPT");
// formData.append("role", "developer");
// const { data, error } = await serverPostForm<any>("/api/upload", formData);
// 3. json
// const body = { username: "CodeGPT", role: "developer" };
// const { data, error } = await serverPostJson<any>("/api/save", body);
- 아래와 같이 모든 메소드를 처리할 수 있는 범용 함수 serverFetch를 만들 수도 있었지만, 의도적으로 각 메소드별 함수를 분리하는 방식을 택했다.
- 그 이유는 다음과 같다.
- 명확성 및 가독성 (Clarity & Readability): 가장 큰 이유는 코드의 명확성이다. 코드를 사용하는 입장에서 serverPostJson(…)이라는 함수 이름만 봐도 “아, 이 코드는 JSON 데이터를 POST 방식으로 보내는구나”라고 즉시 파악할 수 있다. 반면, serverFetch(…, { method: ‘POST’ })는 옵션 객체를 직접 확인해야만 요청의 종류를 알 수 있어 가독성이 떨어진다고 생각한다.
- 유지보수성 (Maintainability): 각 함수가 하나의 역할만 담당하므로 유지보수가 매우 용이해진다. 예를 들어, 미래에 모든 POST 요청에 특정 인증 헤더를 추가해야 하는 요구사항이 생겼을 때, 다른 메소드에 영향을 주지 않고 serverPostJson 함수만 안전하게 수정하면 된다. 범용 함수였다면 내부에서 복잡한 분기 처리가 필요했을 것이다.
- 개발 철학: “명확함이 간결함보다 낫다 (Clarity is better than conciseness)” 는 파이썬의 개발 철학처럼, 약간의 코드 중복을 감수하더라도 의도가 명확한 코드를 작성하는 것이 장기적으로 버그를 줄이고 협업 효율을 높이는 데 더 유리하다고 판단했다.
/**
* 모든 HTTP 요청을 처리하는 범용 fetch 래퍼 함수
* @param url 요청할 URL
* @param options fetch API의 RequestInit 옵션 (method, headers, body 등)
*/
export async function serverFetch<T>(
url: string,
options: RequestInit = {}
): Promise<FetchResult<T>> {
try {
const fullUrl = buildFullUrl(url);
// 기본 헤더 설정 (필요 시)
const defaultHeaders = {
"Content-Type": "application/json",
};
// 옵션을 병합하되, 사용자가 직접 제공한 옵션을 우선시합니다.
const finalOptions: RequestInit = {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
cache: options.cache || "no-store", // 기본 캐시 정책
};
const res = await fetch(fullUrl, finalOptions);
if (!res.ok) {
try {
const errorData = await res.json();
throw new Error(
errorData.message || `HTTP error! status: ${res.status}`
);
} catch (e) {
throw new Error(`HTTP error! status: ${res.status}`);
}
}
// 응답 본문이 있는지 확인 후 처리
const contentType = res.headers.get("content-type");
if (
res.status === 204 ||
!contentType ||
!contentType.includes("application/json")
) {
// No Content 또는 JSON이 아닌 응답
return { data: null as T, error: null };
}
const json = await res.json();
return { data: json, error: null };
} catch (err: any) {
return { data: null, error: err.message };
}
}
2.1 server component에서 활용
- 서버 컴포넌트에서는 React Query 같은 클라이언트 상태 관리 라이브러리 없이, serverGet 함수를 사용하여 데이터를 직접 가져올 수 있다.
const result = await serverGet<ApiResponse<SchedulListResponse>>(
`${process.env.NEXT_PUBLIC_CLCT_URL}/wrk/api/clct/scheduls`,
{
page,
size,
keyword,
}
);
2.2 client component에서 활용
- 사용자 인터랙션이 필요하고 클라이언트 사이드에서 데이터를 다시 가져오거나 수정해야 할 때는 React Query를 사용한다. 이때도 표준화된 API 헬퍼 함수를 활용하여 일관성을 유지했다.
- 데이터 조회시 useQuery를 사용할 때 serverGet함수를 사용하게 했다.
- 데이터 생성, 수정, 삭제시 useMutation을 사용할 때 serverPostJson함수를 사용하게 했다.
export function useQueryWithResult<T>(
queryKey: readonly (string | number)[],
url: string,
queryParams?: Record<string, any>,
options?: Omit<UseQueryOptions<FetchResult<T>>, "queryKey" | "queryFn">
) {
return useQuery<FetchResult<T>>({
queryKey,
queryFn: () => fetchGet<T>(url, queryParams),
staleTime: 60 * 1000,
...options,
});
}
export function useSchedulDetailQuery(schedulId: string, enabled = true) {
return useQueryWithResult<ApiResponse<SchedulDetail>>(
["schedulDetail", schedulId],
`${process.env.NEXT_PUBLIC_CLCT_URL}/wrk/api/clct/${schedulId}`,
undefined,
{
enabled: !!schedulId && enabled,
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
}
);
}
export function usePostMutation<T, U>(
url: string,
options?: Omit<UseMutationOptions<FetchResult<T>, Error, U>, "mutationFn">
) {
return useMutation<FetchResult<T>, Error, U>({
mutationFn: (payload: U) => serverPostJson<T>(url, payload),
...options,
});
}
export function useSaveSchedulMutation() {
const queryClient = useQueryClient();
return usePostMutation<ApiResponse<any>, Omit<SchedulSaveDto, "schedulId">>(
`${process.env.NEXT_PUBLIC_CLCT_URL}/wrk/api/clct`,
{
onSuccess: async () => {
toast.success("연계 작업이 등록되었습니다.");
await queryClient.invalidateQueries({ queryKey: ["schedulList"] });
window.location.reload();
},
onError: (err) => {
toast.error("등록 실패: " + err.message);
},
}
);
}
3. Zustand store
interface DomainState {
selectedItem: DomnItem | null;
setSelectedItem: (item: DomnItem) => void;
clearSelectedItem: () => void;
updateSelectedField: (field: keyof DomnItem, value: string) => void;
resetSelectedItem: () => void;
}
export const useDomainStore = create<DomainState>((set) => ({
selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }),
clearSelectedItem: () => set({ selectedItem: null }),
updateSelectedField: (field, value) =>
set((state) =>
state.selectedItem
? {
selectedItem: {
...state.selectedItem,
[field]: value,
},
}
: state
),
resetSelectedItem: () => set({ selectedItem: null }),
}));
- 대략 이런식으로 사용할 수 있게 하였다.