본문으로 건너뛰기

상태 관리

하이브리드 상태 관리 전략

Lumie 프론트엔드는 상태의 성격에 따라 도구를 선택하는 계층적 전략을 사용합니다.

상태 유형도구대상
서버 상태TanStack React QueryAPI 응답, 캐시, 로딩/에러 상태
인증 상태React Query (useMe)/api/v1/me 응답 캐시
공유 클라이언트 UIZustand전역 UI 상태 (예: OMR 작업 상태)
URL 상태useSearchParams필터, 페이지네이션, 탭 선택
폼 상태React Hook Form + Zod입력 값, 유효성 검사, 제출 상태

서버 상태: TanStack React Query

전역 설정

shared/providers/QueryProvider.tsx에서 QueryClient를 생성하고 기본 옵션을 설정합니다.

// src/shared/providers/QueryProvider.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { getQueryClient } from '@/shared/lib/query-client';

export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(getQueryClient);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

orval 코드 생성 + queries.ts 래퍼

모든 엔티티의 React Query 훅은 BE OpenAPI 스냅샷(openapi.json)에서 orval이 생성합니다. 직접 손으로 작성하는 queries.ts가 아닌 generated.ts가 네트워크 계층을 담당합니다.

entities/student/api/
├── generated.ts ← orval 자동 생성 (편집 금지)
└── queries.ts ← 도메인 래퍼 (adaptMutation + invalidation + toast)

generated.ts는 직접 편집하지 않습니다. BE OpenAPI 스냅샷 갱신 절차:

# 1. BE 스냅샷 갱신 (lumie-backend)
./gradlew snapshotOpenApi

# 2. FE openapi.json 교체 후 코드 재생성
npx orval --config orval.config.ts

queries.ts — 도메인 래퍼 패턴

// src/entities/student/api/queries.ts (발췌)
import { adaptMutation } from '@/shared/api/adaptMutation';
import { useRegisterStudent, getGetStudentsQueryKey } from './generated';

export function useCreateStudent() {
const queryClient = useQueryClient();
return adaptMutation(
useRegisterStudent({
mutation: {
onSuccess: () => {
// 목록 쿼리 전체 무효화
queryClient.invalidateQueries({ queryKey: getGetStudentsQueryKey() });
toast.success('학생이 등록되었습니다.');
},
},
}),
// generated hook: mutate({ data }) → 래퍼 hook: mutate(CreateStudentInput)
(data: CreateStudentInput) => ({ data: data as StudentRequest }),
);
}

쿼리 키 팩토리

orval이 각 엔티티의 쿼리 키 팩토리(예: getGetStudentsQueryKey())를 함께 생성합니다. invalidateQueries 호출 시 이 팩토리를 사용해 오타를 방지합니다.

컴포넌트에서 사용

// features/student-management/ui/student-list.tsx
'use client';
import { useGetStudents } from '@/entities/student'; // generated hook re-exported

export function StudentList() {
const { data, isLoading, isError } = useGetStudents({ params: { page: 0, size: 20 } });

if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage />;

return <ul>{data?.content.map((s) => <StudentItem key={s.id} student={s} />)}</ul>;
}

인증 상태: React Query (useMe)

인증 상태는 Zustand 스토어가 아닌 React Query로 관리합니다. /api/v1/me 엔드포인트를 5분 staleTime으로 캐싱합니다.

// src/entities/session/model/useMe.ts
'use client';
export function useMe(): User | null {
return useQuery({
queryKey: sessionKeys.me(),
queryFn: getMe,
staleTime: 5 * 60 * 1000,
retry: false,
}).data ?? null;
}

export const useUser = useMe;
export function useUserRole(): Role | null { return useMe()?.role ?? null; }
export function useIsAuthenticated(): boolean { return useMe() !== null; }

SSR이 필요한 페이지(테넌트 랜딩 등)에서는 getServerUser로 서버 컴포넌트에서 사용자 정보를 읽습니다.

공유 클라이언트 상태: Zustand

인증 세션 이외의 전역 클라이언트 상태에 Zustand를 사용합니다. 예를 들어 grade-omr 피처는 페이지 전환 중에도 OMR 채점 작업 진행 상태를 유지하기 위해 모듈 전역 Zustand 스토어를 사용합니다.

// Zustand 사용 예 (entities 또는 features 내부)
import { create } from 'zustand';

interface OmrJobState {
activeJobId: string | null;
status: 'idle' | 'running' | 'done';
setJob: (id: string) => void;
clearJob: () => void;
}

export const useOmrJobStore = create<OmrJobState>((set) => ({
activeJobId: null,
status: 'idle',
setJob: (id) => set({ activeJobId: id, status: 'running' }),
clearJob: () => set({ activeJobId: null, status: 'idle' }),
}));

폼 상태: React Hook Form + Zod

기본 패턴

React Hook Form과 Zod를 @hookform/resolvers로 연결합니다. 스키마는 엔티티의 model/schema.ts에 정의됩니다.

// src/entities/student/model/schema.ts
import { z } from 'zod';

export const CreateStudentSchema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
loginId: z.string().min(4, '아이디는 4자 이상이어야 합니다'),
phone: z.string().regex(/^010-\d{4}-\d{4}$/, '010-0000-0000 형식으로 입력해주세요'),
});

export type CreateStudentInput = z.infer<typeof CreateStudentSchema>;
// features/student-management/ui/create-student-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateStudentSchema, type CreateStudentInput } from '@/entities/student';
import { useCreateStudent } from '@/entities/student';

export function CreateStudentForm() {
const { mutate: createStudent, isPending } = useCreateStudent();

const form = useForm<CreateStudentInput>({
resolver: zodResolver(CreateStudentSchema),
});

return (
<form onSubmit={form.handleSubmit((data) => createStudent(data))} className="space-y-4">
{/* ... */}
<button type="submit" disabled={isPending}>
{isPending ? '등록 중...' : '학생 등록'}
</button>
</form>
);
}

단일 비행 토큰 갱신 (클라이언트 측)

미들웨어가 리프레시 토큰 폴백으로 요청을 통과시킨 뒤, 클라이언트는 첫 번째 API 401에서 토큰을 갱신합니다. shared/api/base.tstryRefreshToken()refreshPromise를 공유해 병렬 요청이 동시에 401을 받아도 갱신이 한 번만 실행됩니다.

// src/shared/api/base.ts (핵심 발췌)
let isRefreshing = false;
let refreshPromise: Promise<Response> | null = null;

export async function tryRefreshToken(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
// 이미 갱신 중 → 기존 Promise 재사용 (single-flight)
const response = await refreshPromise;
return response.ok;
}
isRefreshing = true;
refreshPromise = fetch(`${ENV.API_URL}/v1/refresh`, {
method: 'POST',
credentials: 'include',
});
// ...
}

상태 관리 결정 가이드

테스트 환경에서 상태를 초기화하는 방법은 테스팅 문서를 참고하세요.