From aedc8191f0d1932efc0e162c8f6190a7e813917f Mon Sep 17 00:00:00 2001 From: kyounggu Date: Tue, 10 Nov 2026 10:53:38 +0900 Subject: [PATCH] - --- server.js | 207 +++++++++++++++------------ src/views/LocationSubjectLanding.vue | 60 ++++---- 2 files changed, 151 insertions(+), 116 deletions(-) diff --git a/server.js b/server.js index 715f232..149b611 100644 --- a/server.js +++ b/server.js @@ -144,106 +144,133 @@ function tryServeStatic(req, res, pathname) { } const server = http.createServer(async (req, res) => { - const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); - let rawPathname; try { - rawPathname = decodeURIComponent(reqUrl.pathname); - } catch { - rawPathname = reqUrl.pathname; - } - - // 헬스체크 엔드포인트 (Docker / Nginx Proxy Manager 상태 확인용) - if (rawPathname === '/healthz') { - sendResponse(req, res, 200, 'text/plain; charset=utf-8', 'OK', { - 'Cache-Control': 'no-store' - }); - return; - } - - // 1. 정적 파일 우선 서빙 (/assets/*, /img/*, /sitemap*.xml, /robots.txt, /favicon.svg 등, 단 '/' 제외) - if (rawPathname !== '/' && tryServeStatic(req, res, rawPathname)) { - return; - } - - // 끝 슬래시 정규화 (루트 '/' 제외) - const pathname = rawPathname.length > 1 && rawPathname.endsWith('/') - ? rawPathname.replace(/\/+$/, '') - : rawPathname; - - const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin'); - - // 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외) - if (!isAdmin) { - const cached = getCachedHtml(pathname); - if (cached) { - sendResponse(req, res, cached.statusCode, 'text/html; charset=utf-8', cached.html, { - 'X-Cache': 'HIT', - 'Cache-Control': 'public, max-age=3600' - }); - return; - } - } - - // 3. 온디맨드 실시간 SSR 렌더링 (전국 50,380개 지역 페이지 전체 200 OK 및 고유 메타·본문 생성) - if (ssrRender && template) { + let rawPathname = '/'; try { - const { html, head, routeName } = await ssrRender(pathname); + const safeHost = (req.headers.host || 'localhost').replace(/[^a-zA-Z0-9.:-]/g, ''); + const reqUrl = new URL(req.url || '/', `http://${safeHost || 'localhost'}`); + rawPathname = decodeURIComponent(reqUrl.pathname); + } catch { + rawPathname = (req.url || '/').split('?')[0] || '/'; + } - if (routeName === 'NotFound') { - let notFoundHtml = template; - notFoundHtml = notFoundHtml.replace(/\s*/i, ''); - notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, ''); - notFoundHtml = notFoundHtml - .replace(HEAD_PLACEHOLDER, head || '페이지를 찾을 수 없습니다') - .replace(HTML_PLACEHOLDER, html || ''); - sendResponse(req, res, 404, 'text/html; charset=utf-8', notFoundHtml, { - 'X-Robots-Tag': 'noindex, nofollow', - 'Cache-Control': 'no-cache, no-store, must-revalidate' - }); - return; - } - - // 정상 완성형 HTML 조립 - let fullHtml = template; - if (head) { - fullHtml = fullHtml.replace(/\s*/i, ''); - fullHtml = fullHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, ''); - fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, head); - } else { - fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, ''); - } - fullHtml = fullHtml.replace(HTML_PLACEHOLDER, html); - - if (isAdmin || routeName === 'AdminHome' || routeName === 'LocationSubjectFullAdmin') { - sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, { - 'X-Robots-Tag': 'noindex, nofollow', - 'Cache-Control': 'no-cache, no-store, must-revalidate' - }); - return; - } - - // 캐시에 저장 (다음 요청부터 0.001초 응답) - setCachedHtml(pathname, { statusCode: 200, html: fullHtml }); - - sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, { - 'X-Cache': 'MISS', - 'Cache-Control': 'public, max-age=3600' + // 헬스체크 엔드포인트 (Docker / Nginx Proxy Manager 상태 확인용) + if (rawPathname === '/healthz') { + sendResponse(req, res, 200, 'text/plain; charset=utf-8', 'OK', { + 'Cache-Control': 'no-store' }); return; - } catch (err) { - console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err); } - } - // 4. 예외 시 SPA 기본 index.html 폴백 서빙 - if (template) { - sendResponse(req, res, 200, 'text/html; charset=utf-8', template); - } else { - res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end('서버 빌드 산출물이 존재하지 않습니다. npm run build:fast 를 먼저 실행해주세요.'); + // 브라우저 기본 /favicon.ico 요청 시 정적 아이콘으로 즉시 응답 (불필요한 SSR/404 방지) + if (rawPathname === '/favicon.ico') { + if (tryServeStatic(req, res, '/img/pavicon.png') || tryServeStatic(req, res, '/favicon.svg')) { + return; + } + res.writeHead(204); + res.end(); + return; + } + + // 1. 정적 파일 우선 서빙 (/assets/*, /img/*, /sitemap*.xml, /robots.txt, /favicon.svg 등, 단 '/' 제외) + if (rawPathname !== '/' && tryServeStatic(req, res, rawPathname)) { + return; + } + + // 끝 슬래시 정규화 (루트 '/' 제외) + const pathname = rawPathname.length > 1 && rawPathname.endsWith('/') + ? rawPathname.replace(/\/+$/, '') + : rawPathname; + + const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin'); + + // 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외) + if (!isAdmin) { + const cached = getCachedHtml(pathname); + if (cached) { + sendResponse(req, res, cached.statusCode, 'text/html; charset=utf-8', cached.html, { + 'X-Cache': 'HIT', + 'Cache-Control': 'public, max-age=3600' + }); + return; + } + } + + // 3. 온디맨드 실시간 SSR 렌더링 (전국 50,380개 지역 페이지 전체 200 OK 및 고유 메타·본문 생성) + if (ssrRender && template) { + try { + const { html, head, routeName } = await ssrRender(pathname); + + if (routeName === 'NotFound') { + let notFoundHtml = template; + notFoundHtml = notFoundHtml.replace(/\s*/i, ''); + notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, ''); + notFoundHtml = notFoundHtml + .replace(HEAD_PLACEHOLDER, head || '페이지를 찾을 수 없습니다') + .replace(HTML_PLACEHOLDER, html || ''); + sendResponse(req, res, 404, 'text/html; charset=utf-8', notFoundHtml, { + 'X-Robots-Tag': 'noindex, nofollow', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }); + return; + } + + // 정상 완성형 HTML 조립 + let fullHtml = template; + if (head) { + fullHtml = fullHtml.replace(/\s*/i, ''); + fullHtml = fullHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, ''); + fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, head); + } else { + fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, ''); + } + fullHtml = fullHtml.replace(HTML_PLACEHOLDER, html); + + if (isAdmin || routeName === 'AdminHome' || routeName === 'LocationSubjectFullAdmin') { + sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, { + 'X-Robots-Tag': 'noindex, nofollow', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }); + return; + } + + // 캐시에 저장 (다음 요청부터 0.001초 응답) + setCachedHtml(pathname, { statusCode: 200, html: fullHtml }); + + sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, { + 'X-Cache': 'MISS', + 'Cache-Control': 'public, max-age=3600' + }); + return; + } catch (err) { + console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err); + } + } + + // 4. 예외 시 SPA 기본 index.html 폴백 서빙 + if (template) { + sendResponse(req, res, 200, 'text/html; charset=utf-8', template); + } else { + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('서버 빌드 산출물이 존재하지 않습니다. npm run build:fast 를 먼저 실행해주세요.'); + } + } catch (fatalErr) { + console.error('[HTTP Handler Error]:', fatalErr); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + } + res.end('Internal Server Error'); } }); +// 비정상 봇/스캐너 패킷으로 인한 프로세스 종료 원천 차단 +process.on('uncaughtException', (err) => { + console.error('[Uncaught Exception]:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[Unhandled Rejection]:', reason); +}); + await loadSsrEntry(); server.listen(PORT, HOST, () => { diff --git a/src/views/LocationSubjectLanding.vue b/src/views/LocationSubjectLanding.vue index 812145c..0b46334 100644 --- a/src/views/LocationSubjectLanding.vue +++ b/src/views/LocationSubjectLanding.vue @@ -275,7 +275,7 @@ - +