라우팅
App Router 구조
Lumie 프론트엔드는 Next.js App Router를 사용합니다. 파일 시스템 기반 라우팅으로 레이아웃 중첩, 그룹 라우트, 미들웨어 기반 인증 가드를 구현합니다.
app/
├── layout.tsx # 루트 레이아웃 (QueryProvider, AuthModal, Toaster)
├── (auth)/ # 로그인/회원가입 그룹 (URL 미포함)
│ ├── layout.tsx # 블러 오버레이 + 카드 레이아웃
│ ├── login/
│ │ └── page.tsx # /login
│ └── register/
│ └── page.tsx # /register
├── (marketing)/ # 마케팅 공개 페이지 그룹 (SSR)
│ ├── about/page.tsx # /about
│ ├── blog/ # /blog/*
│ ├── cases/ # /cases/*
│ ├── features/ # /features/*
│ ├── pricing/page.tsx # /pricing
│ └── ... # /changelog, /help, /security
├── [customId]/ # 테넌트 랜딩 페이지 (SSR, 공개)
│ └── page.tsx # /<customId> 또는 /<customId>/login
├── admin/ # 관리자 섹션 (CSR, ADMIN 역할)
│ ├── layout.tsx # 'use client' — AdminSidebar + Header + OmrJobStatusSync
│ ├── page.tsx # /admin
│ ├── students/
│ ├── exams/
│ ├── grades/
│ ├── attendance/
│ ├── schedules/
│ ├── announcements/
│ ├── assignments/
│ ├── lectures/
│ ├── classes/
│ ├── sms/
│ ├── reports/
│ ├── omr-grading/
│ ├── qna/
│ ├── resources/
│ ├── textbooks/
│ ├── ai/
│ ├── reviews/
│ ├── spreadsheets/
│ ├── employees/
│ └── settings/
├── dashboard/ # 학생 대시보드 (CSR, STUDENT 역할)
│ ├── layout.tsx # 'use client' — StudentSidebar + Header
│ ├ ── page.tsx # /dashboard
│ ├── exams/
│ ├── attendance/
│ ├── schedules/
│ ├── announcements/
│ ├── lectures/
│ └── qna/
└── api/
├── [...path]/
│ └── route.ts # BE 게이트웨이 프록시 (모든 HTTP 메서드)
└── health/
└── route.ts # 헬스체크 (미들웨어 우회)
렌더링 전략 (라우트별)
라우트마다 렌더링 전략이 다릅니다. 특히 auth routes(admin/dashboard)에 대한 이전의 blanket-SSR 시도는 되돌려졌습니다.
| 라우트 그룹 | 전략 | 이유 |
|---|---|---|
(marketing)/ | SSR | 공개 콘텐츠, SEO 필요 |
[customId]/ | SSR | 테넌트 랜딩 페이지, SEO + 공개 데이터 서버 패치 |
admin/ | CSR | 인증 후 데이터, React Query 클라이언트 패치 |
dashboard/ | CSR | 인증 후 데이터, React Query 클라이언트 패치 |
(auth)/ | 레이아웃만 SSR | 로그인/회원가입 폼 UI |
admin/layout.tsx와 dashboard/layout.tsx에는 'use client'가 명시되어 있습니다. 해당 레이아웃 하위의 모든 페이지 컴포넌트가 클라이언트 측에서 데이터를 패치합니다. 서버 컴포넌트로의 전환(HydrationBoundary)은 별도의 SSR 회귀 테스트(e2e/ssr/)가 존재하지만, 현재 auth 라우트는 CSR이 기본입니다.
[customId] 테넌트 랜딩 (SSR)
// app/[customId]/page.tsx
export default async function TenantLandingPage({ params }: PageProps) {
const { customId } = await params;
const tenant = await fetchPublicTenantByCustomId(customId);
if (!tenant) notFound();
const homepage = (await fetchPublicHomepageConfig(customId)) ?? defaultHomepageConfig();
return <LandingTemplateRenderer config={homepage} tenantName={tenant.name} ... />;
}
라우트 요약 테이블
| 경로 | 렌더링 | 접근 권한 | 설명 |
|---|---|---|---|
/ | SSR | 공개 | 마케팅 랜딩 페이지 |
/about, /pricing, … | SSR | 공개 | 마케팅 공개 페이지 |
/<customId> | SSR | 공개 | 테넌트 맞춤 랜딩 |
/login, /register | CSR | 비인증 | 로그인/회원가입 |
/admin/* | CSR | ADMIN | 관리자 백오피스 |
/dashboard/* | CSR | STUDENT | 학생 대시보드 |
미들웨어 인증 가드
middleware.ts가 모든 요청(정적 에셋 및 _next/* 제외)을 가로챕니다.
핵심 동작: 리프레시 토큰 폴백
1h 액세스 토큰만 검사하던 이전 방식은 만료 직후 로그인 화면으로 튕기는 문제를 일으켰습니다. 현재 미들웨어는 다음 순서로 라우팅 역할을 결정합니다.
lumie_access_token쿠키 디코드 → 만료되지 않았으면 역할 반환- 만료됐거나 없으면
lumie_refresh_token쿠키로 폴백 (7일 유효) - 두 토큰 모두 없거나 만료됐으면 미인증으로 처리
// middleware.ts (핵심 발췌)
function decodeRoleIfValid(token: string | undefined): string | undefined {
if (!token) return undefined;
try {
const payload = decodeJwt(token); // Edge 런타임: 서명 검증 없이 디코드
if (typeof payload.exp === 'number' && payload.exp * 1000 <= Date.now()) {
return undefined; // 만료
}
return typeof payload.role === 'string' ? payload.role : undefined;
} catch {
return undefined;
}
}
function readRoutingRole(request: NextRequest): string | undefined {
return (
decodeRoleIfValid(request.cookies.get('lumie_access_token')?.value) ??
decodeRoleIfValid(request.cookies.get('lumie_refresh_token')?.value) // 폴백
);
}
리프레시 토큰이 유효하면 미들웨어는 요청을 통과시킵니다. 클라이언트 사이드 apiRequest가 첫 번째 API 401 응답에서 단일 비행 토큰 갱신을 수행합니다(shared/api/base.ts의 tryRefreshToken). 미들웨어에서 선제적 갱신을 하지 않는 이유는 병렬 요청이 로테이팅 리프레시 토큰에서 경쟁 조건을 일으키기 때문입니다.
화이트라벨 커스텀 도메인
CUSTOM_DOMAIN_RESOLUTION_ENABLED=true이면 미들웨어가 요청의 Host 헤더를 확인합니다. 카노니컬 호스트(예: lumie-edu.com)가 아닌 경우 커스텀 도메인 테넌트를 조회하고, x-tenant-slug를 헤더에 설정하여 클라이언트가 임의로 변조하지 못하도록 합니다.
// middleware.ts — 커스텀 도메인 분기 (개요)
if (isResolutionEnabled()) {
const host = normalizeHost(request.headers.get('host'));
if (host && !isCanonicalHost(host)) {
const tenant = await resolveCustomDomainTenant(host);
if (tenant?.slug) {
headers.set('x-tenant-slug', tenant.slug); // 호스트 고정
// / → /<customId> 리라이트, /login → /<customId>?auth=login 리라이트
}
}
}
인증 리다이렉션 로직
// 미인증 사용자가 보호된 경 로 접근 → 홈으로 리다이렉트 (auth=login 쿼리 파라미터)
if (!isAuthenticated) {
const homeUrl = new URL('/', request.url);
homeUrl.searchParams.set('auth', 'login');
homeUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(homeUrl);
}
// 역할 기반 리다이렉트
if (DASHBOARD_ROUTES.test(pathname) && userRole !== 'STUDENT') {
return NextResponse.redirect(new URL('/admin', request.url));
}
if (ADMIN_ROUTES.test(pathname) && userRole === 'STUDENT') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
FE 프록시 (app/api/[...path]/route.ts)
브라우저는 동일 오리진 /api/v1/...을 호출합니다. 이 Route Handler가 ${NEXT_PUBLIC_API_BASE}/api/v1/...로 포워딩합니다. 세 가지 헤더 재작성이 핵심입니다.
1. Origin 제거 (화이트라벨 CORS)
headers.delete('origin');
// 이유: 화이트라벨 커스텀 도메인(예: joossameng.com)의 브라우저 Origin을
// 그대로 전달하면 Spring CORS 필터가 정적 허용 목록에서 찾지 못해 403 반환.
// 프록시→BE 구간은 서버-서버 요청이므로 Origin이 의미 없음.
2. Content-Encoding + Content-Length 제거 (gzip 이중 디코딩 방지)
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
// 이유: Node.js fetch는 gzip/br/deflate를 자동 압축 해제하지만
// Content-Encoding 헤더를 그대로 남겨둡니다.
// 헤더를 그대로 전달하면 브라우저가 이미 풀린 본문을 다시 디코딩하려다
// Safari "cannot decode raw data" / Chrome ERR_CONTENT_DECODING_FAILED 오류 발생.
3. Set-Cookie 재작성 (localhost 호스트 전용 쿠키)
// 각 Set-Cookie에서 Domain + Secure + SameSite=None 제거/변경
const fixed = cookie
.replace(/;\s*Domain=[^;]*/gi, '')
.replace(/;\s*Secure/gi, '')
.replace(/;\s*SameSite=None/gi, '; SameSite=Lax');
// 이유: 백엔드가 Domain=lumie-edu.com을 설정하면 localhost에서 쿠키가 무시됨.
// Secure 속성을 제거해야 http://localhost에서 쿠키가 저장됨.
// SameSite=None은 Secure 없이 Chromium에서 거부되므로 Lax로 다운그레이드.
레이아웃 중첩 다이어그램
상태 관리와 API 호출 방식은 상태 관리 문서를 참고하세요.