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 > {`${name}Image`} { 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 ? ( {`${name} 회고 템플릿 미리보기 {`${space}) => (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로 라우트별 ``, `<meta>`, `<link rel="canonical">`을 동적으로 갈아끼움. + +**왜** — 크롤러(특히 카카오톡·페이스북 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`에 박혀 있던 정적 `<link rel="canonical" href="https://www.layerapp.io/" />` 한 줄 삭제. 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` + 로딩 최적화 + +**무엇** — 다수 `<img>`에 `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`의 `<script type="application/ld+json">`에 Organization + WebSite + SoftwareApplication 스키마를 `@graph`로 연결. + +**왜** — Google 리치 결과(브랜드 정보 카드, 사이트 검색박스 등) 적격성 확보. SoftwareApplication 표기로 무료 앱임을 명시. + +**영향** — Rich Results Test 통과 시 SERP에서 시각적 차별화. + +--- + +## 5. 단일 소스 아키텍처 — DRY 위반 제거 (c9fcccb0 + 이 세션) + +이 섹션은 1차 작업 리뷰에서 가장 큰 개선 항목으로 지적된 부분입니다. + +### 5-1. `seo.config.cjs` — SEO 정책 단일 소스 + +**무엇** — `apps/web/seo.config.cjs` 신설. `vite.config.ts`와 `server.cjs`가 공유하는 SEO 정책을 한 곳에 모음. + +``` +exports: + BASE_URL, DEFAULT_OG_IMAGE, INVITE_OG_IMAGE + INDEXABLE_ROUTES (sitemap dynamicRoutes) + PRIVATE_ROUTE_PREFIXES (sitemap exclude + server noindex 공유) + NOINDEX_PATH_PREFIXES (위의 별칭) + SITEMAP_EXCLUDE (glob 형식으로 변환) + ROBOTS_DISALLOW_PREFIXES (robots.txt 별도 정책) +``` + +**왜** — 1차 작업 후 동일 정책이 4곳에 분산: +- `vite.config.ts` Sitemap `exclude` +- `server.cjs` `NOINDEX_PATH_PREFIXES` +- `server.cjs` `STATIC_ROUTE_META`의 noindex 엔트리 +- `public/robots.txt` + +라우트 하나 추가하려면 4번 손대야 했고 누락 시 신호 불일치로 인덱싱 결정이 흐려짐. + +**어떻게** — `vite.config.ts`와 `server.cjs` 양쪽에서 `require("./seo.config.cjs")`로 import. ESM `import`/CJS `require` interop은 Vite의 esbuild가 자동 처리. + +**영향** — 변경 시나리오별 손댈 곳이 1~2곳으로 축소. `server.cjs`의 중복 noindex 엔트리 10개 제거. + +### 5-2. `routes.cjs` — 라우트 경로 단일 소스 (이 세션) + +**무엇** — `apps/web/routes.cjs` 신설. 모든 라우트 path를 명명 상수로 export하고 `KNOWN_ROUTE_PATTERNS`(soft 404 매칭용 정규식)를 자동 derive. + +``` +exports: + ROUTES : 38개 명명 상수 (절대 경로, `/login`, `/space/:spaceId` 등) + DESKTOP_PATHS : 7개 데스크탑 전용 URL + KNOWN_ROUTE_PATTERNS : 위 두 목록을 routeToRegex로 변환한 정규식 배열 + routeToRegex : React Router :param → [^/]+ 변환 + toChildPath : 절대 경로 → React Router 자식 경로 (앞 슬래시 제거) +``` + +**왜** — 1차 작업 리뷰에서 가장 마지막에 남은 동기화 부담. 라우터(`router/index.tsx`)에 새 경로를 추가하면 `server.cjs`의 22행짜리 정규식 배열도 함께 갱신해야 했음 → 누락 시 멀쩡한 페이지가 soft 404로 분류됨. + +**어떻게** +1. `routes.cjs`가 절대 경로의 ROUTES 상수와 DESKTOP_PATHS를 export +2. `KNOWN_ROUTE_PATTERNS = ALL_ABSOLUTE_PATHS.map(routeToRegex)`로 자동 derive +3. `router/index.tsx`의 47곳 inline path 문자열을 `toChildPath(ROUTES.X)`로 교체 +4. `server.cjs`는 `require("../routes.cjs")`로 KNOWN_ROUTE_PATTERNS import +5. `seo.config.cjs`도 `INDEXABLE_ROUTES`를 `[ROUTES.LOGIN, ROUTES.TEMPLATE]`로 derive + +**의도적 minor 동작 변화** — `/retrospect/write`(모바일 컨텍스트): 이전 404 → 이제 200 + noindex 메타. `ROUTES.RETROSPECT_WRITE`를 단일 상수로 추가하면서 ALL_ABSOLUTE_PATHS에 포함됨. noindex 메타가 적용되어 SEO상 무차이. + +**영향** — 라우트 추가 시 손댈 곳이 `routes.cjs` + `router/index.tsx` 2곳으로 축소. KNOWN_ROUTE_PATTERNS 손편집 0회. + +### 5-3. `toRelative` → `toChildPath` 명명 개선 (이 세션) + +**무엇** — 헬퍼 함수 이름을 `toRelative`에서 `toChildPath`로 변경 + 사용처 47곳 일괄 치환. + +**왜** — "relative"는 무엇에 대한 상대인지 모호. 실제로는 React Router v6의 nested route에서 자식 경로(`path: "login"`) 형식으로 만드는 것이라 의도를 명시. + +### 5-4. `.cjs` 모듈의 TypeScript 선언 파일 추가 (이 세션) + +**무엇** — `apps/web/seo.config.d.cts`와 `apps/web/routes.d.cts` 신설. 각 `.cjs` 모듈의 export 시그니처를 TypeScript 타입으로 선언. + +**왜** — `.cjs`를 .tsx에서 import하면 tsconfig 설정에 따라 동작이 갈렸음: +- 메인 `tsconfig.json`은 `allowJs: true`라 `router/index.tsx`에서는 자동 분석 → 정상 +- `vite.config.ts` 전용 `tsconfig.node.json`은 `allowJs`가 없어 **"모듈 './seo.config.cjs'에 대한 선언 파일을 찾을 수 없습니다"** 에러 발생 (IDE 빨간 줄) + +`tsc --noEmit -p tsconfig.json`만 검증했을 때는 통과해서 처음엔 `@ts-expect-error`를 제거했는데, IDE는 양쪽 tsconfig을 모두 본다는 점에서 vite.config.ts에 에러가 표시되었음. `tsc --build`로 두 tsconfig을 함께 검사하면 같은 에러가 재현됨. + +**어떻게** — `.d.cts` 확장자는 `.cjs`의 canonical 선언 파일 형식. `moduleResolution: "bundler"`에서 우선적으로 탐색됨. +- `routes.d.cts`에는 `ROUTES`를 **리터럴 타입**(`readonly LOGIN: "/login"` 등)으로 선언 → IDE 자동완성이 라우트 38개를 모두 후보로 노출, 오타가 컴파일 타임에 잡힘 +- `seo.config.d.cts`는 string/string[] 시그니처만 제공 + +**영향** — `@ts-expect-error` 주석 2곳(`vite.config.ts`, `router/index.tsx`) 완전 제거. 양쪽 tsconfig 모두에서 정상 동작. 라우터 작업 시 DX 향상. + +**유지보수 노트** — 라우트 추가 시 `routes.cjs`(런타임)와 `routes.d.cts`(타입) 두 곳을 함께 갱신 필요. 한쪽만 추가하면 IDE에서 잡힘. + +--- + +## 6. 회복탄력성 (이 세션) + +### 6-1. `/space/join/:id` 에러 폴백 + +**무엇** — 복호화 실패 또는 백엔드 4xx/5xx 발생 시 raw 텍스트 500 응답을 **404 + noindex 메타 + 정상 SPA 셸** 응답으로 변경. + +**왜** — 기존 동작은 두 문제가 있었음: +1. **봇 관점**: 500은 "일시적 오류"로 해석되어 봇이 재시도. 영구히 부재인 잘못된 초대 토큰을 반복 크롤. +2. **사용자 관점**: SPA가 마운트되지 않아 raw "Failed to fetch space data." 텍스트만 표시 → 자체 에러 UI/Channel Talk 등 모두 동작 안 함. + +**어떻게** +```js +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); +``` + +**영향** — 봇은 영구 부재로 인식해 재시도 안 함. 사용자는 SPA의 자체 404 컴포넌트와 정상적인 UI를 만남. + +--- + +## 7. 문서화 (이 세션) + +### 7-1. `docs/seo-server-guide.md` + +**무엇** — `server.cjs`의 동작 원리·아키텍처·검증 방법·유지보수 시나리오를 정리한 22KB / 517행 가이드. + +**왜** — server.cjs는 SPA SEO를 위한 비표준 패턴이라 다음 담당자가 "이 파일 왜 있어?"라는 질문에서 출발해야 함. 한 번에 큰 그림과 세부 동작을 모두 전달. + +**구성** — 0) 한 문장 요약 → 1) 배경(SPA SEO 문제) → 2) 아키텍처 다이어그램(3개 cjs + 2개 ts) → 3-4) 파일 구조 및 섹션별 상세 → 5) 라이프사이클 예시 → 6) curl 검증 → 7) 키워드 사전 → 8) 유지보수 시나리오별 체크리스트. + +### 7-2. `docs/seo-improvements.md` (이 문서) + +**무엇** — 이번 브랜치의 모든 변경사항·근거·영향을 주제별로 정리한 변경 이력. + +**왜** — PR 리뷰어와 미래의 자기 자신이 "이 결정 왜 했지?"를 5분 안에 파악할 수 있도록 함. 시간 순서가 아닌 주제별(메타/URL/인덱싱/접근성/단일 소스/회복탄력성/문서화)로 묶어 cross-cutting 의사결정의 맥락 유지. + +--- + +## 8. 최종 파일 변경 목록 + +### 신규 (6개) +| 경로 | 역할 | +|---|---| +| `apps/web/seo.config.cjs` | SEO 정책 단일 소스 (BASE_URL, OG 이미지, prefix 목록) | +| `apps/web/seo.config.d.cts` | seo.config.cjs의 TypeScript 선언 파일 | +| `apps/web/routes.cjs` | 라우트 경로 단일 소스 (ROUTES, KNOWN_ROUTE_PATTERNS derive) | +| `apps/web/routes.d.cts` | routes.cjs의 TypeScript 선언 파일 (ROUTES 리터럴 타입 포함) | +| `docs/seo-server-guide.md` | server.cjs 완벽 가이드 (아키텍처·동작 원리·검증) | +| `docs/seo-improvements.md` | 이 문서 — 변경사항 정리 | + +### 수정 (6개) +| 경로 | 변경 | +|---|---| +| `apps/web/index.html` | 정적 canonical 제거, OG/Twitter 메타 통일, 중복 SDK 제거, viewport 복원, JSON-LD 구조화 데이터 추가 | +| `apps/web/server/server.cjs` | Express 메타 인젝션 서버 신설 → seo.config.cjs/routes.cjs 단일 소스화 → `/space/join` 404 폴백 | +| `apps/web/vite.config.ts` | Sitemap 정밀 설정 + robots.txt 자동 생성 Vite 플러그인 + seo.config.cjs import | +| `apps/web/src/router/index.tsx` | 47곳 inline path → `toChildPath(ROUTES.X)`로 derive | +| `apps/web/public/robots.txt` | 손편집 → Vite 플러그인 자동 생성 (헤더에 명시) | +| `apps/web/vercel.json` | server.cjs를 모든 비-정적 요청 핸들러로 등록 | + +### 컴포넌트 단위 변경 +다수 컴포넌트의 `<img>`에 `alt`, `width`, `height`, `loading`, `decoding` 속성 추가: +- `ProfileImage`, `JoinLetter`, `SpaceOverview`, `UserBox`, `TemplateCard` (mobile + desktop), +- `MemberActionView`, `MemberList`, `MembersItem`, `ImageUploader`, `SpaceItem`, `UserProfile`, `UserProfileIcon` + +--- + +## 9. 검증 결과 (PR 머지 시점 기준) + +| 검증 | 결과 | +|---|---| +| `pnpm tsc --noEmit -p tsconfig.json` (메인) | ✅ exit 0 | +| `pnpm tsc --build` (project references — vite.config.ts 포함) | ✅ exit 0 | +| `pnpm vite build` | ✅ exit 0 | +| `node --check server/server.cjs` | ✅ | +| `seo.config.cjs` / `routes.cjs` 런타임 로드 | ✅ 모든 export 정상 | +| noindex prefix 매칭 단위 테스트 12/12 | ✅ | +| KNOWN_ROUTE_PATTERNS 회귀 테스트 18/18 | ✅ | +| `dist/sitemap.xml` 검사 (공개 라우트 3개) | ✅ `/`, `/login`, `/template` | +| `dist/robots.txt` 자동 생성 헤더 확인 | ✅ | +| canonical 중복 0건 확인 | ✅ | +| IDE: vite.config.ts / router/index.tsx 타입 에러 | ✅ 해소 (`.d.cts` 추가) | + +--- + +## 10. 남은 후속 작업 (이번 PR 범위 외) + +### 단기 (배포 직후) +- Google Search Console에서 sitemap 제출 + 주요 URL의 "사용자 선언 canonical" vs "Google 선택 canonical" 확인 +- Rich Results Test로 JSON-LD 검증 +- Lighthouse SEO/접근성 점수 캡처 (배포 전후 비교) + +### 중기 +- `routes.cjs`의 `DESKTOP_PATHS`와 `router/index.tsx`의 `deviceType: "desktop"` 라우트가 여전히 손동기화 — 라우터의 deviceType metadata를 routes.cjs로 옮기면 완전한 단일 소스 달성 +- `/retrospect/write`의 모바일 404 → 200 변경에 대한 운영 모니터링 + +### 장기 +- router/index.tsx의 `lazy()` 컴포넌트 매핑을 컨벤션 기반으로 자동화 → routes.cjs가 라우트의 진짜 단일 소스가 됨 + +--- + +## 부록 — 커밋 일람 + +| SHA | 메시지 | +|---|---| +| eddb684d | 중복 OG/Twitter 메타 제거 / 초기 정적 canonical 제거 / Kakao SDK 중복 적용 제거 / 도메인 통일 www 제거 | +| f4faa5a6 | #847 SEO 메타 태그 주입 기능 추가 및 동적 라우트 설정 | +| a9e40237 | SEO 메타 태그 및 robots.txt 업데이트 / Vercel 설정 수정 | +| 8d65efd4 | 이미지 alt 속성 추가 및 로딩 최적화 / SEO 메타 태그 개선 | +| 391e256d | #847 Canonical 태그 중복 문제 수정 | +| c9fcccb0 | #847 SEO 라우트 정책을 seo.config.cjs로 분리 및 문서화 / 메타 주입 로직 개선 | +| 01ef9117 | #847 robots.txt 및 seo.config.cjs 동기화 기능 추가 / Vite 플러그인으로 자동 생성 | +| (미커밋) | `/space/join/:id` 에러 폴백, `routes.cjs` 단일 소스, `toRelative` → `toChildPath`, `@ts-expect-error` 제거, `.d.cts` 선언 파일 2개 추가, docs 작성 | diff --git a/docs/seo-server-guide.md b/docs/seo-server-guide.md new file mode 100644 index 000000000..9a4344a5a --- /dev/null +++ b/docs/seo-server-guide.md @@ -0,0 +1,517 @@ +# `apps/web/server/server.cjs` 완벽 가이드 + +> SPA의 SEO 한계를 보완하기 위해 라우트별 메타 태그를 동적으로 주입하고, soft 404를 막고, 캐시 정책을 잘게 적용하는 SEO 미들레이어. + +--- + +## 0. 한 문장 요약 + +이 파일은 **"SPA의 빈 HTML을 크롤러가 만나기 전에, 라우트별로 적절한 SEO 메타 태그를 주입해서 돌려주는 Express 서버"** 입니다. + +`.cjs` 확장자 = CommonJS 모듈. 프로젝트 전체는 ESM이지만 Vercel Serverless Function이 CommonJS 진입점을 요구해서 이 파일만 `.cjs`입니다. + +--- + +## 1. 왜 이런 서버가 필요한가? + +### 일반 SPA의 SEO 문제 + +Vite로 빌드한 SPA는 `dist/index.html` 하나가 모든 경로의 응답입니다. + +```html +<title>성장하는 당신을 위한 회고 서비스, Layer + +
+ +``` + +문제는 + +1. **크롤러는 JS를 즉시 실행하지 않습니다.** Googlebot은 일부 실행하지만 카카오톡 / 페이스북 / X(Twitter) OG 미리보기 봇은 거의 실행하지 않습니다. +2. 결과: `/template`을 카톡으로 공유해도 **루트 페이지의 메타**(`성장하는 당신을…`)가 보이고, 라우트별 description/og:image가 반영되지 않습니다. +3. Google 입장에서도 모든 라우트의 메타가 동일 → 인덱싱 품질 저하. + +### 이 파일의 해법 + +요청이 들어오면 + +1. `dist/index.html` (빈 SPA 셸)을 읽고 +2. 요청 경로(`req.path`)를 보고 라우트별 메타를 결정한 뒤 +3. cheerio로 HTML을 파싱해서 ``, `<meta>`, `<link rel="canonical">`을 **갈아끼우고** +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(`<link rel="canonical" href="${url}" data-rh="true" />`); + } + + 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) + │ ├ <title>, <meta>들 업데이트 + │ ├ <link rel="canonical" href="https://layerapp.io/template" /> 추가 + │ └ $.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 +# 기대: <link rel="canonical" href="https://layerapp.io/template" data-rh="true" /> + +# 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 '<loc>[^<]*</loc>' +``` + +--- + +## 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()` 매핑을 컨벤션 기반으로 자동화하면 단일 소스가 완성됨.