2026년 7월 14일

해당 글은 Claude를 기반으로 작성된 부분이 있습니다. 실제 설계 자체는 대부분 직접 한 것이 맞으나, 글로 작성할 때 Claude를 활용하여 다듬은 내용이 있습니다. 혹시나 틀리거나 부정확한 부분이 있다면 [email protected]으로 제보 부탁 드립니다.
요즘 사내외 프로젝트에서 Next.js를 쓰는 일이 잦아졌다. 문제는 새 프로젝트를 시작할 때마다 공식 문서의 App Router 가이드를 일일이 확인하며 구조를 처음부터 다시 잡게 된다는 점이다. 익숙해질 법도 한데, 매번 이 과정에서 적지 않은 시간을 흘려보내곤 했다. 이 소모적인 반복을 끝내기 위해, 개인 프로젝트를 진행하며 다져 온 나만의 Next.js 프로젝트 기준을 정리해 보려 한다.
📦example
┣ 📂api
┃ ┗ 📂auth
┃ ┃ ┣ 📜constants.ts
┃ ┃ ┣ 📜requests.ts
┃ ┃ ┣ 📜queries.ts
┃ ┃ ┗ 📜types.ts
┣ 📂app
┃ ┣ 📂(auth)
┃ ┃ ┣ 📂login
┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┣ 📂sign-up
┃ ┃ ┃ ┗ 📜page.tsx
┃ ┃ ┗ 📜layout.tsx
┃ ┣ 📜favicon.ico
┃ ┣ 📜layout.tsx
┃ ┗ 📜page.tsx
┣ 📂components
┃ ┣ 📂FullScreen
┃ ┃ ┣ 📂parts
┃ ┃ ┃ ┗ 📜Body.tsx
┃ ┃ ┣ 📜index.tsx
┃ ┃ ┣ 📜types.ts
┃ ┃ ┗ 📜utils.ts
┃ ┣ 📂MultiStepForm
┃ ┃ ┣ 📂contexts
┃ ┃ ┃ ┗ 📜MultiStepContext.ts
┃ ┃ ┣ 📂hooks
┃ ┃ ┃ ┗ 📜useMultiStepContext.ts
┃ ┃ ┣ 📂parts
┃ ┃ ┃ ┗ 📜FormContent.tsx
┃ ┃ ┣ 📜index.tsx
┃ ┃ ┗ 📜types.ts
┃ ┗ 📜index.tsx
┣ 📂lib
┃ ┣ 📜axios.ts
┃ ┗ 📜query-client.ts
┣ 📂providers
┃ ┣ 📜AuthProvider.tsx
┃ ┣ 📜QueryProvider.tsx
┃ ┗ 📜auth-ready-context.ts
┣ 📂styles
┃ ┣ 📂fonts
┃ ┃ ┣ 📜PretendardJPVariable.woff2
┃ ┃ ┣ 📜PretendardVariable.woff2
┃ ┃ ┗ 📜index.ts
┃ ┗ 📜globals.css
┣ 📜.env.local
┣ 📜.gitignore
┣ 📜AGENTS.md
┣ 📜CLAUDE.
┣ 📜.
┣ 📜eslint..
┣ 📜next-env..
┣ 📜next..
┣ 📜package.
┣ 📜postcss..
┗ 📜yarn.
node_modules, .git처럼 예제에 불필요한 파일을 걷어내면 대략 이런 형태가 된다.
이제부터 각 영역을 하나씩 풀어보겠다.
API 호출과 직접 맞닿아 있는 영역이다.
인증, 상품 리스트·상세 조회 등 API 요청을 도맡아 관리하며, 도메인마다 constants.ts, requests.ts, types.ts 세 파일로 나눠 구성한다.
먼저 constants.ts는 요청과 응답 양쪽에서 공통으로 쓰이는 상수를 모아둔 파일이다. API BaseURL, 파라미터, 응답 enum 값 등을 한곳에 담는다.
requests.ts는 실제 API 요청을 담당하는 파일로, 주로 Axios와 TanStack Query를 조합해 관리한다.
물론 TanStack Query 대신 Redux-Saga를, Axios 대신 Fetch API를 선택할 수도 있다. 다만 나는 다음과 같은 기준으로 스택을 골랐다.
TanStack Query는 서버 상태를 관리하는 라이브러리다. 클라이언트 상태를 전역으로 다루는 Zustand·Redux와 달리 서버 상태를 직접 다룬다는 점에서 이점이 있다고 판단해 선택했다.
Zustand·Redux로 서버 상태를 다룰 때는 요청을 수동으로 보내고, 성공·실패 상태도 코드에서 하나씩 직접 선언해 관리해야 했다. 반면 TanStack Query는 이 과정을 라이브러리 내부에서 알아서 처리해주기 때문에 서버 상태 관리에 드는 수고가 크게 줄어든다. async/await에 익숙하다면 어렵지 않게 쓸 수 있다는 점도 매력이었다.
Axios는 Fetch API와 장단점이 뚜렷해 그 위에서 골랐다. 대표적으로 데이터 가공이 Fetch보다 편하고, 요청 중단(CancelToken) 기능을 쓸 수 있다는 점이 컸다.
마지막으로 types.ts는 API 요청·응답에 쓰이는 타입을 정의하는 파일이다. constants.ts의 enum 값과 맞물려 type과 interface 형태로 내보낸다.
이렇게 나누면 API 요청을 한 폴더 안에서 도메인 단위로 명확하게 관리할 수 있다고 봐서 이 구조를 택했다.
처음엔 위에서 말한 대로 requests.ts 한 파일에 Axios 요청 함수와 TanStack Query 코드를 전부 몰아넣었다. 도메인 파일이 셋뿐이라 단출했고, 한 파일만 열면 요청부터 훅까지 다 보인다는 장점도 있었다.
그런데 프로젝트가 커지면서 이 파일이 두 가지 성격의 코드를 동시에 짊어지고 있다는 게 걸리기 시작했다.
queryOptions, useXxxQuery/useXxxMutation 훅
이 둘은 관심사가 다르다. 요청단은 "무엇을 어떻게 호출하는가"만 알면 되고, TanStack 계층은 "그 결과를 캐시에서 어떻게 다루는가"를 책임진다. 그래서 requests.ts에서 TanStack 코드를 떼어내 queries.ts로 분리했다. 네이밍은 TanStack Query 커뮤니티에서 사실상 표준처럼 쓰이는 queries.ts를 그대로 따랐다.
바뀐 폴더 구조는 이렇다. 도메인마다 네 파일이 된다.📂api
┣ 📂auth
┃ ┣ 📜constants.ts // 공통 상수 (BaseURL, enum 등)
┃ ┣ 📜types.ts // 요청·응답 타입
┃ ┣ 📜requests.ts // 순수 요청단 (Axios만)
┃ ┗ 📜queries.ts // 서버 상태 관리 (TanStack Query)
requests.ts에는 이제 TanStack Query import가 한 줄도 없다. 오직 Axios 인스턴스(api)를 호출해 응답을 돌려주는 함수만 남는다.
// api/auth/requests.ts
import { api, setAccessToken } from "@/lib/axios";
import { AUTH_BASE } from "./constants";
import type { AuthLoginRequest, AuthLoginResponse } from "./types";
// POST /api/v1/web/auth/login — 이메일 로그인
export async function loginWithEmail(body: AuthLoginRequest) {
const { data } = await api.post<AuthLoginResponse>(`${AUTH_BASE}/login`, body);
setAccessToken(data.session.access_token);
return data;
}
// GET /api/v1/web/auth/me — 현재 로그인 사용자 조회
export async function getMyInfo(): Promise<AuthMyInfoResponse | null> {
const { data } = await api.get<AuthMyInfoResponse>(`${AUTH_BASE}/me`);
return data;
}
이렇게 두면 요청 함수는 React나 TanStack에 얽매이지 않는다. 훅 밖(예: Axios 인터셉터, 앱 부트스트랩)에서 그냥 await refreshSession()처럼 불러 쓸 수도 있다.
queries.ts는 requests.ts의 함수를 가져와 TanStack Query로 감싼다. 의존 방향은 queries.ts** → requests.ts 한 방향**이다. 반대로 요청단이 TanStack을 import하는 일은 없다.
// api/auth/queries.ts
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { getMyInfo, loginWithEmail } from "./requests";
// 1) 쿼리 키 팩토리
export const authKeys = {
all: ["auth"] as const,
me: () => [...authKeys.all, "me"] as const,
};
// 2) queryOptions — 유저 정보는 login/logout/refresh로만 갱신하므로 자동 refetch 금지
export const myInfoQueryOptions = queryOptions({
queryKey: authKeys.me(),
queryFn: getMyInfo,
staleTime: Infinity,
});
// 3) 훅 — 컴포넌트는 이 훅만 가져다 쓴다
export function useMyInfoQuery() {
return useQuery(myInfoQueryOptions);
}
export function useEmailLoginMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: loginWithEmail, // ← requests.ts의 순수 함수를 그대로 주입
onSuccess: (data) => (queryClient, data),
});
}
컴포넌트 입장에선 여전히 useMyInfoQuery(), useEmailLoginMutation() 하나만 import하면 된다. 내부에서 요청단과 캐시 계층이 어떻게 나뉘어 있는지는 몰라도 되니, 분리를 했어도 사용하는 쪽 경험은 그대로다.
분리하면서 가장 신경 쓴 부분이 위 코드의 authKeys다. 나는 처음에 이런 의문이 들었다. *"**all: ["auth"]**로 잡아두면, 나중에 **user*나 product 같은 다른 도메인을 다룰 때 auth 캐시가 날아가지 않을까?"
결론은 날아가지 않는다. TanStack Query의 무효화·삭제는 키 배열의 앞부분(prefix)이 일치하는지로 판정하기 때문이다.
authKeys.all // ["auth"]
authKeys.me() // ["auth", "me"]
로그아웃에서 removeQueries({ queryKey: authKeys.all })를 부르면 ["auth", ...]로 시작하는 키만 지워진다. 다른 도메인이 ["user"], ["product"]처럼 고유한 최상위 문자열을 쓰는 한, 두 캐시 트리는 완전히 분리돼 서로를 건드리지 않는다.
// 다른 도메인은 최상위 키만 다르게 두면 끝
export const userKeys = {
all: ["user"] as const, // ← "auth"와 겹치지 않으니 격리된다
detail: (id: string) => [...userKeys.all, "detail", id] as const,
};
즉 auth 상태가 실제로 초기화되는 경우는 ①다른 도메인이 ["auth"]를 재사용하는 네이밍 충돌, ②queryClient.clear(), ③키 없는 invalidateQueries() — 이 셋뿐이고 전부 의도적으로 해야 벌어진다. 그래서 규칙은 단 하나, **"도메인마다 all의 최상위 문자열을 고유하게 둘 것"**이면 충분하다.
오히려 이 팩토리 덕분에 자식 키를 [...authKeys.all, ...]로 파생시켜 두면, 로그아웃 한 번에 authKeys.all 하위 캐시(지금의 me, 앞으로 늘어날 키까지) 전부를 정확히 그 도메인만 골라 정리할 수 있다. 키를 컴포넌트에서 손으로 조립하지 않고 팩토리로만 만드는 이유가 여기에 있다.
requests.ts = 무엇을 호출하는가 (Axios, 순수 함수)queries.ts = 그 결과를 캐시에서 어떻게 다루는가 (쿼리 키·옵션·훅)
한 파일에 뭉쳐 있던 걸 관심사 기준으로 가른 것뿐이지만, 요청 로직을 훅과 무관하게 재사용할 수 있게 되고, 도메인별 캐시 경계도 쿼리 키 팩토리로 또렷해졌다. 새 도메인을 추가할 때 네 파일을 같은 규칙으로 찍어내기만 하면 된다는 점이, 매번 구조를 새로 고민하던 그 소모를 줄여준 지점이다.📂app
┣ 📂(auth)
┃ ┣ 📂login
┃ ┃ ┗ 📜page.tsx
┃ ┣ 📂sign-up
┃ ┃ ┗ 📜page.tsx
┃ ┗ 📜layout.tsx // login·sign-up 공통 레이아웃
┣ 📜favicon.ico
┣ 📜layout.tsx // 루트 레이아웃 (html/body, Provider, 폰트)
┗ 📜page.tsx // 홈 (/)
Next.js의 App Router를 기준으로 잡았다. 먼저 (auth)처럼 괄호로 감싼 폴더는 Route Group이다. 여기서 한 가지 짚고 넘어갈 점은, 괄호로 묶은 폴더명은 URL 경로에 포함되지 않는다는 것이다. 즉 app/(auth)/login/page.tsx의 실제 경로는 /auth/login이 아니라 /login이 된다. 그래서 route group은 URL을 나누려는 목적이 아니라, URL에 영향을 주지 않으면서 연관된 페이지를 묶고 공통 레이아웃을 씌우기 위한 장치로 쓴다. 앞서 본 api 폴더의 도메인 구분이 순수한 코드 정리였다면, 이쪽은 Next.js가 부여한 "레이아웃 공유 경계"라는 의미가 하나 더 얹힌 셈이다. 나는 이걸 인증 관련 화면(로그인·회원가입)을 묶는 용도로 활용했다. 레이아웃은 두 겹으로 중첩된다. 바깥의 루트 layout.tsx 는 앱 전체를 감싸며 html/body, 전역 Provider, 폰트, 전역 스타일을 세팅한다.
// app/layout.tsx
import "@/styles/globals.css";
import { pretendard, pretendardJP } from "@/styles/fonts";
import { AuthProvider } from "@/providers/AuthProvider";
import { QueryProvider } from "@/providers/QueryProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={`${pretendard.variable} ${pretendardJP.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">
<QueryProvider>
<AuthProvider>{children}</AuthProvider>
</QueryProvider>
</body>
</html>
);
}
그리고 안쪽의 (auth)/layout.tsx 는 그룹 안의 페이지에만 적용되는 레이아웃이다. 로그인·회원가입 화면을 화면 정중앙의 좁은 카드 형태로 정렬해주는데, 이 레이아웃 한 곳만 두면 그룹 내 두 페이지가 자동으로 같은 틀을 공유한다.
// app/(auth)/layout.tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">{children}</div>
</main>
);
}
한편 page.tsx는 각 경로의 실제 화면을 담당한다. 여기서 개인적으로 지키려는 원칙이 하나 있는데, app 폴더에는 되도록 "페이지(라우팅)와 직접 관련된 것"만 둔다는 것이다. Provider·전역 스타일·재사용 컴포넌트처럼 특정 경로에 종속되지 않는 코드는 providers, styles, components 같은 별도 폴더로 빼둔다. 그래서 페이지 파일은 데이터 조합과 화면 조립에만 집중하게 된다. 예컨대 홈(app/page.tsx)은 이렇게 구성했다. 페이지 자체는 얇게 두고, 실제 사용자 정보 UI는 components/UserInfoCard에 위임한다. 다만 라우팅과 직접 얽힌 로직 — OAuth 콜백이 실패했을 때 넘어오는 ?error= 쿼리를 읽어 로그인 페이지로 넘겨주는 처리 — 은 페이지의 책임으로 보고 여기에 둔다.
// app/page.tsx
import { redirect } from "next/navigation";
import { UserInfoCard } from "@/components/UserInfoCard";
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ error?: string | string[] }>;
}) {
// OAuth 실패 시 백엔드가 이 페이지로 ?error=<코드>를 붙여 돌려보낸다
const { error } = await searchParams;
const oauthError = Array.isArray(error) ? error[0] : error;
if (oauthError) {
redirect(`/login?error=${encodeURIComponent(oauthError)}`);
}
return (
<main className="flex flex-1 flex-col items-center justify-center gap-6 px-4 py-12">
<h1 className="text-2xl font-bold">IInfo DX</h1>
<UserInfoCard />
</main>
);
}
이렇게 "페이지는 라우팅과 화면 조립, 그 외 관심사는 바깥 폴더"라는 경계를 지키면, 새 화면을 추가할 때도 어디에 무엇을 둘지 매번 고민하지 않게 된다.
참고로 searchParams가 Promise로 감싸여 await 대상이 된 점을 눈여겨볼 만하다. 이는 최근 Next.js에서 바뀐 부분으로, 예전 감각으로 동기 접근하면 동작하지 않는다. 이런 변경점은 프로젝트에 포함된 App Router 가이드 문서에서 확인하고 넘어가는 편이 안전하다.
📂components
┣ 📂FullScreen
┃ ┣ 📂parts // 특정 컴포넌트 내 보조 컴포넌트
┃ ┃ ┗ 📜Body.tsx
┃ ┣ 📜index.tsx // 컴포넌트의 메인
┃ ┣ 📜types.ts // 컴포넌트 내부 타입
┃ ┗ 📜utils.ts // 컴포넌트 내부 유틸 함수 (인증 분기 등)
┗ 📂MultiStepForm
┣ 📂contexts // 특정 컴포넌트 내부에서만 사용하는 Context
┃ ┗ 📜MultiStepContext.ts
┣ 📂hooks // 특정 컴포넌트에서만 사용하는 Hooks
┃ ┗ 📜useMultiStepContext.ts
┣ 📂parts
┃ ┗ 📜FormContent.tsx
┣ 📜index.tsx
┗ 📜types.ts
컴포넌트를 어떻게 나눌지는 사람마다 지향점이 달라서 가장 의견이 갈리는 부분이 아닐까 싶다. 일단 나의 기준은 기능별로 분리하고, 1 파일 1 컴포넌트를 고수하는 것이다. 다만 지금 개발 중인 Next.js 개인 프로젝트는 아직 예제로 보여줄 만한 결과물이 없어서, 부득이하게 React Native 프로젝트를 예시로 든다. 세부 구현은 다르지만 파일을 나누는 흐름 자체는 Next.js에도 그대로 적용할 수 있다.
이 컴포넌트는 모바일 화면의 헤더 · 컨텐츠 · GNB(흔히 Bottom Navigation)로 이어지는 구조를 하나로 묶어, 템플릿처럼 가져다 쓸 수 있게 만든 것이다.
아래 코드에서 주목할 건 내부 구현(RN 종속 API)이 아니라
index / parts / types / utils로 나눈 파일 분리 방식이다.
해당 내용은 임시로 작성된 내용으로, 포트폴리오가 완성되는 대로 변경 될 예정입니다.
// components/FullScreen/index.tsx
import { useNavigation, useRoute } from "@react-navigation/native";
import type { ColorToken } from "@ubittz/native-design-system";
import {
BottomNavigation,
useDesignSystem,
usePopup,
} from "@ubittz/native-design-system";
import React, { cloneElement, useMemo } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Body from "@@/components/FullScreen/parts/Body";
import type { FullScreenProps } from "@@/components/FullScreen/types";
import {
getAuthScreenNames,
getTabRoutesLazy,
} from "@@/components/FullScreen/utils";
import { SCREENS } from "@@/router/constants";
import type {
,
,
} ;
{ useAppState } ;
{ cn } ;
() {
{ bottom } = ();
{ brandPrimaryBaseLevel } = ();
brandPrimaryColor =
;
nav = useNavigation<>();
route = ();
safeEdges = (
(navigation ? edges.( e !== ) : edges),
[navigation, edges],
);
navigationItems = (
().( {
inactiveIcon = r.!.;
activeIcon = r.!. ?? r.!.;
{
: r.,
: (inactiveIcon, {
: ,
} <, >),
: (activeIcon, {
: brandPrimaryColor,
} <, >),
: r.!.,
};
}),
[brandPrimaryColor],
);
{ token } = ( state.);
isAuthenticated = !!token;
{ showPopup, hidePopup } = ();
currentTab = route.;
= () => {
(value === currentTab) ;
(!isAuthenticated && ().(value)) {
({
: ,
: [
{
: ,
: ,
: hidePopup,
},
{
: ,
: {
();
nav.(. );
},
},
],
});
;
}
nav.({
: ,
: [{ : value keyof }],
});
};
(
);
}
. = ;
;
먼저 index.tsx는 컴포넌트의 메인이 되는 파일이다. 컴포넌트의 실제 화면을 여기서 선언하며, FullScreen.Body처럼 모듈 형식으로 덧붙일 경우 export default 문 직전에 추가하면 된다.
물론 index.tsx에서는 import/export만 처리하고 실제 렌더링은 별도 파일로 빼도 문제는 없다. 다만 개인적으로는 지금 구조가 가장 편했다.
// components/FullScreen/parts/Body.tsx
import React from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import type { FullScreenBodyProps } from "@@/components/FullScreen/types";
import { cn } from "@@/utils/cn";
function Body({
children,
className,
disableScroll,
refreshing,
onRefresh,
...props
}: FullScreenBodyProps) {
if (disableScroll) {
return (
<View className={cn("flex-1", className)} {...props}>
{children}
</View>
);
}
return (
<ScrollView
className={cn("flex-1", className)}
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
refreshControl={
? (
<
= ?? }
=
/>
) : undefined
}
{...props}
>
{children}
);
}
;
전역 컴포넌트로 빼기엔 애매하지만 그래도 떼어놓는 게 나은 보조 컴포넌트는, parts 폴더에 각자 이름으로 분리해 둔다.
모듈형으로 쓸 수 있게 index.tsx에서 다시 선언해 <FullScreen.Body> 형태로 노출한다는 점만 빼면, 작성 방식은 index.tsx와 동일하다.
// components/FullScreen/types.ts
import type { ReactNode } from "react";
import type { ViewProps } from "react-native";
export type SafeAreaEdge = "top" | "left" | "right" | "bottom";
export interface FullScreenProps extends ViewProps {
children: ReactNode;
/** true이면 하단 바텀 네비게이션 표시 */
navigation?: boolean;
/** 배경색. 미지정 시 #fff */
backgroundColor?: string;
/**
* safe area 적용할 방향. navigation=true이면 bottom 자동 제외.
* 가급적 기본값을 사용하고 꼭 필요한 경우에만 명시.
* AI 에이전트는 이 프롭을 명시하지 말 것.
*/
edges?: SafeAreaEdge[];
}
export interface FullScreenBodyProps extends ViewProps {
children: ReactNode;
/** true이면 ScrollView 대신 일반 View 사용 */
disableScroll?: boolean;
/** pull-to-refresh 진행 중 여부 */
refreshing?: boolean;
/** pull-to-refresh 콜백 */
onRefresh?: ;
}
types.ts는 이름 그대로 컴포넌트 내부 타입을 모아 둔 파일이다.
컴포넌트 파일 안에 타입을 함께 선언하는 방식도 있지만, 나는 이렇게 파일로 떼어 두는 편이 더 보기 편했다. 한 파일 안에서 구분이 필요하면 주석으로 나눠도 충분하니 문제 될 게 없다.
// components/FullScreen/utils.ts
import { appRoutes } from "@@/router/routes";
import { flattenRoutes, getTabRoutes } from "@@/router/utils";
/**
* factory를 최초 호출 시 한 번만 실행하고 결과를 캐싱한다.
* (let 없이 객체 변이로 지연 초기화 구현)
*/
function once<T>(factory: () => T): () => T {
const cache: { computed: boolean; value?: T } = { computed: false };
return () => {
if (!cache.computed) {
cache.value = factory();
cache.computed = true;
}
return cache.value as T;
};
}
/** AUTHENTICATED 스크린 이름 집합 (지연 계산 + 캐싱) */
export const getAuthScreenNames = once(
() =>
new Set(
flattenRoutes(appRoutes)
.filter((r) => r.authType === "AUTHENTICATED")
.map((r) => r.screenName),
),
);
/** 탭 네비게이션 라우트 목록 (지연 계산 + 캐싱) */
export getTabRoutesLazy = ( (appRoutes));
마지막으로 utils.ts다.
여기서는 권한 제어처럼 컴포넌트 내부에서만 쓰는 유틸 로직을 주로 다루며, 필요 없으면 두지 않아도 된다.
앞선 FullScreen이 "화면 껍데기"를 다루는 정적인 컴포넌트였다면, MultiStepForm은 여러 스텝에 걸친 폼 상태를 컴포넌트 내부에서 직접 관리해야 하는 조금 더 복잡한 경우다. 이럴 때 parts / types 외에 contexts, hooks 폴더가 추가로 등장한다.
📂MultiStepForm
┣ 📂contexts // 이 컴포넌트 안에서만 쓰는 Context
┃ ┗ 📜MultiStepContext.ts
┣ 📂hooks // 이 컴포넌트 안에서만 쓰는 Hook
┃ ┗ 📜useMultiStepContext.ts
┣ 📂parts
┃ ┗ 📜FormContent.tsx
┣ 📜index.tsx
┗ 📜types.ts
여기서 짚고 넘어갈 점은 이 두 폴더의 판단 기준이다. 예를 들어 hooks는 전역 폴더(src/hooks)에도 따로 존재하는데, 둘을 가르는 건 딱 하나 적용 범위(scope)다. 여러 화면에서 공유하는 훅이면 전역(src/hooks)에 두고, 이 컴포넌트 밖에서는 쓸 일이 없으면 컴포넌트 내부 hooks 폴더로 내린다. Context 역시 같은 기준을 따르는데, 이 컴포넌트에서만 쓰이니 전역으로 빼지 않고 컴포넌트 내부 contexts 폴더에 뒀다. parts가 "전역 컴포넌트로 빼기엔 애매한 보조 컴포넌트"를 담았던 것과 정확히 같은 논리다. (Context와 Hook을 언제/어떻게 설계하는지에 대한 상세한 기준은 별도 카테고리에서 따로 다룬다. 여기서는 "컴포넌트 지역 스코프로 내렸다"는 구조적 판단에만 집중한다.)
// components/MultiStepForm/index.tsx
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { Formik } from "formik";
import { useCallback, useRef, useState } from "react";
import { BackHandler } from "react-native";
import MultiStepContext from "@@/components/MultiStepForm/contexts/MultiStepContext";
import FormContent from "@@/components/MultiStepForm/parts/FormContent";
import { MultiStepFormProps } from "@@/components/MultiStepForm/types";
function MultiStepForm<T>({
headerTitle,
steps,
initialValues,
onSubmit,
}: MultiStepFormProps<T>) {
const navigation = useNavigation();
const [currentStep, setCurrentStep] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const onNextPressRef = useRef<(values: T) => void>(() => {});
const goNext = () => setCurrentStep(currentStep + );
= () => (currentStep - );
(
( {
= () => {
(currentStep === ) {
navigation.();
;
}
(currentStep - );
;
};
subscription = .(
,
onBackPress,
);
subscription.();
}, [currentStep]),
);
(
);
}
;
index.tsx가 메인이라는 원칙은 FullScreen과 똑같다. 다만 여기서 index.tsx가 맡는 역할이 조금 더 분명하다. 화면을 직접 그리지 않고, "상태와 컨텍스트를 조립하는 컨테이너" 역할만 한다. 스텝 인덱스(currentStep), 로딩 상태, 다음 버튼 핸들러(onNextPressRef)를 Formik과 MultiStepContext.Provider로 감싸고, 실제 렌더링은 전부 parts/FormContent에 위임한다.
이렇게 "상태를 만드는 곳(index)"과 "상태를 소비해서 그리는 곳(parts)"을 분리하면, 폼이 아무리 복잡해져도 index.tsx만 보면 이 컴포넌트가 무슨 상태를 들고 있는지 한눈에 파악된다. 이게 1파일 1컴포넌트 원칙을 지키는 실질적인 이득이다.
참고:
onNextPressRef를useState가 아니라useRef로 둔 이유는, 다음 버튼 핸들러가 바뀌어도 리렌더를 유발할 필요가 없기 때문이다. "인증번호 전송 후 로딩 제어" 같은 스텝별 커스텀 동작을 자식(part)에서 주입받되, 그 주입이 렌더링과 무관하도록 만든 장치다.
// components/MultiStepForm/parts/FormContent.tsx
import { useNavigation } from "@react-navigation/native";
import {
Button,
Round,
TopNavigation,
Typography,
} from "@ubittz/native-design-system";
import { useFormikContext } from "formik";
import { useEffect } from "react";
import { Keyboard, Pressable, ScrollView, View } from "react-native";
import BottomButtonContainer from "@@/components/BottomButtonContainer";
import { useMultiStepContext } from "@@/components/MultiStepForm/hooks/useMultiStepContext";
import { FormContentProps } from "@@/components/MultiStepForm/types";
function FormContent({ headerTitle, steps, currentStep }: FormContentProps) {
const { handleSubmit, isValid, dirty, validateForm, setErrors, setTouched, values } =
useFormikContext();
const { goNext, goPrevious, onNextPress, isLoading } = useMultiStepContext();
const navigation = useNavigation();
const = () => {
.();
(steps. - === currentStep) {
();
;
}
(steps[currentStep].) {
onNextPress?.(values);
;
}
();
};
= () => {
(currentStep === ) {
navigation.();
;
}
();
};
( {
({});
({});
timer = ( (), );
(timer);
}, [currentStep]);
(
);
}
;
parts/FormContent의 역할은 FullScreen.Body와 동일하게 "메인 밖으로 뺀 보조 렌더링"이지만, 규모가 다르다. 헤더 · 스텝 본문 · 하단 버튼까지 실제 화면 전부를 여기서 그린다. 핵심은 이 컴포넌트가 자기 상태를 하나도 갖지 않는다는 점이다. 폼 값은 useFormikContext에서, 스텝 제어는 useMultiStepContext에서 가져올 뿐이다. 그래서 index.tsx(상태 소유) ↔ FormContent(상태 소비)의 경계가 깔끔하게 유지된다.
contexts/와 hooks/는 이 경계를 잇는 다리다. 먼저 contexts에는 스텝 제어 상태를 담을 Context를 선언한다.
// components/MultiStepForm/contexts/MultiStepContext.ts
import { Context, createContext } from "react";
import { MultiStepContextType } from "@@/components/MultiStepForm/types";
const MultiStepContext: Context<MultiStepContextType<any>> =
createContext<MultiStepContextType<any>>({
currentStep: 0,
goNext: () => {},
goPrevious: () => {},
onNextPress: () => {},
setOnNextPress: () => {},
isLoading: false,
setLoading: () => {},
});
export default MultiStepContext;
MultiStepContext는 스텝 이동 · 로딩 · 다음 버튼 핸들러를 자식 어디서든 꺼내 쓰게 해주는 통로다. index.tsx에서 Provider로 실제 값을 채워 넣고, 하위 컴포넌트는 이 Context를 통해 값을 읽는다. createContext에 넘긴 기본값은 Provider 없이 쓰였을 때를 대비한 안전한 no-op 형태다.
그리고 hooks에는 이 Context를 편하게 소비하기 위한 훅을 둔다.
// components/MultiStepForm/hooks/useMultiStepContext.ts
import { useContext, useEffect } from "react";
import MultiStepContext from "@@/components/MultiStepForm/contexts/MultiStepContext";
import { useMultiStepContextProps } from "@@/components/MultiStepForm/types";
export function useMultiStepContext<T>({
onNextPress,
}: useMultiStepContextProps<T> = {}) {
const context = useContext(MultiStepContext);
useEffect(() => {
if (onNextPress !== undefined) {
context.setOnNextPress(onNextPress);
}
}, [onNextPress]);
return context;
}
useMultiStepContext는 단순히 Context를 꺼내주는 데서 그치지 않고, 인자로 onNextPress를 넘기면 그 핸들러를 Context에 등록까지 해주는 소비 훅이다. 덕분에 각 스텝 컴포넌트는 useMultiStepContext({ onNextPress }) 한 줄로 "이 스텝의 다음 버튼이 눌렸을 때 할 일"을 주입할 수 있다.
두 폴더의 상세한 설계 기준(왜 prop drilling 대신 Context를 썼는지, 훅으로 한 번 감싼 이유 등)은 각각 별도 카테고리에서 다루므로, 여기서는 "컴포넌트 지역 스코프로 분리했다"는 구조만 기억하면 된다.
// components/MultiStepForm/types.ts
import { FormikValues } from "formik";
import { Schema } from "yup";
export interface MultiStepFormStep {
title: string;
description: string;
component: React.ReactNode;
schema?: Schema;
preventDefaultNext?: boolean;
}
export interface MultiStepFormProps<T> {
headerTitle: string;
steps: MultiStepFormStep[];
initialValues: FormikValues & T;
onSubmit: (values: T) => void;
}
export interface FormContentProps {
headerTitle: string;
steps: MultiStepFormStep[];
currentStep: number;
setCurrentStep: (step: number) => void;
}
export interface <T> {
: ;
: ;
: ;
: ;
: ;
: ;
: ;
}
useMultiStepContextProps<T> {
?: ;
}
types.ts의 위치와 목적은 FullScreen과 완전히 동일하다. 다만 심화 예시답게 담기는 타입이 더 많다 — 외부에서 받는 MultiStepFormProps<T>, 스텝 정의(MultiStepFormStep), 그리고 Context 타입(MultiStepContextType<T>)까지. 컴포넌트가 커질수록 타입을 파일 하나로 몰아두는 이 규칙의 값어치가 커진다. 구현 파일(index, parts)을 열지 않고 types.ts 하나만 봐도 "이 컴포넌트가 뭘 주고받는지"가 목차처럼 정리되기 때문이다.
정리하면, FullScreen은 index / parts / types / utils, MultiStepForm은 거기에 contexts / hooks가 더해진 형태다. 규칙 자체는 하나다 — "전역으로 빼기 애매하지만 분리가 필요한 것들"을 기능별 폴더로 컴포넌트 안에 내린다. 컴포넌트가 단순하면 폴더가 적게, 복잡하면 많이 생길 뿐, 판단 기준은 처음부터 끝까지 동일하다.
📂lib
┣ 📜axios.ts // 공유 HTTP 인스턴스 + 401 자동 복구
┗ 📜query-client.ts // 서버/브라우저용 QueryClient 팩토리
lib는 특정 도메인이나 화면에 묶이지 않고 앱 전체가 공유하는 하부 설정을 두는 곳이다. api가 "무엇을 호출하는가"를 도메인별로 나눴다면, lib는 그 호출이 딛고 서는 공통 토대 — HTTP 클라이언트와 캐시 클라이언트 — 를 담는다. 화면이 늘어도, 도메인이 늘어도 이 두 파일은 그대로 재사용된다.
먼저 앱 전역에서 쓰는 Axios 인스턴스 api를 한 곳에서 만들어 내보낸다. baseURL은 환경변수에서 읽되 없으면 로컬 기본값으로 떨어지고, 세션이 쿠키 기반이라 withCredentials: true로 모든 요청에 세션 쿠키를 실어 보낸다.
// lib/axios.ts
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL ?? "<http://localhost:8000>";
export const api = axios.create({
baseURL: API_BASE_URL,
withCredentials: true, // 세션 쿠키를 모든 요청에 포함
headers: { "Content-Type": "application/json" },
});
여기서 인증 설계의 핵심 결정이 하나 있다. /me처럼 Bearer 토큰으로 보호되는 엔드포인트용 access token을 메모리에만 둔다는 것이다.
// /me 등 HTTPBearer 보호 엔드포인트용 access token (메모리 보관).
// 새로고침으로 사라지면 401 → refresh 인터셉터가 쿠키로 재발급받아 복구한다.
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
토큰을 localStorage가 아니라 모듈 스코프 변수에 담아 두면 새로고침 시 사라진다. 언뜻 손해처럼 보이지만, 이건 의도된 설계다. 실제 신원의 근거는 httpOnly refresh 쿠키(자바스크립트가 못 건드리는)에 있고, 메모리의 access token은 그 쿠키로 언제든 되살릴 수 있는 파생물일 뿐이다. XSS로 토큰을 긁어가기 어려운 구조를 택하면서, 사라져도 복구 경로가 있으니 사용성도 잃지 않는다.
그 복구를 책임지는 게 응답 인터셉터다. 401이 오면 쿠키로 세션을 갱신한 뒤 원래 요청을 딱 한 번 재시도한다.
// lib/axios.ts
// 동시 다발 401에 대해 refresh 요청을 한 번만 보내기 위한 공유 프로미스
let refreshPromise: Promise<unknown> | null = null;
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as
| (InternalAxiosRequestConfig & { _retried?: boolean })
| undefined;
const isAuthAttempt =
original?.url === REFRESH_URL ||
original?.url?.startsWith(LOGIN_URL_PREFIX);
// 401이면 쿠키 기반 세션 갱신 후 원 요청을 1회 재시도한다.
// 로그인/갱신 요청 자체의 401은 자격 증명 오류이므로 그대로 전파한다.
if (
error.response?.status === 401 &&
original &&
!original._retried &&
!isAuthAttempt
) {
refreshPromise ??= api
.post<AuthRefreshResponse>(REFRESH_URL)
.then((response) => setAccessToken(response.data.session.access_token))
.finally( {
refreshPromise = ;
});
{
refreshPromise;
} {
.(error);
}
original. = ;
(original);
}
.(error);
},
);
이 인터셉터에서 신경 쓴 지점이 셋 있다.
refresh나 login 요청 자체가 401을 뱉으면(isAuthAttempt) 이건 갱신으로 풀 수 있는 문제가 아니라 자격 증명 오류다. 그대로 던져 인터셉터가 자기 자신을 다시 호출하는 재귀를 막는다._retried 플래그를 달아, 갱신 후 재시도한 요청이 또 401이면 이번엔 순순히 실패시킨다. 갱신됐는데도 계속 막힌다면 재시도를 반복해봐야 결과는 같기 때문이다./refresh를 때리면 갱신이 여러 번 겹친다. refreshPromise ??=로 진행 중인 갱신이 있으면 그 프로미스를 공유하고, finally에서 비워 다음 갱신이 다시 시작될 수 있게 했다. 여러 요청이 하나의 갱신을 기다렸다가 각자 재시도되는 구조다.두 번째 파일은 TanStack Query의 QueryClient를 만드는 팩토리다. Next.js App Router는 서버에서 한 번, 브라우저에서 한 번 렌더하므로, QueryClient를 아무 데서나 new 하면 SSR과 하이드레이션 사이에 캐시가 어긋난다. 그래서 생성 규칙을 이 파일에 못 박아 둔다.
// lib/query-client.ts
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
// 4xx는 재시도해도 결과가 같으므로 중단한다.
// (401은 axios 인터셉터가 refresh 후 1회 재시도까지 마친 최종 실패)
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response && error.response.status < 500) {
return false;
}
return failureCount < 3;
},
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
},
});
}
여기서 앞의 axios.ts와 맞물리는 규칙이 retry다. TanStack Query 계층까지 401이 올라왔다는 건, 이미 Axios 인터셉터가 갱신 후 재시도까지 다 하고도 실패했다는 뜻이다. 그러니 여기서 또 재시도하는 건 무의미하다. 4xx 전반을 재시도에서 빼고, 5xx 같은 일시적 서버 오류만 세 번까지 다시 시도한다. 두 계층(Axios / TanStack)이 각자 재시도를 관리하되 역할이 겹치지 않게 경계를 그은 셈이다.
그리고 이 팩토리를 감싸는 getQueryClient가 서버·브라우저 생성 규칙의 실체다.
// lib/query-client.ts
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (environmentManager.isServer()) {
// 서버: 요청마다 항상 새 클라이언트
return makeQueryClient();
} else {
// 브라우저: 최초 1회만 만들고 이후엔 그 인스턴스를 재사용
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
getQueryClient()만 부르면 되고 "서버냐 브라우저냐"를 호출하는 쪽이 신경 쓸 필요가 없어진다.📂providers
┣ 📜QueryProvider.tsx // QueryClient를 트리에 주입
┣ 📜AuthProvider.tsx // 랜딩 시 세션 1회 복원
┗ 📜auth-ready-context.ts // 부트스트랩 완료 여부 컨텍스트
app 이야기에서 "페이지에 종속되지 않는 코드는 바깥 폴더로 뺀다"고 했는데, providers가 바로 그 "바깥 폴더" 중 하나다. 앱 트리 최상단에 씌우는 React Provider들을 모아 두는 자리로, lib가 만든 클라이언트들을 실제 컴포넌트 트리에 연결하는 접착제 역할을 한다. 루트 레이아웃은 이 Provider들을 순서대로 감싸기만 하면 된다.
// app/layout.tsx (다시 보기)
<QueryProvider>
<AuthProvider>{children}</AuthProvider>
</QueryProvider>
QueryProvider가 바깥, AuthProvider가 안쪽인 순서에는 이유가 있다. AuthProvider는 내부에서 useQueryClient()로 캐시를 만지는데, 그러려면 자신을 감싸는 상위에 이미 QueryClientProvider가 있어야 하기 때문이다.
가장 얇은 Provider다. lib/query-client의 getQueryClient()를 불러 QueryClientProvider에 넘기고, 개발 편의를 위한 Devtools를 함께 얹는다.
// providers/QueryProvider.tsx
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getQueryClient } from "@/lib/query-client";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
}
주목할 건 클라이언트를 여기서 new 하지 않고 getQueryClient()에 위임한다는 점이다. 서버/브라우저 분기라는 까다로운 규칙은 lib에 가둬 뒀으니, Provider는 "받아서 꽂는다"는 역할에만 집중한다. "use client"가 붙은 건 QueryClientProvider가 클라이언트 컴포넌트여야 하기 때문이다.
AuthProvider는 lib/axios의 메모리 토큰 설계와 정확히 짝을 이루는 Provider다. access token이 새로고침으로 사라진다면, 누군가는 페이지 진입 시 그 토큰을 되살려 줘야 한다. 그 "누군가"가 바로 여기다.
// providers/AuthProvider.tsx
"use client";
/*
랜딩 시 세션을 한 번 복원한다.
access token은 메모리에만 있어 새로고침하면 사라지므로, httpOnly refresh
쿠키로 /refresh를 1회 호출해 토큰을 되살리고 그 응답의 user로 me 캐시를 seed한다.
→ /me를 따로 조회하지 않아 랜딩 요청이 3회(/me→refresh→/me)에서 1회로 준다.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
// 마운트 시 메모리에 이미 토큰이 있으면(로그인 직후 client 네비게이션) 곧바로 ready.
const [ready, setReady] = useState(() => getAccessToken() !== null);
useEffect(() => {
if (ready) return;
let active = true;
refreshSession()
.then((data) => seedMyInfo(queryClient, data))
.catch(() =>
// refresh 실패(쿠키 없음/만료) = 미로그인 확정
queryClient.setQueryData<AuthMyInfoResponse | null>(authKeys.me(), null),
)
.finally(() => {
if (active) setReady(true);
});
return () => {
active = false;
};
}, [ready, queryClient]);
return (
< =>
{children}
);
}
이 컴포넌트가 공들인 부분은 "요청을 최소로 줄이는 복원 흐름"이다.
me 캐시를 채워 뒀다. 그래서 getAccessToken() !== null이면 ready를 곧장 true로 시작해 복원을 아예 건너뛴다. 새로고침이나 OAuth 랜딩처럼 토큰이 없는 경우만 false로 시작해 아래 effect가 돈다./refresh 한 번으로 끝낸다 — 순진하게 짜면 /me(401) → /refresh → /me 재시도로 요청이 세 번 나간다. 대신 /refresh 응답에 담겨 오는 user로 seedMyInfo를 호출해 me 캐시를 직접 채우면, 별도 /me 조회 없이 요청 한 번으로 세션 복원이 끝난다. api 계층에서 "로그인 응답으로 me 캐시를 seed한다"던 규칙을, 랜딩 복원에도 똑같이 적용한 것이다./refresh가 실패하면 그건 곧 "로그인 안 된 상태"다. me 캐시에 null을 명시적으로 박아 두어, 이후 화면이 로딩과 미로그인을 헷갈리지 않게 한다.active 플래그로 언마운트 후 setReady가 호출되는 것을 막아, 복원 도중 화면을 벗어나도 경고 없이 정리되게 했다.AuthProvider가 들고 있는 ready 값은 트리 곳곳에서 필요하다. "세션 복원이 끝났는가"를 알아야 me 쿼리를 켤지 말지 정할 수 있기 때문이다. 이 값을 나르는 Context를 AuthProvider 파일 안에 두지 않고 굳이 별도 모듈로 뺐다.
// providers/auth-ready-context.ts
"use client";
import { createContext, useContext } from "react";
/*
부트스트랩 완료 여부 컨텍스트를 별도 모듈로 분리한다 —
AuthProvider와 api/auth/requests가 서로를 import하는 순환 의존을 끊기 위함.
- false: 세션 복원(/refresh) 진행 중 — me 쿼리 비활성
- true : me 캐시가 채워짐(로그인=user 객체 / 미로그인=null)
*/
export const AuthReadyContext = createContext(false);
export const useAuthReady = () => useContext(AuthReadyContext);
이유는 주석에 적힌 대로 순환 의존을 끊기 위해서다. AuthProvider는 api/auth/requests(refreshSession)를 import하는데, 만약 Context까지 AuthProvider 파일에 두면 me 쿼리를 쓰는 쪽이 그 파일을 import하면서 의존 고리가 얽힌다. Context 정의만 이렇게 의존이 없는 얇은 모듈로 떼어 내면, 소비하는 쪽은 AuthProvider의 무거운 의존을 끌고 오지 않고 useAuthReady() 하나만 가져다 쓸 수 있다. providers 폴더 안에서도 "값을 만드는 곳(Provider)"과 "값의 통로(Context)"를 가른 것으로, 앞서 컴포넌트에서 본 index ↔ contexts 분리와 같은 결의 판단이다.
📂styles
┣ 📂fonts
┃ ┣ 📜PretendardJPVariable.woff2
┃ ┣ 📜PretendardVariable.woff2
┃ ┗ 📜index.ts // localFont 선언 → CSS 변수로 노출
┗ 📜globals.css // Tailwind 진입점 + 디자인 토큰
마지막은 전역 스타일과 폰트를 담는 styles다. 이 프로젝트의 스타일링은 Tailwind CSS v4를 쓰는데, v3까지 있던 tailwind.config.js가 사라지고 설정이 CSS 안으로 들어온 게 큰 변화다. 그래서 "설정 파일"의 자리를 globals.css가 대신한다.
/* styles/globals.css */
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-pretendard), var(--font-pretendard-jp), sans-serif;
}
단 몇 줄이지만 하는 일은 분명하다. @import "tailwindcss" 한 줄이 v3 시절의 @tailwind base/components/utilities 세 지시문을 대체하는 v4의 새 진입점이다. 그리고 @theme 블록이 예전 config의 theme.extend 역할을 한다. 여기서 --font-sans를 Pretendard → Pretendard JP → sans-serif 순으로 엮어 두면, Tailwind가 이 토큰을 읽어 font-sans 유틸리티를 만들어 준다. 즉 폰트 우선순위를 CSS 변수 한 줄로 선언하고, 실제 폰트 파일 로딩은 아래 fonts/index.ts가 채워 넣는 변수(--font-pretendard 등)에 연결하는 구조다.
폰트 파일 자체(.woff2)와 그것을 Next.js에 등록하는 코드를 한 폴더에 둔다.
// styles/fonts/index.ts
import localFont from "next/font/local";
export const pretendard = localFont({
src: "./PretendardVariable.woff2",
display: "swap",
weight: "45 920",
variable: "--font-pretendard",
});
export const pretendardJP = localFont({
src: "./PretendardJPVariable.woff2",
display: "swap",
weight: "45 920",
variable: "--font-pretendard-jp",
});
next/font/local로 폰트를 등록하면 Next.js가 빌드 타임에 폰트를 자체 호스팅하고, 로딩을 최적화해 준다. 몇 가지 선택을 짚어 두면 —
weight: "45 920"처럼 범위를 주면 45부터 920까지의 굵기를 파일 하나로 처리한다. 굵기별로 파일을 나눠 받지 않아도 된다.display: "swap" — 폰트가 늦게 로드돼도 일단 시스템 폰트로 텍스트를 보여 주고, 준비되면 바꿔 끼운다. 로딩 중 텍스트가 안 보이는 현상을 막는다.variable**로 CSS 변수 노출 — 각 폰트를 -font-pretendard / -font-pretendard-jp라는 CSS 변수로 내보낸다. 이 변수가 앞서 globals.css의 -font-sans가 참조하던 바로 그 변수다.
이렇게 내보낸 폰트는 루트 레이아웃에서 <html>에 클래스로 걸어 트리 전체에 뿌린다.// app/layout.tsx (다시 보기)
<html className={`${pretendard.variable} ${pretendardJP.variable} ...`}>
정리하면 흐름은 이렇다. fonts/index.ts가 폰트를 로드해 CSS 변수로 노출 → 레이아웃이 그 변수를 <html>에 부착 → globals.css의 @theme이 변수를 font-sans 토큰으로 승격 → 컴포넌트는 font-sans 유틸리티만 쓰면 된다. 폰트 파일 · Next.js 등록 · Tailwind 토큰이 각자 자리에서 한 겹씩 맞물리는 셈이다.
한 가지 덧붙이면, 이 프로젝트는 리버스 프록시 뒤에서 basePath가 켜질 수 있어 이미지를 반드시 정적 import(import logo from "../public/logo.png")로 다뤄야 한다. basePath는 next/image의 문자열 src나 raw <img>, CSS url()에는 적용되지 않아 경로가 깨지기 때문이다. 폰트를 문자열 경로가 아니라 localFont로 import해 다루는 지금 방식은 이 함정에서도 자유롭다.
여기까지가 새 Next.js 프로젝트를 열 때마다 내가 반복해 세우는 기준이다. api는 도메인별 요청과 서버 상태, app은 라우팅과 화면 조립, components는 기능 단위 컴포넌트, lib는 공통 클라이언트 토대, providers는 그 토대를 트리에 잇는 접착제, styles는 전역 스타일과 폰트 — 각 폴더가 맡는 관심사가 겹치지 않게 경계를 그어 둔 게 전부다.
거창한 규칙은 없다. 다만 "이 코드는 어디에 두어야 하는가"라는 질문에 매번 같은 답을 내릴 수 있다는 것, 그래서 새 화면이나 도메인을 추가할 때 구조를 처음부터 다시 고민하지 않아도 된다는 것 — 그 소모를 줄이는 게 이 기준의 목적이다.