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 @@
-
+
@@ -1562,10 +1563,10 @@ watch([() => route.params], () => {
border-color: var(--border-primary);
}
-/* 하단 가로형 과목 이미지 캐러셀 (네이버 가로 멀티 썸네일 최적화) */
+/* 하단 가로형 과목 이미지 캐러셀 (네이버 가로 멀티 썸네일 최적화 - 가로 스크롤 없이 비율 자동 맞춤) */
.bottom-carousel-section {
margin-bottom: 36px;
- padding: 22px;
+ padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
@@ -1579,27 +1580,14 @@ watch([() => route.params], () => {
}
.horizontal-carousel-track {
- display: flex;
- flex-direction: row;
- gap: 12px;
- overflow-x: auto;
- padding-bottom: 8px;
- scroll-snap-type: x mandatory;
- -webkit-overflow-scrolling: touch;
-}
-
-.horizontal-carousel-track::-webkit-scrollbar {
- height: 6px;
-}
-
-.horizontal-carousel-track::-webkit-scrollbar-thumb {
- background: #cbd5e1;
- border-radius: 4px;
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+ width: 100%;
}
.h-carousel-card {
- flex: 0 0 165px;
- scroll-snap-align: start;
+ min-width: 0;
background: #ffffff;
border: 1.5px solid var(--border-color);
border-radius: 14px;
@@ -1619,7 +1607,9 @@ watch([() => route.params], () => {
}
.h-carousel-link {
- display: block;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
text-decoration: none;
color: inherit;
}
@@ -1645,22 +1635,27 @@ watch([() => route.params], () => {
left: 6px;
background: rgba(15, 23, 42, 0.8);
color: #ffffff;
- font-size: 10.5px;
+ font-size: 10px;
font-weight: 700;
- padding: 2px 7px;
+ padding: 2px 6px;
border-radius: 999px;
+ max-width: calc(100% - 12px);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.h-carousel-body {
- padding: 10px 12px;
+ padding: 10px;
}
.h-carousel-body h3 {
- font-size: 13.5px;
+ font-size: 13px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 4px;
line-height: 1.3;
+ word-break: keep-all;
}
.h-carousel-body p {
@@ -1674,6 +1669,19 @@ watch([() => route.params], () => {
overflow: hidden;
}
+@media (max-width: 768px) {
+ .horizontal-carousel-track {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 480px) {
+ .horizontal-carousel-track {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+}
+
/* 빠른 상담 리소스 링크 */
.post-resource-links {
display: grid;