This commit is contained in:
2026-11-10 10:53:38 +09:00
parent ae325fde83
commit aedc8191f0
2 changed files with 151 additions and 116 deletions
+117 -90
View File
@@ -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(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
notFoundHtml = notFoundHtml
.replace(HEAD_PLACEHOLDER, head || '<title>페이지를 찾을 수 없습니다</title><meta name="robots" content="noindex" />')
.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(/<title\s+data-default[\s\S]*?<\/title>\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(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
notFoundHtml = notFoundHtml
.replace(HEAD_PLACEHOLDER, head || '<title>페이지를 찾을 수 없습니다</title><meta name="robots" content="noindex" />')
.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(/<title\s+data-default[\s\S]*?<\/title>\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, () => {