본문으로 건너뛰기

성능

이 페이지는 현재 코드베이스가 이미 강제하고 있는 프론트엔드 성능 가드레일, 그 계약에서 코드가 벗어난 지점, 아직 활성 원격 측정에 연결되지 않은 backlog 항목을 정리한 reference 문서입니다.

현재 성능 경계

Lumie의 현재 App Router 구조는 인증된 모든 화면에 SSR prefetch를 넓게 적용하는 형태가 아닙니다.

서버 읽기는 다음처럼 제한된 영역에 집중되어 있습니다.

  • app/page.tsx, app/[customId]/page.tsx, app/[customId]/layout.tsx 같은 공개 테넌트 및 홈페이지 부트스트랩
  • app/admin/layout.tsx, app/dashboard/layout.tsx, app/(onboarding)/layout.tsx의 인증, 역할, 온보딩, 메타데이터 게이트
  • app/admin/settings/billing/page.tsx 같은 일부 역할 검증 페이지

대부분의 인증된 페이지는 얇은 서버 엔트리포인트로 남아 있고, 실제 렌더링은 app/admin/students/page.tsx, app/admin/exams/page.tsx, app/dashboard/assignments/page.tsx처럼 클라이언트 기능 아일랜드에 넘깁니다.

src/shared/lib/query-client.ts는 여전히 서버와 브라우저의 QueryClient 분리를 안전하게 유지하지만, 그렇다고 해서 모든 인증 화면에 서버 prefetch나 새 hydration 경계를 늘려야 한다는 뜻은 아닙니다. 서버 fetch는 실제 waterfall을 없애고, 한 parser와 한 query-key 기준 원본을 서버와 클라이언트가 함께 쓸 수 있을 때만 추가해야 합니다.

소스 경로

영역소스 경로
App Router와 루트 providerlumie-frontend/app/layout.tsx, lumie-frontend/app/admin/layout.tsx, lumie-frontend/app/dashboard/layout.tsx, lumie-frontend/app/dashboard/DashboardShell.tsx
Query client 수명주기lumie-frontend/src/shared/lib/query-client.ts, lumie-frontend/src/shared/providers/QueryProvider.tsx
로딩, 오류, 좁은 Suspense 경계lumie-frontend/app/loading.tsx, lumie-frontend/app/error.tsx, lumie-frontend/app/admin/loading.tsx, lumie-frontend/app/admin/error.tsx, lumie-frontend/app/dashboard/loading.tsx, lumie-frontend/app/dashboard/error.tsx
무거운 차트와 코드 분할 예시lumie-frontend/src/features/admin-dashboard/view-dashboard/ui/ChartsSection.tsx, lumie-frontend/src/features/admin-dashboard/view-dashboard/ui/HeroKPICard.tsx, lumie-frontend/src/entities/exam/ui/StudentDetailPanel.tsx, lumie-frontend/src/features/student-detail/view-student-detail/ui/AttendanceTab.tsx, lumie-frontend/src/features/student-detail/view-student-detail/ui/ExamResultsTab.tsx
이미지 정책과 avatar URLlumie-frontend/next.config.ts, lumie-frontend/src/shared/lib/avatar.ts, lumie-frontend/src/features/profile-settings/profile-avatar/ui/ProfileAvatar.tsx, lumie-frontend/src/shared/ui/PersonCardGrid.tsx
백그라운드 polling과 job 추적lumie-frontend/app/admin/_components/AdminShell.tsx, lumie-frontend/src/shared/lib/jobTracker.ts, lumie-frontend/src/features/grade-omr/track-omr-grading/lib/OmrJobTrackerProvider.tsx, lumie-frontend/src/features/grade-omr/track-omr-grading/api/queries.ts, lumie-frontend/src/entities/exam/providers/ReportJobTrackerProvider.tsx, lumie-frontend/src/entities/exam/api/queries.ts
채팅 스트리밍 정리lumie-frontend/src/features/ai-chat/api/stream.ts, lumie-frontend/src/features/ai-chat/api/useStreamMessage.ts
대규모 목록 렌더링lumie-frontend/src/features/student-management/list-students/ui/StudentList.tsx, lumie-frontend/src/features/student-management/list-students/ui/StudentListTable.tsx, lumie-frontend/src/features/staff-management/list-staff/ui/StaffList.tsx, lumie-frontend/src/features/staff-management/list-staff/ui/StaffListTable.tsx

App Router 가드레일

app/은 얇게 유지해야 합니다.

  • app/layout.tsxQueryProvider, auth-modal URL sync, 인증 모달, 전역 toaster처럼 앱 전체 provider와 셸 관심사만 소유합니다.
  • app/admin/layout.tsxapp/dashboard/layout.tsx는 인증 리다이렉트, 메타데이터, route progress 같은 라우트 그룹 관심사를 더한 뒤 각 셸에 넘깁니다.
  • 파라미터를 파싱하거나 접근만 확인하는 얇은 서버 페이지는 클라이언트 데이터 orchestration을 페이지 안에 쌓지 말고 src/의 기능 아일랜드를 렌더링해야 합니다.

현재 라우트 트리는 서버 컴포넌트를 광범위한 인증 데이터 prefetch보다 셸과 인증 경계에 더 많이 사용합니다. 서버 fetch가 사용자에게 보이는 waterfall을 실제로 줄인다는 근거가 나오기 전까지는 이를 기본값으로 두세요.

지연 로딩 차트와 무거운 import

next.config.tslucide-react, radix-ui, date-fns, recharts에 대해 experimental.optimizePackageImports를 켭니다. 이 목록은 실제로 fan-out이 큰 패키지에 맞춰 유지해야 하며, 범용 catch-all 목록으로 늘리면 안 됩니다.

무거운 클라이언트 전용 시각 요소의 기본 패턴은 next/dynamic과 최종 슬롯 크기에 맞는 대체 표시입니다.

  • ChartsSection.tsx의 관리자 대시보드 차트
  • HeroKPICard.tsx의 장식용 sparkline
  • StudentDetailPanel.tsx의 시험 상세 차트
  • AttendanceTab.tsx, ExamResultsTab.tsx의 학생 상세 차트

가드레일:

  • 차트 같은 무거운 UI는 루트 레이아웃이 아니라 feature 경계에서 분할합니다
  • ssr: false는 정말 브라우저 전용 컴포넌트일 때만 사용합니다
  • 대체 표시가 최종 높이를 미리 차지하게 해서 chunk 로딩 중 페이지가 재배치되지 않도록 합니다

provider 배치

provider 배치는 성능 계약의 일부입니다. 어떤 provider든 라우트 위로 올릴수록 공통 렌더링 영역이 커집니다.

  • 루트 전용 provider는 app/layout.tsx에 둡니다
  • 라우트 그룹 provider는 app/admin/layout.tsx, app/dashboard/layout.tsx, app/dashboard/DashboardShell.tsx에 둡니다
  • feature 전용 SDK나 상태 브리지는 실제로 필요한 가장 가까운 라우트나 위젯에 남겨 둡니다

오늘 기준으로 billing 경로가 좋은 경계 예시입니다. app/admin/settings/billing/page.tsx는 역할을 검사하고 BillingHome을 렌더링하지만, billing SDK wrapper를 전역 관리자 레이아웃까지 끌어올리지 않습니다.

로딩, 오류, Suspense 경계

기본 라우트 경계는 이미 루트, admin, dashboard 수준에 있습니다.

  • app/loading.tsx, app/admin/loading.tsx, app/dashboard/loading.tsx는 셸을 고려한 skeleton을 제공합니다
  • app/error.tsx, app/admin/error.tsx, app/dashboard/error.tsx는 실패가 빈 화면으로 번지는 것을 막습니다
  • app/layout.tsxSuspenseAuthModalUrlSyncAuthModal 주변에만 좁게 두어 useSearchParams() 지원이 페이지 수준 로딩이나 스트리밍 대체 UI를 삼키지 않게 합니다

새 라우트 그룹이 로딩 비용이나 복구 UX가 크게 다르다면 루트 경계에만 의존하지 말고 자체 loading.tsx, error.tsx를 추가하세요.

이미지 최적화 정책

기본 정책:

  • 래스터 자산에는 next/image를 사용합니다
  • 실제로 필요한 경우에만 올바른 sizespriority를 제공합니다
  • 원격 호스트는 next.config.ts에 선언된 상태를 유지합니다

DiceBear avatar는 의도적인 예외입니다. next.config.ts는 이 SVG URL을 unoptimized로 두는 이유를 설명합니다. Sharp 최적화기를 거치면 SVG가 래스터화되고, 프로세스 내 작업량이 늘며, 256 MiB pod에서 OOM이 날 수 있기 때문입니다. 이 예외는 ProfileAvatar.tsx, PersonCardGrid.tsx 같은 avatar 표면에도 반영되어 있으며, 두 컴포넌트는 모두 src/shared/lib/avatar.tsgetAvatarUrl(...) 결과를 렌더링합니다.

추적해야 할 계약 드리프트:

  • 검토한 코드에는 여전히 src/widgets/landing/ui/Hero.tsx, AnalyticsHighlight.tsx, ConvergedPlatform.tsx, ParentReport.tsx, src/shared/ui/PostDetailCard.tsx처럼 avatar가 아닌 영역의 unoptimized 사용이 남아 있습니다

이 파일들은 next.config.ts의 DiceBear 전용 근거와 맞지 않으므로, 광범위하게 unoptimized를 허용하는 근거가 아니라 backlog 정리 대상으로 봐야 합니다.

백그라운드 polling과 job tracker

polling은 기본적으로 페이지 범위에 묶어야 합니다. 현재 의도적인 예외는 오래 걸리는 관리자 job 추적입니다.

app/admin/_components/AdminShell.tsx는 다음을 마운트합니다.

  • OmrJobStatusSync
  • ReportJobStatusSync

이 daemon이 허용되는 이유는 현재 코드가 다음 경계를 함께 두기 때문입니다.

  • createJobTrackerStore()는 활성 job을 local storage에 유지하지만 merge 중 오래된 job은 버립니다
  • BACKGROUND_JOB_TRACKING_TIMEOUT_MS가 tracker 수명을 135분으로 제한합니다
  • useOmrJobStatus()COMPLETEDFAILED에서 polling을 멈추고, enabled: !!statusUrl을 사용하며, 배치의 시작과 끝에서만 간격을 1500 ms로 좁힙니다
  • useReportJobStatus()도 최종 상태에서 멈추고 5000 ms 주기를 유지합니다

가드레일:

  • 새 polling 루프마다 enable 게이트, 최종 상태 중지 조건, 완료 시 누가 소유하는지 분명한 side effect가 필요합니다
  • refetchIntervalInBackground는 기본값이 아니라 opt-in입니다. 검토한 코드에서는 useAiReportJobStatus()에서만 켜져 있습니다
  • 사용자가 페이지가 열린 동안에만 업데이트가 필요하다면 공유 셸에 마운트하지 말고 페이지 로컬 poller로 유지하세요

채팅과 SSE 중단 소유권

src/features/ai-chat/api/stream.tsAbortSignal을 받고 이를 fetch()에 그대로 전달합니다. src/features/ai-chat/api/useStreamMessage.ts가 controller 수명주기를 소유합니다.

  • 새 스트림을 시작하기 전에 이전 스트림을 중단합니다
  • 언마운트 시 중단합니다
  • 스트리밍된 내용을 ref에 유지해 onDone이 최종 메시지를 캐시에 원자적으로 반영할 수 있게 합니다

이 소유권 분리가 가드레일입니다.

  • 전송 헬퍼는 AbortSignal을 받아야 합니다
  • 스트림을 시작하는 hook 또는 컴포넌트가 정리를 소유합니다
  • 교체 또는 언마운트 시 발생하는 조용한 AbortError는 사용자에게 보여줄 실패가 아니라 기대된 동작입니다

대규모 목록, 메모이제이션, 가상화

현재 목록이 많은 화면은 이미 몇 가지 중요한 기본 원칙을 사용합니다.

  • URL 기반 목록 파라미터는 예를 들어 StudentList.tsxparseStudentListParams(...)처럼 query key 경로와 같은 parser를 공유합니다
  • 테이블 비중이 큰 뷰의 행 렌더링은 StudentListTable.tsx, StaffListTable.tsx에서 memo 처리합니다
  • 모바일과 데스크톱 목록 셸을 카드 그리드와 테이블로 나눠 하나의 DOM 형태를 모든 화면에 강요하지 않습니다

@tanstack/react-virtualpackage.json에 설치되어 있지만, 검토한 프론트엔드 소스 중 이를 import한 곳은 아직 없습니다.

프로파일 규칙:

  • 제한된 목록에는 행 메모이제이션과 페이지네이션이 기본입니다
  • 한 페이지 분량의 DOM을 넘어가거나 스크롤 끊김이 눈에 띄면 먼저 프로파일링한 뒤 손수 만든 windowing보다 @tanstack/react-virtual을 우선 고려합니다
  • 작은 폼이나 단일 카드 뷰에 일괄 메모이제이션을 추가하지 마세요. 실제 프로파일링이나 라우트 상호작용에서 드러나는 반복 행, 셀 작업에만 사용합니다

Web Vitals와 RUM backlog

저장소는 이미 eslint.config.mjs를 통해 Next의 Core Web Vitals 규칙을 lint에 반영하지만, 검토한 코드에는 활성 프론트엔드 Web Vitals 또는 RUM 파이프라인이 없습니다.

  • useReportWebVitals hook은 발견되지 않았습니다
  • Sentry나 PostHog 클라이언트 연결도 발견되지 않았습니다
  • app/error.tsx에는 나중에 서버 측 오류 추적기를 연결하자는 backlog 주석만 있습니다

Web Vitals와 Sentry 또는 PostHog RUM은 현재 릴리스 게이트나 운영 신호가 아니라 backlog 작업으로 취급해야 합니다.

검증

cd lumie-frontend
rg -n "optimizePackageImports|api.dicebear.com|unoptimized|AuthModalUrlSync|loading\\.tsx|error\\.tsx" \
next.config.ts app src
rg -n "dynamic\\(|useOmrJobStatus|useReportJobStatus|BACKGROUND_JOB_TRACKING_TIMEOUT_MS|streamChat|AbortController|memo\\(" \
app src
rg -n "@tanstack/react-virtual|useVirtualizer" package.json src
npx tsc --noEmit

성공은 grep이 여기서 설명한 코드 분할, 이미지 정책, 경계, polling, stream 중단, 메모이제이션 표면을 찾아내고, 가상화 패키지가 package.json에 보이되 직접 추가하지 않는 한 현재 런타임 import는 없으며, npx tsc --noEmit이 TypeScript 오류 없이 끝나는 것을 뜻합니다.

관련 페이지