diff --git a/apps/web/index.html b/apps/web/index.html
index c0dfa4d04..9281ec167 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -29,14 +29,9 @@
-
-
+
-
@@ -46,10 +41,11 @@
-
+
-
+
+
@@ -58,27 +54,53 @@
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt
index cfd7f0d60..8ee79b7a9 100644
--- a/apps/web/public/robots.txt
+++ b/apps/web/public/robots.txt
@@ -1,3 +1,28 @@
+# 자동 생성됨 — 수정 시 apps/web/seo.config.cjs의 ROBOTS_DISALLOW_PREFIXES를 변경하세요.
+
User-agent: *
Allow: /
-sitemap: https://layerapp.io/sitemap.xml
\ No newline at end of file
+
+Disallow: /myinfo
+Disallow: /write
+Disallow: /retrospect/new
+Disallow: /retrospect/recommend
+Disallow: /retrospect/analysis
+Disallow: /retrospect/complete
+Disallow: /space/create
+Disallow: /space/edit/
+Disallow: /setnickname/
+Disallow: /goals
+Disallow: /analysis
+Disallow: /api/
+Disallow: /staging
+Disallow: /desktop/myinfo
+Disallow: /desktop/write
+Disallow: /desktop/retrospect
+Disallow: /desktop/space/create
+Disallow: /desktop/space/edit/
+Disallow: /desktop/setnickname/
+Disallow: /desktop/goals
+Disallow: /desktop/analysis
+
+Sitemap: https://layerapp.io/sitemap.xml
diff --git a/apps/web/routes.cjs b/apps/web/routes.cjs
new file mode 100644
index 000000000..3c91c41ea
--- /dev/null
+++ b/apps/web/routes.cjs
@@ -0,0 +1,128 @@
+/**
+ * 라우트 경로 단일 소스 (Single Source of Truth).
+ *
+ * 사용처:
+ * - src/router/index.tsx → React Router 경로 정의
+ * - server/server.cjs → soft 404 방지 정규식 매칭
+ * - seo.config.cjs → INDEXABLE_ROUTES 등의 SEO 정책 derive
+ *
+ * 동적 파라미터는 React Router 표기법(`:param`)을 사용하며,
+ * `routeToRegex`가 자동으로 `[^/]+`로 변환합니다.
+ */
+
+/**
+ * 명명 라우트 상수 (절대 경로 형태).
+ * 라우터에서 자식 경로로 사용할 때는 `toChildPath()`로 앞 슬래시를 제거합니다.
+ */
+const ROUTES = {
+ // ── 공통 / 모바일 (mobile 레이아웃에서 reachable) ─────
+ ROOT: "/",
+ LOGIN: "/login",
+ TEMPLATE: "/template",
+ STAGING: "/staging",
+ ANALYSIS: "/analysis",
+
+ WRITE: "/write",
+ WRITE_COMPLETE: "/write/complete",
+
+ GOALS: "/goals",
+ GOALS_MORE: "/goals/more",
+ GOALS_EDIT: "/goals/edit",
+
+ SETNICKNAME: "/setnickname/:socialType",
+
+ // 스페이스
+ SPACE_CREATE: "/space/create",
+ SPACE_CREATE_DONE: "/space/create/done",
+ SPACE_CREATE_NEXT: "/space/create/next",
+ SPACE_EDIT: "/space/edit/:id",
+ SPACE_VIEW: "/space/:spaceId",
+ SPACE_TEMPLATES: "/space/:spaceId/templates",
+ SPACE_MEMBERS: "/space/:spaceId/members",
+ SPACE_MEMBERS_EDIT: "/space/:spaceId/members/edit",
+ SPACE_JOIN: "/space/join/:id",
+
+ // 회고
+ RETROSPECT_NEW: "/retrospect/new",
+ RETROSPECT_COMPLETE: "/retrospect/complete",
+ RETROSPECT_ANALYSIS: "/retrospect/analysis",
+ RETROSPECT_WRITE: "/retrospect/write", // 데스크탑 레이아웃에서만 마운트되지만 상수로 명명
+ RETROSPECT_RECOMMEND: "/retrospect/recommend",
+ RETROSPECT_RECOMMEND_SEARCH: "/retrospect/recommend/search",
+ RETROSPECT_RECOMMEND_DONE: "/retrospect/recommend/done",
+
+ // 내 정보
+ MYINFO: "/myinfo",
+ MYINFO_MODIFY: "/myinfo/modify",
+ MYINFO_USERDELETION: "/myinfo/userdeletion",
+ MYINFO_NOTICES: "/myinfo/notices",
+ MYINFO_HELP: "/myinfo/help",
+ MYINFO_LICENSE: "/myinfo/license",
+ MYINFO_TERMSOFSERVICE: "/myinfo/termsofservice",
+ MYINFO_PRIVACYPOLICY: "/myinfo/privacypolicy",
+ MYINFO_FEEDBACK: "/myinfo/feedback",
+
+ // OAuth
+ OAUTH_KAKAO: "/api/auth/oauth2/kakao",
+ OAUTH_GOOGLE: "/api/auth/oauth2/google",
+};
+
+/**
+ * `/desktop` 접두사가 붙어 reachable한 라우트.
+ *
+ * 동일한 상대 경로가 router/index.tsx의 deviceSpecificRoutes에서
+ * `deviceType: "desktop"`으로 정의되어 있어 `/desktop` 마운트 포인트 아래에
+ * 추가로 노출됩니다.
+ *
+ * 라우터에서 `deviceType: "desktop"` 라우트를 추가/제거할 때 이 목록도 갱신하세요.
+ */
+const DESKTOP_PATHS = [
+ "/desktop",
+ "/desktop/login",
+ "/desktop/goals",
+ "/desktop/space/:spaceId",
+ "/desktop/retrospect/analysis",
+ "/desktop/retrospect/write",
+ "/desktop/setnickname/:socialType",
+];
+
+/** 모든 reachable한 절대 경로 — server.cjs의 soft 404 화이트리스트. */
+const ALL_ABSOLUTE_PATHS = [...Object.values(ROUTES), ...DESKTOP_PATHS];
+
+/**
+ * React Router 경로 → 정규식.
+ * - `:param` → `[^/]+`
+ * - 정규식 메타문자는 이스케이프
+ * - 정확 매칭 (`^...$`)
+ */
+function routeToRegex(routePath) {
+ const escaped = routePath
+ .replace(/[.+*?^${}()|[\]\\]/g, "\\$&")
+ .replace(/:[A-Za-z_][A-Za-z0-9_]*/g, "[^/]+");
+ return new RegExp(`^${escaped}$`);
+}
+
+/**
+ * 절대 경로 → React Router 자식 경로 형식 (앞의 "/" 제거).
+ *
+ * React Router v6의 nested route 패턴에서 자식 경로는 부모 경로 기준 상대 형식이어야 합니다.
+ * 예) 부모 `path: "/"` + 자식 `path: "login"` → 최종 URL `/login`
+ *
+ * @example toChildPath(ROUTES.LOGIN) // "/login" → "login"
+ * @example toChildPath(ROUTES.ROOT) // "/" → ""
+ */
+function toChildPath(routePath) {
+ return routePath.startsWith("/") ? routePath.slice(1) : routePath;
+}
+
+/** server.cjs용 soft 404 방지 정규식 배열. */
+const KNOWN_ROUTE_PATTERNS = ALL_ABSOLUTE_PATHS.map(routeToRegex);
+
+module.exports = {
+ ROUTES,
+ DESKTOP_PATHS,
+ ALL_ABSOLUTE_PATHS,
+ KNOWN_ROUTE_PATTERNS,
+ routeToRegex,
+ toChildPath,
+};
diff --git a/apps/web/routes.d.cts b/apps/web/routes.d.cts
new file mode 100644
index 000000000..439d9a1e7
--- /dev/null
+++ b/apps/web/routes.d.cts
@@ -0,0 +1,82 @@
+/**
+ * Type declarations for `./routes.cjs`.
+ *
+ * `.cjs` 모듈을 .ts/.tsx에서 import할 때 TypeScript가 타입을 추론할 수 있도록 제공합니다.
+ * 런타임 동작은 `routes.cjs`에 정의되어 있으며, 이 파일은 시그니처만 선언합니다.
+ *
+ * 라우트 추가 시 두 곳을 함께 갱신하세요:
+ * 1. `routes.cjs`의 `ROUTES` 객체
+ * 2. 이 파일의 `ROUTES` 인터페이스 (IDE 자동완성 + 타입 안전성)
+ */
+
+/**
+ * 명명 라우트 상수 (절대 경로 형태).
+ * 라우터에서 자식 경로로 사용할 때는 `toChildPath()`로 앞 슬래시를 제거합니다.
+ */
+export declare const ROUTES: {
+ readonly ROOT: "/";
+ readonly LOGIN: "/login";
+ readonly TEMPLATE: "/template";
+ readonly STAGING: "/staging";
+ readonly ANALYSIS: "/analysis";
+
+ readonly WRITE: "/write";
+ readonly WRITE_COMPLETE: "/write/complete";
+
+ readonly GOALS: "/goals";
+ readonly GOALS_MORE: "/goals/more";
+ readonly GOALS_EDIT: "/goals/edit";
+
+ readonly SETNICKNAME: "/setnickname/:socialType";
+
+ readonly SPACE_CREATE: "/space/create";
+ readonly SPACE_CREATE_DONE: "/space/create/done";
+ readonly SPACE_CREATE_NEXT: "/space/create/next";
+ readonly SPACE_EDIT: "/space/edit/:id";
+ readonly SPACE_VIEW: "/space/:spaceId";
+ readonly SPACE_TEMPLATES: "/space/:spaceId/templates";
+ readonly SPACE_MEMBERS: "/space/:spaceId/members";
+ readonly SPACE_MEMBERS_EDIT: "/space/:spaceId/members/edit";
+ readonly SPACE_JOIN: "/space/join/:id";
+
+ readonly RETROSPECT_NEW: "/retrospect/new";
+ readonly RETROSPECT_COMPLETE: "/retrospect/complete";
+ readonly RETROSPECT_ANALYSIS: "/retrospect/analysis";
+ readonly RETROSPECT_WRITE: "/retrospect/write";
+ readonly RETROSPECT_RECOMMEND: "/retrospect/recommend";
+ readonly RETROSPECT_RECOMMEND_SEARCH: "/retrospect/recommend/search";
+ readonly RETROSPECT_RECOMMEND_DONE: "/retrospect/recommend/done";
+
+ readonly MYINFO: "/myinfo";
+ readonly MYINFO_MODIFY: "/myinfo/modify";
+ readonly MYINFO_USERDELETION: "/myinfo/userdeletion";
+ readonly MYINFO_NOTICES: "/myinfo/notices";
+ readonly MYINFO_HELP: "/myinfo/help";
+ readonly MYINFO_LICENSE: "/myinfo/license";
+ readonly MYINFO_TERMSOFSERVICE: "/myinfo/termsofservice";
+ readonly MYINFO_PRIVACYPOLICY: "/myinfo/privacypolicy";
+ readonly MYINFO_FEEDBACK: "/myinfo/feedback";
+
+ readonly OAUTH_KAKAO: "/api/auth/oauth2/kakao";
+ readonly OAUTH_GOOGLE: "/api/auth/oauth2/google";
+};
+
+/** `/desktop` 접두사가 붙어 reachable한 라우트. */
+export declare const DESKTOP_PATHS: string[];
+
+/** 모든 reachable한 절대 경로. */
+export declare const ALL_ABSOLUTE_PATHS: string[];
+
+/** server.cjs용 soft 404 방지 정규식 배열. */
+export declare const KNOWN_ROUTE_PATTERNS: RegExp[];
+
+/** React Router 경로 → 정규식 변환. */
+export declare function routeToRegex(routePath: string): RegExp;
+
+/**
+ * 절대 경로 → React Router 자식 경로 형식 (앞의 "/" 제거).
+ *
+ * @example toChildPath("/login") // "login"
+ * @example toChildPath("/") // ""
+ */
+export declare function toChildPath(routePath: string): string;
diff --git a/apps/web/seo.config.cjs b/apps/web/seo.config.cjs
new file mode 100644
index 000000000..297ab8ab4
--- /dev/null
+++ b/apps/web/seo.config.cjs
@@ -0,0 +1,109 @@
+/**
+ * SEO 라우트 정책 단일 소스 (Single Source of Truth).
+ *
+ * 사용처:
+ * - vite.config.ts → sitemap.xml의 dynamicRoutes / exclude
+ * - server/server.cjs → noindex 메타 / canonical 분기 / OG 기본 이미지
+ *
+ * 라우트 추가·변경 시 이 파일만 갱신하면 sitemap·메타·캐시 정책이 일관되게 반영됩니다.
+ * 단, `public/robots.txt`는 정적 파일이라 별도 동기화가 필요합니다.
+ *
+ * @see apps/web/src/router/index.tsx 실제 라우터 정의
+ */
+
+const { ROUTES } = require("./routes.cjs");
+
+const BASE_URL = "https://layerapp.io";
+
+const DEFAULT_OG_IMAGE =
+ "https://kr.object.ncloudstorage.com/layer-bucket/og-image.png";
+
+const INVITE_OG_IMAGE =
+ "https://kr.object.ncloudstorage.com/layer-bucket/retrospectOG.png";
+
+/**
+ * 공개 인덱싱 라우트 (sitemap dynamicRoutes).
+ * 루트("/")는 sitemap hostname이 자동 포함하므로 생략합니다.
+ */
+const INDEXABLE_ROUTES = [ROUTES.LOGIN, ROUTES.TEMPLATE];
+
+/**
+ * 비공개(noindex) 라우트 prefix.
+ *
+ * - 매칭: `req.path.startsWith(prefix)`
+ * - 효과: sitemap 제외 + `` 주입 + canonical 미주입
+ *
+ * 예외: `/space/join/:id`는 server.cjs에 별도 라우트 핸들러가 등록되어 있어
+ * catch-all에 도달하지 않으므로 이 정책이 적용되지 않습니다.
+ * (초대 링크 OG 미리보기를 위해 의도된 예외)
+ */
+const PRIVATE_ROUTE_PREFIXES = [
+ "/myinfo",
+ "/write",
+ "/retrospect", // /retrospect/{new,complete,analysis,recommend}
+ "/space", // /space/:id 등 — /space/join/:id는 별도 핸들러로 우회
+ "/goals",
+ "/analysis",
+ "/setnickname",
+ "/api",
+ "/staging",
+ "/desktop", // 데스크탑은 모바일과 동일 콘텐츠 → canonical 통일 (`/desktop/x` → `/x`)
+];
+
+/** vite-plugin-sitemap의 `exclude` 형식 (glob). */
+const SITEMAP_EXCLUDE = PRIVATE_ROUTE_PREFIXES.flatMap((p) => [p, `${p}/**`]);
+
+/** server.cjs의 `startsWith` 매칭용 prefix 목록. */
+const NOINDEX_PATH_PREFIXES = PRIVATE_ROUTE_PREFIXES;
+
+/**
+ * robots.txt의 `Disallow` 패턴.
+ *
+ * `PRIVATE_ROUTE_PREFIXES`와 별도로 관리하는 이유:
+ * - robots.txt Disallow는 **크롤링 자체 차단** 신호 (강함)
+ * - meta noindex는 **색인만 차단**, 크롤은 허용 (약함)
+ *
+ * 따라서 OG 미리보기를 위해 크롤이 필요한 경로(`/space/join/:id`)는
+ * 상위 prefix(`/space`)로 차단해서는 안 됩니다.
+ * 이 목록은 PRIVATE_ROUTE_PREFIXES보다 더 보수적인 sub-path 단위로 명시합니다.
+ *
+ * meta noindex만 적용되고 robots Disallow는 되지 않는 경로:
+ * - `/space/:id` (private 스페이스 조회) — 크롤되어도 noindex 메타가 처리
+ * - `/space/join/:id` (초대 링크) — OG 봇이 크롤해야 미리보기 동작
+ */
+const ROBOTS_DISALLOW_PREFIXES = [
+ // ── 모바일 인증/개인 페이지 ──
+ "/myinfo",
+ "/write",
+ "/retrospect/new",
+ "/retrospect/recommend",
+ "/retrospect/analysis",
+ "/retrospect/complete",
+ "/space/create",
+ "/space/edit/",
+ "/setnickname/",
+ "/goals",
+ "/analysis",
+ "/api/",
+ "/staging",
+ // ── 데스크탑 인증/개인 페이지 ──
+ "/desktop/myinfo",
+ "/desktop/write",
+ "/desktop/retrospect",
+ "/desktop/space/create",
+ "/desktop/space/edit/",
+ "/desktop/setnickname/",
+ "/desktop/goals",
+ "/desktop/analysis",
+];
+
+module.exports = {
+ BASE_URL,
+ DEFAULT_OG_IMAGE,
+ INVITE_OG_IMAGE,
+ INDEXABLE_ROUTES,
+ PRIVATE_ROUTE_PREFIXES,
+ SITEMAP_EXCLUDE,
+ NOINDEX_PATH_PREFIXES,
+ ROBOTS_DISALLOW_PREFIXES,
+};
diff --git a/apps/web/seo.config.d.cts b/apps/web/seo.config.d.cts
new file mode 100644
index 000000000..7c24f3cc8
--- /dev/null
+++ b/apps/web/seo.config.d.cts
@@ -0,0 +1,25 @@
+/**
+ * Type declarations for `./seo.config.cjs`.
+ *
+ * `.cjs` 모듈을 .ts/.tsx에서 import할 때 TypeScript가 타입을 추론할 수 있도록 제공합니다.
+ * 런타임 동작은 `seo.config.cjs`에 정의되어 있으며, 이 파일은 시그니처만 선언합니다.
+ */
+
+export declare const BASE_URL: string;
+export declare const DEFAULT_OG_IMAGE: string;
+export declare const INVITE_OG_IMAGE: string;
+
+/** 공개 인덱싱 라우트 (sitemap dynamicRoutes). */
+export declare const INDEXABLE_ROUTES: string[];
+
+/** noindex 라우트 prefix (sitemap exclude + server noindex 공유). */
+export declare const PRIVATE_ROUTE_PREFIXES: string[];
+
+/** `PRIVATE_ROUTE_PREFIXES`의 별칭 (server.cjs용 startsWith 매칭). */
+export declare const NOINDEX_PATH_PREFIXES: string[];
+
+/** `PRIVATE_ROUTE_PREFIXES`를 vite-plugin-sitemap의 glob 형식으로 변환한 결과. */
+export declare const SITEMAP_EXCLUDE: string[];
+
+/** robots.txt의 Disallow 패턴 (PRIVATE_ROUTE_PREFIXES와 별도 관리). */
+export declare const ROBOTS_DISALLOW_PREFIXES: string[];
diff --git a/apps/web/server/server.cjs b/apps/web/server/server.cjs
index ab90214bc..bb6dbf3fa 100644
--- a/apps/web/server/server.cjs
+++ b/apps/web/server/server.cjs
@@ -4,22 +4,33 @@ const fs = require("fs");
const path = require("path");
const cheerio = require("cheerio");
const axios = require("axios");
-const CryptoJS = require("crypto-js"); // crypto-js를 추가합니다.
+const CryptoJS = require("crypto-js");
-const app = express();
+// SEO 라우트 정책 단일 소스 — vite.config.ts와 공유
+const {
+ BASE_URL,
+ DEFAULT_OG_IMAGE,
+ INVITE_OG_IMAGE,
+ NOINDEX_PATH_PREFIXES,
+} = require("../seo.config.cjs");
+
+// 라우트 정의 단일 소스 — src/router/index.tsx와 공유
+const { KNOWN_ROUTE_PATTERNS } = require("../routes.cjs");
-// 프로젝트 루트 경로에서 dist 폴더 제공
-const distPath = path.resolve(__dirname, "../dist"); // 절대 경로 사용
+const app = express();
+// Vite 빌드 결과물(`dist/`)에서 정적 자산을 먼저 응답.
+// HTML 요청만 catch-all로 전달되어 cheerio 메타 인젝션을 거칩니다.
+const distPath = path.resolve(__dirname, "../dist");
app.use(express.static(distPath));
+// AES 복호화 — `/space/join/:id`의 암호화된 space ID를 풀어 백엔드 조회에 사용
const CRYPTO_KEY = process.env.VITE_CRYPTO_KEY;
const VECTOR_KEY = process.env.VITE_VECTOR_KEY;
const key = CryptoJS.enc.Utf8.parse(CRYPTO_KEY);
const iv = CryptoJS.enc.Utf8.parse(VECTOR_KEY);
-// 복호화 함수 구현
function decryptId(encryptedId) {
const word_array = CryptoJS.enc.Base64.parse(encryptedId);
const decoding = word_array.toString(CryptoJS.enc.Utf8);
@@ -31,65 +42,289 @@ function decryptId(encryptedId) {
return decrypted.toString(CryptoJS.enc.Utf8);
}
+/**
+ * Injects SEO meta tags into HTML using Cheerio.
+ * Adds data-rh="true" for react-helmet-async compatibility.
+ *
+ * @param {string} html - Source HTML.
+ * @param {object} opts
+ * @param {string} opts.title
+ * @param {string} opts.description
+ * @param {string} [opts.image] - OG image URL. Falls back to existing meta if omitted.
+ * @param {string} opts.url - Canonical URL.
+ * @param {boolean} [opts.noindex] - When true, sets robots meta to noindex,nofollow.
+ */
+function injectMeta(html, { title, description, image, url, noindex }) {
+ const $ = cheerio.load(html);
+
+ $('title').text(title);
+ $('meta[name="description"]').attr('content', description).attr('data-rh', 'true');
+ $('meta[property="og:title"]').attr('content', title).attr('data-rh', 'true');
+ $('meta[property="og:description"]').attr('content', description).attr('data-rh', 'true');
+ $('meta[property="og:url"]').attr('content', url).attr('data-rh', 'true');
+ if (image) {
+ $('meta[property="og:image"]').attr('content', image).attr('data-rh', 'true');
+ }
+ $('meta[name="twitter:title"]').attr('content', title).attr('data-rh', 'true');
+ $('meta[name="twitter:description"]').attr('content', description).attr('data-rh', 'true');
+ if (image) {
+ $('meta[name="twitter:image"]').attr('content', image).attr('data-rh', 'true');
+ }
+
+ // robots 메타: noindex 라우트는 검색엔진 차단
+ $('meta[name="robots"]').attr(
+ 'content',
+ noindex ? 'noindex, nofollow' : 'index, follow'
+ ).attr('data-rh', 'true');
+
+ // Dynamic canonical URL injection (noindex 페이지에는 canonical 미주입 — 인덱싱 신호 혼란 방지)
+ if (!noindex) {
+ $('head').append(``);
+ }
+
+ return $.html();
+}
+
+/**
+ * 정적 라우트별 메타 정보.
+ *
+ * key: `req.path` (query string 제외)
+ *
+ * 공개 페이지(인덱싱 허용) 엔트리만 명시합니다.
+ * 비공개 페이지는 `NOINDEX_PATH_PREFIXES` (seo.config.cjs)의 prefix 매칭으로 일괄 처리됩니다.
+ *
+ * @see apps/web/src/router/index.tsx 라우터 정의와 일치해야 함
+ */
+const STATIC_ROUTE_META = {
+ "/": {
+ title: "성장하는 당신을 위한 회고 서비스, Layer",
+ description:
+ "회고 작성부터 AI 분석까지, Layer에서 KPT·5F 등 다양한 템플릿으로 개인·팀 회고를 시작해보세요.",
+ image: DEFAULT_OG_IMAGE,
+ },
+ "/login": {
+ title: "로그인 | Layer",
+ description: "카카오, 구글 계정으로 간편하게 Layer에 로그인하세요.",
+ image: DEFAULT_OG_IMAGE,
+ },
+ "/template": {
+ title: "회고 템플릿 모음 | Layer",
+ description: "KPT, 5F, Mad Sad Glad 등 검증된 회고 템플릿을 무료로 만나보세요.",
+ image: DEFAULT_OG_IMAGE,
+ },
+ // 데스크탑 진입점은 모바일과 동일 콘텐츠 — canonical은 `/desktop/x` → `/x`로 정규화됨
+ "/desktop": {
+ title: "성장하는 당신을 위한 회고 서비스, Layer",
+ description:
+ "회고 작성부터 AI 분석까지, Layer에서 KPT·5F 등 다양한 템플릿으로 개인·팀 회고를 시작해보세요.",
+ image: DEFAULT_OG_IMAGE,
+ },
+ "/desktop/login": {
+ title: "로그인 | Layer",
+ description: "카카오, 구글 계정으로 간편하게 Layer에 로그인하세요.",
+ image: DEFAULT_OG_IMAGE,
+ },
+};
+
+// KNOWN_ROUTE_PATTERNS는 routes.cjs에서 derive (src/router/index.tsx의 ROUTES와 단일 소스)
+
+function isKnownRoute(reqPath) {
+ return KNOWN_ROUTE_PATTERNS.some((re) => re.test(reqPath));
+}
+
+// NOINDEX_PATH_PREFIXES는 seo.config.cjs에서 관리 (vite.config.ts의 sitemap exclude와 공유)
+
+const DEFAULT_META = {
+ title: "성장하는 당신을 위한 회고 서비스, Layer",
+ description:
+ "회고 작성부터 AI 분석까지, Layer에서 KPT·5F 등 다양한 템플릿으로 개인·팀 회고를 시작해보세요.",
+ image: DEFAULT_OG_IMAGE,
+};
+
+function readIndexHtml() {
+ const filePath = path.join(distPath, "index.html");
+ return fs.readFileSync(filePath, "utf8");
+}
+
+function getCanonicalUrl(reqPath) {
+ // /desktop prefix 정규화 + 끝 슬래시 제거
+ let canonicalPath = reqPath.replace(/^\/desktop/, '') || '/';
+ if (canonicalPath.length > 1 && canonicalPath.endsWith('/')) {
+ canonicalPath = canonicalPath.slice(0, -1);
+ }
+ return `${BASE_URL}${canonicalPath}`;
+}
+
+function isNoindexPath(reqPath) {
+ return NOINDEX_PATH_PREFIXES.some((prefix) => reqPath.startsWith(prefix));
+}
+
+function resolveRouteMeta(reqPath) {
+ if (STATIC_ROUTE_META[reqPath]) {
+ return STATIC_ROUTE_META[reqPath];
+ }
+ if (isNoindexPath(reqPath)) {
+ return { noindex: true };
+ }
+ return null;
+}
+
+/**
+ * 정적 라우트 결과 캐시 (cheerio 파싱/직렬화 비용 절감).
+ * key: `${path}` — 정적 라우트와 noindex 분기는 결정론적이므로 캐싱 안전.
+ * 동적 라우트(/space/join/:id)는 캐시 대상 아님.
+ */
+const META_CACHE = new Map();
+const META_CACHE_MAX = 100;
+
+function setMetaCache(key, value) {
+ if (META_CACHE.size >= META_CACHE_MAX) {
+ const firstKey = META_CACHE.keys().next().value;
+ META_CACHE.delete(firstKey);
+ }
+ META_CACHE.set(key, value);
+}
+
+/**
+ * Cache-Control 헤더 정책
+ * - 동적(개인화 가능): no-store
+ * - 정적(SEO 메타 페이지): edge에서 1시간, 브라우저 5분, stale-while-revalidate 1일
+ * - noindex 페이지: edge 1분 (인증 게이트라 자주 변경될 일 적음)
+ */
+function setCacheHeaders(res, kind) {
+ if (kind === "static-public") {
+ res.set('Cache-Control', 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400');
+ } else if (kind === "static-noindex") {
+ res.set('Cache-Control', 'public, max-age=0, s-maxage=60');
+ } else {
+ res.set('Cache-Control', 'public, max-age=0, s-maxage=300, stale-while-revalidate=600');
+ }
+}
+
+/**
+ * 스페이스 초대 링크 — `/space/join/:id`.
+ *
+ * 이 라우트는 catch-all(`app.get("*")`)보다 먼저 등록되어 있어 noindex 정책의 **예외**입니다.
+ * `seo.config.cjs`의 `/space` prefix는 이 핸들러를 우회하지 못합니다 (Express 매칭 순서).
+ *
+ * 동작:
+ * 1. 암호화된 ID → AES 복호화 → space ID
+ * 2. 백엔드에서 리더·팀 이름 fetch
+ * 3. "{leader}님의 회고 초대장" 등 개인화된 OG 메타로 HTML 반환
+ *
+ * 이유: 카카오톡·X 등의 OG 봇이 이 URL을 직접 GET 했을 때 미리보기에
+ * 정확한 초대 정보가 노출되어야 하기 때문.
+ */
app.get("/space/join/:id", async (req, res) => {
const encryptedId = req.params.id;
- const filePath = path.join(distPath, "index.html"); // dist 경로에 있는 index.html 사용
let html;
- // 1. HTML 파일 읽기
try {
- console.log(`Reading HTML file from ${filePath}`);
- html = fs.readFileSync(filePath, "utf8");
+ html = readIndexHtml();
} catch (err) {
console.error("Failed to read index.html:", err);
return res.status(500).send("Error loading the page.");
}
- let leaderName, teamName;
- console.log("VITE_API_URL:", process.env.VITE_API_URL);
- console.log(`Decoded ID: ${encryptedId}`);
-
try {
const decryptedId = decryptId(encryptedId);
const apiResponse = await axios.get(`${process.env.VITE_API_URL}/api/space/public/${decryptedId}`);
const spaceData = apiResponse.data;
- console.log("Space Data:", spaceData);
- leaderName = spaceData?.leader?.name;
- teamName = spaceData?.name;
+ const leaderName = spaceData?.leader?.name;
+ const teamName = spaceData?.name;
if (!leaderName || !teamName) {
throw new Error("Missing leaderName or teamName from API response.");
}
- } catch (err) {
- console.error("Error fetching space data:", err.message);
- return res.status(500).send("Failed to fetch space data.");
- }
- try {
- const $ = cheerio.load(html);
-
- $('title').text(`${leaderName}님의 회고 초대장`);
- $('meta[name="description"]').attr('content', `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`);
- $('meta[property="og:title"]').attr('content', `${leaderName}님의 회고 초대장`);
- $('meta[property="og:description"]').attr('content', `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`);
- $('meta[property="og:image"]').attr('content', 'https://kr.object.ncloudstorage.com/layer-bucket/retrospectOG.png');
- $('meta[name="twitter:title"]').attr('content', `${leaderName}님의 회고 초대장`);
- $('meta[name="twitter:description"]').attr('content', `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`);
- $('meta[name="twitter:image"]').attr('content', 'https://kr.object.ncloudstorage.com/layer-bucket/retrospectOG.png');
-
- res.send($.html());
+ const result = injectMeta(html, {
+ title: `${leaderName}님의 회고 초대장`,
+ description: `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`,
+ image: INVITE_OG_IMAGE,
+ url: getCanonicalUrl(req.path),
+ });
+
+ setCacheHeaders(res, "dynamic");
+ res.send(result);
} catch (err) {
- console.error("Error manipulating HTML:", err);
- return res.status(500).send("Error processing HTML.");
+ console.error("Error processing space join page:", err.message);
+ // 복호화 실패·백엔드 4xx/5xx → 404 + noindex 메타 + SPA 셸 반환.
+ // 봇은 "존재하지 않음"으로 인식해 재시도하지 않고,
+ // 사용자는 SPA가 마운트되어 자체 에러 화면을 렌더할 수 있습니다.
+ const fallback = injectMeta(html, {
+ title: "초대장을 찾을 수 없습니다 | Layer",
+ description: "이 회고 초대 링크는 만료되었거나 존재하지 않습니다.",
+ image: DEFAULT_OG_IMAGE,
+ url: getCanonicalUrl(req.path),
+ noindex: true,
+ });
+ setCacheHeaders(res, "static-noindex");
+ return res.status(404).send(fallback);
}
});
+/**
+ * Catch-all 핸들러 — 정적 파일과 `/space/join/:id`를 제외한 모든 GET 요청.
+ *
+ * 의사결정:
+ * 1. KNOWN_ROUTE_PATTERNS 매칭 실패 → 404 status + noindex (soft 404 방지)
+ * 2. STATIC_ROUTE_META 정확 매칭 → 그 메타로 인덱싱
+ * 3. NOINDEX_PATH_PREFIXES startsWith 매칭 → DEFAULT 메타 + noindex
+ * 4. 그 외 known route → DEFAULT 메타로 인덱싱
+ *
+ * 메모리 캐시(`META_CACHE`)는 결정론적 경로(=정의된 라우트)에만 적용됩니다.
+ */
app.get("*", (req, res) => {
- const filePath = path.join(distPath, "index.html");
- res.sendFile(filePath);
+ try {
+ const knownRoute = isKnownRoute(req.path);
+ const routeMeta = resolveRouteMeta(req.path);
+ const isNoindex = !!routeMeta?.noindex || !knownRoute;
+ const cacheKind = isNoindex ? "static-noindex" : "static-public";
+ const cacheKey = knownRoute ? req.path : "__not_found__";
+
+ // 캐시 히트 (정의된 라우트만)
+ if (knownRoute && META_CACHE.has(cacheKey)) {
+ setCacheHeaders(res, cacheKind);
+ return res.send(META_CACHE.get(cacheKey));
+ }
+
+ const html = readIndexHtml();
+ const canonicalUrl = getCanonicalUrl(req.path);
+
+ let meta;
+ if (!knownRoute) {
+ meta = {
+ title: "페이지를 찾을 수 없습니다 | Layer",
+ description: "요청하신 페이지가 존재하지 않거나 이동되었습니다.",
+ image: DEFAULT_OG_IMAGE,
+ url: canonicalUrl,
+ noindex: true,
+ };
+ } else if (isNoindex) {
+ meta = { ...DEFAULT_META, ...routeMeta, url: canonicalUrl, noindex: true };
+ } else {
+ meta = { ...DEFAULT_META, ...(routeMeta || {}), url: canonicalUrl };
+ }
+
+ const result = injectMeta(html, meta);
+
+ if (knownRoute) {
+ setMetaCache(cacheKey, result);
+ }
+ setCacheHeaders(res, cacheKind);
+ // 미정의 경로는 명시적 404 status (SPA Error 컴포넌트가 본문 렌더)
+ res.status(knownRoute ? 200 : 404).send(result);
+ } catch (err) {
+ console.error("Error serving page:", err);
+ const filePath = path.join(distPath, "index.html");
+ res.sendFile(filePath);
+ }
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
-});
\ No newline at end of file
+});
+
+// Vercel Serverless Function 진입점 호환을 위해 app export
+module.exports = app;
diff --git a/apps/web/src/app/desktop/component/retrospect/template/card/TemplateCard.tsx b/apps/web/src/app/desktop/component/retrospect/template/card/TemplateCard.tsx
index 6051b6df0..e8353fad4 100644
--- a/apps/web/src/app/desktop/component/retrospect/template/card/TemplateCard.tsx
+++ b/apps/web/src/app/desktop/component/retrospect/template/card/TemplateCard.tsx
@@ -42,6 +42,10 @@ export function TemplateCard({ name, tag, imgUrl, size = "default", ...props }:
-
(e.currentTarget.src = DefaultSpaceImgUrl)} alt="Preview" />
+
(e.currentTarget.src = DefaultSpaceImgUrl)}
+ alt="이미지 미리보기"
+ width={isDesktop ? 120 : 180}
+ height={isDesktop ? 120 : 180}
+ loading="lazy"
+ decoding="async"
+ />
(({ space, isColl
>

{
e.currentTarget.src = spaceDefaultImg;
}}
diff --git a/apps/web/src/component/common/LocalNavigationBar/UserProfile.tsx b/apps/web/src/component/common/LocalNavigationBar/UserProfile.tsx
index 35196c7c6..00e3239d7 100644
--- a/apps/web/src/component/common/LocalNavigationBar/UserProfile.tsx
+++ b/apps/web/src/component/common/LocalNavigationBar/UserProfile.tsx
@@ -73,6 +73,11 @@ const ProfileButton = forwardRef
(({ name,
{imageUrl ? (
(
>
{
e.currentTarget.src = spaceDefaultImg;
}}
diff --git a/apps/web/src/component/info/UserBox.tsx b/apps/web/src/component/info/UserBox.tsx
index 4b7999136..394290a9d 100644
--- a/apps/web/src/component/info/UserBox.tsx
+++ b/apps/web/src/component/info/UserBox.tsx
@@ -44,6 +44,11 @@ export function UserBox({ name, imgUrl }: UserBoxProps) {
{imgUrl ? (
) => (e.currentTarget.src = defaultUserImgUrl)}
css={css`
width: 5rem;
diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx
index a0208327b..ea92c39db 100644
--- a/apps/web/src/router/index.tsx
+++ b/apps/web/src/router/index.tsx
@@ -13,6 +13,9 @@ import { getDeviceType, markDeviceTypeOnHtml } from "@/utils/deviceUtils";
import { HomePage } from "@/app/desktop/home/HomePage";
import { RetrospectViewPage } from "@/app/mobile/home/RetrospectViewPage";
+// 라우트 경로 단일 소스 — server/server.cjs와 공유
+import { ROUTES, toChildPath } from "../../routes.cjs";
+
// 페이지 컴포넌트 lazy loading
const lazyNamed = >(factory: () => Promise, name: keyof T) =>
lazy(() => factory().then((m) => ({ default: m[name] as React.ComponentType })));
@@ -91,17 +94,17 @@ const withSuspense = (element: React.ReactNode) => {el
// 공통 라우트 (모바일/데스크탑 구분 없음)
const commonRoutes: RouteChildren[] = [
{
- path: "api/auth/oauth2/kakao",
+ path: toChildPath(ROUTES.OAUTH_KAKAO),
element: withSuspense(),
auth: false,
},
{
- path: "api/auth/oauth2/google",
+ path: toChildPath(ROUTES.OAUTH_GOOGLE),
element: withSuspense(),
auth: false,
},
{
- path: "space/join/:id",
+ path: toChildPath(ROUTES.SPACE_JOIN),
element: isDesktop ? withSuspense() : withSuspense(),
auth: false,
},
@@ -111,19 +114,19 @@ const commonRoutes: RouteChildren[] = [
const deviceSpecificRoutes: RouteChildren[] = [
// 홈 관련 라우트 - 모바일
{
- path: "",
+ path: toChildPath(ROUTES.ROOT),
element: ,
children: [
{
- path: "",
+ path: toChildPath(ROUTES.ROOT),
element: ,
},
{
- path: "analysis",
+ path: toChildPath(ROUTES.ANALYSIS),
element: withSuspense(),
},
{
- path: "goals",
+ path: toChildPath(ROUTES.GOALS),
element: withSuspense(),
},
],
@@ -132,27 +135,27 @@ const deviceSpecificRoutes: RouteChildren[] = [
},
// 홈 관련 라우트 - 데스크탑
{
- path: "",
+ path: toChildPath(ROUTES.ROOT),
element: ,
children: [
{
- path: "",
+ path: toChildPath(ROUTES.ROOT),
element: ,
},
{
- path: "goals",
+ path: toChildPath(ROUTES.GOALS),
element: Desktop Goals
,
},
{
- path: "space/:spaceId",
+ path: toChildPath(ROUTES.SPACE_VIEW),
element: withSuspense(),
},
{
- path: "retrospect/analysis",
+ path: toChildPath(ROUTES.RETROSPECT_ANALYSIS),
element: withSuspense(),
},
{
- path: "retrospect/write",
+ path: toChildPath(ROUTES.RETROSPECT_WRITE),
element: withSuspense(),
},
],
@@ -161,26 +164,26 @@ const deviceSpecificRoutes: RouteChildren[] = [
},
// 로그인 관련
{
- path: "login",
+ path: toChildPath(ROUTES.LOGIN),
element: withSuspense(),
auth: false,
deviceType: "mobile",
},
{
- path: "login",
+ path: toChildPath(ROUTES.LOGIN),
element: withSuspense(),
auth: false,
deviceType: "desktop",
},
// 회고 작성 - 모바일
{
- path: "write",
+ path: toChildPath(ROUTES.WRITE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "write/complete",
+ path: toChildPath(ROUTES.WRITE_COMPLETE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
@@ -188,7 +191,7 @@ const deviceSpecificRoutes: RouteChildren[] = [
// 템플릿 - 모바일
{
- path: "template",
+ path: toChildPath(ROUTES.TEMPLATE),
element: withSuspense(),
auth: false,
deviceType: "mobile",
@@ -196,21 +199,21 @@ const deviceSpecificRoutes: RouteChildren[] = [
// 스테이징 - 모바일
{
- path: "staging",
+ path: toChildPath(ROUTES.STAGING),
element: withSuspense(),
auth: false,
deviceType: "mobile",
},
// 닉네임 설정 - 모바일
{
- path: "setnickname/:socialType",
+ path: toChildPath(ROUTES.SETNICKNAME),
element: withSuspense(),
auth: false,
deviceType: "mobile",
},
// 닉네임 설정 - 데스크탑
{
- path: "setnickname/:socialType",
+ path: toChildPath(ROUTES.SETNICKNAME),
element: withSuspense(),
auth: false,
deviceType: "desktop",
@@ -218,49 +221,49 @@ const deviceSpecificRoutes: RouteChildren[] = [
// 스페이스 관련 - 모바일
{
- path: "space/create",
+ path: toChildPath(ROUTES.SPACE_CREATE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/create/done",
+ path: toChildPath(ROUTES.SPACE_CREATE_DONE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/create/next",
+ path: toChildPath(ROUTES.SPACE_CREATE_NEXT),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/edit/:id",
+ path: toChildPath(ROUTES.SPACE_EDIT),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/:spaceId",
+ path: toChildPath(ROUTES.SPACE_VIEW),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/:spaceId/templates",
+ path: toChildPath(ROUTES.SPACE_TEMPLATES),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/:spaceId/members",
+ path: toChildPath(ROUTES.SPACE_MEMBERS),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "space/:spaceId/members/edit",
+ path: toChildPath(ROUTES.SPACE_MEMBERS_EDIT),
element: withSuspense(),
auth: true,
deviceType: "mobile",
@@ -268,37 +271,37 @@ const deviceSpecificRoutes: RouteChildren[] = [
// 회고 생성 - 모바일
{
- path: "retrospect/new",
+ path: toChildPath(ROUTES.RETROSPECT_NEW),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "retrospect/complete",
+ path: toChildPath(ROUTES.RETROSPECT_COMPLETE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "retrospect/recommend",
+ path: toChildPath(ROUTES.RETROSPECT_RECOMMEND),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "retrospect/recommend/search",
+ path: toChildPath(ROUTES.RETROSPECT_RECOMMEND_SEARCH),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "retrospect/recommend/done",
+ path: toChildPath(ROUTES.RETROSPECT_RECOMMEND_DONE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "retrospect/analysis",
+ path: toChildPath(ROUTES.RETROSPECT_ANALYSIS),
element: withSuspense(),
auth: true,
deviceType: "mobile",
@@ -306,55 +309,55 @@ const deviceSpecificRoutes: RouteChildren[] = [
// 내 정보 - 모바일
{
- path: "myinfo",
+ path: toChildPath(ROUTES.MYINFO),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/modify",
+ path: toChildPath(ROUTES.MYINFO_MODIFY),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/userdeletion",
+ path: toChildPath(ROUTES.MYINFO_USERDELETION),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/notices",
+ path: toChildPath(ROUTES.MYINFO_NOTICES),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/help",
+ path: toChildPath(ROUTES.MYINFO_HELP),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/license",
+ path: toChildPath(ROUTES.MYINFO_LICENSE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/termsofservice",
+ path: toChildPath(ROUTES.MYINFO_TERMSOFSERVICE),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/privacypolicy",
+ path: toChildPath(ROUTES.MYINFO_PRIVACYPOLICY),
element: withSuspense(),
auth: true,
deviceType: "mobile",
},
{
- path: "myinfo/feedback",
+ path: toChildPath(ROUTES.MYINFO_FEEDBACK),
element: withSuspense(),
auth: true,
deviceType: "mobile",
@@ -362,13 +365,13 @@ const deviceSpecificRoutes: RouteChildren[] = [
// 목표/액션 아이템 - 모바일
{
- path: "goals/more",
+ path: toChildPath(ROUTES.GOALS_MORE),
element: withSuspense(),
auth: false,
deviceType: "mobile",
},
{
- path: "goals/edit",
+ path: toChildPath(ROUTES.GOALS_EDIT),
element: withSuspense(),
auth: false,
deviceType: "mobile",
diff --git a/apps/web/vercel.json b/apps/web/vercel.json
index 35c58311a..3a839f72e 100644
--- a/apps/web/vercel.json
+++ b/apps/web/vercel.json
@@ -15,13 +15,9 @@
],
"routes": [
{ "handle": "filesystem" },
- {
- "src": "/space/join/(.*)",
- "dest": "/server/server.cjs"
- },
{
"src": "/.*",
- "dest": "/index.html"
+ "dest": "/server/server.cjs"
}
]
}
\ No newline at end of file
diff --git a/apps/web/vite.config.d.ts b/apps/web/vite.config.d.ts
new file mode 100644
index 000000000..089eeef96
--- /dev/null
+++ b/apps/web/vite.config.d.ts
@@ -0,0 +1,2 @@
+declare const _default: import("vite").UserConfigFnObject;
+export default _default;
diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js
new file mode 100644
index 000000000..0825d6b17
--- /dev/null
+++ b/apps/web/vite.config.js
@@ -0,0 +1,203 @@
+var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
+ if (ar || !(i in from)) {
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
+ ar[i] = from[i];
+ }
+ }
+ return to.concat(ar || Array.prototype.slice.call(from));
+};
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import Sitemap from "vite-plugin-sitemap";
+import svgr from "vite-plugin-svgr";
+import fs from "fs";
+import path from "path";
+import dotenv from "dotenv";
+import { VitePluginRadar } from "vite-plugin-radar";
+import { VitePWA } from "vite-plugin-pwa";
+// SEO 라우트 정책 단일 소스 — server/server.cjs와 공유
+import { INDEXABLE_ROUTES, SITEMAP_EXCLUDE, BASE_URL, ROBOTS_DISALLOW_PREFIXES } from "./seo.config.cjs";
+dotenv.config();
+/**
+ * `public/robots.txt`를 seo.config.cjs로부터 생성하는 Vite 플러그인.
+ * dev/build 진입 시 1회 실행되어 단일 소스와 robots.txt를 동기화합니다.
+ */
+var generateRobotsTxt = function () { return ({
+ name: "generate-robots-txt",
+ buildStart: function () {
+ var lines = __spreadArray(__spreadArray([
+ "# 자동 생성됨 — 수정 시 apps/web/seo.config.cjs의 ROBOTS_DISALLOW_PREFIXES를 변경하세요.",
+ "",
+ "User-agent: *",
+ "Allow: /",
+ ""
+ ], ROBOTS_DISALLOW_PREFIXES.map(function (p) { return "Disallow: ".concat(p); }), true), [
+ "",
+ "Sitemap: ".concat(BASE_URL, "/sitemap.xml"),
+ "",
+ ], false);
+ var outPath = path.resolve(__dirname, "public/robots.txt");
+ var next = lines.join("\n");
+ var prev = fs.existsSync(outPath) ? fs.readFileSync(outPath, "utf-8") : "";
+ if (prev !== next)
+ fs.writeFileSync(outPath, next);
+ },
+}); };
+// https://vitejs.dev/config/
+export default defineConfig(function () { return ({
+ plugins: [
+ generateRobotsTxt(),
+ react({
+ jsxImportSource: "@emotion/react",
+ babel: {
+ presets: ["jotai/babel/preset"],
+ },
+ }),
+ svgr(),
+ // dynamicRoutes / exclude는 seo.config.cjs에서 관리 (server.cjs와 공유)
+ Sitemap({
+ hostname: "https://layerapp.io",
+ dynamicRoutes: INDEXABLE_ROUTES,
+ exclude: SITEMAP_EXCLUDE,
+ changefreq: "weekly",
+ priority: 0.8,
+ lastmod: new Date(),
+ generateRobotsTxt: false, // public/robots.txt를 직접 관리하므로 자동 생성 비활성화
+ }),
+ VitePluginRadar({
+ analytics: process.env.VITE_GOOGLE_ANALYTICS ? { id: process.env.VITE_GOOGLE_ANALYTICS } : undefined,
+ }),
+ VitePWA({
+ registerType: "autoUpdate",
+ // useRegisterSW 훅으로 직접 등록하므로 자동 주입 비활성화
+ injectRegister: null,
+ includeAssets: ["favicon.ico", "white_layer.svg", "apple-touch-icon-180x180.png", "robots.txt"],
+ manifest: {
+ name: "성장하는 당신을 위한 회고 서비스, Layer",
+ short_name: "Layer",
+ description: "편리한 회고 작성부터 AI 분석까지 Layer에서 함께해요!",
+ theme_color: "#ffffff",
+ background_color: "#ffffff",
+ display: "standalone",
+ orientation: "portrait",
+ scope: "/",
+ start_url: "/",
+ lang: "ko",
+ icons: [
+ {
+ src: "pwa-64x64.png",
+ sizes: "64x64",
+ type: "image/png",
+ },
+ {
+ src: "pwa-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "pwa-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "any",
+ },
+ {
+ src: "maskable-icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ },
+ workbox: {
+ // SPA 라우팅: 모든 navigate 요청을 index.html로 fallback
+ navigateFallback: "index.html",
+ // API 요청은 서비스 워커에서 제외 (인증 데이터 캐싱 방지)
+ navigateFallbackDenylist: [/^\/api\//, /^\/oauth2\//, /^\/__/],
+ // 프리캐시: Vite 빌드 결과물 자동 수집 (sourcemap 제외)
+ globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2}"],
+ globIgnores: ["**/*.map"],
+ maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
+ runtimeCaching: [
+ // * CDN 폰트: 1년 캐시
+ {
+ urlPattern: /^https:\/\/cdn\.jsdelivr\.net\/.*/i,
+ handler: "CacheFirst",
+ options: {
+ cacheName: "cdn-fonts",
+ expiration: {
+ maxEntries: 10,
+ maxAgeSeconds: 60 * 60 * 24 * 365,
+ },
+ cacheableResponse: {
+ statuses: [0, 200],
+ },
+ },
+ },
+ // * NCloud 오브젝트 스토리지 이미지: 1년 캐시
+ // * 회고 공유 사진 및 일러스트 이미지에 사용되고 있음
+ {
+ urlPattern: /^https:\/\/kr\.object\.ncloudstorage\.com\/.*/i,
+ handler: "CacheFirst",
+ options: {
+ cacheName: "ncloud-images",
+ expiration: {
+ maxEntries: 100,
+ maxAgeSeconds: 60 * 60 * 24 * 365,
+ },
+ cacheableResponse: {
+ statuses: [0, 200],
+ },
+ },
+ },
+ // * API 요청: 네트워크 우선, 오프라인 시 캐시 (개인 데이터 제외)
+ {
+ urlPattern: /^https:\/\/.*\/api\/.*/i,
+ handler: "NetworkOnly",
+ },
+ ],
+ },
+ devOptions: {
+ enabled: false,
+ type: "module",
+ },
+ }),
+ ],
+ server: {
+ host: "0.0.0.0",
+ },
+ define: {
+ APP_VERSION: JSON.stringify(process.env.npm_package_version),
+ },
+ build: {
+ outDir: "dist", // 빌드 결과가 저장되는 폴더
+ sourcemap: true, // 디버깅을 위한 소스맵 생성 (선택 사항)
+ // 현재 일부 서드파티 패키지의 sourcemap이 깨져 있어, 조치 불가능한 노이즈 경고를 필터링합니다.
+ rollupOptions: {
+ onwarn: function (warning, warn) {
+ if (warning.code === "SOURCEMAP_ERROR" && warning.message.includes("Can't resolve original location of error")) {
+ return;
+ }
+ warn(warning);
+ },
+ output: {
+ manualChunks: {
+ "vendor-react": ["react", "react-dom", "react-router-dom"],
+ "vendor-query": ["@tanstack/react-query"],
+ "vendor-emotion": ["@emotion/react", "@emotion/styled"],
+ "vendor-motion": ["framer-motion"],
+ "vendor-dnd": ["@hello-pangea/dnd"],
+ "vendor-swiper": ["swiper"],
+ "vendor-lottie": ["lottie-react"],
+ },
+ },
+ },
+ // 현재 번들 크기는 의도적으로 큰 상태이므로, 경고 로그를 실질적으로 대응 가능한 수준으로 유지합니다.
+ chunkSizeWarningLimit: 7000,
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+}); });
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 335b08b73..22b4f7224 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -2,15 +2,45 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import Sitemap from "vite-plugin-sitemap";
import svgr from "vite-plugin-svgr";
+import fs from "fs";
import path from "path";
import dotenv from "dotenv";
import { VitePluginRadar } from "vite-plugin-radar";
import { VitePWA } from "vite-plugin-pwa";
+// SEO 라우트 정책 단일 소스 — server/server.cjs와 공유
+import { INDEXABLE_ROUTES, SITEMAP_EXCLUDE, BASE_URL, ROBOTS_DISALLOW_PREFIXES } from "./seo.config.cjs";
+
dotenv.config();
+
+/**
+ * `public/robots.txt`를 seo.config.cjs로부터 생성하는 Vite 플러그인.
+ * dev/build 진입 시 1회 실행되어 단일 소스와 robots.txt를 동기화합니다.
+ */
+const generateRobotsTxt = () => ({
+ name: "generate-robots-txt",
+ buildStart() {
+ const lines = [
+ "# 자동 생성됨 — 수정 시 apps/web/seo.config.cjs의 ROBOTS_DISALLOW_PREFIXES를 변경하세요.",
+ "",
+ "User-agent: *",
+ "Allow: /",
+ "",
+ ...ROBOTS_DISALLOW_PREFIXES.map((p: string) => `Disallow: ${p}`),
+ "",
+ `Sitemap: ${BASE_URL}/sitemap.xml`,
+ "",
+ ];
+ const outPath = path.resolve(__dirname, "public/robots.txt");
+ const next = lines.join("\n");
+ const prev = fs.existsSync(outPath) ? fs.readFileSync(outPath, "utf-8") : "";
+ if (prev !== next) fs.writeFileSync(outPath, next);
+ },
+});
// https://vitejs.dev/config/
export default defineConfig(() => ({
plugins: [
+ generateRobotsTxt(),
react({
jsxImportSource: "@emotion/react",
babel: {
@@ -18,7 +48,16 @@ export default defineConfig(() => ({
},
}),
svgr(),
- Sitemap({ hostname: "https://layerapp.io" }),
+ // dynamicRoutes / exclude는 seo.config.cjs에서 관리 (server.cjs와 공유)
+ Sitemap({
+ hostname: "https://layerapp.io",
+ dynamicRoutes: INDEXABLE_ROUTES,
+ exclude: SITEMAP_EXCLUDE,
+ changefreq: "weekly",
+ priority: 0.8,
+ lastmod: new Date(),
+ generateRobotsTxt: false, // public/robots.txt를 직접 관리하므로 자동 생성 비활성화
+ }),
VitePluginRadar({
analytics: process.env.VITE_GOOGLE_ANALYTICS ? { id: process.env.VITE_GOOGLE_ANALYTICS } : undefined,
}),
diff --git a/docs/seo-improvements.md b/docs/seo-improvements.md
new file mode 100644
index 000000000..8ed3e993e
--- /dev/null
+++ b/docs/seo-improvements.md
@@ -0,0 +1,339 @@
+# #847 SEO 검토 및 개선 — 변경 사항 및 근거 정리
+
+> **브랜치**: `847-ops-seo-검토-및-개선`
+> **이슈**: #847
+> **범위**: `apps/web/` SEO 인프라 전반 (메타 태그, 라우팅, 캐싱, 단일 소스, 문서화)
+
+---
+
+## 0. 개요
+
+### 문제 의식
+
+Layer는 React SPA로 빌드되어 `dist/index.html` 한 장이 모든 라우트의 응답이 됩니다. 이 구조에서 발생하는 4가지 SEO 한계:
+
+1. **메타 정적화**: 모든 라우트가 같은 title·description·OG 이미지를 보여줌 → 카카오톡/페이스북 공유 미리보기가 항상 홈페이지 메타
+2. **Soft 404**: 존재하지 않는 URL도 200 응답에 SPA 셸을 돌려줌 → Google이 페널티
+3. **canonical 중복**: 정적 HTML의 canonical과 도메인 통일 의도가 불일치
+4. **접근성**: 이미지 alt 누락, viewport `maximum-scale=1.0`로 WCAG 1.4.4 위반
+
+### 작업의 흐름
+
+총 7개 커밋으로 진행되었고 크게 **2단계**로 나뉩니다:
+
+1. **1단계 — 인프라 구축** (eddb684d, f4faa5a6, a9e40237, 8d65efd4): 서버사이드 메타 인젝션, robots/sitemap 정밀 제어, 접근성 회귀 방지
+2. **2단계 — 리뷰 피드백 반영** (391e256d, c9fcccb0, 01ef9117 + 미커밋 변경): canonical 중복 해결, DRY 위반 제거, 단일 소스 아키텍처, `/space/join` 회복탄력성
+
+---
+
+## 1. 메타 태그 인프라 — 서버사이드 인젝션
+
+### 1-1. Express 메타 인젝션 서버 (`server.cjs`)
+
+**무엇** — `apps/web/server/server.cjs` 신설. 모든 HTML 요청을 가로채 cheerio로 라우트별 ``, ``, ``을 동적으로 갈아끼움.
+
+**왜** — 크롤러(특히 카카오톡·페이스북 OG 봇)는 JS를 거의 실행하지 않으므로 React가 렌더한 메타를 못 봄. 빌드 시점에 라우트별 HTML을 만들 수 없는 SPA의 한계를 서버 응답 가공으로 보완.
+
+**어떻게**
+- `cheerio.load(html)`로 DOM 파싱 → `.attr()`로 메타 갱신 → `$.html()`로 직렬화
+- react-helmet-async 충돌 방지를 위해 `data-rh="true"` 마커 부착
+- 인메모리 FIFO 캐시(최대 100) + 3-tier `Cache-Control` 헤더 (`static-public` / `static-noindex` / `dynamic`)
+
+**영향** — 같은 URL이라도 라우트별 정확한 미리보기, Lighthouse SEO 점수 개선, 크롤 예산 절약.
+
+### 1-2. 동적 라우트 메타 (`/space/join/:id`)
+
+**무엇** — 스페이스 초대 링크가 catch-all보다 먼저 매칭되는 전용 핸들러를 가짐. 암호화된 ID를 AES 복호화 → 백엔드 API에서 리더·팀 이름 fetch → `"{leader}님의 회고 초대장"` 등 개인화된 OG 메타 주입.
+
+**왜** — 카카오톡으로 초대 링크를 공유했을 때 일반 메타가 아닌 **누가 어느 스페이스로 초대했는지**가 보여야 클릭률이 오름. 단순 메타 매핑으로는 dynamic 파라미터 처리가 불가능.
+
+**어떻게** — `vercel.json`의 라우팅 룰이 `/space/join/*`를 server.cjs로 위임, server.cjs는 `app.get("/space/join/:id")`로 catch-all 이전에 매칭.
+
+**영향** — 초대 링크 미리보기가 일반 OG 이미지 대신 초대장 전용 이미지(`INVITE_OG_IMAGE`)와 동적 텍스트로 표시.
+
+---
+
+## 2. URL 정규화 — Canonical 및 도메인 통일
+
+### 2-1. `www` → 루트 도메인 통일 (eddb684d)
+
+**무엇** — `www.layerapp.io`를 제거하고 모든 OG URL·canonical·sitemap을 `layerapp.io`로 통일. 중복 OG/Twitter 메타 태그 제거. Kakao SDK 중복 로드 제거.
+
+**왜** — 같은 콘텐츠가 두 도메인으로 인덱싱되면 link equity가 분산되고 중복 콘텐츠 페널티. Kakao SDK 중복 로드는 단순 성능 낭비.
+
+**영향** — Google이 정식 도메인 신호를 일관되게 받음. SDK 로드 1회로 줄어 초기 페이로드 감소.
+
+### 2-2. Canonical 중복 제거 (391e256d, 이 세션)
+
+**무엇** — `apps/web/index.html`에 박혀 있던 정적 `` 한 줄 삭제. server.cjs의 동적 인젝션이 단독 소스가 됨.
+
+**왜** — 1차 작업 후에도 정적 canonical이 남아 있어 모든 응답에 **canonical 태그가 2개**(정적 `www/` + 동적 라우트별) 노출되는 버그. Google은 다수 canonical을 만나면 [전부 무시](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)하므로 1차 작업의 정성스러운 라우트별 canonical이 무력화되어 있었음.
+
+**근거** — `curl -s https://layerapp.io/template | grep canonical`로 노출 확인. www 도메인 통일 커밋(eddb684d) 의도와도 모순.
+
+**영향** — 라우트별 canonical 신호가 정상 작동 → 인덱싱 품질 회복.
+
+---
+
+## 3. 인덱싱 정책 — Sitemap, Robots, Noindex 일관성
+
+### 3-1. Sitemap 정밀 제어 (f4faa5a6, a9e40237)
+
+**무엇** — `vite-plugin-sitemap` 설정을 한 줄 호출에서 정식 설정으로 확장:
+- `dynamicRoutes: ["/login", "/template"]` — SPA 라우트 명시 노출
+- `exclude` 14개 — 비공개 페이지 제외 (`/desktop/**` 포함, canonical 통일과 짝)
+- `changefreq: weekly`, `priority: 0.8`, `lastmod: new Date()` — 네이버·다음 등 비-Google 크롤러용 신호
+- `generateRobotsTxt: false` — 손으로 관리하는 robots.txt 덮어쓰기 방지
+
+**왜** — 기본 호출은 정적 페이지만 수집하므로 SPA의 React Router 정의 라우트는 sitemap에 포함되지 않음. 또한 데스크탑·모바일 중복 콘텐츠가 sitemap에 함께 들어가면 정규화 신호가 깨짐.
+
+**영향** — Google Search Console에서 의도한 3개 라우트만 indexable로 노출.
+
+### 3-2. Robots.txt 자동 생성 (01ef9117, 이 세션)
+
+**무엇** — Vite 플러그인 `generateRobotsTxt`가 빌드/dev 시작 시 `seo.config.cjs`의 `ROBOTS_DISALLOW_PREFIXES`로부터 `public/robots.txt`를 자동 생성.
+
+**왜** — 1차 작업의 sitemap exclude·noindex prefix·robots Disallow 3곳이 손동기화되어 있어 라우트 추가 시 누락 가능성. 정적 파일이라 import 불가하므로 빌드 시점에 단일 소스에서 생성.
+
+**핵심 설계** — `PRIVATE_ROUTE_PREFIXES`(noindex용)와 `ROBOTS_DISALLOW_PREFIXES`(robots용)를 **별도로 관리**.
+- robots Disallow는 크롤링 자체 차단 (강함) — `/space/join/:id` 같은 OG 미리보기가 필요한 경로는 막으면 안 됨
+- noindex 메타는 색인만 차단 (약함) — `/space/[id]` 같은 sub-route는 크롤 허용 + noindex로 충분
+
+**영향** — 동기화 부담 3곳 → 1곳(`seo.config.cjs`).
+
+### 3-3. `/space/[id]` Noindex 누락 보강 (c9fcccb0)
+
+**무엇** — `PRIVATE_ROUTE_PREFIXES`에 `/space` 추가. `/space/:id`(인증 필요한 스페이스 조회)와 모든 sub-route가 noindex 처리됨. `/space/join/:id`는 별도 핸들러가 catch-all 이전에 등록되어 정책 우회.
+
+**왜** — 1차 작업의 `/space/edit/`, `/space/create/`는 명시했지만 `/space/:id`는 빠져 있어 private한 스페이스 콘텐츠가 인덱싱될 위험.
+
+**영향** — 모든 인증 필수 페이지가 일관되게 검색에서 제외.
+
+---
+
+## 4. 접근성 + 성능 (8d65efd4)
+
+### 4-1. 이미지 `alt` + 로딩 최적화
+
+**무엇** — 다수 `
`에 `alt` 텍스트 + `width/height` + `loading="eager"/lazy"` + `decoding="async"` 부여.
+
+**왜**
+- `alt`: 접근성(스크린 리더) + Google 이미지 검색 신호
+- `width/height`: CLS(Cumulative Layout Shift) 방지
+- `loading="lazy"`: 뷰포트 밖 이미지 지연 로딩으로 초기 LCP 개선
+- `decoding="async"`: 메인 스레드 블로킹 감소
+
+**영향** — Lighthouse 접근성·성능 점수 동시 개선.
+
+### 4-2. 구조화 데이터 (JSON-LD)
+
+**무엇** — `index.html`의 `
+```
+
+문제는
+
+1. **크롤러는 JS를 즉시 실행하지 않습니다.** Googlebot은 일부 실행하지만 카카오톡 / 페이스북 / X(Twitter) OG 미리보기 봇은 거의 실행하지 않습니다.
+2. 결과: `/template`을 카톡으로 공유해도 **루트 페이지의 메타**(`성장하는 당신을…`)가 보이고, 라우트별 description/og:image가 반영되지 않습니다.
+3. Google 입장에서도 모든 라우트의 메타가 동일 → 인덱싱 품질 저하.
+
+### 이 파일의 해법
+
+요청이 들어오면
+
+1. `dist/index.html` (빈 SPA 셸)을 읽고
+2. 요청 경로(`req.path`)를 보고 라우트별 메타를 결정한 뒤
+3. cheerio로 HTML을 파싱해서 ``, ``, ``을 **갈아끼우고**
+4. 수정된 HTML을 응답 → 크롤러는 라우트에 맞는 메타를 봅니다.
+
+React는 브라우저에서 평소처럼 실행되므로 사용자 경험은 그대로입니다.
+
+---
+
+## 2. 아키텍처 한눈에 — SEO 단일 소스
+
+이 서버는 단독으로 동작하지 않고 두 개의 단일 소스 파일을 import합니다.
+
+```
+apps/web/
+├── routes.cjs ← 라우트 경로 (router/index.tsx와 공유)
+│ ├ ROUTES : 명명 상수 (절대 경로)
+│ ├ DESKTOP_PATHS : /desktop 접두사 URL
+│ ├ KNOWN_ROUTE_PATTERNS : 위에서 derive (soft 404 매칭용)
+│ ├ routeToRegex : `:param` → `[^/]+` 변환
+│ └ toChildPath : 절대 → React Router 자식 경로
+│
+├── seo.config.cjs ← SEO 정책 (vite.config.ts와 공유)
+│ ├ BASE_URL, DEFAULT_OG_IMAGE, INVITE_OG_IMAGE
+│ ├ INDEXABLE_ROUTES : sitemap dynamicRoutes
+│ ├ PRIVATE_ROUTE_PREFIXES : noindex prefix (sitemap exclude + server noindex 공유)
+│ ├ NOINDEX_PATH_PREFIXES : ↑의 별칭
+│ ├ SITEMAP_EXCLUDE : ↑의 glob 형식
+│ └ ROBOTS_DISALLOW_PREFIXES: robots.txt Disallow 패턴
+│
+├── vite.config.ts ← seo.config.cjs를 import해 sitemap + robots.txt 자동 생성
+├── src/router/index.tsx ← routes.cjs의 ROUTES를 import해 path 정의
+└── server/server.cjs ← routes.cjs + seo.config.cjs 모두 import (이 파일!)
+```
+
+**라우트를 추가/변경할 때 손대는 곳:**
+- 새 라우트 추가 → `routes.cjs`의 `ROUTES`에 상수 추가, `router/index.tsx`에 컴포넌트 매핑
+- 비공개 페이지 정책 변경 → `seo.config.cjs`의 `PRIVATE_ROUTE_PREFIXES` 1곳
+
+---
+
+## 3. 파일 구조 한눈에
+
+| 섹션 | 역할 |
+|---|---|
+| 초기화 / 외부 모듈 require | Express + dist 정적 서빙 + 단일 소스 require |
+| 복호화 | `/space/join/:id`의 암호화 ID 풀기 |
+| 메타 주입기 | `injectMeta()` — 가장 중요한 함수 |
+| 라우트 메타 사전 | `STATIC_ROUTE_META` — 공개 페이지 title/desc (이전엔 noindex 엔트리도 있었으나 prefix로 일원화) |
+| noindex / canonical 헬퍼 | `resolveRouteMeta`, `getCanonicalUrl`, `isKnownRoute` |
+| 캐시 | In-memory FIFO + `Cache-Control` 헤더 |
+| 동적 라우트 | `/space/join/:id` — 초대장 OG 이미지 (에러 시 404 fallback) |
+| catch-all | 나머지 모든 GET 요청 처리 |
+| 진입점 | 로컬 listen + Vercel export |
+
+---
+
+## 4. 섹션별 상세 해부
+
+### 4-1. 초기화 + 단일 소스 require
+
+```js
+require('dotenv').config();
+const express = require("express");
+const fs = require("fs");
+const path = require("path");
+const cheerio = require("cheerio"); // 서버사이드 jQuery → HTML 파싱/조작
+const axios = require("axios");
+const CryptoJS = require("crypto-js");
+
+// SEO 라우트 정책 단일 소스 — vite.config.ts와 공유
+const {
+ BASE_URL,
+ DEFAULT_OG_IMAGE,
+ INVITE_OG_IMAGE,
+ NOINDEX_PATH_PREFIXES,
+} = require("../seo.config.cjs");
+
+// 라우트 정의 단일 소스 — src/router/index.tsx와 공유
+const { KNOWN_ROUTE_PATTERNS } = require("../routes.cjs");
+
+const app = express();
+const distPath = path.resolve(__dirname, "../dist");
+app.use(express.static(distPath));
+```
+
+**핵심**:
+- `express.static(distPath)`이 먼저 등록되어 있어 정적 자산(JS·CSS·이미지)은 cheerio를 거치지 않고 바로 응답됩니다.
+- BASE_URL, 이미지 URL, noindex prefix는 모두 외부 파일에서 import → 이 파일에는 SEO 정책이 하드코딩되어 있지 않습니다.
+
+### 4-2. 복호화
+
+```js
+function decryptId(encryptedId) {
+ // /space/join/abc123== 같은 URL의 abc123== 부분을 풀어 실제 space ID 추출
+ const word_array = CryptoJS.enc.Base64.parse(encryptedId);
+ const decoding = word_array.toString(CryptoJS.enc.Utf8);
+ const decrypted = CryptoJS.AES.decrypt(decoding, key, {
+ iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7,
+ });
+ return decrypted.toString(CryptoJS.enc.Utf8);
+}
+```
+
+스페이스 초대 URL은 raw space ID를 노출하지 않기 위해 AES 암호화된 ID를 씁니다. 서버에서 OG 이미지·제목을 만들려면 이 ID를 풀어서 백엔드 API에 물어봐야 합니다.
+
+### 4-3. ⭐ `injectMeta()` — 이 파일의 심장
+
+```js
+function injectMeta(html, { title, description, image, url, noindex }) {
+ const $ = cheerio.load(html); // jQuery 같은 API로 HTML 조작 가능
+
+ $('title').text(title);
+ $('meta[name="description"]').attr('content', description).attr('data-rh', 'true');
+ $('meta[property="og:title"]').attr('content', title).attr('data-rh', 'true');
+ $('meta[property="og:description"]').attr('content', description).attr('data-rh', 'true');
+ $('meta[property="og:url"]').attr('content', url).attr('data-rh', 'true');
+ if (image) {
+ $('meta[property="og:image"]').attr('content', image).attr('data-rh', 'true');
+ }
+ $('meta[name="twitter:title"]').attr('content', title).attr('data-rh', 'true');
+ $('meta[name="twitter:description"]').attr('content', description).attr('data-rh', 'true');
+ if (image) {
+ $('meta[name="twitter:image"]').attr('content', image).attr('data-rh', 'true');
+ }
+
+ $('meta[name="robots"]').attr(
+ 'content',
+ noindex ? 'noindex, nofollow' : 'index, follow'
+ ).attr('data-rh', 'true');
+
+ if (!noindex) {
+ $('head').append(``);
+ }
+
+ return $.html();
+}
+```
+
+**라인별 해설**
+
+- `cheerio.load(html)` — HTML 문자열을 DOM 트리로 파싱. `$`는 jQuery API와 동일.
+- `.attr('data-rh', 'true')` — react-helmet-async가 클라이언트에서 SSR 메타를 인식할 때 쓰는 마커. 안 붙이면 React가 마운트되면서 메타가 사라질 수 있음.
+- `image`가 옵셔널인 이유: 라우트가 별도 og:image 지정 안 하면 `index.html`의 기본값을 그대로 두려고.
+- canonical은 `append`로 항상 새로 추가 (noindex일 땐 추가 안 함 — 신호 혼선 방지).
+
+> **중요**: `index.html`에는 정적 canonical을 두지 **않습니다**. 서버 인젝션과 중복되면 Google이 모든 canonical 신호를 무시합니다. (#847 작업에서 제거 완료)
+
+### 4-4. `STATIC_ROUTE_META` — 공개 페이지 메타 사전
+
+```js
+const STATIC_ROUTE_META = {
+ "/": { title: "성장하는 당신을 위한…", description: "…", image: DEFAULT_OG_IMAGE },
+ "/login": { title: "로그인 | Layer", description: "…", image: DEFAULT_OG_IMAGE },
+ "/template": { title: "회고 템플릿 모음 | Layer", /* … */ },
+ "/desktop": { /* alias of "/" */ },
+ "/desktop/login": { /* alias of "/login" */ },
+};
+```
+
+**공개 페이지만** 명시합니다. 비공개 페이지는 `NOINDEX_PATH_PREFIXES` (seo.config.cjs)의 prefix 매칭으로 일괄 처리되므로 여기 적을 필요 없습니다. (이전엔 `/myinfo: { noindex: true }` 같은 엔트리가 있었지만 prefix로 일원화)
+
+### 4-5. ⭐ `KNOWN_ROUTE_PATTERNS` — soft 404 방지 (routes.cjs에서 derive)
+
+```js
+// 이 서버 파일 안에는 인라인 정규식이 없음.
+const { KNOWN_ROUTE_PATTERNS } = require("../routes.cjs");
+
+function isKnownRoute(reqPath) {
+ return KNOWN_ROUTE_PATTERNS.some((re) => re.test(reqPath));
+}
+```
+
+`routes.cjs`가 `ROUTES`와 `DESKTOP_PATHS`의 모든 절대 경로를 `routeToRegex`로 변환한 결과를 export합니다. 라우터(`src/router/index.tsx`)도 같은 `ROUTES` 상수를 사용하므로 **라우트 추가 시 양쪽이 동시에 업데이트**됩니다.
+
+**Soft 404가 뭔가?**
+
+- SPA의 `/asdfasdf` 같은 존재 안 하는 경로에 접근해도 SPA는 보통 200 응답에 "페이지 없음" 컴포넌트를 보여줍니다.
+- Google은 이를 "내용은 404 같은데 status가 200이네?" → **soft 404** 페널티 매김.
+- 해결: 서버에서 라우터 정의와 비교해 **존재하지 않는 경로면 명시적 404 status**를 반환.
+
+### 4-6. `NOINDEX_PATH_PREFIXES` (seo.config.cjs에서 derive)
+
+```js
+const { NOINDEX_PATH_PREFIXES } = require("../seo.config.cjs");
+
+function isNoindexPath(reqPath) {
+ return NOINDEX_PATH_PREFIXES.some((prefix) => reqPath.startsWith(prefix));
+}
+
+function resolveRouteMeta(reqPath) {
+ if (STATIC_ROUTE_META[reqPath]) return STATIC_ROUTE_META[reqPath]; // 정확 매칭 (공개 페이지)
+ if (isNoindexPath(reqPath)) return { noindex: true }; // prefix 매칭 (비공개)
+ return null; // 모르는 라우트
+}
+```
+
+`seo.config.cjs`의 `PRIVATE_ROUTE_PREFIXES`가 동일한 prefix 목록을 sitemap exclude와 noindex 양쪽에서 사용하도록 보장합니다 (단일 소스).
+
+### 4-7. `getCanonicalUrl` — URL 정규화
+
+```js
+function getCanonicalUrl(reqPath) {
+ let canonicalPath = reqPath.replace(/^\/desktop/, '') || '/'; // /desktop/login → /login
+ if (canonicalPath.length > 1 && canonicalPath.endsWith('/')) {
+ canonicalPath = canonicalPath.slice(0, -1); // /login/ → /login
+ }
+ return `${BASE_URL}${canonicalPath}`;
+}
+```
+
+모바일 `/login`과 데스크탑 `/desktop/login`은 같은 콘텐츠 → 둘 다 canonical을 `https://layerapp.io/login`으로 통일해서 **중복 콘텐츠 페널티 방지**.
+
+### 4-8. 캐시
+
+```js
+const META_CACHE = new Map(); // FIFO, 최대 100
+const META_CACHE_MAX = 100;
+
+function setCacheHeaders(res, kind) {
+ if (kind === "static-public") {
+ // 공개 페이지: 브라우저 5분, edge(CDN) 1시간, stale-while-revalidate 1일
+ res.set('Cache-Control', 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400');
+ } else if (kind === "static-noindex") {
+ // 인증 페이지: 브라우저 캐시 X, edge에서만 1분
+ res.set('Cache-Control', 'public, max-age=0, s-maxage=60');
+ } else {
+ // 동적(/space/join/:id): edge 5분
+ res.set('Cache-Control', 'public, max-age=0, s-maxage=300, stale-while-revalidate=600');
+ }
+}
+```
+
+**2-tier 캐시**
+
+1. **In-memory** — cheerio 파싱은 CPU 비용이 있어 결과 HTML 자체를 메모리에 저장.
+2. **HTTP 헤더** — Vercel Edge CDN과 사용자 브라우저가 우리 응답을 얼마나 저장할지 지시.
+
+### 4-9. ⭐ 동적 라우트 `/space/join/:id` — 에러 시 404 fallback
+
+```js
+app.get("/space/join/:id", async (req, res) => {
+ const encryptedId = req.params.id;
+ let html;
+
+ try { html = readIndexHtml(); }
+ catch (err) {
+ console.error("Failed to read index.html:", err);
+ return res.status(500).send("Error loading the page.");
+ }
+
+ try {
+ const decryptedId = decryptId(encryptedId); // 1. 복호화
+ const apiResponse = await axios.get( // 2. 백엔드 fetch
+ `${process.env.VITE_API_URL}/api/space/public/${decryptedId}`
+ );
+ const { leader, name: teamName } = apiResponse.data;
+ if (!leader?.name || !teamName) throw new Error("Missing data.");
+
+ const result = injectMeta(html, { // 3. 메타 주입
+ title: `${leader.name}님의 회고 초대장`,
+ description: `함께 회고해요! ${leader.name}님이 ${teamName} 스페이스에 초대했어요.`,
+ image: INVITE_OG_IMAGE,
+ url: getCanonicalUrl(req.path),
+ });
+
+ setCacheHeaders(res, "dynamic");
+ res.send(result);
+ } catch (err) {
+ console.error("Error processing space join page:", err.message);
+ // 복호화 실패·백엔드 4xx/5xx → 404 + noindex 메타 + SPA 셸 반환.
+ // 봇은 "존재하지 않음"으로 인식해 재시도하지 않고,
+ // 사용자는 SPA가 마운트되어 자체 에러 화면을 렌더할 수 있습니다.
+ const fallback = injectMeta(html, {
+ title: "초대장을 찾을 수 없습니다 | Layer",
+ description: "이 회고 초대 링크는 만료되었거나 존재하지 않습니다.",
+ image: DEFAULT_OG_IMAGE,
+ url: getCanonicalUrl(req.path),
+ noindex: true,
+ });
+ setCacheHeaders(res, "static-noindex");
+ return res.status(404).send(fallback);
+ }
+});
+```
+
+**사용자 시나리오 (성공 경로)**
+
+1. 친구가 카톡으로 `https://layerapp.io/space/join/eHl6PT0=` 같은 링크를 공유.
+2. 카톡 OG 봇이 이 URL을 GET 요청.
+3. 서버는 `eHl6PT0=`를 복호화해 실제 스페이스 ID를 얻고, 백엔드에서 리더·팀 이름을 fetch.
+4. `"홍길동님의 회고 초대장"` 같은 개인화된 메타로 HTML을 만들어 응답.
+5. 카톡 미리보기에 정확한 정보가 표시됨.
+
+**에러 경로 (복호화 실패 / 백엔드 4xx, 5xx)**
+
+- 이전: `500 "Failed to fetch space data."` raw 텍스트 → 봇이 재시도, SPA 안 뜸.
+- 현재: `404` + noindex 메타 + 정상 SPA 셸 → 봇은 영구 부재로 판단, 사용자는 SPA의 자체 에러 UI 확인.
+
+> 이 라우트는 catch-all(`app.get("*")`)보다 먼저 등록되어 있어 `seo.config.cjs`의 `/space` noindex prefix 정책의 **예외**입니다. OG 미리보기를 위해 의도된 설계.
+
+### 4-10. ⭐ Catch-all `app.get("*")`
+
+```js
+app.get("*", (req, res) => {
+ try {
+ const knownRoute = isKnownRoute(req.path);
+ const routeMeta = resolveRouteMeta(req.path);
+ const isNoindex = !!routeMeta?.noindex || !knownRoute; // 모르는 경로는 무조건 noindex
+ const cacheKind = isNoindex ? "static-noindex" : "static-public";
+ const cacheKey = knownRoute ? req.path : "__not_found__";
+
+ // ── 캐시 히트 시 즉시 응답 ──
+ if (knownRoute && META_CACHE.has(cacheKey)) {
+ setCacheHeaders(res, cacheKind);
+ return res.send(META_CACHE.get(cacheKey));
+ }
+
+ const html = readIndexHtml();
+ const canonicalUrl = getCanonicalUrl(req.path);
+
+ // ── 3분기로 메타 결정 ──
+ let meta;
+ if (!knownRoute) {
+ // (1) 존재하지 않는 경로 → 404
+ meta = { title: "페이지를 찾을 수 없습니다 | Layer", /* … */ noindex: true };
+ } else if (isNoindex) {
+ // (2) 정의됐지만 인덱싱 차단해야 하는 경로
+ meta = { ...DEFAULT_META, ...routeMeta, url: canonicalUrl, noindex: true };
+ } else {
+ // (3) 공개 페이지
+ meta = { ...DEFAULT_META, ...(routeMeta || {}), url: canonicalUrl };
+ }
+
+ const result = injectMeta(html, meta);
+
+ if (knownRoute) setMetaCache(cacheKey, result); // 캐시 적중률 위해 알려진 경로만 저장
+ setCacheHeaders(res, cacheKind);
+ res.status(knownRoute ? 200 : 404).send(result); // ⭐ soft 404 방지 핵심
+ } catch (err) {
+ console.error("Error serving page:", err);
+ const filePath = path.join(distPath, "index.html");
+ res.sendFile(filePath); // 인젝션 실패해도 최소한 SPA는 띄움
+ }
+});
+```
+
+**판단 로직**
+
+| 라우트 상태 | status | 메타 | 캐시 |
+|---|---|---|---|
+| `KNOWN_ROUTE_PATTERNS`에 있음 + STATIC에 등록 | 200 | 등록된 title/desc | static-public |
+| `KNOWN_ROUTE_PATTERNS`에 있음 + noindex prefix | 200 | DEFAULT + noindex | static-noindex |
+| `KNOWN_ROUTE_PATTERNS`에 없음 | **404** | 404 메타 + noindex | static-noindex (`__not_found__` 키) |
+
+### 4-11. 진입점
+
+```js
+app.listen(3000, () => console.log("Server running on http://localhost:3000"));
+module.exports = app; // Vercel Serverless Function이 이 export를 핸들러로 사용
+```
+
+**로컬 vs 프로덕션**
+
+- 로컬 `pnpm web start` — 3000번 포트로 실제 listen.
+- Vercel — `listen`은 무시되고, `module.exports = app`이 serverless 핸들러로 호출됨. `vercel.json`의 라우팅 룰이 모든 비-정적 요청을 이 함수로 보냄.
+
+---
+
+## 5. 한 요청의 라이프사이클 예시
+
+**사용자가 `/template`에 접속했을 때**
+
+```
+브라우저 → Vercel Edge
+ ↓
+ (정적 파일 아님 → server.cjs로 위임)
+ ↓
+app.get("*") 진입
+ ├ isKnownRoute("/template") → true (ROUTES.TEMPLATE 매칭)
+ ├ resolveRouteMeta("/template") → STATIC_ROUTE_META["/template"]
+ ├ isNoindex → false
+ ├ META_CACHE.has("/template") ?
+ │ ├ YES → 캐시된 HTML 반환 (끝)
+ │ └ NO → 계속
+ ├ readIndexHtml() → dist/index.html 읽음
+ ├ getCanonicalUrl("/template") → "https://layerapp.io/template"
+ ├ meta = { title: "회고 템플릿 모음…", description: "…", image: …, url: "…/template" }
+ ├ injectMeta(html, meta)
+ │ ├ cheerio.load(html)
+ │ ├ , 들 업데이트
+ │ ├ 추가
+ │ └ $.html()로 직렬화
+ ├ META_CACHE에 저장
+ ├ Cache-Control: public, max-age=300, s-maxage=3600, …
+ └ res.status(200).send(html)
+ ↓
+브라우저: 메타 적용된 HTML 수신 → React 마운트 → SPA 정상 동작
+크롤러: 메타만 읽고 종료 → 라우트별 정확한 정보로 인덱싱
+```
+
+---
+
+## 6. 검증 방법
+
+```bash
+cd apps/web
+pnpm build && pnpm start
+
+# 1. canonical은 라우트별로 1개만, 정확한 절대 URL
+curl -s http://localhost:3000/template | grep -i canonical
+# 기대:
+
+# 2. noindex 페이지는 canonical 없음
+curl -s http://localhost:3000/myinfo | grep -i canonical
+# 기대: 빈 출력
+
+# 3. 존재하지 않는 경로는 명시적 404 status
+curl -sI http://localhost:3000/asdf-no-such-page | head -1
+# 기대: HTTP/1.1 404 Not Found
+
+# 4. /space/join 에러 시 404 + noindex
+curl -sI http://localhost:3000/space/join/invalid-id | head -1
+# 기대: HTTP/1.1 404 Not Found
+
+# 5. robots.txt가 seo.config.cjs로부터 자동 생성됐는지
+head -3 dist/robots.txt
+# 기대: # 자동 생성됨 — 수정 시 apps/web/seo.config.cjs의 ROBOTS_DISALLOW_PREFIXES를 변경하세요.
+
+# 6. sitemap.xml에 공개 라우트 3개 (/ + /login + /template)만 포함
+cat dist/sitemap.xml | grep -o '[^<]*'
+```
+
+---
+
+## 7. 핵심 키워드 정리
+
+| 용어 | 의미 |
+|---|---|
+| **cheerio** | 서버에서 jQuery처럼 HTML을 파싱·수정 |
+| **injectMeta** | 빈 SPA HTML에 라우트별 메타를 주입하는 함수 |
+| **canonical** | "이 URL이 정식 주소" 신호 → 중복 콘텐츠 방지 |
+| **noindex** | "검색엔진아 이 페이지 색인하지 마" |
+| **soft 404** | 200으로 응답하지만 내용은 없는 페이지 → Google 페널티 |
+| **data-rh="true"** | react-helmet-async가 SSR 메타를 인식하는 마커 |
+| **stale-while-revalidate** | "캐시는 만료됐지만 일단 보여주고 백그라운드에서 갱신" |
+| **Vercel Serverless Function** | 요청마다 임시 인스턴스가 뜨는 서버리스 함수 |
+
+---
+
+## 8. 유지보수 시 주의 사항
+
+#847 작업으로 SEO 정책의 동기화 부담이 크게 줄었습니다. 변경 시나리오별 손댈 곳:
+
+### 새 공개 라우트 추가 (e.g. `/about`)
+1. `apps/web/routes.cjs` — `ROUTES.ABOUT = "/about"` 추가
+2. `apps/web/src/router/index.tsx` — 컴포넌트 매핑 (`path: toChildPath(ROUTES.ABOUT)`)
+3. `apps/web/server/server.cjs` — `STATIC_ROUTE_META["/about"]`에 title/description/image 추가
+4. (선택) `apps/web/seo.config.cjs` — `INDEXABLE_ROUTES`에 `ROUTES.ABOUT` 추가 (sitemap 노출 시)
+
+### 새 비공개 라우트 추가 (e.g. `/admin`)
+1. `apps/web/routes.cjs` — `ROUTES.ADMIN = "/admin"` 추가
+2. `apps/web/src/router/index.tsx` — 컴포넌트 매핑
+3. `apps/web/seo.config.cjs` — `PRIVATE_ROUTE_PREFIXES`에 `/admin` 추가 (sitemap exclude + server noindex 동시 적용)
+4. (선택) `ROBOTS_DISALLOW_PREFIXES`에도 추가 (robots.txt가 빌드 시 자동 갱신)
+
+### 데스크탑 reachable 라우트 추가
+- 추가로 `apps/web/routes.cjs`의 `DESKTOP_PATHS`에 `/desktop/...` 형식으로 명시 (router의 deviceType: "desktop" 라우트와 일치)
+
+### 자동화된 부분 (손댈 필요 없음)
+- `dist/sitemap.xml` — `vite-plugin-sitemap`이 `seo.config.cjs`에서 자동 생성
+- `public/robots.txt` — Vite 빌드 플러그인이 `seo.config.cjs`에서 자동 생성
+- `server.cjs`의 `KNOWN_ROUTE_PATTERNS` — `routes.cjs`에서 자동 derive
+- `seo.config.cjs`의 `INDEXABLE_ROUTES` — `routes.cjs`의 `ROUTES`에서 derive
+
+### 남은 수동 동기화 포인트
+- `routes.cjs`의 `DESKTOP_PATHS`와 `router/index.tsx`의 `deviceType: "desktop"` 라우트 정의
+- 라우터의 `lazy()` 컴포넌트 매핑 자체
+
+장기 개선 후보 — router 정의를 routes.cjs로 합치고 `lazy()` 매핑을 컨벤션 기반으로 자동화하면 단일 소스가 완성됨.