아키텍처
레이어 개요
Lumie 프론트엔드는 FSD(Feature-Sliced Design) 패턴 을 따릅니다. 코드를 역할과 책임에 따라 레이어로 분리하여 의존성을 단방향으로 유지합니다.
widgets/ ← 페이지 레이아웃 조립 (최상위)
↓ 사용
features/ ← 사용자 기능 모듈
↓ 사용
entities/ ← 비즈니스 도메인 모델
↓ 사용
shared/ ← 공통 기반 인프라 (최하위)
의존성 규칙: 상위 레이어는 하위 레이어를 참조할 수 있지만, 하위 레이어는 상위 레이어를 참조할 수 없습니다.
shared → (아무것도 참조하지 않음)
entities → shared
features → entities, shared
widgets → features, entities, shared
app/* → widgets, features, entities, shared (compose only)
shared 레이어
모든 레이어에서 공통으로 사용하는 기반 인프라입니다.
shared/api - API 클라이언트
모든 브라우저 API 요청은 shared/api/base.ts의 apiRequest 함수를 통해 처리됩니다. ENV.API_URL(/api)을 베이스로 사용하며, 다음 공통 동작을 포함합니다.
- 쿠키 기반 인증: HttpOnly 쿠키로 액세스/리프레시 토큰을 관리합니다.
- 단일 비행 토큰 갱신(single-flight refresh): 401 응답 수신 시
tryRefreshToken()이/api/v1/refresh를 호출합니다. 병렬 요청이 동시에 401을 받아도refreshPromise공유로 갱신 요청이 한 번만 실행됩니다. - 멀티테넌시: 모든 요청에
X-Tenant-Slug헤더를 자동으로 첨부합니다. - 단일 엔드포인트: 개발/프로덕션 모두
/api를 사용합니다.NEXT_PUBLIC_*서비스별 URL은 더 이상 존재하지 않습니다.
// src/shared/config/env.ts
// 모든 백엔드 도메인은 단일 Spring Boot 모놀리스로 제공됩니다.
// 브라우저는 항상 상대 경로 /api를 호출합니다.
export const ENV = {
API_URL: '/api',
} as const;
미리 구성된 서비스 클라이언트들은 모두 같은 ENV.API_URL로 초기화됩니다.
// src/shared/api/base.ts (일부)
export const authClient = createServiceClient(ENV.API_URL);
export const studentClient = createServiceClient(ENV.API_URL);
export const examClient = createServiceClient(ENV.API_URL);
export const classClient = createServiceClient(ENV.API_URL);
// ...
shared/api/orval-mutator - orval 뮤테이터
orval 코드 생성 훅이 내부적으로 사용하는 커스텀 인스턴스입니다. apiRequest에 위임하여 인증·테넌트·에러 처리를 한 곳에서 관리합니다.
// src/shared/api/orval-mutator.ts (핵심 부분)
export const orvalMutator = <T>(
{ url, method, params, data, headers, responseType, signal }: OrvalRequestConfig,
): Promise<T> => {
const fullUrl = `${ENV.API_URL}${appendQuery(url, params)}`;
// responseType === 'blob'이면 apiDownload, 아니면 apiRequest
return apiRequest<T>(fullUrl, init);
};
shared/api/adaptMutation - mutation 변수 래핑
orval 생성 훅의 변 수 형태(packed object)와 도메인 친화적 호출 시그니처를 연결하는 브리지입니다.
// src/shared/api/adaptMutation.ts
// 생성된 훅: mutate({ id, data })
// UI 호출: mutate(data) ← adaptMutation이 변환
export function adaptMutation<TPublicVars, TGenVars, TData, TError, TContext>(
result: UseMutationResult<TData, TError, TGenVars, TContext>,
toGenVars: (vars: TPublicVars) => TGenVars,
) { ... }
shared/ui - 공통 UI 컴포넌트
Lumie 커스텀 래퍼와 패턴은 src/shared/ui/에 위치합니다. components/ui/에 있는 owned-fork shadcn 프리미티브와 구분됩니다.
components/ui/— owned-fork shadcn 프리미티브: Lumie가 직접 유지관리하는 포크본. 비즈니스 로직이나 Lumie 전용 variant를 추가하지 않습니다.src/shared/ui/— Lumie 커스텀 래퍼와 패턴: 새로운 커스텀 UI는 여기에 둡니다.
shared/providers - React Context 프로바이더
// src/shared/providers/QueryProvider.tsx
'use client';
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(getQueryClient);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
entities 레이어
비즈니스 도메인 모델을 정의합니다. 현재 19개 엔티티가 있으며, 각 엔티티는 동일한 구조를 따릅니다.
엔티티 디렉토리 구조
entities/
└── student/
├── model/
│ └── schema.ts # Zod 스키마 + 타입 추론
├── api/
│ ├── generated.ts # orval 자동 생성 훅 (BE OpenAPI 스냅샷에서)
│ └── queries.ts # 도메인 친화적 래퍼 (adaptMutation + invalidation + toast)
└── index.ts # 공개 API (re-export)
orval 코드 생성
openapi.json(BE ./gradlew snapshotOpenApi로 갱신)을 입력으로 npx orval --config orval.config.ts가 각 엔티티의 api/generated.ts를 생성합니다. 17개 이상의 엔티티가 생성된 클라이언트를 사용합니다.
// orval.config.ts (개념)
function slice(name: string, tags: string[]) {
return {
input: { target: './openapi.json', filters: { tags } },
output: {
target: `./src/entities/${name}/api/generated.ts`,
client: 'react-query',
override: { mutator: { path: './src/shared/api/orval-mutator.ts', name: 'orvalMutator' } },
},
};
}
api/queries.ts - 도메인 래퍼
생성된 훅을 그대로 노출하지 않고, adaptMutation으로 변수 형태를 정리하고 onSuccess toast와 캐시 무효화를 추가합니다.
// src/entities/student/api/queries.ts (발췌)
export function useCreateStudent() {
const queryClient = useQueryClient();
return adaptMutation(
useRegisterStudent({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getGetStudentsQueryKey() });
toast.success('학생이 등록되었습니다.');
},
},
}),
(data: CreateStudentInput) => ({ data: data as StudentRequest }),
);
}
entities/session - 인증 상태
인증 상태는 Zustand가 아닌 React Query로 관리합니다. /api/v1/me 엔드포인트를 쿼리하며, 'use client'로 마킹됩니다.
// src/entities/session/model/useMe.ts
'use client';
export function useMeQuery(): UseQueryResult<User> {
return useQuery({
queryKey: sessionKeys.me(),
queryFn: getMe,
staleTime: 5 * 60 * 1000,
retry: false,
});
}
export function useMe(): User | null { return useMeQuery().data ?? null; }
export const useUser = useMe;
export function useUserRole(): Role | null { return useMe()?.role ?? null; }
export function useIsAuthenticated(): boolean { return useMe() !== null; }
SSR용 getServerUser는 쿠키를 읽 어 서버 사이드에서 사용자 정보를 가져옵니다.
features 레이어
사용자가 직접 인터랙션하는 기능 단위입니다. 30개 이상의 피처 모듈이 있습니다.
features/
└── exam-management/
├── ui/
│ ├── exam-form.tsx
│ └── exam-list-table.tsx
├── model/
│ └── schema.ts
├── lib/
│ └── utils.ts
└── index.ts
피처 간 import는 금지되어 있습니다(features/X → features/Y). 공유 로직은 entities/로 올립니다.
widgets 레이어
페이지 레이아웃을 구성하는 대형 컴포넌트입니다.
widgets/
├── admin-sidebar/ # 관리자 내비게이션 사이드바
├── student-sidebar/ # 학생 대시보드 사이드바
├── header/ # 공통 헤더
├── auth-modal/ # 인증 모달 (전역)
└── landing-templates/ # 테넌트 랜딩 페이지 템플릿 렌더러
각 위젯은 ui/Component.tsx + index.ts 배럴로 구성됩니다.