From d1405205d7237df1a5f96d7ab7677045a44e172c Mon Sep 17 00:00:00 2001 From: prgmr99 Date: Tue, 24 Mar 2026 12:18:11 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20OG/Twitt?= =?UTF-8?q?er=20=EB=A9=94=ED=83=80=20=ED=83=9C=EA=B7=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20/=20=EC=B4=88=EA=B8=B0=20=EC=A0=95=EC=A0=81=20canon?= =?UTF-8?q?ical=20=EC=A0=9C=EA=B1=B0=20/=20Kakao=20sdk=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=81=EC=9A=A9=20=EC=A0=9C=EA=B1=B0=20/=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=86=B5=EC=9D=BC=20www=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/index.html | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index c0dfa4d0..22ea81bf 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -46,7 +46,7 @@ - + @@ -61,24 +61,6 @@ - - - - - - - - - - - - - - - - - - From ebd16027922ef27d9b6f8cdcadd5a67f9534e24e Mon Sep 17 00:00:00 2001 From: prgmr99 Date: Tue, 24 Mar 2026 13:51:15 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20#847=20SEO=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=ED=83=9C=EA=B7=B8=20=EC=A3=BC=EC=9E=85=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/server/server.cjs | 120 +++++++++++++++++++++++++++---------- apps/web/vite.config.ts | 25 +++++++- 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/apps/web/server/server.cjs b/apps/web/server/server.cjs index ab90214b..289179c0 100644 --- a/apps/web/server/server.cjs +++ b/apps/web/server/server.cjs @@ -31,65 +31,121 @@ 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. + */ +function injectMeta(html, { title, description, image, url }) { + 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'); + } + + // Dynamic canonical URL injection + $('head').append(``); + + return $.html(); +} + +const BASE_URL = "https://layerapp.io"; + +const STATIC_ROUTE_META = { + "/": { + title: "성장하는 당신을 위한 회고 서비스, Layer", + description: "편리한 회고 작성부터 AI 분석까지 Layer에서 함께해요!", + }, + "/login": { + title: "로그인 | Layer", + description: "카카오, 구글 계정으로 간편하게 Layer에 로그인하세요.", + }, + "/m/template": { + title: "회고 템플릿 모음 | Layer", + description: "KPT, 5F, Mad Sad Glad 등 다양한 회고 양식을 제공합니다.", + }, +}; + +const DEFAULT_META = { + title: "성장하는 당신을 위한 회고 서비스, Layer", + description: "편리한 회고 작성부터 AI 분석까지 Layer에서 함께해요!", +}; + +function readIndexHtml() { + const filePath = path.join(distPath, "index.html"); + return fs.readFileSync(filePath, "utf8"); +} + +function getCanonicalUrl(reqPath) { + const canonicalPath = reqPath.replace(/^\/desktop/, '') || '/'; + return `${BASE_URL}${canonicalPath}`; +} + 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."); } + + const result = injectMeta(html, { + title: `${leaderName}님의 회고 초대장`, + description: `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`, + image: "https://kr.object.ncloudstorage.com/layer-bucket/retrospectOG.png", + url: getCanonicalUrl(req.path), + }); + + res.send(result); } catch (err) { - console.error("Error fetching space data:", err.message); + console.error("Error processing space join page:", err.message); return res.status(500).send("Failed to fetch space data."); } +}); +app.get("*", (req, res) => { 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 html = readIndexHtml(); + const canonicalUrl = getCanonicalUrl(req.path); + const routeMeta = STATIC_ROUTE_META[req.path] || DEFAULT_META; + + const result = injectMeta(html, { + ...routeMeta, + url: canonicalUrl, + }); + + res.send(result); } catch (err) { - console.error("Error manipulating HTML:", err); - return res.status(500).send("Error processing HTML."); + console.error("Error serving page:", err); + const filePath = path.join(distPath, "index.html"); + res.sendFile(filePath); } }); -app.get("*", (req, res) => { - 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 +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 335b08b7..506c0265 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,7 +18,30 @@ export default defineConfig(() => ({ }, }), svgr(), - Sitemap({ hostname: "https://layerapp.io" }), + Sitemap({ + hostname: "https://layerapp.io", + dynamicRoutes: ["/login", "/m/template"], + exclude: [ + "/desktop", + "/desktop/**", + "/staging", + "/setnickname/**", + "/myinfo", + "/myinfo/**", + "/write", + "/write/**", + "/retrospect", + "/retrospect/**", + "/space/create", + "/space/create/**", + "/goals", + "/goals/**", + "/api/**", + ], + changefreq: "weekly", + priority: 0.8, + lastmod: new Date(), + }), VitePluginRadar({ analytics: process.env.VITE_GOOGLE_ANALYTICS ? { id: process.env.VITE_GOOGLE_ANALYTICS } : undefined, }), From c8bdf029874a110d7fb2d57d890c5bce455cdba7 Mon Sep 17 00:00:00 2001 From: prgmr99 Date: Tue, 12 May 2026 10:53:47 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20SEO=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EB=B0=8F=20robots.txt=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20/=20Vercel=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/robots.txt | 28 +++++- apps/web/server/server.cjs | 170 +++++++++++++++++++++++++++++++++---- apps/web/vercel.json | 6 +- apps/web/vite.config.ts | 8 +- 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index cfd7f0d6..ae57c9b6 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -1,3 +1,29 @@ 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/server/server.cjs b/apps/web/server/server.cjs index 289179c0..77524eb2 100644 --- a/apps/web/server/server.cjs +++ b/apps/web/server/server.cjs @@ -31,11 +31,23 @@ function decryptId(encryptedId) { return decrypted.toString(CryptoJS.enc.Utf8); } +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"; + /** * 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 }) { +function injectMeta(html, { title, description, image, url, noindex }) { const $ = cheerio.load(html); $('title').text(title); @@ -52,32 +64,92 @@ function injectMeta(html, { title, description, image, url }) { $('meta[name="twitter:image"]').attr('content', image).attr('data-rh', 'true'); } - // Dynamic canonical URL injection - $('head').append(``); + // 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(); } -const BASE_URL = "https://layerapp.io"; - +/** + * 정적 라우트별 메타 정보. + * key: req.path (Express에서 query string 제외된 경로) + * 라우터 정의(`apps/web/src/router/index.tsx`)와 일치해야 함. + */ const STATIC_ROUTE_META = { + // ── 공개 페이지 (인덱싱 허용) "/": { title: "성장하는 당신을 위한 회고 서비스, Layer", - description: "편리한 회고 작성부터 AI 분석까지 Layer에서 함께해요!", + description: + "회고 작성부터 AI 분석까지, Layer에서 KPT·5F 등 다양한 템플릿으로 개인·팀 회고를 시작해보세요.", + image: DEFAULT_OG_IMAGE, }, "/login": { title: "로그인 | Layer", description: "카카오, 구글 계정으로 간편하게 Layer에 로그인하세요.", + image: DEFAULT_OG_IMAGE, }, - "/m/template": { + "/template": { title: "회고 템플릿 모음 | Layer", - description: "KPT, 5F, Mad Sad Glad 등 다양한 회고 양식을 제공합니다.", + description: "KPT, 5F, Mad Sad Glad 등 검증된 회고 템플릿을 무료로 만나보세요.", + image: DEFAULT_OG_IMAGE, + }, + "/desktop": { + title: "성장하는 당신을 위한 회고 서비스, Layer", + description: + "회고 작성부터 AI 분석까지, Layer에서 KPT·5F 등 다양한 템플릿으로 개인·팀 회고를 시작해보세요.", + image: DEFAULT_OG_IMAGE, }, + "/desktop/login": { + title: "로그인 | Layer", + description: "카카오, 구글 계정으로 간편하게 Layer에 로그인하세요.", + image: DEFAULT_OG_IMAGE, + }, + + // ── 인증 필요 / 개인 페이지 (noindex) + "/myinfo": { noindex: true }, + "/write": { noindex: true }, + "/retrospect/new": { noindex: true }, + "/retrospect/recommend": { noindex: true }, + "/retrospect/analysis": { noindex: true }, + "/retrospect/complete": { noindex: true }, + "/space/create": { noindex: true }, + "/goals": { noindex: true }, + "/analysis": { noindex: true }, + "/staging": { noindex: true }, }; +const NOINDEX_PATH_PREFIXES = [ + "/myinfo/", + "/write/", + "/retrospect/recommend/", + "/space/edit/", + "/space/create/", + "/setnickname/", + "/goals/", + "/api/", + "/desktop/myinfo", + "/desktop/write", + "/desktop/retrospect", + "/desktop/space/create", + "/desktop/space/edit/", + "/desktop/setnickname/", + "/desktop/goals", + "/desktop/analysis", +]; + const DEFAULT_META = { title: "성장하는 당신을 위한 회고 서비스, Layer", - description: "편리한 회고 작성부터 AI 분석까지 Layer에서 함께해요!", + description: + "회고 작성부터 AI 분석까지, Layer에서 KPT·5F 등 다양한 템플릿으로 개인·팀 회고를 시작해보세요.", + image: DEFAULT_OG_IMAGE, }; function readIndexHtml() { @@ -86,10 +158,60 @@ function readIndexHtml() { } function getCanonicalUrl(reqPath) { - const canonicalPath = reqPath.replace(/^\/desktop/, '') || '/'; + // /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'); + } +} + app.get("/space/join/:id", async (req, res) => { const encryptedId = req.params.id; let html; @@ -116,10 +238,11 @@ app.get("/space/join/:id", async (req, res) => { const result = injectMeta(html, { title: `${leaderName}님의 회고 초대장`, description: `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`, - image: "https://kr.object.ncloudstorage.com/layer-bucket/retrospectOG.png", + 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); @@ -129,15 +252,27 @@ app.get("/space/join/:id", async (req, res) => { app.get("*", (req, res) => { try { + const routeMeta = resolveRouteMeta(req.path); + const isNoindex = !!routeMeta?.noindex; + const cacheKind = isNoindex ? "static-noindex" : "static-public"; + + // 캐시 히트 + if (META_CACHE.has(req.path)) { + setCacheHeaders(res, cacheKind); + return res.send(META_CACHE.get(req.path)); + } + const html = readIndexHtml(); const canonicalUrl = getCanonicalUrl(req.path); - const routeMeta = STATIC_ROUTE_META[req.path] || DEFAULT_META; - const result = injectMeta(html, { - ...routeMeta, - url: canonicalUrl, - }); + const meta = isNoindex + ? { ...DEFAULT_META, ...routeMeta, url: canonicalUrl, noindex: true } + : { ...DEFAULT_META, ...(routeMeta || {}), url: canonicalUrl }; + const result = injectMeta(html, meta); + + setMetaCache(req.path, result); + setCacheHeaders(res, cacheKind); res.send(result); } catch (err) { console.error("Error serving page:", err); @@ -149,3 +284,6 @@ app.get("*", (req, res) => { app.listen(3000, () => { console.log("Server running on http://localhost:3000"); }); + +// Vercel Serverless Function 진입점 호환을 위해 app export +module.exports = app; diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 35c58311..3a839f72 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.ts b/apps/web/vite.config.ts index 506c0265..de4bd913 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,7 +20,9 @@ export default defineConfig(() => ({ svgr(), Sitemap({ hostname: "https://layerapp.io", - dynamicRoutes: ["/login", "/m/template"], + // 라우터(`apps/web/src/router/index.tsx`)의 공개 라우트와 일치해야 함 + // 루트("/")는 hostname이 자동 포함하므로 dynamicRoutes에 명시하지 않음 + dynamicRoutes: ["/login", "/template"], exclude: [ "/desktop", "/desktop/**", @@ -34,13 +36,17 @@ export default defineConfig(() => ({ "/retrospect/**", "/space/create", "/space/create/**", + "/space/edit/**", "/goals", "/goals/**", + "/analysis", "/api/**", ], changefreq: "weekly", priority: 0.8, lastmod: new Date(), + // public/robots.txt를 직접 관리하므로 자동 생성 비활성화 + generateRobotsTxt: false, }), VitePluginRadar({ analytics: process.env.VITE_GOOGLE_ANALYTICS ? { id: process.env.VITE_GOOGLE_ANALYTICS } : undefined, From ccc8c7ffbaf9c98796e261a9ae660b37531ce9fd Mon Sep 17 00:00:00 2001 From: prgmr99 Date: Tue, 12 May 2026 13:31:32 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20alt=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=B5=9C=EC=A0=81=ED=99=94=20/=20SEO=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=20=ED=83=9C=EA=B7=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/index.html | 61 +++++++++++++--- apps/web/server/server.cjs | 71 ++++++++++++++++--- .../retrospect/template/card/TemplateCard.tsx | 4 ++ .../common/ImageUploader/ImageUploader.tsx | 10 ++- .../Navigation/space/SpaceItem.tsx | 6 +- .../common/LocalNavigationBar/UserProfile.tsx | 5 ++ .../common/ProfileImage/ProfileImage.tsx | 4 ++ .../common/appBar/UserProfileIcon.tsx | 5 ++ apps/web/src/component/home/SpaceOverview.tsx | 5 ++ apps/web/src/component/info/UserBox.tsx | 5 ++ .../space/members/MemberActionView.tsx | 4 ++ .../retrospect/space/members/MemberList.tsx | 4 ++ .../retrospect/template/card/TemplateCard.tsx | 3 + .../src/component/space/join/JoinLetter.tsx | 5 ++ .../component/space/members/MembersItem.tsx | 5 ++ 15 files changed, 178 insertions(+), 19 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 22ea81bf..af2df348 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -14,7 +14,11 @@ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" /> 성장하는 당신을 위한 회고 서비스, Layer - + + @@ -29,11 +33,7 @@ - - + @@ -48,8 +48,9 @@ - + + @@ -58,8 +59,52 @@ - + + + + + diff --git a/apps/web/server/server.cjs b/apps/web/server/server.cjs index 77524eb2..4e13ff14 100644 --- a/apps/web/server/server.cjs +++ b/apps/web/server/server.cjs @@ -126,6 +126,42 @@ const STATIC_ROUTE_META = { "/staging": { noindex: true }, }; +/** + * 라우터(`apps/web/src/router/index.tsx`)에 실제 정의된 경로 패턴 화이트리스트. + * 미매칭 시 catch-all에서 404 status를 반환해 Google "soft 404" 패널티를 방지. + * + * 라우터 변경 시 함께 갱신할 것. + */ +const KNOWN_ROUTE_PATTERNS = [ + /^\/$/, // 홈 + /^\/login$/, + /^\/template$/, + /^\/analysis$/, + /^\/goals(\/(more|edit))?$/, + /^\/write(\/complete)?$/, + /^\/staging$/, + /^\/setnickname\/(kakao|google|apple)$/, + /^\/space\/create(\/(done|next))?$/, + /^\/space\/edit\/[^/]+$/, + /^\/space\/join\/[^/]+$/, + /^\/space\/[^/]+(\/(templates|members(\/edit)?))?$/, + /^\/retrospect\/(new|complete|analysis)$/, + /^\/retrospect\/recommend(\/(search|done))?$/, + /^\/myinfo(\/(modify|userdeletion|notices|help|license|termsofservice|privacypolicy|feedback))?$/, + /^\/api\/auth\/oauth2\/(kakao|google)$/, + // 데스크탑 + /^\/desktop$/, + /^\/desktop\/login$/, + /^\/desktop\/goals$/, + /^\/desktop\/space\/[^/]+$/, + /^\/desktop\/retrospect\/(analysis|write)$/, + /^\/desktop\/setnickname\/(kakao|google|apple)$/, +]; + +function isKnownRoute(reqPath) { + return KNOWN_ROUTE_PATTERNS.some((re) => re.test(reqPath)); +} + const NOINDEX_PATH_PREFIXES = [ "/myinfo/", "/write/", @@ -252,28 +288,45 @@ app.get("/space/join/:id", async (req, res) => { app.get("*", (req, res) => { try { + const knownRoute = isKnownRoute(req.path); const routeMeta = resolveRouteMeta(req.path); - const isNoindex = !!routeMeta?.noindex; + // 미정의 라우트 → soft 404 방지를 위해 noindex + 404 status + const isNoindex = !!routeMeta?.noindex || !knownRoute; const cacheKind = isNoindex ? "static-noindex" : "static-public"; + const cacheKey = knownRoute ? req.path : "__not_found__"; - // 캐시 히트 - if (META_CACHE.has(req.path)) { + // 캐시 히트 (정의된 라우트만) + if (knownRoute && META_CACHE.has(cacheKey)) { setCacheHeaders(res, cacheKind); - return res.send(META_CACHE.get(req.path)); + return res.send(META_CACHE.get(cacheKey)); } const html = readIndexHtml(); const canonicalUrl = getCanonicalUrl(req.path); - const meta = isNoindex - ? { ...DEFAULT_META, ...routeMeta, url: canonicalUrl, noindex: true } - : { ...DEFAULT_META, ...(routeMeta || {}), url: canonicalUrl }; + 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); - setMetaCache(req.path, result); + if (knownRoute) { + setMetaCache(cacheKey, result); + } setCacheHeaders(res, cacheKind); - res.send(result); + // 미정의 경로는 명시적 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"); 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 6051b6df..e8353fad 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 35196c7c..00e3239d 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 4b799913..394290a9 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; From a024cf64d91ea832fdff2b1733f7c5bdbe89152b Mon Sep 17 00:00:00 2001 From: prgmr99 Date: Mon, 18 May 2026 16:44:12 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20#847=20Canonical=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A4=91=EB=B3=B5=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/index.html b/apps/web/index.html index af2df348..3795239e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -36,7 +36,6 @@ - From 66b9b6f33b11bb89775203e8e4da79dc27dd3a9c Mon Sep 17 00:00:00 2001 From: prgmr99 Date: Mon, 18 May 2026 17:49:42 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20#847=20SEO=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EC=A0=95=EC=B1=85=EC=9D=84=20seo.config.c?= =?UTF-8?q?js=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20/=20=EB=A9=94=ED=83=80=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/seo.config.cjs | 65 ++++++ apps/web/server/server.cjs | 90 ++++---- apps/web/vite.config.ts | 32 +-- docs/seo-server-guide.md | 453 +++++++++++++++++++++++++++++++++++++ 4 files changed, 573 insertions(+), 67 deletions(-) create mode 100644 apps/web/seo.config.cjs create mode 100644 docs/seo-server-guide.md diff --git a/apps/web/seo.config.cjs b/apps/web/seo.config.cjs new file mode 100644 index 00000000..afb4c63d --- /dev/null +++ b/apps/web/seo.config.cjs @@ -0,0 +1,65 @@ +/** + * 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 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 = ["/login", "/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; + +module.exports = { + BASE_URL, + DEFAULT_OG_IMAGE, + INVITE_OG_IMAGE, + INDEXABLE_ROUTES, + PRIVATE_ROUTE_PREFIXES, + SITEMAP_EXCLUDE, + NOINDEX_PATH_PREFIXES, +}; diff --git a/apps/web/server/server.cjs b/apps/web/server/server.cjs index 4e13ff14..cd272e44 100644 --- a/apps/web/server/server.cjs +++ b/apps/web/server/server.cjs @@ -4,22 +4,30 @@ 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"); -// 프로젝트 루트 경로에서 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,10 +39,6 @@ function decryptId(encryptedId) { return decrypted.toString(CryptoJS.enc.Utf8); } -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"; - /** * Injects SEO meta tags into HTML using Cheerio. * Adds data-rh="true" for react-helmet-async compatibility. @@ -80,11 +84,15 @@ function injectMeta(html, { title, description, image, url, noindex }) { /** * 정적 라우트별 메타 정보. - * key: req.path (Express에서 query string 제외된 경로) - * 라우터 정의(`apps/web/src/router/index.tsx`)와 일치해야 함. + * + * 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: @@ -101,6 +109,7 @@ const STATIC_ROUTE_META = { description: "KPT, 5F, Mad Sad Glad 등 검증된 회고 템플릿을 무료로 만나보세요.", image: DEFAULT_OG_IMAGE, }, + // 데스크탑 진입점은 모바일과 동일 콘텐츠 — canonical은 `/desktop/x` → `/x`로 정규화됨 "/desktop": { title: "성장하는 당신을 위한 회고 서비스, Layer", description: @@ -112,18 +121,6 @@ const STATIC_ROUTE_META = { description: "카카오, 구글 계정으로 간편하게 Layer에 로그인하세요.", image: DEFAULT_OG_IMAGE, }, - - // ── 인증 필요 / 개인 페이지 (noindex) - "/myinfo": { noindex: true }, - "/write": { noindex: true }, - "/retrospect/new": { noindex: true }, - "/retrospect/recommend": { noindex: true }, - "/retrospect/analysis": { noindex: true }, - "/retrospect/complete": { noindex: true }, - "/space/create": { noindex: true }, - "/goals": { noindex: true }, - "/analysis": { noindex: true }, - "/staging": { noindex: true }, }; /** @@ -162,24 +159,7 @@ function isKnownRoute(reqPath) { return KNOWN_ROUTE_PATTERNS.some((re) => re.test(reqPath)); } -const NOINDEX_PATH_PREFIXES = [ - "/myinfo/", - "/write/", - "/retrospect/recommend/", - "/space/edit/", - "/space/create/", - "/setnickname/", - "/goals/", - "/api/", - "/desktop/myinfo", - "/desktop/write", - "/desktop/retrospect", - "/desktop/space/create", - "/desktop/space/edit/", - "/desktop/setnickname/", - "/desktop/goals", - "/desktop/analysis", -]; +// NOINDEX_PATH_PREFIXES는 seo.config.cjs에서 관리 (vite.config.ts의 sitemap exclude와 공유) const DEFAULT_META = { title: "성장하는 당신을 위한 회고 서비스, Layer", @@ -248,6 +228,20 @@ function setCacheHeaders(res, kind) { } } +/** + * 스페이스 초대 링크 — `/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; let html; @@ -286,11 +280,21 @@ app.get("/space/join/:id", async (req, res) => { } }); +/** + * 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) => { try { const knownRoute = isKnownRoute(req.path); const routeMeta = resolveRouteMeta(req.path); - // 미정의 라우트 → soft 404 방지를 위해 noindex + 404 status const isNoindex = !!routeMeta?.noindex || !knownRoute; const cacheKind = isNoindex ? "static-noindex" : "static-public"; const cacheKey = knownRoute ? req.path : "__not_found__"; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index de4bd913..e552f7cc 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,10 @@ import dotenv from "dotenv"; import { VitePluginRadar } from "vite-plugin-radar"; import { VitePWA } from "vite-plugin-pwa"; +// SEO 라우트 정책 단일 소스 — server/server.cjs와 공유 +// @ts-expect-error — CommonJS 모듈 (CJS↔ESM interop은 esbuild가 처리) +import { INDEXABLE_ROUTES, SITEMAP_EXCLUDE } from "./seo.config.cjs"; + dotenv.config(); // https://vitejs.dev/config/ export default defineConfig(() => ({ @@ -18,35 +22,15 @@ export default defineConfig(() => ({ }, }), svgr(), + // dynamicRoutes / exclude는 seo.config.cjs에서 관리 (server.cjs와 공유) Sitemap({ hostname: "https://layerapp.io", - // 라우터(`apps/web/src/router/index.tsx`)의 공개 라우트와 일치해야 함 - // 루트("/")는 hostname이 자동 포함하므로 dynamicRoutes에 명시하지 않음 - dynamicRoutes: ["/login", "/template"], - exclude: [ - "/desktop", - "/desktop/**", - "/staging", - "/setnickname/**", - "/myinfo", - "/myinfo/**", - "/write", - "/write/**", - "/retrospect", - "/retrospect/**", - "/space/create", - "/space/create/**", - "/space/edit/**", - "/goals", - "/goals/**", - "/analysis", - "/api/**", - ], + dynamicRoutes: INDEXABLE_ROUTES, + exclude: SITEMAP_EXCLUDE, changefreq: "weekly", priority: 0.8, lastmod: new Date(), - // public/robots.txt를 직접 관리하므로 자동 생성 비활성화 - generateRobotsTxt: false, + generateRobotsTxt: false, // public/robots.txt를 직접 관리하므로 자동 생성 비활성화 }), VitePluginRadar({ analytics: process.env.VITE_GOOGLE_ANALYTICS ? { id: process.env.VITE_GOOGLE_ANALYTICS } : undefined, diff --git a/docs/seo-server-guide.md b/docs/seo-server-guide.md new file mode 100644 index 00000000..ecf262ea --- /dev/null +++ b/docs/seo-server-guide.md @@ -0,0 +1,453 @@ +# `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 +성장하는 당신을 위한 회고 서비스, 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. 파일 구조 한눈에 + +| 섹션 | 행 번호 | 역할 | +|---|---|---| +| 초기화 | 1–14 | Express + dist 정적 서빙 | +| 복호화 | 16–32 | `/space/join/:id`의 암호화 ID 풀기 | +| 상수 | 34–36 | 도메인, OG 이미지 URL | +| 메타 주입기 | 50–79 | `injectMeta()` — 가장 중요한 함수 | +| 라우트 메타 사전 | 86–127 | `STATIC_ROUTE_META` — 경로별 title/desc | +| 라우트 화이트리스트 | 135–163 | `KNOWN_ROUTE_PATTERNS` — soft 404 방지 | +| noindex 정책 | 165–217 | 어떤 경로를 검색엔진에서 숨길지 | +| 캐시 | 224–249 | 메모리 캐시 + `Cache-Control` 헤더 | +| 동적 라우트 | 251–287 | `/space/join/:id` — 초대장 OG 이미지 | +| catch-all | 289–335 | 나머지 모든 GET 요청 처리 | +| 진입점 | 337–342 | 로컬 listen + Vercel export | + +--- + +## 3. 섹션별 상세 해부 + +### 3-1. 초기화 (1–14행) + +```js +require('dotenv').config(); // .env 파일 로드 (CRYPTO_KEY 등) +const express = require("express"); +const fs = require("fs"); // index.html 읽기 +const path = require("path"); +const cheerio = require("cheerio"); // 서버사이드 jQuery → HTML 파싱/조작 +const axios = require("axios"); // 백엔드 API 호출 (스페이스 정보 가져오기) +const CryptoJS = require("crypto-js"); // AES 복호화 + +const app = express(); +const distPath = path.resolve(__dirname, "../dist"); // Vite 빌드 결과물 위치 +app.use(express.static(distPath)); // /favicon.ico, /assets/*.js 등 정적 파일 먼저 매칭 +``` + +**핵심**: `express.static(distPath)`이 먼저 등록되어 있어 정적 자산(JS·CSS·이미지)은 cheerio를 거치지 않고 바로 응답됩니다. cheerio는 HTML 요청에만 동작합니다. + +### 3-2. 복호화 (16–32행) + +```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에 물어봐야 합니다. + +### 3-3. ⭐ `injectMeta()` — 이 파일의 심장 (50–79행) + +```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와 동일. +- `$('title').text(title)` — `<title>` 텍스트 교체. +- `.attr('content', …)` — 메타 태그의 `content` 속성 갱신. +- `.attr('data-rh', 'true')` — react-helmet-async가 클라이언트에서 SSR 메타를 인식할 때 쓰는 마커. 안 붙이면 React가 마운트되면서 메타가 사라질 수 있음. +- `image`가 옵셔널인 이유: 라우트가 별도 og:image 지정 안 하면 `index.html`의 기본값(`og-image.png`)을 그대로 두려고. +- `noindex ? 'noindex, nofollow' : 'index, follow'` — 검색엔진에 "이 페이지 색인하지 마 / 링크 따라가지 마"를 알리는 표준 메타. +- canonical은 `append`로 항상 새로 추가 (noindex일 땐 추가 안 함 — 신호 혼선 방지). +- `$.html()` — 변경된 DOM을 다시 HTML 문자열로 직렬화. + +> **Note**: `index.html`에는 정적 canonical을 두지 않습니다. 서버 인젝션과 중복되어 Google이 모든 canonical 신호를 무시하는 문제가 생깁니다. + +### 3-4. `STATIC_ROUTE_META` — 라우트별 메타 사전 (86–127행) + +```js +const STATIC_ROUTE_META = { + "/": { title: "성장하는 당신을 위한…", description: "…", image: DEFAULT_OG_IMAGE }, + "/login": { title: "로그인 | Layer", description: "…", image: DEFAULT_OG_IMAGE }, + "/template": { title: "회고 템플릿 모음 | Layer", /* … */ }, + // … + "/myinfo": { noindex: true }, // 개인 페이지는 메타도 의미 없고 인덱싱 차단 + "/write": { noindex: true }, + // … +}; +``` + +경로 → 메타 매핑. 공개 페이지엔 title/desc/image를, 개인 페이지엔 `noindex: true`만 표시. catch-all에서 이 사전을 lookup해서 응답을 만듭니다. + +### 3-5. ⭐ `KNOWN_ROUTE_PATTERNS` — soft 404 방지 (135–163행) + +```js +const KNOWN_ROUTE_PATTERNS = [ + /^\/$/, + /^\/login$/, + /^\/template$/, + /^\/space\/[^/]+(\/(templates|members(\/edit)?))?$/, // 동적 ID 포함 + /^\/retrospect\/(new|complete|analysis)$/, + // … +]; + +function isKnownRoute(reqPath) { + return KNOWN_ROUTE_PATTERNS.some((re) => re.test(reqPath)); +} +``` + +**Soft 404가 뭔가?** + +- SPA의 `/asdfasdf` 같은 존재 안 하는 경로에 접근해도 SPA는 보통 200 응답에 "페이지 없음" 컴포넌트를 보여줍니다. +- Google은 이를 "내용은 404 같은데 status가 200이네?" → **soft 404** 페널티 매김. +- 해결: 서버에서 라우터 정의와 비교해 **존재하지 않는 경로면 명시적 404 status**를 반환. + +### 3-6. `NOINDEX_PATH_PREFIXES`와 `isNoindexPath` (165–217행) + +```js +const NOINDEX_PATH_PREFIXES = ["/myinfo/", "/write/", "/api/", "/desktop/myinfo", /* … */]; + +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; // 모르는 라우트 +} +``` + +`STATIC_ROUTE_META`는 **정확한 경로 매칭**이고, `NOINDEX_PATH_PREFIXES`는 **prefix 매칭**입니다. 예를 들어 `/myinfo`는 STATIC_ROUTE_META에, `/myinfo/modify`는 prefix `/myinfo/`에 잡힙니다. + +### 3-7. `getCanonicalUrl` — URL 정규화 (196–203행) + +```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`으로 통일해서 **중복 콘텐츠 페널티 방지**. + +### 3-8. 캐시 (224–249행) + +```js +const META_CACHE = new Map(); // 메모리 LRU (정확히는 FIFO, 최대 100) +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); +} + +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분 (DDoS 완화 목적) + 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과 사용자 브라우저가 우리 응답을 얼마나 저장할지 지시. + +### 3-9. ⭐ 동적 라우트 `/space/join/:id` (251–287행) + +```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. 백엔드에서 스페이스 정보 + `${process.env.VITE_API_URL}/api/space/public/${decryptedId}` + ); + const spaceData = apiResponse.data; + const leaderName = spaceData?.leader?.name; + const teamName = spaceData?.name; + + if (!leaderName || !teamName) throw new Error("Missing leaderName or teamName."); + + const result = injectMeta(html, { // 3. 메타 주입 + title: `${leaderName}님의 회고 초대장`, + description: `함께 회고해요! ${leaderName}님이 ${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); + return res.status(500).send("Failed to fetch space data."); + } +}); +``` + +**사용자 시나리오** + +1. 친구가 카톡으로 `https://layerapp.io/space/join/eHl6PT0=` 같은 링크를 공유. +2. 카톡 OG 봇이 이 URL을 GET 요청. +3. 서버는 `eHl6PT0=`를 복호화해 실제 스페이스 ID를 얻고, 백엔드에서 리더 이름 · 팀 이름을 fetch. +4. `"홍길동님의 회고 초대장"` 같은 개인화된 메타로 HTML을 만들어 응답. +5. 카톡 미리보기에 정확한 정보가 표시됨. + +### 3-10. ⭐ Catch-all `app.get("*")` (289–335행) + +```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__` 키) | + +### 3-11. 진입점 (337–342행) + +```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`의 라우팅 룰이 모든 비-정적 요청을 이 함수로 보냄. + +--- + +## 4. 한 요청의 라이프사이클 예시 + +**사용자가 `/template`에 접속했을 때** + +``` +브라우저 → Vercel Edge + ↓ + (정적 파일 아님 → server.cjs로 위임) + ↓ +app.get("*") 진입 + ├ isKnownRoute("/template") → true (/^\/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 정상 동작 +크롤러: 메타만 읽고 종료 → 라우트별 정확한 정보로 인덱싱 +``` + +--- + +## 5. 검증 방법 + +`index.html`의 정적 canonical을 제거한 뒤(옵션 A 수정) 다음과 같이 확인할 수 있습니다. + +```bash +# 로컬에서: +pnpm web build && pnpm web start + +curl -s http://localhost:3000/template | grep -i canonical +# 기대 출력: <link rel="canonical" href="https://layerapp.io/template" data-rh="true" /> (1개만) + +curl -s http://localhost:3000/myinfo | grep -i canonical +# 기대 출력: 없음 (noindex 페이지) + +curl -sI http://localhost:3000/asdf-no-such-page | head -1 +# 기대 출력: HTTP/1.1 404 Not Found +``` + +`grep`이 한 줄만 출력하면 옵션 A 수정이 정상 적용된 것이고, canonical 중복이 해결됐다는 의미입니다. + +--- + +## 6. 핵심 키워드 정리 + +| 용어 | 의미 | +|---|---| +| **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** | 요청마다 임시 인스턴스가 뜨는 서버리스 함수 | + +--- + +## 7. 유지보수 시 주의 사항 + +라우트를 추가 / 변경 / 삭제할 때 **다음 4곳을 함께 갱신**해야 정합성이 유지됩니다. + +1. `apps/web/src/router/index.tsx` — 실제 React Router 정의 +2. `apps/web/server/server.cjs` — `STATIC_ROUTE_META`, `KNOWN_ROUTE_PATTERNS`, `NOINDEX_PATH_PREFIXES` +3. `apps/web/public/robots.txt` — 크롤러 차단 경로 +4. `apps/web/vite.config.ts` — Sitemap 플러그인의 `dynamicRoutes` / `exclude` + +이 중 하나라도 빠지면 + +- 사용자는 정상이지만 크롤러가 잘못된 메타를 봄 +- 또는 존재하는 라우트가 404로 잘못 분류됨 +- 또는 비공개 페이지가 검색에 노출됨 + +장기적으로는 단일 진실 소스(예: 공통 상수 모듈)에서 import하도록 리팩토링하는 것을 권장합니다. From 2c54a5a6f870e9e7230d30c52d2ea66d13d61d58 Mon Sep 17 00:00:00 2001 From: prgmr99 <johnyeom24@naver.com> Date: Tue, 19 May 2026 09:29:15 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20#847=20robots.txt=20=EB=B0=8F?= =?UTF-8?q?=20seo.config.cjs=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20/=20Vite=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/robots.txt | 5 ++--- apps/web/seo.config.cjs | 42 ++++++++++++++++++++++++++++++++++++++ apps/web/server/server.cjs | 13 +++++++++++- apps/web/vite.config.ts | 29 +++++++++++++++++++++++++- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index ae57c9b6..8ee79b7a 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -1,7 +1,8 @@ +# 자동 생성됨 — 수정 시 apps/web/seo.config.cjs의 ROBOTS_DISALLOW_PREFIXES를 변경하세요. + User-agent: * Allow: / -# 인증 필요 / 개인 페이지 (모바일) Disallow: /myinfo Disallow: /write Disallow: /retrospect/new @@ -15,8 +16,6 @@ Disallow: /goals Disallow: /analysis Disallow: /api/ Disallow: /staging - -# 인증 필요 / 개인 페이지 (데스크탑) Disallow: /desktop/myinfo Disallow: /desktop/write Disallow: /desktop/retrospect diff --git a/apps/web/seo.config.cjs b/apps/web/seo.config.cjs index afb4c63d..734557a0 100644 --- a/apps/web/seo.config.cjs +++ b/apps/web/seo.config.cjs @@ -54,6 +54,47 @@ 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, @@ -62,4 +103,5 @@ module.exports = { PRIVATE_ROUTE_PREFIXES, SITEMAP_EXCLUDE, NOINDEX_PATH_PREFIXES, + ROBOTS_DISALLOW_PREFIXES, }; diff --git a/apps/web/server/server.cjs b/apps/web/server/server.cjs index cd272e44..c762fee2 100644 --- a/apps/web/server/server.cjs +++ b/apps/web/server/server.cjs @@ -276,7 +276,18 @@ app.get("/space/join/:id", async (req, res) => { res.send(result); } catch (err) { console.error("Error processing space join page:", err.message); - return res.status(500).send("Failed to fetch space data."); + // 복호화 실패·백엔드 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); } }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e552f7cc..e35e29db 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,6 +2,7 @@ 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"; @@ -9,12 +10,38 @@ import { VitePWA } from "vite-plugin-pwa"; // SEO 라우트 정책 단일 소스 — server/server.cjs와 공유 // @ts-expect-error — CommonJS 모듈 (CJS↔ESM interop은 esbuild가 처리) -import { INDEXABLE_ROUTES, SITEMAP_EXCLUDE } from "./seo.config.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: { From 9371fda409649b2fb2385da673f5ae970549cc80 Mon Sep 17 00:00:00 2001 From: prgmr99 <johnyeom24@naver.com> Date: Tue, 19 May 2026 13:42:40 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20#847=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/routes.cjs | 128 +++++++++++++ apps/web/routes.d.cts | 82 ++++++++ apps/web/seo.config.cjs | 4 +- apps/web/seo.config.d.cts | 25 +++ apps/web/server/server.cjs | 35 +--- apps/web/src/router/index.tsx | 95 +++++----- apps/web/vite.config.d.ts | 2 + apps/web/vite.config.js | 203 ++++++++++++++++++++ apps/web/vite.config.ts | 1 - docs/seo-improvements.md | 347 ++++++++++++++++++++++++++++++++++ docs/seo-server-guide.md | 272 ++++++++++++++++---------- 11 files changed, 1011 insertions(+), 183 deletions(-) create mode 100644 apps/web/routes.cjs create mode 100644 apps/web/routes.d.cts create mode 100644 apps/web/seo.config.d.cts create mode 100644 apps/web/vite.config.d.ts create mode 100644 apps/web/vite.config.js create mode 100644 docs/seo-improvements.md diff --git a/apps/web/routes.cjs b/apps/web/routes.cjs new file mode 100644 index 00000000..3c91c41e --- /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 00000000..439d9a1e --- /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 index 734557a0..297ab8ab 100644 --- a/apps/web/seo.config.cjs +++ b/apps/web/seo.config.cjs @@ -11,6 +11,8 @@ * @see apps/web/src/router/index.tsx 실제 라우터 정의 */ +const { ROUTES } = require("./routes.cjs"); + const BASE_URL = "https://layerapp.io"; const DEFAULT_OG_IMAGE = @@ -23,7 +25,7 @@ const INVITE_OG_IMAGE = * 공개 인덱싱 라우트 (sitemap dynamicRoutes). * 루트("/")는 sitemap hostname이 자동 포함하므로 생략합니다. */ -const INDEXABLE_ROUTES = ["/login", "/template"]; +const INDEXABLE_ROUTES = [ROUTES.LOGIN, ROUTES.TEMPLATE]; /** * 비공개(noindex) 라우트 prefix. diff --git a/apps/web/seo.config.d.cts b/apps/web/seo.config.d.cts new file mode 100644 index 00000000..7c24f3cc --- /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 c762fee2..bb6dbf3f 100644 --- a/apps/web/server/server.cjs +++ b/apps/web/server/server.cjs @@ -14,6 +14,9 @@ const { NOINDEX_PATH_PREFIXES, } = require("../seo.config.cjs"); +// 라우트 정의 단일 소스 — src/router/index.tsx와 공유 +const { KNOWN_ROUTE_PATTERNS } = require("../routes.cjs"); + const app = express(); // Vite 빌드 결과물(`dist/`)에서 정적 자산을 먼저 응답. @@ -123,37 +126,7 @@ const STATIC_ROUTE_META = { }, }; -/** - * 라우터(`apps/web/src/router/index.tsx`)에 실제 정의된 경로 패턴 화이트리스트. - * 미매칭 시 catch-all에서 404 status를 반환해 Google "soft 404" 패널티를 방지. - * - * 라우터 변경 시 함께 갱신할 것. - */ -const KNOWN_ROUTE_PATTERNS = [ - /^\/$/, // 홈 - /^\/login$/, - /^\/template$/, - /^\/analysis$/, - /^\/goals(\/(more|edit))?$/, - /^\/write(\/complete)?$/, - /^\/staging$/, - /^\/setnickname\/(kakao|google|apple)$/, - /^\/space\/create(\/(done|next))?$/, - /^\/space\/edit\/[^/]+$/, - /^\/space\/join\/[^/]+$/, - /^\/space\/[^/]+(\/(templates|members(\/edit)?))?$/, - /^\/retrospect\/(new|complete|analysis)$/, - /^\/retrospect\/recommend(\/(search|done))?$/, - /^\/myinfo(\/(modify|userdeletion|notices|help|license|termsofservice|privacypolicy|feedback))?$/, - /^\/api\/auth\/oauth2\/(kakao|google)$/, - // 데스크탑 - /^\/desktop$/, - /^\/desktop\/login$/, - /^\/desktop\/goals$/, - /^\/desktop\/space\/[^/]+$/, - /^\/desktop\/retrospect\/(analysis|write)$/, - /^\/desktop\/setnickname\/(kakao|google|apple)$/, -]; +// KNOWN_ROUTE_PATTERNS는 routes.cjs에서 derive (src/router/index.tsx의 ROUTES와 단일 소스) function isKnownRoute(reqPath) { return KNOWN_ROUTE_PATTERNS.some((re) => re.test(reqPath)); diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index a0208327..ea92c39d 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 = <T extends Record<string, any>>(factory: () => Promise<T>, name: keyof T) => lazy(() => factory().then((m) => ({ default: m[name] as React.ComponentType<any> }))); @@ -91,17 +94,17 @@ const withSuspense = (element: React.ReactNode) => <Suspense fallback={null}>{el // 공통 라우트 (모바일/데스크탑 구분 없음) const commonRoutes: RouteChildren[] = [ { - path: "api/auth/oauth2/kakao", + path: toChildPath(ROUTES.OAUTH_KAKAO), element: withSuspense(<KakaoLoginRedirection />), auth: false, }, { - path: "api/auth/oauth2/google", + path: toChildPath(ROUTES.OAUTH_GOOGLE), element: withSuspense(<GoogleLoginRedirection />), auth: false, }, { - path: "space/join/:id", + path: toChildPath(ROUTES.SPACE_JOIN), element: isDesktop ? withSuspense(<JoinDesktopSpacePage />) : withSuspense(<JoinMobileSpacePage />), auth: false, }, @@ -111,19 +114,19 @@ const commonRoutes: RouteChildren[] = [ const deviceSpecificRoutes: RouteChildren[] = [ // 홈 관련 라우트 - 모바일 { - path: "", + path: toChildPath(ROUTES.ROOT), element: <HomeLayout />, children: [ { - path: "", + path: toChildPath(ROUTES.ROOT), element: <RetrospectViewPage />, }, { - path: "analysis", + path: toChildPath(ROUTES.ANALYSIS), element: withSuspense(<AnalysisViewPage />), }, { - path: "goals", + path: toChildPath(ROUTES.GOALS), element: withSuspense(<GoalViewPage />), }, ], @@ -132,27 +135,27 @@ const deviceSpecificRoutes: RouteChildren[] = [ }, // 홈 관련 라우트 - 데스크탑 { - path: "", + path: toChildPath(ROUTES.ROOT), element: <DesktopHomeLayout />, children: [ { - path: "", + path: toChildPath(ROUTES.ROOT), element: <HomePage />, }, { - path: "goals", + path: toChildPath(ROUTES.GOALS), element: <div>Desktop Goals</div>, }, { - path: "space/:spaceId", + path: toChildPath(ROUTES.SPACE_VIEW), element: withSuspense(<RetroSpectSpacePage />), }, { - path: "retrospect/analysis", + path: toChildPath(ROUTES.RETROSPECT_ANALYSIS), element: withSuspense(<AnalysisPage />), }, { - path: "retrospect/write", + path: toChildPath(ROUTES.RETROSPECT_WRITE), element: withSuspense(<RetroSpectWritePage />), }, ], @@ -161,26 +164,26 @@ const deviceSpecificRoutes: RouteChildren[] = [ }, // 로그인 관련 { - path: "login", + path: toChildPath(ROUTES.LOGIN), element: withSuspense(<LoginPage />), auth: false, deviceType: "mobile", }, { - path: "login", + path: toChildPath(ROUTES.LOGIN), element: withSuspense(<DesktopLoginPage />), auth: false, deviceType: "desktop", }, // 회고 작성 - 모바일 { - path: "write", + path: toChildPath(ROUTES.WRITE), element: withSuspense(<RetrospectWritePage />), auth: true, deviceType: "mobile", }, { - path: "write/complete", + path: toChildPath(ROUTES.WRITE_COMPLETE), element: withSuspense(<RetrospectWriteCompletePage />), auth: true, deviceType: "mobile", @@ -188,7 +191,7 @@ const deviceSpecificRoutes: RouteChildren[] = [ // 템플릿 - 모바일 { - path: "template", + path: toChildPath(ROUTES.TEMPLATE), element: withSuspense(<TemplatePage />), auth: false, deviceType: "mobile", @@ -196,21 +199,21 @@ const deviceSpecificRoutes: RouteChildren[] = [ // 스테이징 - 모바일 { - path: "staging", + path: toChildPath(ROUTES.STAGING), element: withSuspense(<Staging />), auth: false, deviceType: "mobile", }, // 닉네임 설정 - 모바일 { - path: "setnickname/:socialType", + path: toChildPath(ROUTES.SETNICKNAME), element: withSuspense(<SetNickNamePage />), auth: false, deviceType: "mobile", }, // 닉네임 설정 - 데스크탑 { - path: "setnickname/:socialType", + path: toChildPath(ROUTES.SETNICKNAME), element: withSuspense(<DesktopSetNickNamePage />), auth: false, deviceType: "desktop", @@ -218,49 +221,49 @@ const deviceSpecificRoutes: RouteChildren[] = [ // 스페이스 관련 - 모바일 { - path: "space/create", + path: toChildPath(ROUTES.SPACE_CREATE), element: withSuspense(<CreateSpacePage />), auth: true, deviceType: "mobile", }, { - path: "space/create/done", + path: toChildPath(ROUTES.SPACE_CREATE_DONE), element: withSuspense(<CreateDonePage />), auth: true, deviceType: "mobile", }, { - path: "space/create/next", + path: toChildPath(ROUTES.SPACE_CREATE_NEXT), element: withSuspense(<CreateNextPage />), auth: true, deviceType: "mobile", }, { - path: "space/edit/:id", + path: toChildPath(ROUTES.SPACE_EDIT), element: withSuspense(<SpaceEditPage />), auth: true, deviceType: "mobile", }, { - path: "space/:spaceId", + path: toChildPath(ROUTES.SPACE_VIEW), element: withSuspense(<SpaceViewPage />), auth: true, deviceType: "mobile", }, { - path: "space/:spaceId/templates", + path: toChildPath(ROUTES.SPACE_TEMPLATES), element: withSuspense(<TemplateListPage />), auth: true, deviceType: "mobile", }, { - path: "space/:spaceId/members", + path: toChildPath(ROUTES.SPACE_MEMBERS), element: withSuspense(<MembersListPage />), auth: true, deviceType: "mobile", }, { - path: "space/:spaceId/members/edit", + path: toChildPath(ROUTES.SPACE_MEMBERS_EDIT), element: withSuspense(<MembersEditListPage />), auth: true, deviceType: "mobile", @@ -268,37 +271,37 @@ const deviceSpecificRoutes: RouteChildren[] = [ // 회고 생성 - 모바일 { - path: "retrospect/new", + path: toChildPath(ROUTES.RETROSPECT_NEW), element: withSuspense(<RetrospectCreate />), auth: true, deviceType: "mobile", }, { - path: "retrospect/complete", + path: toChildPath(ROUTES.RETROSPECT_COMPLETE), element: withSuspense(<RetrospectCreateComplete />), auth: true, deviceType: "mobile", }, { - path: "retrospect/recommend", + path: toChildPath(ROUTES.RETROSPECT_RECOMMEND), element: withSuspense(<RecommendTemplatePage />), auth: true, deviceType: "mobile", }, { - path: "retrospect/recommend/search", + path: toChildPath(ROUTES.RETROSPECT_RECOMMEND_SEARCH), element: withSuspense(<RecommendSearch />), auth: true, deviceType: "mobile", }, { - path: "retrospect/recommend/done", + path: toChildPath(ROUTES.RETROSPECT_RECOMMEND_DONE), element: withSuspense(<RecommendDonePage />), auth: true, deviceType: "mobile", }, { - path: "retrospect/analysis", + path: toChildPath(ROUTES.RETROSPECT_ANALYSIS), element: withSuspense(<RetrospectAnalysisPage />), auth: true, deviceType: "mobile", @@ -306,55 +309,55 @@ const deviceSpecificRoutes: RouteChildren[] = [ // 내 정보 - 모바일 { - path: "myinfo", + path: toChildPath(ROUTES.MYINFO), element: withSuspense(<MyInfo />), auth: true, deviceType: "mobile", }, { - path: "myinfo/modify", + path: toChildPath(ROUTES.MYINFO_MODIFY), element: withSuspense(<ModifyMyInfo />), auth: true, deviceType: "mobile", }, { - path: "myinfo/userdeletion", + path: toChildPath(ROUTES.MYINFO_USERDELETION), element: withSuspense(<UserDeletion />), auth: true, deviceType: "mobile", }, { - path: "myinfo/notices", + path: toChildPath(ROUTES.MYINFO_NOTICES), element: withSuspense(<NoticePage />), auth: true, deviceType: "mobile", }, { - path: "myinfo/help", + path: toChildPath(ROUTES.MYINFO_HELP), element: withSuspense(<HelpPage />), auth: true, deviceType: "mobile", }, { - path: "myinfo/license", + path: toChildPath(ROUTES.MYINFO_LICENSE), element: withSuspense(<LicensePage />), auth: true, deviceType: "mobile", }, { - path: "myinfo/termsofservice", + path: toChildPath(ROUTES.MYINFO_TERMSOFSERVICE), element: withSuspense(<TermsOfServicePage />), auth: true, deviceType: "mobile", }, { - path: "myinfo/privacypolicy", + path: toChildPath(ROUTES.MYINFO_PRIVACYPOLICY), element: withSuspense(<PrivacyPolicyPage />), auth: true, deviceType: "mobile", }, { - path: "myinfo/feedback", + path: toChildPath(ROUTES.MYINFO_FEEDBACK), element: withSuspense(<FeedbackPage />), auth: true, deviceType: "mobile", @@ -362,13 +365,13 @@ const deviceSpecificRoutes: RouteChildren[] = [ // 목표/액션 아이템 - 모바일 { - path: "goals/more", + path: toChildPath(ROUTES.GOALS_MORE), element: withSuspense(<ActionItemMorePage />), auth: false, deviceType: "mobile", }, { - path: "goals/edit", + path: toChildPath(ROUTES.GOALS_EDIT), element: withSuspense(<ActionItemEditPage />), auth: false, deviceType: "mobile", diff --git a/apps/web/vite.config.d.ts b/apps/web/vite.config.d.ts new file mode 100644 index 00000000..089eeef9 --- /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 00000000..0825d6b1 --- /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 e35e29db..22b4f722 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,7 +9,6 @@ import { VitePluginRadar } from "vite-plugin-radar"; import { VitePWA } from "vite-plugin-pwa"; // SEO 라우트 정책 단일 소스 — server/server.cjs와 공유 -// @ts-expect-error — CommonJS 모듈 (CJS↔ESM interop은 esbuild가 처리) import { INDEXABLE_ROUTES, SITEMAP_EXCLUDE, BASE_URL, ROBOTS_DISALLOW_PREFIXES } from "./seo.config.cjs"; dotenv.config(); diff --git a/docs/seo-improvements.md b/docs/seo-improvements.md new file mode 100644 index 00000000..d6fcf6cc --- /dev/null +++ b/docs/seo-improvements.md @@ -0,0 +1,347 @@ +# #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로 라우트별 `<title>`, `<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. Viewport pinch-to-zoom 복원 + +**무엇** — `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />` → `maximum-scale=1.0, user-scalable=0` 제거. + +**왜** — WCAG 1.4.4(텍스트 크기 조정 200%) 위반. 시력 약한 사용자가 확대를 못 함. 특정 화면에서 확대를 막아야 한다면 JS로 페이지 단위 동적 제어 권장. + +**근거** — 코드에 WCAG 1.4.4 주석으로 의도 명시 — 무지로 인한 회귀를 방지. + +### 4-2. 이미지 `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-3. 구조화 데이터 (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 index ecf262ea..9a4344a5 100644 --- a/docs/seo-server-guide.md +++ b/docs/seo-server-guide.md @@ -44,45 +44,88 @@ React는 브라우저에서 평소처럼 실행되므로 사용자 경험은 그 --- -## 2. 파일 구조 한눈에 - -| 섹션 | 행 번호 | 역할 | -|---|---|---| -| 초기화 | 1–14 | Express + dist 정적 서빙 | -| 복호화 | 16–32 | `/space/join/:id`의 암호화 ID 풀기 | -| 상수 | 34–36 | 도메인, OG 이미지 URL | -| 메타 주입기 | 50–79 | `injectMeta()` — 가장 중요한 함수 | -| 라우트 메타 사전 | 86–127 | `STATIC_ROUTE_META` — 경로별 title/desc | -| 라우트 화이트리스트 | 135–163 | `KNOWN_ROUTE_PATTERNS` — soft 404 방지 | -| noindex 정책 | 165–217 | 어떤 경로를 검색엔진에서 숨길지 | -| 캐시 | 224–249 | 메모리 캐시 + `Cache-Control` 헤더 | -| 동적 라우트 | 251–287 | `/space/join/:id` — 초대장 OG 이미지 | -| catch-all | 289–335 | 나머지 모든 GET 요청 처리 | -| 진입점 | 337–342 | 로컬 listen + Vercel export | +## 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. 섹션별 상세 해부 +## 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 | -### 3-1. 초기화 (1–14행) +--- + +## 4. 섹션별 상세 해부 + +### 4-1. 초기화 + 단일 소스 require ```js -require('dotenv').config(); // .env 파일 로드 (CRYPTO_KEY 등) +require('dotenv').config(); const express = require("express"); -const fs = require("fs"); // index.html 읽기 +const fs = require("fs"); const path = require("path"); const cheerio = require("cheerio"); // 서버사이드 jQuery → HTML 파싱/조작 -const axios = require("axios"); // 백엔드 API 호출 (스페이스 정보 가져오기) -const CryptoJS = require("crypto-js"); // AES 복호화 +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"); // Vite 빌드 결과물 위치 -app.use(express.static(distPath)); // /favicon.ico, /assets/*.js 등 정적 파일 먼저 매칭 +const distPath = path.resolve(__dirname, "../dist"); +app.use(express.static(distPath)); ``` -**핵심**: `express.static(distPath)`이 먼저 등록되어 있어 정적 자산(JS·CSS·이미지)은 cheerio를 거치지 않고 바로 응답됩니다. cheerio는 HTML 요청에만 동작합니다. +**핵심**: +- `express.static(distPath)`이 먼저 등록되어 있어 정적 자산(JS·CSS·이미지)은 cheerio를 거치지 않고 바로 응답됩니다. +- BASE_URL, 이미지 URL, noindex prefix는 모두 외부 파일에서 import → 이 파일에는 SEO 정책이 하드코딩되어 있지 않습니다. -### 3-2. 복호화 (16–32행) +### 4-2. 복호화 ```js function decryptId(encryptedId) { @@ -96,9 +139,9 @@ function decryptId(encryptedId) { } ``` -**왜 필요한가?** 스페이스 초대 URL은 raw space ID를 노출하지 않기 위해 AES 암호화된 ID를 씁니다. 서버에서 OG 이미지·제목을 만들려면 이 ID를 풀어서 백엔드 API에 물어봐야 합니다. +스페이스 초대 URL은 raw space ID를 노출하지 않기 위해 AES 암호화된 ID를 씁니다. 서버에서 OG 이미지·제목을 만들려면 이 ID를 풀어서 백엔드 API에 물어봐야 합니다. -### 3-3. ⭐ `injectMeta()` — 이 파일의 심장 (50–79행) +### 4-3. ⭐ `injectMeta()` — 이 파일의 심장 ```js function injectMeta(html, { title, description, image, url, noindex }) { @@ -134,74 +177,64 @@ function injectMeta(html, { title, description, image, url, noindex }) { **라인별 해설** - `cheerio.load(html)` — HTML 문자열을 DOM 트리로 파싱. `$`는 jQuery API와 동일. -- `$('title').text(title)` — `<title>` 텍스트 교체. -- `.attr('content', …)` — 메타 태그의 `content` 속성 갱신. - `.attr('data-rh', 'true')` — react-helmet-async가 클라이언트에서 SSR 메타를 인식할 때 쓰는 마커. 안 붙이면 React가 마운트되면서 메타가 사라질 수 있음. -- `image`가 옵셔널인 이유: 라우트가 별도 og:image 지정 안 하면 `index.html`의 기본값(`og-image.png`)을 그대로 두려고. -- `noindex ? 'noindex, nofollow' : 'index, follow'` — 검색엔진에 "이 페이지 색인하지 마 / 링크 따라가지 마"를 알리는 표준 메타. +- `image`가 옵셔널인 이유: 라우트가 별도 og:image 지정 안 하면 `index.html`의 기본값을 그대로 두려고. - canonical은 `append`로 항상 새로 추가 (noindex일 땐 추가 안 함 — 신호 혼선 방지). -- `$.html()` — 변경된 DOM을 다시 HTML 문자열로 직렬화. -> **Note**: `index.html`에는 정적 canonical을 두지 않습니다. 서버 인젝션과 중복되어 Google이 모든 canonical 신호를 무시하는 문제가 생깁니다. +> **중요**: `index.html`에는 정적 canonical을 두지 **않습니다**. 서버 인젝션과 중복되면 Google이 모든 canonical 신호를 무시합니다. (#847 작업에서 제거 완료) -### 3-4. `STATIC_ROUTE_META` — 라우트별 메타 사전 (86–127행) +### 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", /* … */ }, - // … - "/myinfo": { noindex: true }, // 개인 페이지는 메타도 의미 없고 인덱싱 차단 - "/write": { noindex: true }, - // … + "/desktop": { /* alias of "/" */ }, + "/desktop/login": { /* alias of "/login" */ }, }; ``` -경로 → 메타 매핑. 공개 페이지엔 title/desc/image를, 개인 페이지엔 `noindex: true`만 표시. catch-all에서 이 사전을 lookup해서 응답을 만듭니다. +**공개 페이지만** 명시합니다. 비공개 페이지는 `NOINDEX_PATH_PREFIXES` (seo.config.cjs)의 prefix 매칭으로 일괄 처리되므로 여기 적을 필요 없습니다. (이전엔 `/myinfo: { noindex: true }` 같은 엔트리가 있었지만 prefix로 일원화) -### 3-5. ⭐ `KNOWN_ROUTE_PATTERNS` — soft 404 방지 (135–163행) +### 4-5. ⭐ `KNOWN_ROUTE_PATTERNS` — soft 404 방지 (routes.cjs에서 derive) ```js -const KNOWN_ROUTE_PATTERNS = [ - /^\/$/, - /^\/login$/, - /^\/template$/, - /^\/space\/[^/]+(\/(templates|members(\/edit)?))?$/, // 동적 ID 포함 - /^\/retrospect\/(new|complete|analysis)$/, - // … -]; +// 이 서버 파일 안에는 인라인 정규식이 없음. +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**를 반환. -### 3-6. `NOINDEX_PATH_PREFIXES`와 `isNoindexPath` (165–217행) +### 4-6. `NOINDEX_PATH_PREFIXES` (seo.config.cjs에서 derive) ```js -const NOINDEX_PATH_PREFIXES = ["/myinfo/", "/write/", "/api/", "/desktop/myinfo", /* … */]; +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 매칭 + if (STATIC_ROUTE_META[reqPath]) return STATIC_ROUTE_META[reqPath]; // 정확 매칭 (공개 페이지) + if (isNoindexPath(reqPath)) return { noindex: true }; // prefix 매칭 (비공개) return null; // 모르는 라우트 } ``` -`STATIC_ROUTE_META`는 **정확한 경로 매칭**이고, `NOINDEX_PATH_PREFIXES`는 **prefix 매칭**입니다. 예를 들어 `/myinfo`는 STATIC_ROUTE_META에, `/myinfo/modify`는 prefix `/myinfo/`에 잡힙니다. +`seo.config.cjs`의 `PRIVATE_ROUTE_PREFIXES`가 동일한 prefix 목록을 sitemap exclude와 noindex 양쪽에서 사용하도록 보장합니다 (단일 소스). -### 3-7. `getCanonicalUrl` — URL 정규화 (196–203행) +### 4-7. `getCanonicalUrl` — URL 정규화 ```js function getCanonicalUrl(reqPath) { @@ -213,28 +246,20 @@ function getCanonicalUrl(reqPath) { } ``` -**왜?** 모바일 `/login`과 데스크탑 `/desktop/login`은 같은 콘텐츠 → 둘 다 canonical을 `https://layerapp.io/login`으로 통일해서 **중복 콘텐츠 페널티 방지**. +모바일 `/login`과 데스크탑 `/desktop/login`은 같은 콘텐츠 → 둘 다 canonical을 `https://layerapp.io/login`으로 통일해서 **중복 콘텐츠 페널티 방지**. -### 3-8. 캐시 (224–249행) +### 4-8. 캐시 ```js -const META_CACHE = new Map(); // 메모리 LRU (정확히는 FIFO, 최대 100) +const META_CACHE = new Map(); // FIFO, 최대 100 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); -} - 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분 (DDoS 완화 목적) + // 인증 페이지: 브라우저 캐시 X, edge에서만 1분 res.set('Cache-Control', 'public, max-age=0, s-maxage=60'); } else { // 동적(/space/join/:id): edge 5분 @@ -248,7 +273,7 @@ function setCacheHeaders(res, kind) { 1. **In-memory** — cheerio 파싱은 CPU 비용이 있어 결과 HTML 자체를 메모리에 저장. 2. **HTTP 헤더** — Vercel Edge CDN과 사용자 브라우저가 우리 응답을 얼마나 저장할지 지시. -### 3-9. ⭐ 동적 라우트 `/space/join/:id` (251–287행) +### 4-9. ⭐ 동적 라우트 `/space/join/:id` — 에러 시 404 fallback ```js app.get("/space/join/:id", async (req, res) => { @@ -263,18 +288,15 @@ app.get("/space/join/:id", async (req, res) => { try { const decryptedId = decryptId(encryptedId); // 1. 복호화 - const apiResponse = await axios.get( // 2. 백엔드에서 스페이스 정보 + const apiResponse = await axios.get( // 2. 백엔드 fetch `${process.env.VITE_API_URL}/api/space/public/${decryptedId}` ); - const spaceData = apiResponse.data; - const leaderName = spaceData?.leader?.name; - const teamName = spaceData?.name; - - if (!leaderName || !teamName) throw new Error("Missing leaderName or teamName."); + const { leader, name: teamName } = apiResponse.data; + if (!leader?.name || !teamName) throw new Error("Missing data."); const result = injectMeta(html, { // 3. 메타 주입 - title: `${leaderName}님의 회고 초대장`, - description: `함께 회고해요! ${leaderName}님이 ${teamName} 스페이스에 초대했어요.`, + title: `${leader.name}님의 회고 초대장`, + description: `함께 회고해요! ${leader.name}님이 ${teamName} 스페이스에 초대했어요.`, image: INVITE_OG_IMAGE, url: getCanonicalUrl(req.path), }); @@ -283,20 +305,38 @@ app.get("/space/join/:id", async (req, res) => { res.send(result); } catch (err) { console.error("Error processing space join page:", err.message); - return res.status(500).send("Failed to fetch space data."); + // 복호화 실패·백엔드 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. +3. 서버는 `eHl6PT0=`를 복호화해 실제 스페이스 ID를 얻고, 백엔드에서 리더·팀 이름을 fetch. 4. `"홍길동님의 회고 초대장"` 같은 개인화된 메타로 HTML을 만들어 응답. 5. 카톡 미리보기에 정확한 정보가 표시됨. -### 3-10. ⭐ Catch-all `app.get("*")` (289–335행) +**에러 경로 (복호화 실패 / 백엔드 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) => { @@ -350,7 +390,7 @@ app.get("*", (req, res) => { | `KNOWN_ROUTE_PATTERNS`에 있음 + noindex prefix | 200 | DEFAULT + noindex | static-noindex | | `KNOWN_ROUTE_PATTERNS`에 없음 | **404** | 404 메타 + noindex | static-noindex (`__not_found__` 키) | -### 3-11. 진입점 (337–342행) +### 4-11. 진입점 ```js app.listen(3000, () => console.log("Server running on http://localhost:3000")); @@ -364,7 +404,7 @@ module.exports = app; // Vercel Serverless Function이 이 export를 핸들러 --- -## 4. 한 요청의 라이프사이클 예시 +## 5. 한 요청의 라이프사이클 예시 **사용자가 `/template`에 접속했을 때** @@ -374,7 +414,7 @@ module.exports = app; // Vercel Serverless Function이 이 export를 핸들러 (정적 파일 아님 → server.cjs로 위임) ↓ app.get("*") 진입 - ├ isKnownRoute("/template") → true (/^\/template$/ 매칭) + ├ isKnownRoute("/template") → true (ROUTES.TEMPLATE 매칭) ├ resolveRouteMeta("/template") → STATIC_ROUTE_META["/template"] ├ isNoindex → false ├ META_CACHE.has("/template") ? @@ -398,29 +438,39 @@ app.get("*") 진입 --- -## 5. 검증 방법 - -`index.html`의 정적 canonical을 제거한 뒤(옵션 A 수정) 다음과 같이 확인할 수 있습니다. +## 6. 검증 방법 ```bash -# 로컬에서: -pnpm web build && pnpm web start +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" /> (1개만) +# 기대: <link rel="canonical" href="https://layerapp.io/template" data-rh="true" /> +# 2. noindex 페이지는 canonical 없음 curl -s http://localhost:3000/myinfo | grep -i canonical -# 기대 출력: 없음 (noindex 페이지) +# 기대: 빈 출력 +# 3. 존재하지 않는 경로는 명시적 404 status curl -sI http://localhost:3000/asdf-no-such-page | head -1 -# 기대 출력: HTTP/1.1 404 Not Found -``` +# 기대: HTTP/1.1 404 Not Found -`grep`이 한 줄만 출력하면 옵션 A 수정이 정상 적용된 것이고, canonical 중복이 해결됐다는 의미입니다. +# 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>' +``` --- -## 6. 핵심 키워드 정리 +## 7. 핵심 키워드 정리 | 용어 | 의미 | |---|---| @@ -435,19 +485,33 @@ curl -sI http://localhost:3000/asdf-no-such-page | head -1 --- -## 7. 유지보수 시 주의 사항 +## 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 노출 시) -라우트를 추가 / 변경 / 삭제할 때 **다음 4곳을 함께 갱신**해야 정합성이 유지됩니다. +### 새 비공개 라우트 추가 (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가 빌드 시 자동 갱신) -1. `apps/web/src/router/index.tsx` — 실제 React Router 정의 -2. `apps/web/server/server.cjs` — `STATIC_ROUTE_META`, `KNOWN_ROUTE_PATTERNS`, `NOINDEX_PATH_PREFIXES` -3. `apps/web/public/robots.txt` — 크롤러 차단 경로 -4. `apps/web/vite.config.ts` — Sitemap 플러그인의 `dynamicRoutes` / `exclude` +### 데스크탑 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 -- 사용자는 정상이지만 크롤러가 잘못된 메타를 봄 -- 또는 존재하는 라우트가 404로 잘못 분류됨 -- 또는 비공개 페이지가 검색에 노출됨 +### 남은 수동 동기화 포인트 +- `routes.cjs`의 `DESKTOP_PATHS`와 `router/index.tsx`의 `deviceType: "desktop"` 라우트 정의 +- 라우터의 `lazy()` 컴포넌트 매핑 자체 -장기적으로는 단일 진실 소스(예: 공통 상수 모듈)에서 import하도록 리팩토링하는 것을 권장합니다. +장기 개선 후보 — router 정의를 routes.cjs로 합치고 `lazy()` 매핑을 컨벤션 기반으로 자동화하면 단일 소스가 완성됨. From 027f27fde60bcc2fe137307de738b076308cf335 Mon Sep 17 00:00:00 2001 From: prgmr99 <johnyeom24@naver.com> Date: Tue, 19 May 2026 14:28:46 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20#847=20pinch=20zoom=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=A0=81=EC=9A=A9=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/index.html | 6 +----- docs/seo-improvements.md | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 3795239e..9281ec16 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -14,11 +14,7 @@ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" /> <title>성장하는 당신을 위한 회고 서비스, Layer - - + diff --git a/docs/seo-improvements.md b/docs/seo-improvements.md index d6fcf6cc..8ed3e993 100644 --- a/docs/seo-improvements.md +++ b/docs/seo-improvements.md @@ -113,15 +113,7 @@ Layer는 React SPA로 빌드되어 `dist/index.html` 한 장이 모든 라우트 ## 4. 접근성 + 성능 (8d65efd4) -### 4-1. Viewport pinch-to-zoom 복원 - -**무엇** — `` → `maximum-scale=1.0, user-scalable=0` 제거. - -**왜** — WCAG 1.4.4(텍스트 크기 조정 200%) 위반. 시력 약한 사용자가 확대를 못 함. 특정 화면에서 확대를 막아야 한다면 JS로 페이지 단위 동적 제어 권장. - -**근거** — 코드에 WCAG 1.4.4 주석으로 의도 명시 — 무지로 인한 회귀를 방지. - -### 4-2. 이미지 `alt` + 로딩 최적화 +### 4-1. 이미지 `alt` + 로딩 최적화 **무엇** — 다수 ``에 `alt` 텍스트 + `width/height` + `loading="eager"/lazy"` + `decoding="async"` 부여. @@ -133,7 +125,7 @@ Layer는 React SPA로 빌드되어 `dist/index.html` 한 장이 모든 라우트 **영향** — Lighthouse 접근성·성능 점수 동시 개선. -### 4-3. 구조화 데이터 (JSON-LD) +### 4-2. 구조화 데이터 (JSON-LD) **무엇** — `index.html`의 `