From a51616f330686497ebd8377289daea1e7708da86 Mon Sep 17 00:00:00 2001 From: kyounggu Date: Tue, 10 Nov 2026 12:35:16 +0900 Subject: [PATCH] - --- deploy.sh | 2 +- public/robots.txt | 18 ++ public/sitemap-main.xml | 6 - scripts/generate-sitemap.js | 24 +- server.js | 77 +++-- src/components/sections/FooterSection.vue | 23 +- src/data/regions.js | 120 ++++++++ src/data/subjects.js | 30 +- src/router/index.js | 14 +- src/utils/seo.js | 5 +- src/views/LocationSubjectLanding.vue | 333 +++++++++++++--------- vite.config.js | 2 +- 12 files changed, 451 insertions(+), 203 deletions(-) diff --git a/deploy.sh b/deploy.sh index 3619e92..1d8b728 100644 --- a/deploy.sh +++ b/deploy.sh @@ -16,4 +16,4 @@ fi echo "[3/3] 컨테이너 내부 80 포트 SSR 정상 응답 확인 중..." sleep 1 -docker exec teacherpick_frontend wget -qO- http://127.0.0.1:80/healthz && echo " -> 정상 작동 중 (http://teacherpick_frontend:80)" +docker exec teacherpick_frontend wget -qO- http://127.0.0.1:80/healthz && echo " -> 정상 작동 중 (http://teacherpick_frontend:80)" \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt index 25552b4..06a88dc 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -4,3 +4,21 @@ Disallow: /admin Disallow: /regions/admin Sitemap: https://teacherpick.kr/sitemap.xml +Sitemap: https://teacherpick.kr/sitemap-main.xml +Sitemap: https://teacherpick.kr/sitemap-seoul.xml +Sitemap: https://teacherpick.kr/sitemap-gyeonggi.xml +Sitemap: https://teacherpick.kr/sitemap-incheon.xml +Sitemap: https://teacherpick.kr/sitemap-daegu.xml +Sitemap: https://teacherpick.kr/sitemap-busan.xml +Sitemap: https://teacherpick.kr/sitemap-daejeon.xml +Sitemap: https://teacherpick.kr/sitemap-gwangju.xml +Sitemap: https://teacherpick.kr/sitemap-ulsan.xml +Sitemap: https://teacherpick.kr/sitemap-sejong.xml +Sitemap: https://teacherpick.kr/sitemap-gangwon.xml +Sitemap: https://teacherpick.kr/sitemap-chungbuk.xml +Sitemap: https://teacherpick.kr/sitemap-chungnam.xml +Sitemap: https://teacherpick.kr/sitemap-jeonbuk.xml +Sitemap: https://teacherpick.kr/sitemap-jeonnam.xml +Sitemap: https://teacherpick.kr/sitemap-gyeongbuk.xml +Sitemap: https://teacherpick.kr/sitemap-gyeongnam.xml +Sitemap: https://teacherpick.kr/sitemap-jeju.xml diff --git a/public/sitemap-main.xml b/public/sitemap-main.xml index d3855dd..ce202af 100644 --- a/public/sitemap-main.xml +++ b/public/sitemap-main.xml @@ -12,10 +12,4 @@ daily 0.9 - - https://teacherpick.kr/daegu/suseong-gu/hwanggeum-dong/middle/math - 2026-11-10 - daily - 1.0 - diff --git a/scripts/generate-sitemap.js b/scripts/generate-sitemap.js index de5155d..9b29c30 100644 --- a/scripts/generate-sitemap.js +++ b/scripts/generate-sitemap.js @@ -31,11 +31,10 @@ function generate() { const sitemapFiles = []; let totalIndexedUrls = 0; - // 1. 공통 및 메인 대표 랜딩 페이지 사이트맵 (sitemap-main.xml) + // 1. 공통 및 메인 대표 페이지 사이트맵 (sitemap-main.xml - 지역 사이트맵과 중복되지 않는 고유 URL만 수록) const mainUrls = [ { loc: `${SITE_ORIGIN}`, priority: '1.0', changefreq: 'daily' }, - { loc: `${SITE_ORIGIN}/regions`, priority: '0.9', changefreq: 'daily' }, - { loc: `${SITE_ORIGIN}${DEFAULT_PATH}`, priority: '1.0', changefreq: 'daily' } + { loc: `${SITE_ORIGIN}/regions`, priority: '0.9', changefreq: 'daily' } ]; const mainXml = generateUrlXml(mainUrls, today); fs.writeFileSync(path.join(publicDir, 'sitemap-main.xml'), mainXml, 'utf-8'); @@ -81,14 +80,17 @@ ${sitemapFiles `; fs.writeFileSync(path.join(publicDir, 'sitemap.xml'), indexXml, 'utf-8'); - // 4. robots.txt - const robots = `User-agent: * -Allow: / -Disallow: /admin -Disallow: /regions/admin - -Sitemap: ${SITE_ORIGIN}/sitemap.xml -`; + // 4. robots.txt (루트 사이트맵 인덱스 및 시·도별 분할 사이트맵 전체 명시) + const robots = [ + 'User-agent: *', + 'Allow: /', + 'Disallow: /admin', + 'Disallow: /regions/admin', + '', + `Sitemap: ${SITE_ORIGIN}/sitemap.xml`, + ...sitemapFiles.map((file) => `Sitemap: ${SITE_ORIGIN}/${file}`), + '' + ].join('\n'); fs.writeFileSync(path.join(publicDir, 'robots.txt'), robots, 'utf-8'); console.log(`[OK] 사이트맵 인덱스(sitemap.xml) 및 ${sitemapFiles.length}개 시·도 분할 사이트맵 생성 완료!`); diff --git a/server.js b/server.js index 149b611..60c3cae 100644 --- a/server.js +++ b/server.js @@ -85,8 +85,9 @@ const MIME_TYPES = { '.woff': 'font/woff' }; -// Gzip 압축 전송 유틸 -function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}) { +// Gzip 압축 전송 유틸 (HEAD 요청 규격 완벽 대응 및 사전 압축 버퍼 즉시 전송) +function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}, preGzipped = null) { + const isHead = req.method === 'HEAD'; const acceptEncoding = req.headers['accept-encoding'] || ''; const headers = { 'Content-Type': contentType, @@ -98,21 +99,29 @@ function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {} } if (acceptEncoding.includes('gzip') && body.length > 512) { + if (preGzipped) { + headers['Content-Encoding'] = 'gzip'; + headers['Content-Length'] = preGzipped.length; + res.writeHead(statusCode, headers); + res.end(isHead ? undefined : preGzipped); + return; + } zlib.gzip(body, (err, compressed) => { if (err) { + headers['Content-Length'] = body.length; res.writeHead(statusCode, headers); - res.end(body); + res.end(isHead ? undefined : body); return; } headers['Content-Encoding'] = 'gzip'; headers['Content-Length'] = compressed.length; res.writeHead(statusCode, headers); - res.end(compressed); + res.end(isHead ? undefined : compressed); }); } else { headers['Content-Length'] = body.length; res.writeHead(statusCode, headers); - res.end(body); + res.end(isHead ? undefined : body); } } @@ -177,21 +186,36 @@ const server = http.createServer(async (req, res) => { return; } - // 끝 슬래시 정규화 (루트 '/' 제외) - const pathname = rawPathname.length > 1 && rawPathname.endsWith('/') - ? rawPathname.replace(/\/+$/, '') - : rawPathname; + // 끝 슬래시 정규화 (루트 '/' 제외, 끝 슬래시 요청 시 표준 URL로 301 영구 리다이렉트하여 중복 색인 방지) + if (rawPathname.length > 1 && rawPathname.endsWith('/')) { + const normalized = rawPathname.replace(/\/+$/, ''); + res.writeHead(301, { + Location: normalized, + 'Cache-Control': 'public, max-age=86400' + }); + res.end(); + return; + } + const pathname = rawPathname; const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin'); - // 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외) + // 2. 캐시 확인 (인메모리 캐시 히트 시 사전 압축 버퍼로 0.0001초 즉시 응답, 관리자 경로 제외) 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' - }); + sendResponse( + req, + res, + cached.statusCode, + 'text/html; charset=utf-8', + cached.html, + { + 'X-Cache': 'HIT', + 'Cache-Control': 'public, max-age=3600' + }, + cached.gzipped + ); return; } } @@ -234,13 +258,26 @@ const server = http.createServer(async (req, res) => { return; } - // 캐시에 저장 (다음 요청부터 0.001초 응답) - setCachedHtml(pathname, { statusCode: 200, html: fullHtml }); + // 캐시에 원본 Buffer 및 Gzip 압축 Buffer 함께 저장 (대량 봇 크롤링 시 CPU 부하 제로화) + const htmlBuf = Buffer.from(fullHtml, 'utf-8'); + let gzippedBuf = null; + try { + gzippedBuf = zlib.gzipSync(htmlBuf); + } catch {} + setCachedHtml(pathname, { statusCode: 200, html: htmlBuf, gzipped: gzippedBuf }); - sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, { - 'X-Cache': 'MISS', - 'Cache-Control': 'public, max-age=3600' - }); + sendResponse( + req, + res, + 200, + 'text/html; charset=utf-8', + htmlBuf, + { + 'X-Cache': 'MISS', + 'Cache-Control': 'public, max-age=3600' + }, + gzippedBuf + ); return; } catch (err) { console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err); diff --git a/src/components/sections/FooterSection.vue b/src/components/sections/FooterSection.vue index 0ee89b5..525a57e 100644 --- a/src/components/sections/FooterSection.vue +++ b/src/components/sections/FooterSection.vue @@ -18,31 +18,16 @@ 전국 지역별 과외 과목 라인업 기능 안내 - 미디어 쇼케이스 + 무료 상담 신청 - - + diff --git a/src/data/regions.js b/src/data/regions.js index c51f87b..eec5613 100644 --- a/src/data/regions.js +++ b/src/data/regions.js @@ -158,6 +158,126 @@ export function getRegionInfo(rawSido, rawSigungu, rawDong) { }; } +// 정식 등록된 시·도 / 시·군·구 / 법정동 경로인지 엄격 검증 (소프트 404 및 스캐너 가짜 URL 색인 원천 차단) +export function isValidRegion(rawSido, rawSigungu, rawDong) { + if (!rawSido || !rawSigungu || !rawDong) return false; + const s = REGIONS_DATA.find( + item => item.sidoSlug === rawSido || item.sidoShort === rawSido || item.sido === rawSido + ); + if (!s) return false; + const g = s.sigunguList.find( + item => item.sigunguSlug === rawSigungu || item.name === rawSigungu + ); + if (!g) return false; + const altDong = rawDong.endsWith('-dong') ? rawDong.replace(/-dong$/, '') : `${rawDong}-dong`; + const d = g.dongList.find( + item => item.dongSlug === rawDong || item.name === rawDong || item.dongSlug === altDong + ); + return Boolean(d); +} + +// 현재 페이지 기준 6개 추천 카드 데이터 생성 (UI 변동 없이 전국 50,380개 페이지 100% 순환 크롤링 링 형성) +export function getRankPlaceCards(rawSido, rawSigungu, rawDong) { + const sIdx = Math.max( + 0, + REGIONS_DATA.findIndex( + item => item.sidoSlug === rawSido || item.sidoShort === rawSido || item.sido === rawSido + ) + ); + const s = REGIONS_DATA[sIdx] || REGIONS_DATA[0]; + + const gIdx = Math.max( + 0, + s.sigunguList.findIndex( + item => item.sigunguSlug === rawSigungu || item.name === rawSigungu + ) + ); + const g = s.sigunguList[gIdx] || s.sigunguList[0]; + + const altDong = rawDong && rawDong.endsWith('-dong') ? rawDong.replace(/-dong$/, '') : `${rawDong}-dong`; + const dIdx = Math.max( + 0, + g.dongList.findIndex( + item => item.dongSlug === rawDong || item.name === rawDong || item.dongSlug === altDong + ) + ); + const d = g.dongList[dIdx] || g.dongList[0]; + + const cards = []; + + // 1번 카드: 현재 동 (반대 학년 중등 <-> 고등 연결용) + cards.push({ + sidoShort: s.sidoShort, + sidoSlug: s.sidoSlug, + sigungu: g.name, + sigunguSlug: g.sigunguSlug, + dong: d.name, + dongSlug: d.dongSlug, + isOppositeTarget: true + }); + + // 2~5번 카드: 동일 시·군·구 내 순환 인접 동 (항상 유효한 실제 법정동 슬러그 보장) + const dongCount = g.dongList.length; + for (let offset = 1; offset <= 4; offset++) { + if (offset < dongCount) { + const nextDong = g.dongList[(dIdx + offset) % dongCount]; + cards.push({ + sidoShort: s.sidoShort, + sidoSlug: s.sidoSlug, + sigungu: g.name, + sigunguSlug: g.sigunguSlug, + dong: nextDong.name, + dongSlug: nextDong.dongSlug, + isOppositeTarget: false + }); + } else if (s.sigunguList.length > 1) { + const altG = s.sigunguList[(gIdx + offset) % s.sigunguList.length]; + const altD = altG.dongList[0]; + if (altD) { + cards.push({ + sidoShort: s.sidoShort, + sidoSlug: s.sidoSlug, + sigungu: altG.name, + sigunguSlug: altG.sigunguSlug, + dong: altD.name, + dongSlug: altD.dongSlug, + isOppositeTarget: false + }); + } + } + } + + // 6번 카드: 동일 시·도 내 다음 시·군·구의 대표 동 연결 (시·군·구 간 크롤링 브릿지) + if (s.sigunguList.length > 1) { + const nextG = s.sigunguList[(gIdx + 1) % s.sigunguList.length]; + const nextD = nextG.dongList[0]; + if (nextD) { + cards.push({ + sidoShort: s.sidoShort, + sidoSlug: s.sidoSlug, + sigungu: nextG.name, + sigunguSlug: nextG.sigunguSlug, + dong: nextD.name, + dongSlug: nextD.dongSlug, + isOppositeTarget: false + }); + } + } else if (dongCount > 5) { + const nextDong = g.dongList[(dIdx + 5) % dongCount]; + cards.push({ + sidoShort: s.sidoShort, + sidoSlug: s.sidoSlug, + sigungu: g.name, + sigunguSlug: g.sigunguSlug, + dong: nextDong.name, + dongSlug: nextDong.dongSlug, + isOppositeTarget: false + }); + } + + return cards.slice(0, 6); +} + // 현재 페이지의 시·도 내 다른 구·군 및 동일 구·군 내 다른 동 링크만 경량 조회 (검색엔진 50,380개 전체 크롤링 그래프 형성) export function getRelatedRegionLinks(rawSido, rawSigungu) { const s = REGIONS_DATA.find( diff --git a/src/data/subjects.js b/src/data/subjects.js index 15c6e8a..87fd487 100644 --- a/src/data/subjects.js +++ b/src/data/subjects.js @@ -92,6 +92,32 @@ export const SUBJECTS = [ } ]; +// 대상 유효성 검사 (영문/한글 정식 슬러그만 허용) +export function isValidTarget(param) { + if (!param) return false; + const clean = String(param).trim().toLowerCase(); + if (clean === '중학생' || clean === '중등' || clean === 'middle') return true; + if (clean === '고등학생' || clean === '고등' || clean === 'high') return true; + return TARGETS.some(t => + t.slug === clean || + t.id === clean || + t.koreanSlug === param || + t.name === param + ); +} + +// 과목 유효성 검사 (5대 과목 영문/한글 정식 슬러그만 허용) +export function isValidSubject(param) { + if (!param) return false; + const clean = String(param).trim().toLowerCase(); + return SUBJECTS.some(s => + s.slug === clean || + s.id === clean || + s.koreanSlug === param || + s.name === param + ); +} + // 대상 탐색 헬퍼 (영문/한글 모두 지원) export function findTarget(param) { if (!param) return TARGETS[0]; @@ -108,12 +134,12 @@ export function findTarget(param) { // 과목 탐색 헬퍼 (영문/한글 모두 지원) export function findSubject(param) { - if (!param) return SUBJECTS[1]; // 기본 수학 + if (!param) return SUBJECTS[2]; // 기본 수학 const clean = String(param).trim().toLowerCase(); return SUBJECTS.find(s => s.slug === clean || s.id === clean || s.koreanSlug === param || s.name === param - ) || SUBJECTS[1]; + ) || SUBJECTS[2]; } diff --git a/src/router/index.js b/src/router/index.js index d4d16b4..75dd2f8 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,6 +3,8 @@ import LocationSubjectLanding from '../views/LocationSubjectLanding.vue'; import NotFoundView from '../views/NotFoundView.vue'; import HomeView from '../views/HomeView.vue'; import RegionsView from '../views/RegionsView.vue'; +import { isValidRegion } from '../data/regions.js'; +import { isValidTarget, isValidSubject } from '../data/subjects.js'; const routes = [ // 1. 기본 루트 경로: 자동 리다이렉트 대신 지역·학년·과목 탐색이 가능한 메인 홈 화면 노출 @@ -35,10 +37,20 @@ const routes = [ // 5. 표준 랜딩 라우트 (사이트맵/사전 렌더링 대상 유일한 URL 형식) // 예: /daegu/suseong-gu/hwanggeum-dong/middle/math + // 존재하지 않는 가짜 시·도/시·군·구/동 또는 잘못된 학년/과목 요청 시 즉시 404(NotFound)로 전환하여 소프트 404 원천 차단 { path: '/:sido/:sigungu/:dong/:target/:subject', name: 'LocationSubjectFullLanding', - component: LocationSubjectLanding + component: LocationSubjectLanding, + beforeEnter(to) { + const { sido, sigungu, dong, target, subject } = to.params; + if (!isValidRegion(sido, sigungu, dong) || !isValidTarget(target) || !isValidSubject(subject)) { + return { + name: 'NotFound', + params: { pathMatch: to.path.replace(/^\/+/, '').split('/') } + }; + } + } }, // 6. 존재하지 않는 경로: JS 리다이렉트 대신 404 페이지 (서버는 404 상태코드 반환) diff --git a/src/utils/seo.js b/src/utils/seo.js index 7a188d4..0b127da 100644 --- a/src/utils/seo.js +++ b/src/utils/seo.js @@ -108,7 +108,7 @@ export function buildLandingHead({ region, target, subject, faqs = [] }) { '@id': `${url}#breadcrumb`, itemListElement: [ { '@type': 'ListItem', position: 1, name: '티처PICK', item: SITE_ORIGIN }, - { '@type': 'ListItem', position: 2, name: `${region.sidoShort} ${region.sigungu}`, item: url }, + { '@type': 'ListItem', position: 2, name: `${region.sidoShort} ${region.sigungu} 과외`, item: `${SITE_ORIGIN}/regions` }, { '@type': 'ListItem', position: 3, name: pageName, item: url } ] }, @@ -332,7 +332,7 @@ export function renderHeadToString(head) { export function applyHead(head) { if (typeof document === 'undefined') return; - document.head.querySelectorAll(`[${HEAD_ATTR}], [data-default]`).forEach((el) => el.remove()); + document.head.querySelectorAll(`title, [${HEAD_ATTR}], [data-default]`).forEach((el) => el.remove()); const add = (tag, attrs, text) => { const el = document.createElement(tag); @@ -342,7 +342,6 @@ export function applyHead(head) { document.head.appendChild(el); }; - document.title = head.title; add('title', {}, head.title); for (const m of head.meta || []) { add('meta', m.name ? { name: m.name, content: m.content } : { property: m.property, content: m.content }); diff --git a/src/views/LocationSubjectLanding.vue b/src/views/LocationSubjectLanding.vue index e9b2038..00ff597 100644 --- a/src/views/LocationSubjectLanding.vue +++ b/src/views/LocationSubjectLanding.vue @@ -4,9 +4,10 @@
- [지역] {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong - }} - [분류] {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · {{ currentTarget.name }} {{ currentSubject.name }} 과외 + [지역] {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ + regionInfo.dong }} + [분류] {{ regionInfo.sidoShort }} {{ + currentTarget.name }} 과외 · {{ currentTarget.name }} {{ currentSubject.name }} 과외
@@ -16,9 +17,11 @@ -

{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 및 {{ regionInfo.sigungu }} {{ regionInfo.dong }} 1:1 맞춤 내신·수능 전문 지도

+

{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 및 {{ + regionInfo.sigungu }} {{ regionInfo.dong }} 1:1 맞춤 내신·수능 전문 지도

- {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외와 {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외를 찾으시는 학생·학부모님을 위해, {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} 인근 학교별 내신 기출 분석과 검증된 베테랑 선생님의 1:1 방문·맞춤 수업 커리큘럼 및 무료 모의수업 상담 정보를 한눈에 비교해 드립니다. + {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외와 {{ + regionInfo.sidoShort }} {{ currentTarget.name }} 과외를 찾으시는 학생·학부모님을 위해, {{ regionInfo.sidoShort }} + {{ regionInfo.sigungu }} {{ regionInfo.dong }} 인근({{ targetSchools.join(', ') }}) 학교별 내신 기출 분석과 {{ + activeCurriculum }} 중심의 1:1 방문·맞춤 수업 커리큘럼 및 무료 모의수업 상담 정보를 한눈에 비교해 드립니다.

- + 상담
1:1 무료 모의수업 상담 받기 @@ -163,9 +171,13 @@
-

{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 · {{ regionInfo.sigungu }} {{ regionInfo.dong }} 맞춤 학습 안내

-

{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} 일대에서 {{ currentTarget.name }} {{ - currentSubject.name }} 과외, 내신 관리·수능 대비 등 핵심 키워드를 기준으로 인근 학교 기출 분석과 베테랑 선생님 카드를 정리했습니다.

+

{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 · {{ regionInfo.sigungu + }} {{ regionInfo.dong }} 맞춤 학습 안내

+

{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ regionInfo.sigungu + }} {{ regionInfo.dong }} 일대(활동 선생님 {{ regionInfo.teachersCount }}명 · 누적 상담 {{ regionInfo.consultCount }}건)에서 + {{ currentTarget.name }} {{ + currentSubject.name }} 과외({{ currentSubject.headline }})를 중심으로 {{ targetSchools.join(', ') }} 기출 분석과 베테랑 선생님 + 카드를 정리했습니다.

@@ -174,11 +186,12 @@
대표 과목
-
{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외
+
{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 ({{ currentSubject.tag + }})
핵심 키워드
-
{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · 1:1 맞춤과외
+
{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · {{ currentTarget.label }}
@@ -192,11 +205,13 @@
- 선택됨
-
{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ sub.name }} 과외
+
{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ sub.name }} + 과외
@@ -210,21 +225,26 @@
-

- {{ school }} {{ currentSubject.name }} 과목 최근 3개년 기출 족보 및 서술형 킬러 문항 1:1 밀착 클리닉 제공 + {{ school }} {{ currentTarget.name }} {{ currentSubject.name }} 최근 3개년 기출 족보 분석 및 {{ + currentSubject.points[index % currentSubject.points.length] }} 중심 1:1 밀착 클리닉 제공

#{{ currentSubject.name }}내신대비 - #기출분석 + #{{ currentTarget.name }}기출분석 #수행평가지도
@@ -263,78 +284,47 @@
-
- -
+
+ + {{ faq.q }} + ▼ + +

{{ faq.a }}

-
+
- - -