Skip to content

feat(blog-web-svelte): 블로그를 SvelteKit으로 병행 재구현해 React와 나란히 잰다 #392

Description

@Han5991

배경

apps/blog/web은 Next.js 16 + React 19로 굳어 있습니다. 소스 191개, 글 70편, React 전용 런타임 의존만 8종(@ssgoi/react·@giscus/react·react-markdown·recharts·@tanstack/react-query·react-medium-image-zoom·react-syntax-highlighter·lucide-react)입니다.

여기서 궁금한 것이 넷입니다.

  1. 같은 사이트를 컴파일 기반 프레임워크로 지으면 번들이 실제로 얼마나 줄어드는가
  2. 이 블로그의 저작 문법(커스텀 태그 15종)이 React 없이도 같은 모양으로 서는가
  3. @blog/content·check-seo·Panda blog-preset이 정말 프레임워크 중립인가 — 지금까지 소비자가 React 하나뿐이라 검증된 적이 없습니다
  4. 이 저장소의 저작 스킬(blog-design-system·blog-components·blog-diagrams·tech-blog-writer)이 React를 전제하고 있는가

넷 다 읽어서는 답이 안 나오고, 지어 봐야 나옵니다. 그래서 운영 블로그는 그대로 두고 SvelteKit으로 같은 사이트를 한 벌 더 짓습니다.

무엇을 만드는가

apps/blog/web-svelte(@blog/web-svelte) 워크스페이스를 새로 만들어, apps/blog/web같은 콘텐츠·같은 토큰·같은 URL 계약으로 블로그 전체를 SvelteKit + adapter-static으로 재구현합니다. 기존 React 앱은 지우지 않고 그대로 운영합니다.

결정 요약

결정 근거
프레임워크 SvelteKit + adapter-static 선정 기준은 문법 직관성 + 번들 최소. Solid 스파이크 없이 Svelte로 확정
대상 범위 공개 6개 라우트 + Admin 4개 = 전체 부분만 지으면 번들 비교가 사과와 오렌지가 됨
React 앱 유지 3중 비교가 아니라 2중 비교(React vs Svelte). 되돌리기가 항상 가능
워크스페이스 apps/blog/web-svelte / @blog/web-svelte 나중에 진짜 교체할 때 web으로 개명하는 경로가 자연스럽다
콘텐츠 소스 apps/blog/posts 공유 원고를 복제하면 두 사이트가 조용히 갈라진다
콘텐츠 파이프라인 @blog/content 그대로 재사용 자체 content.config.mts + content.values.mts만 새로 둔다
스타일 Panda CSS + blog-preset 그대로 색 토큰의 단일 출처가 blog-preset.ts라는 금지선을 유지. @design-system/ui의 React 컴포넌트만 재구현
Supabase 로컬 인스턴스만 프로덕션 post_views·post_view_logs에 쓰지 않는다 → 운영 조회수·Analytics 오염이 구조적으로 불가능
배포 별도 Worker, wrangler versions upload 프리뷰 URL만 custom_domain 없음. 최종 배포 전환은 이 이슈 범위 밖
기능 플래그 해당 없음 별도 워크스페이스라 기존 블로그 사용자에게 노출되는 경로가 없다. 워크스페이스의 존재 여부가 곧 스위치
마감 없음 실험·학습이 목적. 스택 PR을 틈날 때 쌓는다

목적 (성공 기준의 출처)

  • 학습·비교 실험 — 반응성 모델·컴파일 전략·DX 차이를 직접 겪는다
  • 번들 크기·성능 개선 여지 확인
  • 블로그 글 소재 확보
  • 저작 스킬 4종의 사용성·성능 점검 — React 전제가 얼마나 박혀 있는지

대시보드 (2026-09-13 3차 갱신)

구현은 전부 릴리즈 브랜치(claude/react-svelte-solid-migration-dkj247)에 머지됐습니다. main에는 아직 아무것도 들어가지 않았습니다.

무엇 PR 릴리즈 브랜치 머지 커밋
구현 스택 14개 #393~#406, #408 9a2082b (맨 위 #408)
프리뷰를 앱별로 조건부 빌드 #409 286b603
mermaid 글 본문 소실 수정 #413 f3340b2
main 승격 #412 (초안) 미결정 — 9월 말까지 판단을 보류합니다

머지 기록

순서 PR 브랜치 내용
1 #393 svelte-1-baseline 기준선 측정 + 워크스페이스 뼈대
2 #394 svelte-2-site-values 사이트 값을 @blog/site-values
3 #395 svelte-3-routing 공개 라우트 5개 + check-seo 통과
4 #396 svelte-4-markdown-components 커스텀 태그 9종을 HAST 변환으로
5 #398 svelte-5-diagrams 레이아웃 엔진을 @blog/diagram으로
6 #399 svelte-6-code-blocks check-bundle에서 경로 관례 제거
7 #400 svelte-7-code-blocks 코드 블록 빌드 타임 강조
8 #401 svelte-8-client-runtime 읽기 경로 클라이언트 조각
9 #402 svelte-9-search-comments ⌘K 검색 + Giscus
10 #403 svelte-10-admin 조회수·대시보드를 @blog/analytics
11 #404 svelte-11-admin-ui Admin 대시보드 + 조회수
12 #405 svelte-12-ci-deploy CI 게이트 + 프리뷰 배포
13 #406 svelte-13-home-parity 화면 파리티 + check-parity + 주석 규칙
14 #408 svelte-14-admin-charts 차트 반응형·곡선 파리티 + 기하 스냅샷
  • 머지 커밋으로 내렸습니다. 스쿼시하면 PR마다 새 SHA로 들어가 다음 PR의 merge-base가 옛 커밋에 남고, 그 PR의 diff가 앞 PR의 내용까지 끌어안습니다(실측 10 → 29파일)
  • 머지 경로. REST 머지 엔드포인트는 스택 PR을 403(Merging stacked PRs via this endpoint is not supported)으로 거절합니다. gh stack merge <스택 번호> --yes --merge로 내렸습니다 — 메서드를 빼면 마지막에 쓴 방식을 따르므로 --merge를 명시해야 합니다
  • base는 미리 못 돌립니다. GitHub이 스택 멤버의 base 변경을 막습니다(Cannot change the base branch because the pull request is part of a stack)
  • #408은 GitHub 스택 UI에 묶여 있지 않았습니다. 생성 시점에 REST API가 POST /pulls를 500/502로 거절해 웹 UI로 만들었고, base가 올바라 머지 순서에는 영향이 없었습니다

#409 — 프리뷰를 앱별로 조건부 빌드

preview-blog.yml 매트릭스의 두 줄(preview = Next.js, preview-svelte = SvelteKit)이 자기 앱 경로나 두 앱이 함께 읽는 경로(원고·packages/@blog·이 워크플로)가 바뀐 PR에서만 빌드·업로드합니다. 잡 if에서는 matrix를 쓸 수 없어 첫 스텝이 pulls/{n}/files로 판정하고 뒤 13스텝을 건너뜁니다. 옮긴 파일은 옛 경로도 보고, API 실패나 3000개 상한에서는 건너뛰지 않습니다.

  • React 판만 고친 PR에서 preview-svelte 잡이 70초 → 6초
  • 테스트: workflowPreviewGate.test.ts(구조) + workflowPreviewGateScript.test.ts(워크플로의 run: 블록을 꺼내 가짜 gh로 실행, 27개)

스벨트를 몰라도 리뷰하는 법

코드 구조는 알지만 Svelte 문법이 처음일 때를 위한 안내입니다.

문법 대응표 — 이 스택에 실제로 나오는 것만

React에서 아는 것 이 스택의 Svelte 비고
useState let x = $state(0) 그냥 변수. 대입하면 다시 그린다
useMemo / 파생값 const y = $derived(...), 여러 줄이면 $derived.by(() => ...) 의존성 배열이 없다 — 읽은 것을 컴파일러가 안다
useEffect $effect(() => { ...; return () => 정리 }) 정리 함수는 React와 같은 자리
function C({ a, b }) const { a, b } = $props() 타입은 그 자리에 붙인다
children {@render children()}, 타입은 Snippet
onClick={fn} onclick={fn} 소문자다. DOM 속성 이름 그대로
{cond && <X/>} {#if cond}<X/>{/if}
xs.map(x => <Row key={x.id}/>) {#each xs as x (x.id)}<Row/>{/each} 괄호 안이 key
ref={el} bind:this={el}
useRef + ResizeObserver bind:clientWidth={w} Svelte가 ResizeObserver로 구현한다. #408에서 <ResponsiveContainer> 자리에 씀

파일 배치도 대응됩니다.

Next.js SvelteKit
app/posts/page.tsx routes/posts/+page.svelte
서버 컴포넌트의 데이터 로드 routes/posts/+page.server.tsload()
generateStaticParams +page.server.tsentries()
app/layout.tsx routes/+layout.svelte
'use client' 없다. 대신 src/lib/server/ 아래 모듈을 화면이 import하면 빌드가 실패한다

마지막 줄이 실제로 도움이 된 차이입니다. React 판은 "이 모듈은 서버 전용"을 주석과 리뷰로 지키는데(src/content.ts), 여기서는 도구가 강제합니다.

문법을 몰라도 볼 수 있는 것 — 체크리스트 여섯

Svelte 코드 한 줄도 읽지 않고 이것만 확인해도 위험은 거의 덮입니다.

  1. 화면이 @blog/content 배럴을 여는 곳이 있는가 — 있으면 안 됩니다. 배럴은 node:fs를 함께 열어서, 클라이언트 그래프에 들어가면 Vite가 빈 스텁으로 바꿉니다(빌드는 성공하고 런타임에만 깨집니다). 화면은 @blog/content/client만 씁니다
    grep -rn "from '@blog/content'" apps/blog/web-svelte/src/lib/components apps/blog/web-svelte/src/lib/client
  2. 값의 단일 출처를 건너뛴 곳이 있는가 — 사이트 값은 @blog/site-values, 앱 경로는 src/lib/shared/routes.ts, 글 URL은 postPath
  3. 색 hex 리터럴이 컴포넌트에 있는가 — 0건이어야 합니다
    grep -rn "#[0-9a-fA-F]\{6\}" apps/blog/web-svelte/src
  4. 레일 폭(maxW·px)을 페이지가 직접 쓴 곳이 있는가Rail.svelte만 압니다
  5. 인라인 eslint-disable이 있는가 — 0건이어야 합니다(noInlineConfig로 애초에 안 먹습니다)
  6. 화면 문자열이 React 판과 같은가 — 라벨·placeholder·aria-label. Svelte를 몰라도 두 파일을 나란히 놓으면 보입니다

결정이 든 파일 일곱

나머지는 기계적인 이식입니다. 논쟁할 값이 있는 것은 이 일곱입니다.

파일 무엇을 정하나
apps/blog/web-svelte/content.config.mts 경로 앵커와 배선. __CONTENT_ROOT__로 앵커를 덮는 이유가 주석에
apps/blog/web-svelte/content.values.mts 이 앱의 번들 규칙 12개. 마커를 왜 React 판과 다르게 골랐는지가 핵심
apps/blog/web-svelte/panda.config.ts 구문 강조 토큰 매핑. React는 서드파티 테마의 hex를 치환하고, 여기는 Prism 클래스에 역할을 직접 잇습니다
src/lib/server/markdown/index.ts + 형제들 커스텀 태그를 빌드 타임 HAST 변환으로. React는 런타임 컴포넌트 맵입니다
src/lib/client/archiveParams.ts /posts URL 계약. nuqs를 안 쓰고 순수 함수 둘로
src/lib/shared/tocRail.ts 차례 레일 좌표 + 활성 구간 판정. 데스크톱·모바일이 같은 함수를 씁니다
src/lib/admin/charts/geometry.ts 차트 좌표. 영역 차트는 0→max, 스파크라인은 min→max로 스케일이 다르고, 그 이유가 주석에 있습니다(React 판이 그렇게 나뉘어 있습니다)

게이트가 대신 봐 주는 것

  • check-seo — 산출물 HTML의 SEO 계약 99페이지. React 판과 같은 검사기를 수정 없이 통과합니다
  • check-bundle — 청크 마커 규칙 12개. admin 코드·mermaid·구문 강조 파서·차트 곡선 계산이 공개 페이지 첫 로드에 없다는 것
  • check-parity — 두 산출물의 클래스 어휘·헤딩·링크·요소 수·아이콘 선언 대조
  • 계약 테스트 116개 / 파일 15개 — URL 계약, 공개 판정, 커스텀 태그 구조, 좌표 계산, 차트 기하 스냅샷
  • lint --max-warnings=0, check-types

게이트는 전부 JS가 돌기 전의 산출물만 봅니다. 브라우저에서 JS가 DOM을 바꾸거나 읽는 조각(mermaid·이미지 줌·코드 복사·차례)은 어느 게이트에도 걸리지 않습니다. mermaid 글 5편의 본문이 사라진 채로 #401부터 #412까지 통과한 이유입니다 — 아래 「배포 뒤 발견한 결함」


측정 결과 (2026-09-13 재측정, 양쪽 새로 빌드)

blog-content measure-bundle — gzip 첫 로드 전송량(HTML + CSS + JS), 프로덕션 빌드.

페이지 React Svelte 비율 차이
/ 313.6 KB 87.4 KB 27.9% −226.2 KB
/about/ 313.0 KB 86.9 KB 27.8% −226.1 KB
/posts/ (중앙값) 339.7 KB 114.8 KB 33.8% −224.9 KB
/series/ 255.1 KB 87.5 KB 34.3% −167.6 KB
/privacy/ 249.9 KB 85.2 KB 34.1% −164.7 KB
/404/ 248.1 KB 82.4 KB 33.2% −165.7 KB
/admin/ (중앙값) 447.9 KB 146.1 KB 32.6% −301.8 KB

산출물 전체: 921개 파일 96.5 MB → 529개 파일 64.0 MB

어디서 갈리는가 — 분해

페이지 HTML CSS JS
/ React 9.3 KB 30.3 KB 274.0 KB (13개)
Svelte 5.8 KB 29.5 KB 52.1 KB (19개)
/404/ React 5.2 KB 30.3 KB 212.7 KB (9개)
Svelte 3.3 KB 29.5 KB 49.6 KB (18개)
/admin/ React 5.6 KB 30.3 KB 412.1 KB (17개)
Svelte 3.4 KB 29.5 KB 113.2 KB (25개)

CSS가 30.3 대 29.5로 사실상 같습니다. 파리티 전에는 30.3 대 10.3이었습니다. 그 격차는 프레임워크가 아니라 기능 수였다는 것이 이걸로 확인됩니다 — 같은 Panda 프리셋이라 화면이 같아지면 유틸리티도 같아집니다. 이 결론은 라이브러리 선택과 무관합니다.

JS 수치를 어디까지 믿을 수 있나

JS는 페이지마다 오염 정도가 다릅니다. 가장 깨끗한 것은 /404/ 입니다 — 차트도 쿼리도 URL 파라미터도 애니메이션도 없어서, 논쟁 중인 라이브러리가 하나도 걸리지 않습니다.

/404/ JS React Svelte
212.7 KB 49.6 KB (23.3%)

차이 163.1 KB 중 확실하게 귀속되는 것:

크기 식별된 내용
69.5 KB react-dom + unstable_scheduleCallback + createRoot
41.5 KB React 내부(Minified React error)
나머지 확실하게 귀속하지 못함

/posts/·/admin/은 여전히 라이브러리 선택이 섞여 있습니다 — 아래 절을 보세요.

라이브러리 비용 — 실측 (2026-09-13)

「손으로 만들기 전에 대안을 찾지 않았다」는 지적에 대한 조사 결과입니다. Vite + Svelte 5.57 프로브를 만들어 실제로 번들했습니다(gzip, Svelte 런타임 베이스라인 16.1 KB 제외).

후보 추가 렌더 Panda 토큰 비고
d3-shape (곡선만) +2.3 KB SVG 채택 — #408에서 들어감
@tanstack/svelte-query +6.8 KB 미적용
d3-shape + d3-scale +10.6 KB SVG
layercake 8.4 +20.9 KB SVG(마크는 직접) 유일한 진지한 대안
uPlot 1.x +22.5 KB Canvas 구조적 탈락
chart.js (tree-shaken) +58.4 KB Canvas 구조적 탈락
@observablehq/plot +130.0 KB SVG
layerchart 2.5 (/svg 서브패스) +148 KB SVG ❌ Tailwind recharts보다 큼
recharts (React 판 현재) ~129 KB SVG 마커를 가진 청크 3개의 합(상한)

두 가지가 뒤집혔습니다.

  • layerchart는 배럴 탓이 아니었습니다. 처음 +132.5 KB가 나왔을 때 export * 트리셰이킹 실패로 보고 넘겼는데, layerchart/svg 서브패스로 좁혀도 오히려 더 큽니다(148 KB, 정적 클로저 32청크). 게다가 스타일이 @layerstack/tailwind에 묶여 Panda strictTokens 위에 얹을 수 없습니다
  • Canvas 계열은 크기와 무관하게 탈락입니다. 색을 css()로 못 주니 CSS 변수를 읽어 주입하고 테마 전환마다 다시 그려야 하는데, 이 저장소는 다이어그램 SVG까지 전부 토큰에 연결하는 금지선이 있습니다. check-seo·check-bundle도 canvas 내용은 못 봅니다

recharts 129 KB가 이 대시보드에서 실제로 하는 계산은 2.3 KB입니다. recharts의 victory-vendor가 d3-shape/d3-scale을 벤더링한 것이고, type="monotone"curveMonotoneX 그 자체입니다.

라이브러리 교체 비용의 분해

recharts를 쪼개면 셋으로 갈립니다. 이 축이 「무엇이 비쌌는가」를 설명합니다.

조각 프레임워크 종속? 이 화면의 비용
계산 (d3-shape·d3-scale) 무관 2.3 KB — 그대로 가져옴
DOM 측정 (ResponsiveContainer) 무관 (브라우저 API) 0 KBbind:clientWidth
컴포넌트 조립 (<XAxis/>·<Area/>) React 전용 나머지 전부

셋째가 가치는 가장 작고 번들은 가장 큽니다. 같은 분해를 nuqs에 대면 셋째가 거의 0이라 73줄로 끝났고, motion은 셋째가 아예 없어서 패키지가 그대로 갈 수 있었습니다. 분해가 곧 비용 예측입니다. 전문은 이 코멘트.

남은 오염 — 무엇이 정리됐고 무엇이 아닌가

라이브러리 상태
recharts ↔ 직접 그린 SVG 정리됨(다른 방식으로). 대안 8종을 실측했고 공정한 교체 대상이 없음을 확인했습니다(위 표). 동시에 Svelte 판이 빠뜨리고 있던 반응형 사이징과 곡선 보간을 #408에서 채워 기능이 동등해졌습니다. 「같은 라이브러리」는 아니지만 「같은 기능, 근거 있는 선택」입니다
@tanstack/react-query ↔ 69줄 스토어 미정리. svelte-query 비용만 쟀습니다(+6.8 KB). 넣어서 재지는 않았습니다
nuqs ↔ 73줄 순수 함수 미정리. sveltekit-search-params 4.0.0을 재지 않았습니다
motionsvelte/transition 미정리. motion은 프레임워크 중립이라 같은 패키지를 쓸 수 있는데 쓰지 않았습니다. 현재 svelte/transitionfly·fade·scale을 씁니다 — 스프링과 다른 움직임입니다

HTML에서도 갈립니다 — 하이드레이션 페이로드

글 상세 HTML을 페이로드와 마크업으로 갈라 재면:

전체(gzip) 페이로드 제외 페이로드 원문
React 34.4 KB 13.2 KB 153.5 KB
Svelte 20.1 KB 12.0 KB 49.3 KB

마크업 자체는 13.2 대 12.0으로 거의 같습니다 — 픽셀 파리티가 다른 각도에서 한 번 더 확인됩니다. 차이 14 KB는 전부 RSC flight 페이로드입니다.

#406 시점 대비 변화

#406 기록 지금 변화
/ 86.6 KB 87.4 KB +0.8
/posts/ 114.0 KB 114.8 KB +0.8
/404/ 81.6 KB 82.4 KB +0.8
/admin/ 142.8 KB 146.1 KB +3.3

#408이 d3-shape(+2.3 KB)와 스파크라인 채움 경로를 넣은 결과입니다. admin의 +3.3 KB가 그 실제 비용이고, 공개 페이지도 0.8 KB씩 늘었습니다 — d3-shape가 샌 것은 아닙니다(check-bundle 규칙 12번이 curveMonotoneX가 어느 페이지 첫 로드에도 없음을 강제하고 통과합니다). 청크가 하나 늘면서 SvelteKit 매니페스트가 커진 것으로 보이나 확인하지 않은 추정입니다.

아직 못 잰 것

  • query·url-params·motion의 라이브러리 비용 분리 — 위 표의 미정리 셋
  • LCP · 하이드레이션 시간 — 측정 항목에 있지만 재지 못했습니다. 번들 크기만 있습니다
  • 빌드 시간 — React 49초는 기록돼 있으나 같은 조건의 Svelte 수치가 없습니다

프레임워크 중립성 — 가정이 어떻게 판명됐나

자산 가정 판명
@blog/content 로더·공개 판정·URL 계약 중립 참. 다만 배럴이 node:fs를 함께 열어, 클라이언트 그래프에서 빈 스텁으로 externalize됐습니다(빌드는 성공하고 런타임에만 깨집니다). 클라이언트 문 @blog/content/client를 새로 냈습니다
@blog/content 빌드 스크립트 중립 참. 진입점을 blog-content 하나로 모았습니다
check-seo 중립 참 — 수정 없이 통과합니다. 이 실험에서 가장 강한 중립성 증거
check-bundle 반쯤 중립 거짓이었습니다. /_next/static/chunks/가 경로에 박혀 있어 SvelteKit 산출물에서 청크를 하나도 못 찾았습니다. 음성 검사만 있었다면 규칙 전부가 "누수 없음"으로 통과했겠지만, 양성 대조(requiredIn)가 #328(2026-08-27)부터 규칙마다 필수라 전 규칙이 marker-dead로 실패하며 드러났습니다. 경로 관례를 걷어냈습니다(#399). 정정: 이전 기록의 "규칙마다 양성 대조를 필수로 만들었다"는 틀렸습니다 — 필수화는 이 실험 전의 일입니다
Panda blog-preset 중립 참. jsxFramework 없이 css() 함수만 씁니다
@design-system/ui 컴포넌트 비중립 예상대로. Svelte로 재구현
React 전용 런타임 의존 8종 전부 다시 만들어야 한다 거짓. 셋(react-markdown·react-syntax-highlighter·@giscus/react)은 React 바인딩일 뿐이라 벗겨 내면 같은 엔진(unified·refractor·giscus 스크립트)을 그대로 씁니다. 넷(react-query·recharts·nuqs·motion)은 Svelte 대안이 있는데 찾아보지 않고 손으로 만들었습니다 — 뒤늦게 전부 실측했고(위 표), 그중 recharts만 "대안이 더 나쁘다"가 확인됐습니다

예상 밖의 결과

  • 사이트 값이 중립이 아니었습니다. React 앱의 content.values.mts에 묶여 있어 @blog/site-values 패키지로 뺐습니다
  • 조회수·대시보드 도메인 2,560줄에 react·next/ import가 0이었습니다. 원래 중립이었고 앱에 묶여 있던 것은 env를 읽어 클라이언트를 만드는 일 하나였습니다 → @blog/analytics
  • 다이어그램 좌표 계산기도 중립이었습니다@blog/diagram. 두 앱이 같은 좌표를 쓰므로 같은 원고가 픽셀 단위로 같은 그림이 됩니다
  • "React 전용 의존"의 절반은 React 전용이 아니었습니다. 위 표의 마지막 줄
  • Svelte 앱 11,093줄 중 3,053줄이 프레임워크 import가 0입니다(lib/server 2,248 + lib/shared 654 + lib/domain·platform 151). boundaries 린트가 강제한 결과고, 그만큼은 다음 프레임워크로 그대로 갑니다

이 실험이 실제로 가르쳐 준 것

계획에 없던 발견들입니다. 깨진 가정 자체가 산출물이라는 방침에 따라 남깁니다.

  1. 기계가 세는 게이트는 화면을 보지 않습니다. check-seo·check-bundle·테스트가 전부 초록인 채로 여섯 페이지가 자리만 잡은 화면이었습니다. 12개 PR 동안 드러나지 않았습니다
  2. 완료 기준에 없는 것은 만들어지지 않습니다. 원장의 18개 기준 중 "화면이 같다"고 말하는 줄이 없었고, 그래서 화면이 같아지지 않았습니다
  3. "계약·회귀 테스트만 이식"이 시각 계약 테스트를 정확히 제외했습니다. React 판 Rail.test.tsxtest('기본 폭은 text다')를 이식했다면 레일 폭 버그는 없었을 것입니다
  4. 웹폰트 누락은 박스로는 안 잡힙니다. JetBrains Mono를 안 싣고 있었는데 모노는 0.6em 고정폭이라 글자 상자가 픽셀 단위로 같았습니다. 크롬의 실제 렌더 폰트 조회(CSS.getPlatformFontsForNode)로만 드러났습니다. Pretendard 때는 한글 폴백의 폭이 달라 줄바꿈이 어긋나며 드러났습니다
  5. 번들 수치에는 유효성 전제가 필요합니다. 기능이 다른 두 산출물의 크기 비교는 아무것도 말해 주지 않습니다
  6. 대체물을 만들기 전에 대안을 찾아야 합니다. 라이브러리 넷을 손으로 만들면서 Svelte 대안을 찾아보지 않았고, 그 선택이 주 지표를 오염시켰습니다. 「찾아봤는데 없어서」와 「안 찾아봐서」는 글로 적으면 똑같이 읽힙니다 — 전자였는지 확인할 방법이 기록에 없으면 후자로 보는 편이 안전합니다
  7. 애니메이션은 정지 마크업 대조로 못 잡습니다. motion의 스프링을 svelte/transition의 280ms fly로 바꿔 놓고 픽셀 파리티를 달성했다고 적었습니다. 다른 움직임입니다
  8. 게이트에 사각지대가 있으면 거기에 결함이 고입니다. /admin은 로그인 게이트 뒤라 배포된 프리뷰에서 열리지 않고, check-parityPARITY_PAGES 5개에도 없습니다 — 두 검증 장치 어느 쪽도 보지 않는 화면입니다. 그 틈에서 AreaChartpreserveAspectRatio="none"으로 viewBox를 가로로만 늘려 축 글자가 뭉개지고 커서 점이 타원인 채로 살아남았습니다. 빌드도 check-seocheck-bundle도 전부 통과하고 있었습니다(#408에서 수정 + 좌표 스냅샷으로 잠금)
  9. 손으로 만든 코드가 실패한 자리는 계산이 아니라 렌더링 환경 대응이었습니다. 차트 428줄을 쪼개면 geometry.ts 101줄(순수 함수 + 테스트)에서도, 마크업 250줄에서도 버그가 안 났습니다. 난 곳은 실제 폭 측정·곡선 보간·라벨 겹침 — 전부 브라우저에서만 드러나고 순수 함수로 안 떨어지는 축입니다. 라이브러리가 파는 것의 상당 부분이 사실 이쪽이고, 직접 만들면 이 축이 구조적으로 빕니다
  10. 라이브러리 교체 비용은 "대응 패키지가 있나"가 아니라 "렌더 트리에 얼마나 붙어 있나"로 결정됩니다. 위 「라이브러리 교체 비용의 분해」 절
  11. 본문을 빌드 타임 HTML로 구우면, 상호작용은 "마운트 뒤 DOM 고치기"가 되고 그 코드는 게이트 밖에 놓입니다. React 판은 react-markdown이 렌더할 때 코드 블록 노드를 컴포넌트로 바꿔(CodeBlock.tsxlanguage === 'mermaid'<MermaidLazy>) 그림이 트리의 제자리에 들어갑니다. 무엇을 바꿀지 고르는 코드가 없습니다. Svelte 판은 본문을 {@html} 문자열로 넣어 그 안에 컴포넌트를 둘 수 없으므로, Mermaid.svelte가 마운트 뒤 DOM을 찾아 replaceWith로 바꿉니다. 바꿀 대상은 다른 파일(codeBlock.ts)이 정한 마크업 모양에 대한 가정이었고, 그 가정이 틀려 본문 전체가 그림 하나로 대체됐습니다(fix(blog-web-svelte): mermaid 그림이 글 본문 전체를 대신하지 않게 한다 #413). 그림 그리기는 두 판 모두 같은 mermaid.render입니다 — 라이브러리의 문제가 아니라 10번 분해의 컴포넌트 조립 조각을 손으로 대신한 자리입니다. 빌드 타임 굽기를 택한 이유(load 데이터가 하이드레이션용으로 HTML에 한 번 더 직렬화된다)는 apps/blog/web-svelte/README.md에 있습니다

배포 뒤 발견한 결함

mermaid가 있는 글의 본문이 사라졌다 — #413에서 수정

발견 2026-09-13, 프리뷰의 /posts/payment-system-architecture/가 헤더와 썸네일만 보인다는 제보
증상 서버 HTML(curl)에는 본문이 전부 있다(h2 6 · p 33). 브라우저에서 JS가 돈 뒤에는 #post-content가 없고, 그 자리에 mermaid 그림 상자 하나만 남는다
원인 codeBlock.ts는 mermaid 펜스를 상자로 감싸지 않는다(<pre><code class="language-mermaid">가 본문 바로 아래). Mermaid.svelte<div><pre><code> 모양을 가정하고 closest('pre')?.parentElement를 바꿨다 → 그 부모가 #post-content
기간 Mermaid.svelte가 들어온 #401(4bf2cae)부터 #413 머지(f3340b2)까지
영향 mermaid 펜스가 있는 글 5편 — payment-system-architecture · next-js-ecs-deploy · 번들러 2·3·4편. React 판은 무관
수정 <pre> 하나만 바꾼다. 서버 쪽 테스트에 "mermaid 펜스는 상자 없이 <pre>로 나간다"를 잠갔다 — 이 단언은 수정 전에도 통과한다. 클라이언트 버그는 이 앱의 node 테스트 환경에서 재현되지 않는다
검증 JS가 돈 뒤 #post-content의 h2·p 수를 서버 HTML과 대조 — 로컬 빌드에서 글 44편 전부, PR 프리뷰 배포본에서 mermaid 글 5편. 그림 8개가 서버의 mermaid <pre> 수와 같다

마운트 뒤 DOM을 만지거나 읽는 다른 조각은 동작을 확인하지 않았습니다. 이번 대조는 "본문이 남는가"만 봤습니다.

  • src/lib/client/CopyCode.svelteclosest('figure')로 코드를 찾는다
  • src/lib/client/ImageZoom.svelte
  • src/lib/client/Toc.svelte · MobileToc.svelte — 헤딩 id로 요소를 찾는다

스택 PR 계획 → 실제 진행

원래 6단계를 계획했으나 14개로 쪼개졌습니다.

계획 # 내용 상태
1 기준선 측정 + 워크스페이스 뼈대
2 라우팅 + 정적 페이지
3 마크다운 렌더 + 커스텀 태그 ✅ 13종(원고가 실제로 쓰는 전부)
4 Mermaid · 구문 강조 · 이미지 줌 + check-bundle 규칙 ✅ 규칙 12개
5 런타임 기능 ✅ 페이지 전환 애니메이션만 제외
6 Admin + 배포 + CI + 문서 + 글 초안 ✅ 스킬 점검 보고서 완료 · 비교 글 초안은 보류
화면 파리티 (계획에 없던 단계)
라이브러리 대안 실측 (계획에 없던 단계) ✅ 8종 측정 완료
라이브러리 비용 분리 (계획에 없던 단계) 🔄 차트 축만 정리됨. query·url-params·motion 남음

성공 기준

목표 수치는 지금 정하지 않습니다. 1번 PR에서 React 쪽 기준선을 먼저 재고, 그 숫자를 본 뒤 목표를 정해 이 이슈에 추가합니다. 지금 지어낸 숫자보다 정직합니다.

측정 항목(양쪽 동일 조건, 프로덕션 빌드 기준):

  • 글 상세 페이지 초기 JS gzip 크기 — 주 지표 → 273.2 KB → 64.4 KB (⚠️ query·url-params 오염 잔존)
  • 홈 초기 JS gzip 크기 → 274.0 KB → 52.1 KB (⚠️ 같은 오염)
  • /404/ 초기 JS gzip 크기 — 오염이 가장 적은 지표212.7 KB → 49.6 KB (23.3%)
  • 총 산출물 크기 → 96.5 MB → 64.0 MB
  • LCP · hydration 시간(로컬 측정, 반복 후 중앙값) → 미측정
  • 빌드 시간 → 미측정(같은 조건의 Svelte 수치 없음)

완료 기준

  • apps/blog/web-sveltepnpm build --filter=@blog/web-svelte로 정적 export 된다
  • 공개 6개 + Admin 4개 라우트가 전부 서고, URL 계약(후행 슬래시 포함)이 React 판과 같다
  • apps/blog/posts의 글 70편이 원고 수정 없이 그대로 렌더된다
  • 커스텀 태그가 전부 동작하고, React 판과 같은 시맨틱 구조를 낸다 — 13종(원고가 실제로 쓰는 전부). code-tabs는 상호작용이 필요해 제외
  • Mermaid · 구문 강조 · 이미지 줌이 글 상세에서만 지연 로드된다 — 다만 mermaid 글 5편에서 본문을 지우고 있었습니다(feat(blog-web-svelte): 읽기 경로의 클라이언트 조각과 check-bundle의 initial 스코프 #401~Claude/react svelte solid migration dkj247 #412, #413에서 수정)
  • 조회수 · Giscus · 검색 · 테마가 로컬 환경에서 동작한다 — 페이지 전환 애니메이션은 미구현
  • blog-content check-seo수정 없이 통과한다
  • check-bundle이 이 앱의 BUNDLE_GUARDS 선언으로 통과한다 — 규칙 12개
  • pnpm lint --filter=@blog/web-svelte--max-warnings=0으로 통과하고 인라인 disable이 0건이다
  • 레이어 경계 위반이 0건이다 — SvelteKit의 $lib/server 강제가 eslint-plugin-boundaries를 대신합니다(도구가 막으므로 빌드가 실패합니다)
  • 색 hex 리터럴이 컴포넌트에 0건이다(전부 blog-preset 토큰 경유)
  • 계약·회귀 테스트가 통과한다 — 116개 / 파일 15개
  • 별도 Worker 프리뷰 URL로 배포되고 PR에 링크가 달린다
  • turbo lint·check-types·test에 편입돼 PR CI가 두 앱을 함께 검사한다
  • 기준선/비교 수치가 이 이슈에 기록된다
  • 스킬 4종 사용성 점검 보고서 — 보고서. 규칙·문법은 중립, 경로·절차는 React 전제
  • README.md·CLAUDE.md·AGENTS.md에 새 워크스페이스가 반영된다
  • 비교 글 초안 — 보류(2026-09-13)
  • apps/blog/web은 한 줄도 바뀌지 않는다 — 예외 조항대로 중립화 수정만 들어갔고(@blog/site-values·@blog/analytics·@blog/diagram 추출), React 앱 테스트 전량이 통과합니다

나중에 추가한 기준 (이번 실험이 가르쳐 준 것)

  • 페이지별 화면 대조 — 문서 높이·링크 수·버튼 수·DOM 기하를 양쪽에서 재서 일치를 확인한다. 「계약이 통과한다」로는 화면이 같은지 알 수 없다
  • 시각 계약 대조를 도구로 남긴다blog-content check-parity. 클래스 어휘·헤딩·링크·요소 수·아이콘 선언 다섯 축을 HTML만 읽고 대조한다
  • 번들 수치에 유효성 전제를 단다 — "두 산출물의 기능이 같을 때만 비교 가능"
  • 라이브러리를 손으로 대체하기 전에 대안을 찾고, 찾은 결과를 기록한다. 없어서 만든 것과 안 찾아보고 만든 것은 결과물이 같아 구분되지 않는다 — 소급해서 8종을 실측하고 위 표에 남겼습니다. 앞으로는 만들기 전에 합니다
  • 번들 비교는 라이브러리 선택을 통제한 뒤에 한다. 차트 축은 정리됐으나(대안이 더 나쁨을 실측 + 기능 동등화) query·url-params·motion 셋이 남았습니다
  • 디자인 결정을 어느 쪽에 맞출지 미리 못 박는다 — 글꼴·토큰 색·애니메이션 곡선처럼 두 판이 갈릴 수 있는 축
  • 게이트가 보지 않는 화면을 찾아 덮는다. /admin이 로그인 게이트와 PARITY_PAGES 양쪽의 사각지대였습니다. 차트 좌표를 인라인 스냅샷으로 잠가(snapshot.test.ts) 회귀가 조용히 지나가지 않게 했습니다
  • JS가 돈 뒤의 화면을 검사한다. 게이트는 전부 JS 실행 전 산출물만 봐서, mermaid 글의 본문 소실(fix(blog-web-svelte): mermaid 그림이 글 본문 전체를 대신하지 않게 한다 #413)이 #401부터 #412까지 통과했습니다. 한 번은 손으로 대조했지만(글 44편) 반복 가능한 검사는 아직 없습니다

테스트 시나리오

계약·회귀 테스트만 이식합니다. 유닛 테스트 전량 이식은 하지 않습니다.

이 방침이 시각 계약 테스트를 함께 제외했습니다. 위 「나중에 추가한 기준」이 그 수정입니다.

  • 글 70편 스모크 — 전부 200으로 서고 빌드가 통과한다
  • 커스텀 태그 각각의 렌더 계약 — 필수 속성, 자식 구조, 누락 속성 시 동작
  • check-seo 규칙 전부 통과 — h1 1개, description 중복·길이, <title> 60자, canonical 자기참조, og 태그, img alt, 산출물↔발행 글 정합성
  • URL 계약 — 후행 슬래시, .이 든 slug(turborepo-next.js-docker·vue-3.0) 처리
  • 공개 판정 회귀 — draft·scheduled 글이 프로덕션 빌드에서 빠지고 dev에서는 배지와 함께 보인다
  • 지연 로드 경계 — Mermaid·구문 강조·차트 곡선 계산이 글 상세 외 라우트 청크에 없다
  • 테마 — pre-paint 스크립트가 FOUC 없이 html[data-theme]를 세팅한다
  • 조회수 — 6시간 쿠키 쿨다운이 지켜지고 두 탭 레이스에서 중복 카운트가 없다
  • 차트 기하 — 좌표 문자열 스냅샷(720·1152 두 폭, 스파크라인 80×32·96×24). /admin이 시각 검증 경로가 없으므로 계산을 대신 잠근다
  • 비회귀: apps/blog/web의 기존 테스트 전량이 계속 통과한다. pnpm build --filter=@blog/web이 계속 통과한다

범위 밖

  • apps/blog/web 교체·제거 — 최종 배포 전환은 비교 결과를 보고 별도로 판단합니다. 이 이슈는 비교 재료를 만드는 데까지입니다
  • 프로덕션 Supabase 연결 — 로컬 인스턴스만. 배포된 프리뷰는 동적 기능이 빈 상태입니다
  • 커스텀 도메인 · 색인 허용 — 프리뷰 URL만 냅니다. 중복 콘텐츠 판단은 최종 배포 결정 때 함께 봅니다
  • Solid 구현 — Svelte 하나로 갑니다
  • 기능 플래그 — 별도 워크스페이스라 필요 없습니다
  • 스킬 파일 자체 수정 — 점검 보고서까지만. 스킬을 고치는 것은 보고서를 보고 별도로 판단합니다
  • apps/react·apps/next.js 등 다른 실험 앱 — 손대지 않습니다
  • 두 앱의 admin 라우트 구조 정렬 — React는 /admin이 글 관리 화면(509줄)이고 대시보드가 /admin/analytics인데, Svelte는 /admin이 대시보드입니다. 그래서 React TopPostsTable의 행별 스파크라인에 대응하는 자리가 Svelte에 없습니다. 컴포넌트 문제가 아니라 구조 문제라 별도 판단입니다

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions