Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/skills/university-web-rewrite-caution/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 다시 조회한다.
Comment on lines +45 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the deployment verification contract

This runbook now requires SSG fetch failures to complete the build with a CSR fallback, but the referenced docs/university-multizone-deployment.md verification section still says such a fetch failure must fail the build. Anyone following the documented cold-build check will treat the newly intended result as a regression, so the deployment reference needs to be updated with this behavior change.

AGENTS.md reference: AGENTS.md:L94-L100

Useful? React with 👍 / 👎.

- `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. **환경변수 의존성 확인.**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListUniversity[] | null> => {
const endpoint = createSearchTextEndpoint("", params);
const response = await serverFetch<UniversitySearchResponse>(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<AllRegionsUniversityList> => {
Expand Down
7 changes: 6 additions & 1 deletion apps/university-web/src/apis/universities/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<UniversityDetailPreparingFallback
backHref={backHref}
title="대학 정보를 불러오는 중입니다."
description="잠시만 기다려주세요."
/>
);
}

if (isError || !university) {
return <UniversityDetailPreparingFallback backHref={backHref} />;
}

return (
<div className="w-full">
<UniversityDetail koreanName={university.koreanName} university={university} />
</div>
);
};

export default UniversityDetailCsrFallback;
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Comment on lines +16 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject mismatched home-university detail URLs

With dynamicParams = true, a request such as /university/inha/<kyunghee-id> is no longer rejected by the generated-parameter whitelist. CollegeDetailPage fetches the detail using only the numeric ID and never verifies that it belongs to the selected home university, so the page renders valid content under the wrong navigation, canonical URL, and metadata. Validate the ID against the scoped university list before rendering, while retaining the fallback only when that validation request itself fails.

Useful? React with 👍 / 👎.


// 모든 homeUniversity + id 조합에 대해 정적 경로 생성
export async function generateStaticParams() {
Expand All @@ -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 ?? [] };
}),
);

Expand Down Expand Up @@ -159,10 +162,11 @@ const CollegeDetailPage = async ({ params }: PageProps) => {
notFound();
}

// 서버에서 데이터를 못 가져온 경우, 준비중 화면으로 굳히지 않고 브라우저에서 다시 조회한다.
return (
<>
<TopDetailNavigation title="파견 학교 상세" backHref={`/university/${homeUniversity}`} />
<UniversityDetailPreparingFallback backHref={`/university/${homeUniversity}`} />
<UniversityDetailCsrFallback universityId={collegeId} backHref={`/university/${homeUniversity}`} />
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SearchTextResponse, Error, ListUniversity[]>({
queryKey: [QueryKeys.universities.searchText, { homeUniversityId }],
queryFn: () => universitiesApi.getSearchText({ value: "", homeUniversityId }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the CSR fallback on the configured term

When NEXT_PUBLIC_UNIVERSITY_TERM_ID is configured, the server-side getAllUniversitiesSafe() automatically includes that term, but this fallback calls getSearchText without useDefaultTermId: true; getScopedTermId therefore omits termId. If the build-time request fails, the recovered page can display universities from every term instead of the catalog that would have been statically generated.

Useful? React with 👍 / 👎.

select: (data) => data.univApplyInfoPreviews,
});

if (isPending) {
return (
<div
className="flex min-h-[50vh] items-center justify-center px-5 text-center text-k-400 typo-regular-3"
role="status"
aria-live="polite"
>
파견학교 목록을 불러오는 중입니다.
</div>
);
}

if (isError || !universities) {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center px-5 text-center"
role="status"
aria-live="polite"
>
<p className="text-k-700 typo-sb-9">목록을 불러오지 못했습니다.</p>
<p className="mt-1 text-k-400 typo-regular-3">잠시 후 다시 확인해주세요.</p>
</div>
);
}

return <UniversityListContent universities={universities} homeUniversitySlug={homeUniversitySlug} />;
};

export default UniversityListCsrFallback;
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -53,14 +54,22 @@ const UniversityListPage = async ({ params }: PageProps) => {
notFound();
}

const universities = await getSearchUniversitiesAllRegions({
const universities = await getAllUniversitiesSafe({
homeUniversityId: universityInfo.homeUniversityId,
});

return (
<>
<TopDetailNavigation title={`${universityInfo.shortName} 파견학교`} backHref="/university" />
<UniversityListContent universities={universities} homeUniversitySlug={homeUniversitySlug} />
{universities === null ? (
// 서버에서 목록을 못 가져온 경우, 빈 목록을 정적으로 굳히지 않고 브라우저에서 다시 조회한다.
<UniversityListCsrFallback
homeUniversityId={universityInfo.homeUniversityId}
homeUniversitySlug={homeUniversitySlug}
/>
) : (
<UniversityListContent universities={universities} homeUniversitySlug={homeUniversitySlug} />
)}
</>
);
};
Expand Down
Loading