first commit

This commit is contained in:
2026-11-10 10:12:09 +09:00
commit 0288412bbb
84 changed files with 312187 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
// 전국 5만 개 지역 대응 온디맨드 초경량 SSR 프로덕션 서버 (Node.js 내장 모듈 기반 / Docker 최적화)
import http from 'http';
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { fileURLToPath, pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number(process.env.PORT) || 80;
const HOST = process.env.HOST || '0.0.0.0';
const rootDir = path.resolve(__dirname);
const distDir = path.join(rootDir, 'dist');
const distSsrDir = path.join(rootDir, 'dist-ssr');
const rawTemplatePath = path.join(distDir, '_template.html');
const indexTemplatePath = path.join(distDir, 'index.html');
const ssrEntryPath = path.join(distSsrDir, 'entry-server.js');
const HEAD_PLACEHOLDER = '<!--app-head-->';
const HTML_PLACEHOLDER = '<!--app-html-->';
// 템플릿 로드 (_template.html 우선, 없으면 dist/index.html 사용)
let template = '';
try {
if (fs.existsSync(rawTemplatePath)) {
template = fs.readFileSync(rawTemplatePath, 'utf-8');
} else {
template = fs.readFileSync(indexTemplatePath, 'utf-8');
}
} catch (e) {
console.warn('[SSR Server] dist/index.html 을 아직 읽을 수 없습니다. 빌드(npm run build:fast)가 필요합니다.');
}
// SSR 렌더러 모듈 로드
let ssrRender = null;
async function loadSsrEntry() {
if (fs.existsSync(ssrEntryPath)) {
const mod = await import(pathToFileURL(ssrEntryPath).href);
ssrRender = mod.render;
console.log('[SSR Server] SSR 렌더러(dist-ssr/entry-server.js) 로드 완료');
} else {
console.warn('[SSR Server] dist-ssr/entry-server.js 를 찾을 수 없습니다. 빌드(npm run build:server)가 필요합니다.');
}
}
// 인메모리 캐시 (최대 5,000개 URL 캐싱, LRU 방식)
const MAX_CACHE_SIZE = 5000;
const htmlCache = new Map();
function getCachedHtml(url) {
if (htmlCache.has(url)) {
const data = htmlCache.get(url);
// 캐시 순서 갱신 (LRU)
htmlCache.delete(url);
htmlCache.set(url, data);
return data;
}
return null;
}
function setCachedHtml(url, html) {
if (htmlCache.size >= MAX_CACHE_SIZE) {
const oldestKey = htmlCache.keys().next().value;
htmlCache.delete(oldestKey);
}
htmlCache.set(url, html);
}
// MIME 타입 매핑
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff'
};
// Gzip 압축 전송 유틸
function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}) {
const acceptEncoding = req.headers['accept-encoding'] || '';
const headers = {
'Content-Type': contentType,
...extraHeaders
};
if (typeof body === 'string') {
body = Buffer.from(body, 'utf-8');
}
if (acceptEncoding.includes('gzip') && body.length > 512) {
zlib.gzip(body, (err, compressed) => {
if (err) {
res.writeHead(statusCode, headers);
res.end(body);
return;
}
headers['Content-Encoding'] = 'gzip';
headers['Content-Length'] = compressed.length;
res.writeHead(statusCode, headers);
res.end(compressed);
});
} else {
headers['Content-Length'] = body.length;
res.writeHead(statusCode, headers);
res.end(body);
}
}
// 정적 파일 서빙 시도
function tryServeStatic(req, res, pathname) {
// dist 디렉터리 내 정적 자산 경로
const filePath = path.join(distDir, pathname);
// 보안: 디렉터리 트래버설 방지
if (!filePath.startsWith(distDir)) {
return false;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const isImmutable = pathname.startsWith('/assets/');
const cacheControl = isImmutable
? 'public, max-age=31536000, immutable'
: 'public, max-age=86400';
const content = fs.readFileSync(filePath);
sendResponse(req, res, 200, contentType, content, {
'Cache-Control': cacheControl
});
return true;
}
return false;
}
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) {
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 를 먼저 실행해주세요.');
}
});
await loadSsrEntry();
server.listen(PORT, HOST, () => {
console.log(`====================================================`);
console.log(`🚀 티처PICK 전국 50,380개 온디맨드 SSR 서버 구동 시작!`);
console.log(`📡 리스닝 주소: http://${HOST}:${PORT}`);
console.log(`⚡ 특징: Nginx Proxy Manager (teacherpick_frontend:80) 연동 + 실시간 SSR + 메모리 캐싱`);
console.log(`====================================================`);
});
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
});
process.on('SIGINT', () => {
server.close(() => process.exit(0));
});