diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 135124a7..b676155a 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -1,5 +1,9 @@ -# API 서버 URL +# API 서버 URL (필수) +# 빌드/프리뷰 시 이 값이 없으면 API 호출은 실패하지만 SSR shell은 500 없이 응답해야 합니다. VITE_API_SERVER_URL=https://api.example.com -# S3 Base URL (성적 인증파일 URL 용) +# 업로드 CDN URL (선택) +VITE_UPLOADED_IMAGE_URL=https://cdn.upload.solid-connection.com + +# S3 Base URL (성적 인증파일 URL 용, 선택) VITE_S3_BASE_URL=https://s3.example.com diff --git a/apps/admin/VINEXT_MIGRATION_REPORT.md b/apps/admin/VINEXT_MIGRATION_REPORT.md index 45f484a1..2bc26387 100644 --- a/apps/admin/VINEXT_MIGRATION_REPORT.md +++ b/apps/admin/VINEXT_MIGRATION_REPORT.md @@ -5,7 +5,7 @@ - `apps/admin`의 TanStack Start 라우팅 구조를 Vinext 호환 App Router 구조로 변경했다. - `src/routes` 기반 라우트를 `src/app`으로 이동하고, 라우트 파일에 있던 UI는 feature 컴포넌트로 분리했다. - TanStack Router navigation 의존을 일반 anchor 및 브라우저 이동 흐름으로 교체했다. -- Vinext `dev`, `build`, `start` script를 적용하고 Vite 설정을 Vinext 플러그인 중심으로 정리했다. +- Vinext `dev`, `build`, `start`/`preview` script를 적용하고 Vite 설정을 Vinext/Nitro Vercel output 중심으로 정리했다. - TanStack Start, TanStack Router, route tree, Nitro 기반 진입점을 제거했다. ## 라우트 매핑 @@ -28,6 +28,14 @@ - localStorage 기반 admin session 저장소 - login, scores, bruno, chat-socket 사용자 플로우 +## SSR 상태 + +- Vinext build는 RSC, client, SSR environment를 모두 생성하며, `.vercel/output/functions/__server.func/index.mjs`를 통해 각 route의 HTML shell을 서버에서 응답한다. +- 현재 admin 인증은 localStorage의 access token을 기준으로 동작한다. 따라서 `/scores`, `/mentor-applications`, `/regions-countries` 같은 보호 페이지의 실제 테이블 데이터 요청은 서버가 아니라 브라우저에서 세션 확인 후 TanStack Query로 실행된다. +- 즉, 현재 상태는 **route shell SSR + client-side authenticated data fetching**이다. 서버 HTML 응답 단계에서 사용자별 보호 데이터를 미리 채워 넣는 full data SSR은 아직 적용하지 않는다. +- full data SSR을 적용하려면 admin access token 또는 session을 서버가 읽을 수 있는 httpOnly cookie 기반으로 전환하고, server-side API client, React Query dehydration, logout cookie clear 흐름을 함께 설계해야 한다. +- localStorage 인증을 유지한 상태에서 서버가 보호 API를 호출하도록 우회하면 토큰 중복 저장 또는 보안 모델 혼선이 생기므로, 이 변경은 별도 인증 아키텍처 PR로 분리한다. + ## 제거한 것 - `RouterProvider` @@ -44,13 +52,18 @@ - Vinext build 결과에서 route classification이 `Unknown`으로 표시된다. 현재 Vinext의 정적 분석 한계 안내이며 빌드는 성공한다. - 현재 로컬 Node가 `v23.10.0`이라 repo 요구사항 `22.x` 경고가 출력된다. - `@tailwindcss/vite`는 현재 Vite 8 peer range를 공식 포함하지 않아 peer warning이 출력된다. 빌드와 라우트 QA는 통과했다. -- `/`, `/auth/login`, `/scores` 등 API 클라이언트를 import하는 라우트는 `VITE_API_SERVER_URL`이 없으면 기존 로직에 의해 500이 난다. QA는 `.env.example` 기준 예시 값을 주입해 진행했다. +- `/`, `/auth/login`, `/scores` 등 API 클라이언트를 import하는 라우트도 `VITE_API_SERVER_URL` 누락만으로 SSR import 단계에서 500이 나지 않아야 한다. 실제 API 호출 시점에는 명확한 환경변수 오류로 실패한다. +- `vinext build`는 Nitro Vercel preset 기준 `.vercel/output`을 생성하므로, 로컬 production preview는 `vinext start`가 아니라 `.vercel/output/functions/__server.func/index.mjs`를 `srvx`로 실행한다. +- Vercel Project Settings에서 Root Directory를 `apps/admin`으로 잡는 경우 output directory는 비워두고, Build Command는 `pnpm build`를 사용한다. `.vercel/output`은 Vercel Build Output API 경로라서 별도 Output Directory로 지정하지 않는다. ## 검증 결과 - dev: `VITE_API_SERVER_URL=https://api.example.com VITE_S3_BASE_URL=https://s3.example.com pnpm --filter @solid-connect/admin dev` 성공 - build: `pnpm --filter @solid-connect/admin build` 성공 +- preview/start: `pnpm --filter @solid-connect/admin start`로 `.vercel/output` SSR preview 성공 - lint: `pnpm --filter @solid-connect/admin lint:check` 성공 - typecheck: `pnpm --filter @solid-connect/admin typecheck` 성공 -- compatibility: `pnpm --filter @solid-connect/admin exec vinext check` 100% compatible -- 주요 페이지 수동 QA: `/`, `/login`, `/auth/login`, `/scores`, `/bruno`, `/chat-socket` 모두 HTTP 200 확인 +- test: `pnpm --filter @solid-connect/admin test` 성공 +- compatibility: `pnpm --filter @solid-connect/admin exec vinext check` 93% compatible + - partial: `next/font/google` 항목은 Vinext가 CDN font 로딩으로 분류한다. 현재 admin source에서 직접 사용 중인 `next/font/google` import는 없다. +- 주요 페이지 수동 QA: `/`, `/login`, `/auth/login`, `/scores`, `/bruno`, `/chat-socket`, `/regions-countries`, `/mentor-applications` 모두 HTTP 200 확인 diff --git a/apps/admin/package.json b/apps/admin/package.json index 0453afa9..6f380a7b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -5,8 +5,9 @@ "scripts": { "dev": "vinext dev -p 4000", "build": "vinext build", - "start": "vinext start -p 4000", - "test": "vitest run", + "start": "pnpm run preview", + "preview": "srvx --prod --host 0.0.0.0 --port 4000 --static \"$PWD/.vercel/output/static\" \"$PWD/.vercel/output/functions/__server.func/index.mjs\"", + "test": "vitest run --config vitest.config.ts", "format": "biome format --write .", "format:check": "biome format .", "lint": "biome check --write .", @@ -49,6 +50,7 @@ "jsdom": "^27.0.0", "nitro": "3.0.260522-beta", "react-server-dom-webpack": "^19.2.6", + "srvx": "^0.11.15", "typescript": "^5.7.2", "vinext": "^0.0.51", "vite": "^8.0.14", diff --git a/apps/admin/src/components/features/auth/AdminLoginPage.tsx b/apps/admin/src/components/features/auth/AdminLoginPage.tsx index 9bca8997..9a071ed6 100644 --- a/apps/admin/src/components/features/auth/AdminLoginPage.tsx +++ b/apps/admin/src/components/features/auth/AdminLoginPage.tsx @@ -42,9 +42,9 @@ export function AdminLoginPage({ onLoginSuccess }: AdminLoginPageProps) { onLoginSuccess(); } catch (err: unknown) { - const error = err as { response?: { data?: { message?: string } } }; + const error = err as { message?: string; response?: { data?: { message?: string } } }; toast.error("로그인 실패", { - description: error.response?.data?.message || "로그인에 실패했습니다.", + description: error.response?.data?.message || error.message || "로그인에 실패했습니다.", }); } }; diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts index bd3a6903..49507fd0 100644 --- a/apps/admin/src/lib/api/auth.ts +++ b/apps/admin/src/lib/api/auth.ts @@ -1,25 +1,34 @@ import axios, { type AxiosResponse } from "axios"; +import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env"; import { loadAccessToken } from "@/lib/utils/localStorage"; import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth"; -const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim(); - -if (!API_SERVER_URL) { - throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment."); -} +const API_SERVER_URL = getAdminApiServerUrl(); const authAxiosInstance = axios.create({ - baseURL: API_SERVER_URL, + baseURL: API_SERVER_URL || undefined, withCredentials: true, }); -export const adminSignInApi = (email: string, password: string): Promise> => - authAxiosInstance.post("/auth/email/sign-in", { email, password }); +const assertAdminApiServerUrl = () => { + if (!API_SERVER_URL) { + throw createMissingAdminApiServerUrlError(); + } +}; + +export const adminSignInApi = (email: string, password: string): Promise> => { + assertAdminApiServerUrl(); + return authAxiosInstance.post("/auth/email/sign-in", { email, password }); +}; -export const reissueAccessTokenApi = (): Promise> => - authAxiosInstance.post("/auth/reissue"); +export const reissueAccessTokenApi = (): Promise> => { + assertAdminApiServerUrl(); + return authAxiosInstance.post("/auth/reissue"); +}; export const adminSignOutApi = (): Promise> => { + assertAdminApiServerUrl(); + const accessToken = loadAccessToken(); return authAxiosInstance.post("/auth/sign-out", undefined, { diff --git a/apps/admin/src/lib/api/client.ts b/apps/admin/src/lib/api/client.ts index c16356a6..ecb4b70b 100644 --- a/apps/admin/src/lib/api/client.ts +++ b/apps/admin/src/lib/api/client.ts @@ -1,16 +1,13 @@ import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios"; import { clearSession, ensureSessionToken, reissueAccessTokenIfPossible } from "@/lib/auth/session"; +import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env"; const convertToBearer = (token: string) => `Bearer ${token}`; -const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim(); - -if (!API_SERVER_URL) { - throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment."); -} +const API_SERVER_URL = getAdminApiServerUrl(); export const axiosInstance: AxiosInstance = axios.create({ - baseURL: API_SERVER_URL, + baseURL: API_SERVER_URL || undefined, withCredentials: true, }); @@ -22,6 +19,10 @@ const redirectToLogin = () => { axiosInstance.interceptors.request.use( async (config) => { + if (!API_SERVER_URL) { + return Promise.reject(createMissingAdminApiServerUrlError()); + } + const newConfig = { ...config }; const accessToken = await ensureSessionToken(); diff --git a/apps/admin/src/lib/env.ts b/apps/admin/src/lib/env.ts new file mode 100644 index 00000000..eae7cada --- /dev/null +++ b/apps/admin/src/lib/env.ts @@ -0,0 +1,6 @@ +const getTrimmedEnv = (key: string) => import.meta.env[key]?.trim() ?? ""; + +export const getAdminApiServerUrl = () => getTrimmedEnv("VITE_API_SERVER_URL"); + +export const createMissingAdminApiServerUrlError = () => + new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment."); diff --git a/apps/admin/vitest.config.ts b/apps/admin/vitest.config.ts new file mode 100644 index 00000000..505566f6 --- /dev/null +++ b/apps/admin/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e28b482f..1cc4f70d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: react-server-dom-webpack: specifier: ^19.2.6 version: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.27.2)) + srvx: + specifier: ^0.11.15 + version: 0.11.15 typescript: specifier: ^5.7.2 version: 5.9.3