From 42df73d0cdd365daddfaf76051282d988f19094c Mon Sep 17 00:00:00 2001
From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com>
Date: Fri, 13 Nov 2026 10:01:31 +0900
Subject: [PATCH 1/3] feat: render public SEO pages and item permalinks
---
docs/search-seo.md | 35 +++
next.config.ts | 2 +
package-lock.json | 4 +-
package.json | 2 +-
.../auction-history/AuctionHistoryClient.tsx | 32 ++-
.../[auctionBuyId]/page.test.tsx | 32 +++
.../auction-history/[auctionBuyId]/page.tsx | 21 +-
.../(main)/auction-history/page.seo.test.tsx | 10 +-
src/app/(main)/auction-history/page.tsx | 4 +
.../AuctionRealtimeClient.tsx | 32 ++-
.../[auctionId]/page.test.tsx | 32 +++
.../auction-realtime/[auctionId]/page.tsx | 21 +-
src/app/(main)/community/CommunityClient.tsx | 23 +-
src/app/(main)/community/[id]/page.test.tsx | 47 ++--
src/app/(main)/community/[id]/page.tsx | 34 ++-
src/app/(main)/community/page.test.tsx | 77 ++++++
src/app/(main)/community/page.tsx | 66 ++++-
src/app/(main)/items/[itemKey]/page.test.tsx | 98 ++++++++
src/app/(main)/items/[itemKey]/page.tsx | 166 +++++++++++++
src/app/(main)/layout.tsx | 2 +-
src/app/(main)/outfits/[id]/page.tsx | 46 +++-
src/app/(main)/outfits/page.test.tsx | 61 +++++
src/app/(main)/outfits/page.tsx | 70 +++++-
src/app/robots.test.ts | 2 +-
src/app/robots.ts | 2 +-
src/app/sitemap-items.xml/route.ts | 6 +
src/app/sitemap-outfits.xml/route.ts | 6 +
src/app/sitemap-posts.xml/route.ts | 6 +
src/app/sitemap-static.xml/route.ts | 6 +
src/app/sitemap.test.ts | 176 +++++++++++++-
src/app/sitemap.ts | 9 -
src/app/sitemap.xml/route.ts | 6 +
.../page/auction-common/AuctionDetailView.tsx | 36 +--
.../auction-common/AuctionMarketSummary.tsx | 77 ++++++
.../page/auction-common/AuctionPageHeader.tsx | 4 +-
src/components/page/community/PostCard.tsx | 14 +-
.../page/community/PostList.test.tsx | 110 +++++++++
src/components/page/community/PostList.tsx | 53 +++-
.../page/item-info/ItemInfoTable.test.tsx | 16 +-
.../page/item-info/ItemInfoTable.tsx | 31 ++-
src/components/page/items/ItemMetadata.tsx | 42 ++++
.../page/items/ItemPriceHistory.test.tsx | 77 ++++++
.../page/items/ItemPriceHistory.tsx | 157 ++++++++++++
.../page/outfits/OutfitDetail.test.tsx | 2 +-
src/components/page/outfits/OutfitDetail.tsx | 21 +-
.../page/outfits/OutfitFeed.test.tsx | 2 +
src/components/page/outfits/OutfitFeed.tsx | 63 ++++-
src/components/seo/HomeLanding.test.tsx | 33 +--
src/components/seo/PopularItemLinks.test.tsx | 38 +++
src/components/seo/PopularItemLinks.tsx | 48 ++--
src/hooks/useInfinitePosts.ts | 142 ++++-------
src/lib/auction/detail.ts | 210 +++-------------
src/lib/community/feed.ts | 87 +++++++
src/lib/community/public-feed.test.ts | 60 +++++
src/lib/community/public-feed.ts | 38 +++
src/lib/items/data.test.ts | 59 +++++
src/lib/items/data.ts | 180 ++++++++++++++
src/lib/items/dates.ts | 21 ++
src/lib/items/identity.test.ts | 43 ++++
src/lib/items/identity.ts | 62 +++++
src/lib/outfits/public-feed.ts | 25 ++
src/lib/outfits/public-outfit.ts | 4 +-
src/lib/outfits/seo.test.ts | 31 +++
src/lib/outfits/seo.ts | 21 ++
src/lib/seo/public-api.ts | 24 ++
src/lib/seo/sitemaps.ts | 229 ++++++++++++++++++
src/lib/seo/structured-data.ts | 20 ++
67 files changed, 2722 insertions(+), 494 deletions(-)
create mode 100644 docs/search-seo.md
create mode 100644 src/app/(main)/community/page.test.tsx
create mode 100644 src/app/(main)/items/[itemKey]/page.test.tsx
create mode 100644 src/app/(main)/items/[itemKey]/page.tsx
create mode 100644 src/app/(main)/outfits/page.test.tsx
create mode 100644 src/app/sitemap-items.xml/route.ts
create mode 100644 src/app/sitemap-outfits.xml/route.ts
create mode 100644 src/app/sitemap-posts.xml/route.ts
create mode 100644 src/app/sitemap-static.xml/route.ts
delete mode 100644 src/app/sitemap.ts
create mode 100644 src/app/sitemap.xml/route.ts
create mode 100644 src/components/page/auction-common/AuctionMarketSummary.tsx
create mode 100644 src/components/page/community/PostList.test.tsx
create mode 100644 src/components/page/items/ItemMetadata.tsx
create mode 100644 src/components/page/items/ItemPriceHistory.test.tsx
create mode 100644 src/components/page/items/ItemPriceHistory.tsx
create mode 100644 src/components/seo/PopularItemLinks.test.tsx
create mode 100644 src/lib/community/feed.ts
create mode 100644 src/lib/community/public-feed.test.ts
create mode 100644 src/lib/community/public-feed.ts
create mode 100644 src/lib/items/data.test.ts
create mode 100644 src/lib/items/data.ts
create mode 100644 src/lib/items/dates.ts
create mode 100644 src/lib/items/identity.test.ts
create mode 100644 src/lib/items/identity.ts
create mode 100644 src/lib/outfits/public-feed.ts
create mode 100644 src/lib/outfits/seo.test.ts
create mode 100644 src/lib/outfits/seo.ts
create mode 100644 src/lib/seo/public-api.ts
create mode 100644 src/lib/seo/sitemaps.ts
create mode 100644 src/lib/seo/structured-data.ts
diff --git a/docs/search-seo.md b/docs/search-seo.md
new file mode 100644
index 0000000..4705da8
--- /dev/null
+++ b/docs/search-seo.md
@@ -0,0 +1,35 @@
+# Search SEO v1
+
+공개 커뮤니티/의장 목록과 영구 아이템 페이지를 서버에서 렌더링한다. 검색 순위나 색인 시점을 보장하지 않으며 Search Console 연동은 포함하지 않는다.
+
+## 공개 URL과 데이터
+
+- `/community`: 최신 공개 글 20개. `?page=N`은 1부터 시작하며 다음/이전 링크와 각 페이지의 canonical을 제공한다.
+- `/outfits`: 최신 의장 24개. 기존 `?cursor=...`를 다음 페이지 링크에 사용한다. 검색/인기/복합 필터 URL은 noindex,follow다.
+- `/items/[itemKey]`: UTF-8 JSON `[topCategory,subCategory,name]`의 unpadded base64url이다. 카탈로그에서 반환한 식별자를 canonical로 사용하고 동등한 다른 인코딩은 308로 보낸다. 잘못된 식별자/없는 아이템은 404다.
+- 아이템은 카탈로그 설명 또는 보존된 일간/주간 통계가 있으면 색인 대상이다. 최근 표시 구간이 비어도 자격을 잃지 않고 마지막 보존 집계를 표시한다.
+- 일간 30일·주간 120일 통계를 사용한다. 판매 수량과 거래 금액을 구분하며 평균은 거래 금액/판매 수량이다. 두 기간 표는 초기 HTML에 포함된다.
+
+공개 서버 fetch는 사용자 쿠키와 인증 헤더를 전달하지 않는다. 개인 반응은 클라이언트에서 별도로 읽으며 인증/개인 상태 확인 전 좋아요·즐겨찾기 변경을 막는다. 의장 초기 데이터에는 저장소 서명 URL 대신 `/api/outfits/{id}/images/{imageId}`만 직렬화한다.
+
+## Provider 의존성
+
+Gateway의 기존 prefix를 사용한다. React보다 아래 provider가 먼저 배포되어야 한다.
+
+- Community: `/dcs/api/posts/sitemap`, `/dcs/api/outfits/sitemap`. 공개 ID와 실제 수정일만 반환하는 ID keyset projection.
+- Batch: `/oab/api/item-infos/seo`, `/oab/api/item-infos/sitemap`. 영구 아이템의 공통 색인 자격과 마지막 보존 통계, 카탈로그 복합 keyset projection.
+- 각각의 서비스 `docs/search-seo.md`, `docs/item-seo.md`와 `contracts/`의 JSON fixture가 응답 계약이다.
+
+## Sitemap와 상태 코드
+
+`/sitemap.xml`은 `/sitemap-static.xml`, `/sitemap-posts.xml`, `/sitemap-outfits.xml`, `/sitemap-items.xml`의 index다. index는 1,000개씩 경량 projection을 순회해 시작 커서를 찾는다. 자식은 자기 커서만 직접 조회한다. 성공 XML만 프로세스 메모리에 최대 300초 보관하며 만료 뒤 장애는 503이다. 일부 결과를 성공 응답으로 반환하지 않는다. 카탈로그에는 수정 시각이 없어 lastmod를 만들지 않는다.
+
+글/의장/아이템 상세는 metadata 단계에서 존재 여부를 확인한다. 전체 main-layout Suspense를 제거하고 metadata streaming을 비활성화해 없는 문서를 HTTP 404, 필수 API 장애를 5xx로 반환한다. 경매장 첫 화면도 force-dynamic SSR을 사용한다. 이 선택은 정확한 상태 코드를 위해 초기 응답을 필수 metadata 조회 완료까지 기다리게 한다.
+
+robots는 기존 API 차단을 유지하면서 공개 의장 이미지 경로만 허용한다. 삭제된 이미지의 404 정책은 유지한다.
+
+## 검증과 배포
+
+`npm run verify`가 format, lint, typecheck, deadcode, Jest, production build를 실행한다. metadata 함수 mock만으로 HTTP 상태를 증명할 수 없으므로 production `next start`에서 200/404/308/5xx, JS-off 본문/표/페이지 링크, XML 오류, 익명/로그인 상호작용을 추가 확인한다.
+
+배포 순서: 사람의 병합 승인 → Community와 Batch provider 배포/공개 GET 확인 → React 배포 → 운영 읽기 전용 점검. 통계의 300초 캐시, sitemap의 300초 프로세스 캐시, 필수 API 장애 시 5xx를 관찰한다. sitemap index 생성 비용은 전체 공개 카탈로그 수에 따라 증가하며 합성 EXPLAIN은 운영 지연의 보장이 아니다.
diff --git a/next.config.ts b/next.config.ts
index 4655bba..d6c0b06 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
+ // Resolve public metadata/existence before streaming a successful response.
+ htmlLimitedBots: /.*/,
async redirects() {
return [
{
diff --git a/package-lock.json b/package-lock.json
index ef5159b..6ed4916 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devnogi-front",
- "version": "1.0.30",
+ "version": "1.0.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devnogi-front",
- "version": "1.0.30",
+ "version": "1.0.31",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.3",
diff --git a/package.json b/package.json
index a1601ad..c5ce303 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
- "version": "1.0.30",
+ "version": "1.0.31",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/src/app/(main)/auction-history/AuctionHistoryClient.tsx b/src/app/(main)/auction-history/AuctionHistoryClient.tsx
index a83f1c0..4e63a72 100644
--- a/src/app/(main)/auction-history/AuctionHistoryClient.tsx
+++ b/src/app/(main)/auction-history/AuctionHistoryClient.tsx
@@ -1035,7 +1035,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
return (
{header}
@@ -1055,7 +1055,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showCategorySidebar && (
@@ -1231,16 +1235,18 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showFilterSidebar && (
- setIsFilterModalOpen(false)}
- layoutMode={layoutMode}
- isExactItemName={!!searchParams.isExactItemName}
- onExactItemNameChange={handleExactItemNameChange}
- isItemPreviewEnabled={isPreviewEnabled}
- onItemPreviewEnabledChange={setIsPreviewEnabled}
- />
+
+ setIsFilterModalOpen(false)}
+ layoutMode={layoutMode}
+ isExactItemName={!!searchParams.isExactItemName}
+ onExactItemNameChange={handleExactItemNameChange}
+ isItemPreviewEnabled={isPreviewEnabled}
+ onItemPreviewEnabledChange={setIsPreviewEnabled}
+ />
+
)}
{showMobileFilter && (
diff --git a/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx b/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx
index fecb884..132d516 100644
--- a/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx
+++ b/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx
@@ -25,3 +25,35 @@ describe("auction history detail metadata", () => {
});
});
});
+
+it("uses a self canonical independent of return_to", async () => {
+ jest.mocked(getAuctionDetail).mockResolvedValue({
+ status: "available",
+ record: { itemName: "검" },
+ } as never);
+ const result = await generateMetadata({
+ params: Promise.resolve({ auctionBuyId: "record-1" }),
+ searchParams: Promise.resolve({ return_to: "/auction-history?page=2" }),
+ });
+ expect(result.alternates?.canonical).toBe(
+ "https://www.memonogi.com/auction-history/record-1",
+ );
+});
+it("propagates missing and failed primary records", async () => {
+ jest
+ .mocked(getAuctionDetail)
+ .mockResolvedValue({ status: "not-found" } as never);
+ await expect(
+ generateMetadata({
+ params: Promise.resolve({ auctionBuyId: "missing" }),
+ searchParams: Promise.resolve({}),
+ }),
+ ).rejects.toThrow();
+ jest.mocked(getAuctionDetail).mockResolvedValue({ status: "error" } as never);
+ await expect(
+ generateMetadata({
+ params: Promise.resolve({ auctionBuyId: "error" }),
+ searchParams: Promise.resolve({}),
+ }),
+ ).rejects.toThrow();
+});
diff --git a/src/app/(main)/auction-history/[auctionBuyId]/page.tsx b/src/app/(main)/auction-history/[auctionBuyId]/page.tsx
index 95b73f1..5c815f8 100644
--- a/src/app/(main)/auction-history/[auctionBuyId]/page.tsx
+++ b/src/app/(main)/auction-history/[auctionBuyId]/page.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
+import { notFound } from "next/navigation";
import AuctionDetailView from "@/components/page/auction-common/AuctionDetailView";
import { getAuctionDetail, safeReturnTo } from "@/lib/auction/detail";
+import { canonicalUrl } from "@/lib/seo/site";
interface PageProps {
params: Promise<{ auctionBuyId: string }>;
@@ -15,16 +17,24 @@ export async function generateMetadata({
}: PageProps): Promise {
const { auctionBuyId } = await params;
const result = await getAuctionDetail("history", auctionBuyId);
+ if (result.status === "not-found") notFound();
+ if (result.status === "error" || !result.record)
+ throw new Error("경매장 상세 정보를 불러오지 못했습니다.");
return {
title: {
absolute: result.record
? `${result.record.itemName} 거래 내역 | 마비노기`
: "경매장 거래 내역 | 마비노기",
},
- robots:
- result.status === "available"
- ? undefined
- : { index: false, follow: false },
+ description: `${result.record.itemName}의 마비노기 경매장 거래 내역과 아이템 시세를 확인하세요.`,
+ alternates: {
+ canonical: canonicalUrl(
+ `/auction-history/${encodeURIComponent(auctionBuyId)}`,
+ ),
+ },
+ openGraph: {
+ url: canonicalUrl(`/auction-history/${encodeURIComponent(auctionBuyId)}`),
+ },
};
}
@@ -34,6 +44,9 @@ export default async function AuctionHistoryDetailPage({
}: PageProps) {
const [{ auctionBuyId }, query] = await Promise.all([params, searchParams]);
const result = await getAuctionDetail("history", auctionBuyId);
+ if (result.status === "not-found") notFound();
+ if (result.status === "error" || !result.record)
+ throw new Error("경매장 상세 정보를 불러오지 못했습니다.");
return (
+ function AuctionMarketSummary() {
+ return 시세 요약
;
+ },
+);
+
jest.mock("./AuctionHistoryClient", () => {
return function AuctionHistoryClient({
header,
@@ -24,7 +32,7 @@ describe("AuctionHistoryPage SEO landing content", () => {
expect(
screen.getByRole("heading", {
level: 1,
- name: "경매장 거래내역",
+ name: "마비노기 경매장 거래내역·시세",
}),
).toBeInTheDocument();
expect(screen.getByText(/NEXON Open API를 기반/)).toBeInTheDocument();
diff --git a/src/app/(main)/auction-history/page.tsx b/src/app/(main)/auction-history/page.tsx
index b4b6113..5229e95 100644
--- a/src/app/(main)/auction-history/page.tsx
+++ b/src/app/(main)/auction-history/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
+import AuctionMarketSummary from "@/components/page/auction-common/AuctionMarketSummary";
import AuctionPageHeader from "@/components/page/auction-common/AuctionPageHeader";
import { itemCategories } from "@/data/item-category";
import { canonicalUrl } from "@/lib/seo/site";
@@ -12,6 +13,8 @@ type AuctionHistoryPageProps = {
searchParams: Promise;
};
+export const dynamic = "force-dynamic";
+
export async function generateMetadata({
searchParams,
}: AuctionHistoryPageProps): Promise {
@@ -40,6 +43,7 @@ export default function AuctionHistoryPage() {
+
diff --git a/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx b/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx
index 90b4c2b..eecb20a 100644
--- a/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx
+++ b/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx
@@ -982,7 +982,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
return (
{header}
@@ -1002,7 +1002,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showCategorySidebar && (
@@ -1176,16 +1180,18 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showFilterSidebar && (
- setIsFilterModalOpen(false)}
- layoutMode={layoutMode}
- isExactItemName={!!searchParams.isExactItemName}
- onExactItemNameChange={handleExactItemNameChange}
- isItemPreviewEnabled={isPreviewEnabled}
- onItemPreviewEnabledChange={setIsPreviewEnabled}
- />
+
+ setIsFilterModalOpen(false)}
+ layoutMode={layoutMode}
+ isExactItemName={!!searchParams.isExactItemName}
+ onExactItemNameChange={handleExactItemNameChange}
+ isItemPreviewEnabled={isPreviewEnabled}
+ onItemPreviewEnabledChange={setIsPreviewEnabled}
+ />
+
)}
{showMobileFilter && (
diff --git a/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx b/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx
index c94533a..da2088e 100644
--- a/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx
+++ b/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx
@@ -25,3 +25,35 @@ describe("auction realtime detail metadata", () => {
});
});
});
+
+it("uses a self canonical independent of return_to", async () => {
+ jest.mocked(getAuctionDetail).mockResolvedValue({
+ status: "available",
+ record: { itemName: "검" },
+ } as never);
+ const result = await generateMetadata({
+ params: Promise.resolve({ auctionId: "record-1" }),
+ searchParams: Promise.resolve({ return_to: "/auction-realtime?page=2" }),
+ });
+ expect(result.alternates?.canonical).toBe(
+ "https://www.memonogi.com/auction-realtime/record-1",
+ );
+});
+it("propagates missing and failed primary records", async () => {
+ jest
+ .mocked(getAuctionDetail)
+ .mockResolvedValue({ status: "not-found" } as never);
+ await expect(
+ generateMetadata({
+ params: Promise.resolve({ auctionId: "missing" }),
+ searchParams: Promise.resolve({}),
+ }),
+ ).rejects.toThrow();
+ jest.mocked(getAuctionDetail).mockResolvedValue({ status: "error" } as never);
+ await expect(
+ generateMetadata({
+ params: Promise.resolve({ auctionId: "error" }),
+ searchParams: Promise.resolve({}),
+ }),
+ ).rejects.toThrow();
+});
diff --git a/src/app/(main)/auction-realtime/[auctionId]/page.tsx b/src/app/(main)/auction-realtime/[auctionId]/page.tsx
index d94bbfe..312b7a6 100644
--- a/src/app/(main)/auction-realtime/[auctionId]/page.tsx
+++ b/src/app/(main)/auction-realtime/[auctionId]/page.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
+import { notFound } from "next/navigation";
import AuctionDetailView from "@/components/page/auction-common/AuctionDetailView";
import { getAuctionDetail, safeReturnTo } from "@/lib/auction/detail";
+import { canonicalUrl } from "@/lib/seo/site";
interface PageProps {
params: Promise<{ auctionId: string }>;
@@ -15,16 +17,24 @@ export async function generateMetadata({
}: PageProps): Promise {
const { auctionId } = await params;
const result = await getAuctionDetail("realtime", auctionId);
+ if (result.status === "not-found") notFound();
+ if (result.status === "error" || !result.record)
+ throw new Error("경매장 상세 정보를 불러오지 못했습니다.");
return {
title: {
absolute: result.record
? `${result.record.itemName} 실시간 거래 | 마비노기`
: "경매장 실시간 거래 | 마비노기",
},
- robots:
- result.status === "available"
- ? undefined
- : { index: false, follow: false },
+ description: `${result.record.itemName}의 마비노기 경매장 실시간 거래과 아이템 시세를 확인하세요.`,
+ alternates: {
+ canonical: canonicalUrl(
+ `/auction-realtime/${encodeURIComponent(auctionId)}`,
+ ),
+ },
+ openGraph: {
+ url: canonicalUrl(`/auction-realtime/${encodeURIComponent(auctionId)}`),
+ },
};
}
@@ -34,6 +44,9 @@ export default async function AuctionRealtimeDetailPage({
}: PageProps) {
const [{ auctionId }, query] = await Promise.all([params, searchParams]);
const result = await getAuctionDetail("realtime", auctionId);
+ if (result.status === "not-found") notFound();
+ if (result.status === "error" || !result.record)
+ throw new Error("경매장 상세 정보를 불러오지 못했습니다.");
return (
= [
@@ -21,7 +22,12 @@ const tabs: Array<{ value: FeedTab; label: string }> = [
{ value: "mine", label: "내 글" },
];
-function CommunityPageContent() {
+interface Props {
+ initial?: BackendPostsData;
+ startPage?: number;
+}
+
+function CommunityPageContent({ initial, startPage = 1 }: Props) {
const keyword = useSearchParams().get("q") ?? "";
const [tab, setTab] = useState("latest");
const [composerOpen, setComposerOpen] = useState(false);
@@ -49,10 +55,10 @@ function CommunityPageContent() {
)}
-
+
{showPopular && (