diff --git a/.claude/skills/university-web-rewrite-caution/SKILL.md b/.claude/skills/university-web-rewrite-caution/SKILL.md index 47c07cdb..2276a080 100644 --- a/.claude/skills/university-web-rewrite-caution/SKILL.md +++ b/.claude/skills/university-web-rewrite-caution/SKILL.md @@ -42,8 +42,13 @@ description: Safety checklist before touching apps/university-web — it is a se - 리라이트 과정에서 공통 레이아웃 프리미티브가 `packages/ui`로 추출되고 있다 (예: `packages/ui/src/mobile-hero-detail-shell.tsx`). - university 전용 화면이라도 web/admin과 겹치는 레이아웃 패턴이면 `apps/university-web` 내부에 새로 만들지 말고 `packages/ui`에 있는지 먼저 확인하고, 없으면 그쪽에 추가하는 것을 우선 고려한다. -6. **SSG 데이터 페칭은 실패를 삼키지 않는다.** - - university-web의 카탈로그 SSG는 "데이터 fetch 실패 시 빈 카탈로그로 조용히 빌드 성공" 대신 **빌드 자체가 실패**하도록 되어 있다. 이 특성을 유지한다 (에러를 try/catch로 삼켜 빈 배열 fallback을 만들지 않는다). +6. **SSG 데이터 페칭 실패는 CSR 폴백으로 넘긴다. 단, 조용히 빈 화면을 만들지 않는다.** + - 카탈로그 목록/상세는 정적 생성에 실패해도 **빌드를 중단시키지 않고** 클라이언트에서 같은 API를 다시 조회한다. + - `getAllUniversitiesSafe()` — 실패 시 throw 대신 `null` 반환 (빈 배열이 아니라 `null` 인 이유는 "0건인 정상 응답"과 "조회 실패"를 호출부가 구분해야 하기 때문). + - `[homeUniversity]/page.tsx` → `UniversityListCsrFallback`, `[homeUniversity]/[id]/page.tsx` → `UniversityDetailCsrFallback`. + - `[homeUniversity]/[id]` 는 `dynamicParams = true`. 정적 목록에서 빠진 경로가 404 가 되지 않고 요청 시점에 렌더되도록 하기 위함이다. + - **여기서 지켜야 할 원칙은 "빈 카탈로그를 조용히 정적으로 굳히지 않는다"이다.** 실패를 try/catch 로 삼켜 빈 배열을 그대로 렌더하는 코드는 여전히 금지다. 실패했으면 반드시 (a) 빌드 로그에 남기고 (b) 클라이언트에서 다시 조회하는 폴백 컴포넌트를 렌더해야 한다. + - `assertUniversitySsgResponse()` 를 쓰는 기존 경로(`getAllUniversities`, `getUniversitiesByText` 등)는 그대로 throw 한다. 폴백이 필요한 호출부만 `*Safe` 변형을 쓴다. - 데이터 갱신은 수동 DB 갱신 후 university zone 재배포로 처리한다(`/university/revalidate` 참고). 7. **환경변수 의존성 확인.** diff --git a/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts b/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts index 036decd8..d5b4f421 100644 --- a/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts +++ b/apps/university-web/src/apis/universities/server/getSearchUniversitiesByText.ts @@ -66,6 +66,26 @@ export const getAllUniversities = async (params?: UniversitySearchTextParams): P return getUniversitiesByText("", params); }; +/** + * 전체 대학 목록을 조회하되, 실패 시 throw 대신 null 을 반환한다. + * + * 빌드를 중단시키는 대신 호출부가 CSR 폴백으로 넘어갈 수 있게 하기 위한 변형이다. + * 빈 배열이 아니라 null 을 돌려주는 이유는 "결과가 0건인 정상 응답"과 "조회 실패"를 + * 호출부에서 반드시 구분할 수 있게 하기 위해서다. + */ +export const getAllUniversitiesSafe = async (params?: UniversitySearchTextParams): Promise => { + const endpoint = createSearchTextEndpoint("", params); + const response = await serverFetch(endpoint); + + if (!response.ok) { + // biome-ignore lint/suspicious/noConsole: 정적 생성이 조용히 축소되는 것을 막기 위해 빌드 로그에 실패를 남긴다. + console.warn(`[university-web] 대학 목록 조회 실패 (status ${response.status}) - CSR 폴백으로 전환합니다.`); + return null; + } + + return response.data.univApplyInfoPreviews; +}; + export const getCategorizedUniversities = async ( params?: UniversitySearchTextParams, ): Promise => { diff --git a/apps/university-web/src/apis/universities/server/index.ts b/apps/university-web/src/apis/universities/server/index.ts index f8993606..ea87f399 100644 --- a/apps/university-web/src/apis/universities/server/index.ts +++ b/apps/university-web/src/apis/universities/server/index.ts @@ -5,7 +5,12 @@ export { getSearchUniversitiesByFilter, type UniversitySearchFilterParams, } from "./getSearchUniversitiesByFilter"; -export { getAllUniversities, getCategorizedUniversities, getUniversitiesByText } from "./getSearchUniversitiesByText"; +export { + getAllUniversities, + getAllUniversitiesSafe, + getCategorizedUniversities, + getUniversitiesByText, +} from "./getSearchUniversitiesByText"; export { getUniversityDetail, getUniversityDetailForSsg, diff --git a/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailCsrFallback.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailCsrFallback.tsx new file mode 100644 index 00000000..7fae9f39 --- /dev/null +++ b/apps/university-web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetailCsrFallback.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useGetUniversityDetail } from "@/apis/universities"; + +import UniversityDetail from "./UniversityDetail"; +import UniversityDetailPreparingFallback from "./UniversityDetailPreparingFallback"; + +interface UniversityDetailCsrFallbackProps { + universityId: number; + backHref: string; +} + +/** + * SSG(빌드 시점) 또는 서버 렌더 단계에서 대학 상세 데이터를 가져오지 못했을 때 사용하는 클라이언트 폴백. + * 정적 생성 실패를 빈 화면으로 넘기지 않고, 브라우저에서 같은 API를 다시 조회해 내용을 채운다. + */ +const UniversityDetailCsrFallback = ({ universityId, backHref }: UniversityDetailCsrFallbackProps) => { + const { data: university, isPending, isError } = useGetUniversityDetail(universityId); + + if (isPending) { + return ( + + ); + } + + if (isError || !university) { + return ; + } + + return ( +
+ +
+ ); +}; + +export default UniversityDetailCsrFallback; diff --git a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx index 29cc0e7c..eb33a95a 100644 --- a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getAllUniversities, getUniversityDetailWithStatus } from "@/apis/universities/server"; +import { getAllUniversitiesSafe, getUniversityDetailWithStatus } from "@/apis/universities/server"; import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS } from "@/constants/university"; import type { HomeUniversitySlug } from "@/types/university"; @@ -10,10 +10,11 @@ import { createUrl, NO_INDEX_ROBOTS } from "@/utils/seo"; // UniversityDetail 컴포넌트 import UniversityDetail from "./_ui/UniversityDetail"; -import UniversityDetailPreparingFallback from "./_ui/UniversityDetailPreparingFallback"; +import UniversityDetailCsrFallback from "./_ui/UniversityDetailCsrFallback"; export const revalidate = false; -export const dynamicParams = false; +// 정적 생성에 실패해 목록에 빠진 경로도 요청 시점에 렌더링한다(404 대신 CSR 폴백으로 이어짐). +export const dynamicParams = true; // 모든 homeUniversity + id 조합에 대해 정적 경로 생성 export async function generateStaticParams() { @@ -24,11 +25,13 @@ export async function generateStaticParams() { return { slug, universities: [] }; } - const universities = await getAllUniversities({ + // 조회 실패 시 빌드를 중단하지 않고 해당 홈 대학 경로만 건너뛴다. + // 빠진 경로는 dynamicParams=true 덕분에 요청 시점에 렌더링되고, 그때도 실패하면 CSR 폴백이 받는다. + const universities = await getAllUniversitiesSafe({ homeUniversityId: homeUniversityInfo.homeUniversityId, }); - return { slug, universities }; + return { slug, universities: universities ?? [] }; }), ); @@ -159,10 +162,11 @@ const CollegeDetailPage = async ({ params }: PageProps) => { notFound(); } + // 서버에서 데이터를 못 가져온 경우, 준비중 화면으로 굳히지 않고 브라우저에서 다시 조회한다. return ( <> - + ); } diff --git a/apps/university-web/src/app/university/[homeUniversity]/_ui/UniversityListCsrFallback.tsx b/apps/university-web/src/app/university/[homeUniversity]/_ui/UniversityListCsrFallback.tsx new file mode 100644 index 00000000..e813a4dd --- /dev/null +++ b/apps/university-web/src/app/university/[homeUniversity]/_ui/UniversityListCsrFallback.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { QueryKeys } from "@/apis/queryKeys"; +import { type SearchTextResponse, universitiesApi } from "@/apis/universities/api"; +import type { HomeUniversitySlug, ListUniversity } from "@/types/university"; + +import UniversityListContent from "./UniversityListContent"; + +interface UniversityListCsrFallbackProps { + homeUniversityId: number; + homeUniversitySlug: HomeUniversitySlug; +} + +/** + * SSG(빌드 시점) 또는 서버 렌더 단계에서 파견학교 목록을 가져오지 못했을 때 사용하는 클라이언트 폴백. + * 빈 목록을 정적으로 굳혀버리지 않고, 브라우저에서 같은 API를 다시 조회한다. + */ +const UniversityListCsrFallback = ({ homeUniversityId, homeUniversitySlug }: UniversityListCsrFallbackProps) => { + const { + data: universities, + isPending, + isError, + } = useQuery({ + queryKey: [QueryKeys.universities.searchText, { homeUniversityId }], + queryFn: () => universitiesApi.getSearchText({ value: "", homeUniversityId }), + select: (data) => data.univApplyInfoPreviews, + }); + + if (isPending) { + return ( +
+ 파견학교 목록을 불러오는 중입니다. +
+ ); + } + + if (isError || !universities) { + return ( +
+

목록을 불러오지 못했습니다.

+

잠시 후 다시 확인해주세요.

+
+ ); + } + + return ; +}; + +export default UniversityListCsrFallback; diff --git a/apps/university-web/src/app/university/[homeUniversity]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/page.tsx index e5cc67f7..24332518 100644 --- a/apps/university-web/src/app/university/[homeUniversity]/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/page.tsx @@ -1,12 +1,13 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getSearchUniversitiesAllRegions } from "@/apis/universities/server"; +import { getAllUniversitiesSafe } from "@/apis/universities/server"; import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS } from "@/constants/university"; import type { HomeUniversitySlug } from "@/types/university"; import UniversityListContent from "./_ui/UniversityListContent"; +import UniversityListCsrFallback from "./_ui/UniversityListCsrFallback"; export const revalidate = false; export const dynamicParams = false; @@ -53,14 +54,22 @@ const UniversityListPage = async ({ params }: PageProps) => { notFound(); } - const universities = await getSearchUniversitiesAllRegions({ + const universities = await getAllUniversitiesSafe({ homeUniversityId: universityInfo.homeUniversityId, }); return ( <> - + {universities === null ? ( + // 서버에서 목록을 못 가져온 경우, 빈 목록을 정적으로 굳히지 않고 브라우저에서 다시 조회한다. + + ) : ( + + )} ); };