Skip to content

Commit b9c74ef

Browse files
committed
리팩토링: 웹 모듈 라우팅/레지스트리 구조 분리
1 parent e67464f commit b9c74ef

5 files changed

Lines changed: 240 additions & 75 deletions

File tree

apps/web/src/components/AppShell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
33
import "../styles/shell.css";
44

55
import { apiFetch } from "../lib/apiFetch";
6-
import type { ModuleSubRoute } from "../moduleConfig";
6+
import type { ModuleSubRoute } from "../moduleRegistry";
77

88
// 코어 라우트 + 설치된 모듈 이름도 RouteKey에 포함 (가계부: "ledger" 등)
99
export type CoreRouteKey = "login" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password";

apps/web/src/main.tsx

Lines changed: 84 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type FormEvent, useEffect, useMemo, useState } from "react";
1+
import { Suspense, type FormEvent, useEffect, useMemo, useState } from "react";
22
import { createRoot } from "react-dom/client";
33
import { useTranslation } from "react-i18next";
44

@@ -23,33 +23,18 @@ import { MarketplaceView } from "./views/MarketplaceView";
2323
import { ChangePasswordView } from "./views/ChangePasswordView";
2424
import { ForgotPasswordView } from "./views/ForgotPasswordView";
2525
import { SetupWizardView } from "./views/SetupWizardView";
26-
import { LedgerView } from "../../../modules/ledger/frontend/LedgerView";
27-
import { SubscriptionView } from "../../../modules/subscription/frontend/SubscriptionView";
28-
import { MODULE_SUB_NAV } from "./moduleConfig";
29-
30-
// ─── Helpers ──────────────────────────────────────────────────
31-
32-
// 코어 라우트 목록 (앱 shell 없이 전체 화면으로 렌더되는 것 제외)
33-
const CORE_ROUTES = ["login", "forgot-password", "home", "marketplace", "admin", "change-password"] as const;
34-
// 모듈 라우트 — module.json name 기준 (서버 레지스트리와 일치)
35-
const MODULE_ROUTES: string[] = ["ledger", "subscription"];
36-
37-
/** 해시에서 베이스 라우트만 추출 ("ledger/import" → "ledger") */
38-
function getRouteFromHash(rawHash: string): RouteKey {
39-
const hash = rawHash.replace("#", "");
40-
const base = hash.split("/")[0] ?? hash;
41-
if (base === "settings") return "home";
42-
if ((CORE_ROUTES as readonly string[]).includes(base)) return base as RouteKey;
43-
if (MODULE_ROUTES.includes(base)) return base as RouteKey;
44-
return "login";
45-
}
46-
47-
/** 해시에서 서브 라우트만 추출 ("ledger/import" → "import", "ledger" → "") */
48-
function getSubRouteFromHash(rawHash: string): string {
49-
const hash = rawHash.replace("#", "");
50-
const parts = hash.split("/");
51-
return parts.length > 1 ? parts.slice(1).join("/") : "";
52-
}
26+
import {
27+
MODULE_SUB_NAV,
28+
getModuleView,
29+
preloadModuleViews,
30+
registerModuleLocales,
31+
} from "./moduleRegistry";
32+
import {
33+
canStoreRedirectTarget,
34+
getDeepLinkTarget,
35+
getRouteFromHash,
36+
getSubRouteFromHash,
37+
} from "./routeConfig";
5338

5439
// ─── Theme ────────────────────────────────────────────────────
5540
type ThemeSetting = "light" | "dark" | "system";
@@ -64,6 +49,23 @@ function applyTheme(setting: ThemeSetting) {
6449
try { localStorage.setItem("fs_theme", setting); } catch { /* ignore */ }
6550
}
6651

52+
function runWhenBrowserIdle(task: () => void): () => void {
53+
// requestIdleCallback 지원 브라우저에서는 유휴 시간에 실행하고,
54+
// 미지원 브라우저에서는 짧은 setTimeout으로 동작을 에뮬레이션한다.
55+
const idleWindow = window as Window & {
56+
requestIdleCallback?: (callback: () => void) => number;
57+
cancelIdleCallback?: (id: number) => void;
58+
};
59+
60+
if (idleWindow.requestIdleCallback) {
61+
const idleId = idleWindow.requestIdleCallback(task);
62+
return () => idleWindow.cancelIdleCallback?.(idleId);
63+
}
64+
65+
const timeoutId = window.setTimeout(task, 120);
66+
return () => window.clearTimeout(timeoutId);
67+
}
68+
6769
function loadTheme(): ThemeSetting {
6870
try {
6971
const saved = localStorage.getItem("fs_theme");
@@ -74,6 +76,9 @@ function loadTheme(): ThemeSetting {
7476

7577
// 초기 테마 적용 (React 렌더 전에 FOUC 방지)
7678
applyTheme(loadTheme());
79+
// 모듈 i18n 번역 리소스를 앱 부트 시점에 선등록해
80+
// 모듈 첫 진입 시 영어 -> 한국어로 늦게 바뀌는 깜빡임을 줄인다.
81+
registerModuleLocales();
7782

7883
// ─── Storage Keys ─────────────────────────────────────────────
7984
const SS = {
@@ -91,13 +96,6 @@ const LS = {
9196
startupRoute: "fs_startup_route",
9297
} as const;
9398

94-
// 딥 링크: 비인증 상태에서 진입한 app route 반환 (모듈 라우트 포함)
95-
function getDeepLinkTarget(): RouteKey | null {
96-
const hash = window.location.hash.replace("#", "");
97-
const appRoutes: string[] = ["home", "marketplace", "admin", ...MODULE_ROUTES];
98-
return appRoutes.includes(hash) ? (hash as RouteKey) : null;
99-
}
100-
10199
// 개인화: 로그인 후 첫 화면 설정
102100
type StartupRoute = "home" | "marketplace";
103101

@@ -155,7 +153,7 @@ function App() {
155153
);
156154
// 딥 링크: 비인증 상태에서 진입한 app route (로그인 후 복귀)
157155
const [redirectAfterLogin, setRedirectAfterLogin] = useState<RouteKey | null>(
158-
() => (sessionStorage.getItem(SS.auth) === "true" ? null : getDeepLinkTarget()),
156+
() => (sessionStorage.getItem(SS.auth) === "true" ? null : getDeepLinkTarget(window.location.hash)),
159157
);
160158
// 첫 방문 온보딩 배너
161159
const [isFirstVisit, setIsFirstVisit] = useState(false);
@@ -184,8 +182,7 @@ function App() {
184182
setRoute(next);
185183
setSubRoute(nextSub);
186184
// 비인증 상태에서 app route로 hash 변경 시 redirect 대상 갱신
187-
const appRoutes: RouteKey[] = ["home", "marketplace", "admin"];
188-
if (sessionStorage.getItem(SS.auth) !== "true" && (appRoutes as string[]).includes(next)) {
185+
if (sessionStorage.getItem(SS.auth) !== "true" && canStoreRedirectTarget(next)) {
189186
setRedirectAfterLogin(next);
190187
}
191188
};
@@ -203,6 +200,14 @@ function App() {
203200
// eslint-disable-next-line react-hooks/exhaustive-deps
204201
}, [isAuthenticated]);
205202

203+
// 초기 로그인 이후 idle 타이밍에 모듈 청크를 미리 받아 첫 진입 체감 지연을 줄인다.
204+
useEffect(() => {
205+
if (!isAuthenticated) return;
206+
return runWhenBrowserIdle(() => {
207+
void preloadModuleViews();
208+
});
209+
}, [isAuthenticated]);
210+
206211
// 관리자 PIN 세션 30분 만료
207212
useEffect(() => {
208213
if (!isPinVerified || pinVerifiedAt === null) return;
@@ -236,16 +241,37 @@ function App() {
236241
}, [isAuthenticated, mustChangePassword, pendingOtpEmail, route]);
237242

238243
useEffect(() => {
239-
if (window.location.hash !== `#${effectiveRoute}`) {
240-
window.location.hash = effectiveRoute;
244+
// 인증/비인증 가드로 effectiveRoute가 바뀔 때 해시를 일치시킨다.
245+
// 모듈 라우트는 subRoute를 보존해 #ledger/import 같은 주소가 사라지지 않게 처리한다.
246+
const shouldKeepSubRoute = getModuleView(effectiveRoute) !== null && subRoute.length > 0;
247+
const expectedHash = shouldKeepSubRoute ? `#${effectiveRoute}/${subRoute}` : `#${effectiveRoute}`;
248+
if (window.location.hash !== expectedHash) {
249+
window.location.hash = expectedHash;
241250
}
242-
}, [effectiveRoute]);
251+
}, [effectiveRoute, subRoute]);
243252

244253
const navigate = (nextRoute: RouteKey) => {
245254
setRoute(nextRoute);
246255
window.location.hash = nextRoute;
247256
};
248257

258+
const ActiveModuleView = getModuleView(effectiveRoute);
259+
260+
const applyServerLanguagePreference = async (): Promise<void> => {
261+
// 로그인 직후 서버 저장 언어를 먼저 반영해
262+
// 페이지 진입 후 번역이 뒤늦게 바뀌는 체감을 줄인다.
263+
try {
264+
const response = await apiFetch("/core/users/me/settings");
265+
const json = await response.json() as { success: boolean; data?: { language?: string } };
266+
const language = json.data?.language;
267+
if (json.success && typeof language === "string" && language.length > 0) {
268+
await changeLanguage(language);
269+
}
270+
} catch {
271+
// 언어 설정 로드 실패는 무음 처리
272+
}
273+
};
274+
249275
// ── 로그인 성공 시 공통 처리 ─────────────────────────────────
250276
const handleLoginSuccess = (email: string, isAdmin: boolean, isTempPassword: boolean) => {
251277
setLoginError(null);
@@ -271,19 +297,12 @@ function App() {
271297
if (localStorage.getItem(LS.firstVisitShown) !== "true") setIsFirstVisit(true);
272298
} catch { /* ignore */ }
273299

274-
// 서버에 저장된 언어 설정 로드 (실패해도 무음 처리)
275-
apiFetch("/core/users/me/settings")
276-
.then((r) => r.json())
277-
.then((json: { success: boolean; data?: { language: string } }) => {
278-
if (json.success && json.data?.language) {
279-
void changeLanguage(json.data.language);
280-
}
281-
})
282-
.catch(() => { /* 언어 설정 로드 실패는 무음 처리 */ });
283-
284-
const target = redirectAfterLogin ?? startupRoute;
285-
setRedirectAfterLogin(null);
286-
navigate(target);
300+
// 화면 이동 전에 서버 언어 설정을 우선 반영해 en → ko 깜빡임을 줄인다.
301+
void applyServerLanguagePreference().finally(() => {
302+
const target = redirectAfterLogin ?? startupRoute;
303+
setRedirectAfterLogin(null);
304+
navigate(target);
305+
});
287306
};
288307

289308
// Auth handlers
@@ -520,8 +539,17 @@ function App() {
520539
/>
521540
)}
522541
{/* ── 모듈 뷰 ────────────────────────────────────── */}
523-
{effectiveRoute === "ledger" && <LedgerView subRoute={subRoute} />}
524-
{effectiveRoute === "subscription" && <SubscriptionView />}
542+
{ActiveModuleView && (
543+
<Suspense
544+
fallback={(
545+
<div style={{ minHeight: 240, display: "flex", alignItems: "center", justifyContent: "center" }}>
546+
<span className="setup-spinner" style={{ width: 24, height: 24, borderWidth: 3 }} />
547+
</div>
548+
)}
549+
>
550+
<ActiveModuleView subRoute={subRoute} />
551+
</Suspense>
552+
)}
525553
{isSettingsOpen && (
526554
<SettingsView
527555
theme={theme}

apps/web/src/moduleConfig.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

apps/web/src/moduleRegistry.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
2+
import { registerModuleLocale } from "./i18n/registerModuleLocale";
3+
import ledgerKoLocale from "../../../modules/ledger/frontend/locales/ko.json";
4+
import ledgerEnLocale from "../../../modules/ledger/frontend/locales/en.json";
5+
import subscriptionKoLocale from "../../../modules/subscription/frontend/locales/ko.json";
6+
import subscriptionEnLocale from "../../../modules/subscription/frontend/locales/en.json";
7+
8+
// 모듈 서브 네비게이션 항목 정의.
9+
// key: "" 이면 모듈 기본 화면, "import" 같은 값이면 #module/import 형태로 이동한다.
10+
export interface ModuleSubRoute {
11+
key: string;
12+
label: string;
13+
icon?: string;
14+
}
15+
16+
// AppShell -> 모듈 화면으로 전달되는 공통 props.
17+
export interface ModuleViewProps {
18+
subRoute: string;
19+
}
20+
21+
type ModuleViewComponent = ComponentType<ModuleViewProps>;
22+
23+
interface ModuleDefinition {
24+
// lazy 컴포넌트와 별도로 보관하는 원본 loader.
25+
// 첫 진입 전 preload 시 이 함수를 사용한다.
26+
load: () => Promise<unknown>;
27+
component: LazyExoticComponent<ModuleViewComponent>;
28+
subNav: ModuleSubRoute[];
29+
locale: {
30+
ko: Record<string, unknown>;
31+
en: Record<string, unknown>;
32+
};
33+
}
34+
35+
// 동적 import 함수는 lazy 로더와 preload 양쪽에서 재사용한다.
36+
const loadLedgerModule = () => import("../../../modules/ledger/frontend/LedgerView");
37+
const loadSubscriptionModule = () => import("../../../modules/subscription/frontend/SubscriptionView");
38+
39+
const LedgerModuleView = lazy(async () => {
40+
const module = await loadLedgerModule();
41+
const LedgerRouteComponent: ModuleViewComponent = ({ subRoute }) => (
42+
<module.LedgerView subRoute={subRoute} />
43+
);
44+
return { default: LedgerRouteComponent };
45+
});
46+
47+
const SubscriptionModuleView = lazy(async () => {
48+
const module = await loadSubscriptionModule();
49+
const SubscriptionRouteComponent: ModuleViewComponent = () => <module.SubscriptionView />;
50+
return { default: SubscriptionRouteComponent };
51+
});
52+
53+
export const MODULE_REGISTRY: Record<string, ModuleDefinition> = {
54+
ledger: {
55+
load: loadLedgerModule,
56+
component: LedgerModuleView,
57+
subNav: [],
58+
locale: {
59+
ko: ledgerKoLocale,
60+
en: ledgerEnLocale,
61+
},
62+
},
63+
subscription: {
64+
load: loadSubscriptionModule,
65+
component: SubscriptionModuleView,
66+
subNav: [],
67+
locale: {
68+
ko: subscriptionKoLocale,
69+
en: subscriptionEnLocale,
70+
},
71+
},
72+
};
73+
74+
// routeConfig에서 모듈 라우트 판단에 사용하는 단일 소스.
75+
export const MODULE_ROUTES = Object.keys(MODULE_REGISTRY);
76+
77+
// AppShell이 요구하는 "모듈명 -> 서브네비 목록" 형태로 변환한다.
78+
const moduleSubNav: Record<string, ModuleSubRoute[]> = {};
79+
for (const [moduleName, definition] of Object.entries(MODULE_REGISTRY)) {
80+
moduleSubNav[moduleName] = definition.subNav;
81+
}
82+
export const MODULE_SUB_NAV = moduleSubNav;
83+
84+
export function getModuleView(route: string): LazyExoticComponent<ModuleViewComponent> | null {
85+
return MODULE_REGISTRY[route]?.component ?? null;
86+
}
87+
88+
// 앱 부트 시 모듈 번역 리소스를 미리 등록해
89+
// 첫 렌더에서 en -> ko로 바뀌는 깜빡임을 줄인다.
90+
export function registerModuleLocales(): void {
91+
for (const [moduleName, definition] of Object.entries(MODULE_REGISTRY)) {
92+
registerModuleLocale(moduleName, definition.locale.ko, definition.locale.en);
93+
}
94+
}
95+
96+
// 로그인 직후 idle 타이밍에 모듈 청크를 미리 받아서
97+
// 모듈 첫 진입 시 lazy 로딩 대기 시간을 줄인다.
98+
export async function preloadModuleViews(routeNames?: string[]): Promise<void> {
99+
const targets = routeNames ?? MODULE_ROUTES;
100+
const loaders = targets
101+
.map((routeName) => MODULE_REGISTRY[routeName]?.load)
102+
.filter((loader): loader is () => Promise<unknown> => loader !== undefined);
103+
await Promise.all(loaders.map((loader) => loader()));
104+
}

0 commit comments

Comments
 (0)