◆ ESSAY
이 블로그의 스타일은 두 곳에 살고 있었다. 하나는 JSX의 className에 들어 있는 Tailwind 유틸리티고, 다른 하나는 src/app/tailwind.css였다. 유틸리티로 표현하기 귀찮거나 불가능한 것이 전부 그 파일에 쌓여서 3,312줄이 됐다. 파일 안은 이런 주석 배너로 구획이 나뉘어 있었다.
/* ========= HEADER & LOGO ========= */
/* ========= TWEAKS PANEL ========= */
/* ========= POST CARD (3D tilt) ========= */
/* ========= POST DETAIL ========= */
배너는 주석일 뿐이다. 컴포넌트를 지워도 그 컴포넌트의 CSS는 남고, 무엇을 함께 지워야 할지 선택자와 사용처를 다시 찾아야 한다. 어떤 규칙이 어느 요소를 겨냥하는지도 선택자를 읽어야 안다. 게다가 이 블로그는 팔레트 5종, 필름 그레인, 미니멀 모드, WebGL 히어로, 3D 기울기 카드처럼 유틸리티로 표현되지 않는 것이 유독 많다. Tailwind가 실제로 담당하는 영역은 절반 정도였다. 절반은 유틸리티, 절반은 3,312줄짜리 전역 파일인 상태를 정리하고 싶었다.
전환의 단위도 유틸리티 클래스 문자열에 한정되지 않았다. 카드의 hover가 썸네일과 제목을 바꾸고, 테마가 문서 전체의 색을 바꾸고, 사용자 설정은 컴포넌트 애니메이션을 끈다. 어느 요소가 값을 소유하고 어떤 상태가 그 값을 바꾸는지를 찾아야 했다. 이 관계를 그대로 둔 채 CSS 파일만 TypeScript 객체로 바꾸면, 파일은 컴포넌트 옆에 생겨도 스타일을 추적하는 방식은 달라지지 않는다.
런타임에 스타일을 주입하는 CSS-in-JS는 처음부터 후보가 아니었다. 이 블로그는 App Router의 서버 컴포넌트가 기본이고 cacheComponents를 켠 상태다. styled-components나 emotion처럼 런타임 주입 방식을 쓰면 스타일이 필요한 지점마다 클라이언트 컴포넌트 경계가 생기고, 스트리밍 중 삽입 순서까지 직접 관리해야 한다. 남는 것은 빌드 타임에 CSS를 뽑아내는 쪽이고, 후보는 vanilla-extract, Panda CSS, Linaria, StyleX 정도였다.
StyleX를 고른 이유는 두 가지였다.
첫째, 같은 속성을 두 스타일이 정의할 때 병합 규칙을 코드에서 표현할 수 있다. Tailwind에서 클래스 문자열을 이어 붙이면 어느 유틸리티가 이기는지는 스타일시트 순서가 정한다. 그래서 tailwind-merge 같은 장치가 따라붙는다. StyleX에서는 stylex.props로 스타일을 합성한다. 정적으로 결정할 수 있는 호출은 컴파일러가 처리하고, 그렇지 않은 호출은 런타임에서 병합한다. 실제로 이 작업 중에 같은 속성을 정의하는 스타일 상수 두 개를 클래스 문자열로 이어 붙이던 자리를 합성으로 바꿨는데, 그 자리들은 원래 스타일시트 순서에 결과를 맡기고 있던 코드였다. 2026년 8월에 styled-components를 걷어내고 StyleX 전환을 마친 Linear도 도구 선정 기준 다섯 개 중 하나로 결정적이고 예측 가능한 스타일 해석을 들었다.
둘째, 이식 경로가 기계적이다. Tailwind 4의 토큰은 이미 CSS 커스텀 속성 이름으로 존재했고, StyleX의 defineVars는 그 이름을 그대로 받는다. 토큰 파일은 사실상 받아쓰기였다.
export const tokens = stylex.defineVars({
'--color-blue-500': 'oklch(62.3% 0.214 259.815)',
'--spacing': '0.25rem',
'--text-sm': '0.875rem',
'--text-sm--line-height': 'calc(1.25 / 0.875)',
'--radius-lg': '0.5rem',
'--ease-out': 'cubic-bezier(0, 0, 0.2, 1)',
})
빌드 타임 컴파일러라는 말은, 컴파일러가 도는지가 곧 동작 여부라는 뜻이다. StyleX 0.19.0의 create와 defineVars 같은 정의 API는 컴파일되지 않은 채 실행되면 예외를 던진다. props처럼 런타임 구현이 있는 API와는 구분해야 한다.
const errorForFn = (name) =>
new Error(
`Unexpected 'stylex.${name}' call at runtime. Styles must be compiled by '@stylexjs/babel-plugin'.`,
)
그래서 시작 전에 확인할 것이 하나 있었다. StyleX 0.19는 Babel 플러그인이 필수인데, Next.js 16의 기본 번들러는 Turbopack이고 .babelrc를 읽는 코드 경로는 webpack 설정 쪽에만 있다. 번들러를 webpack으로 되돌려야 하는 줄 알았는데, 공식 설치 문서는 Next.js 16.0.3 이후로 webpack과 Turbopack 양쪽에서 동작하고 next.config도 손댈 필요가 없다고 밝히고 있다.
{
"presets": ["next/babel"],
"plugins": [
[
"@stylexjs/babel-plugin",
{
"dev": false,
"runtimeInjection": false,
"treeshakeCompensation": true,
"unstable_moduleResolution": {"type": "commonJS"}
}
]
]
}
문서 그대로였다. 빌드 산출물의 프리렌더 HTML에는 컴파일된 원자 클래스가 박혀 있고(class="x1lvo1ro xiv4mfs x13ey09u ..."), 같은 빌드의 트레이스에는 run-turbopack 스팬이 있다. Turbopack 네이티브 바이너리 안에 Babel 설정 파일 이름 목록과 babel-loader 문자열이 들어 있으니 감지해서 넘기는 경로가 따로 있는 것으로 보인다. 확인한 것은 동작한다는 사실까지이고 그 경로의 구현은 들여다보지 않았다.
실제 연결에는 Babel 플러그인과 PostCSS 플러그인이 모두 필요했다. Babel 쪽은 컴포넌트 안의 StyleX 선언과 호출을 변환한다. PostCSS 쪽은 소스에서 스타일 규칙을 수집하고, 전역 진입점인 src/styles/stylex.css의 @stylex;를 생성된 CSS로 바꾼다. JSX에 클래스 이름이 들어갔다는 사실과, 그 이름에 대응하는 CSS가 페이지에 내려간다는 사실은 따로 확인해야 한다.
두 경로가 다른 옵션으로 컴파일하지 않도록 postcss.config.js는 .babelrc.json의 플러그인 설정을 가져다 쓴다. 현재 구성은 다음과 같다. 뒤에서 설명할 축약 속성 해석 옵션도 이 공유 설정에 들어 있다.
'@stylexjs/postcss-plugin': {
include: ['src/**/*.{js,jsx,ts,tsx}'],
babelConfig: {
babelrc: false,
configFile: false,
parserOpts: {plugins: ['typescript', 'jsx']},
plugins: babelConfig.plugins,
},
useCSSLayers: false,
},
runtimeInjection: false는 런타임에 CSS 규칙을 주입하지 않겠다는 설정이다. 이를 켰다고 스타일 합성에 필요한 JS까지 전부 사라지는 것은 아니다. stylex.props가 어떤 입력을 받는지, 그 입력이 컴파일하는 파일 안에서 결정되는지에 따라 호출이 남을 수 있다. 실제로 뒤의 첫 측정에서 CSS는 줄고 JS는 늘었다. 빌드 시점 CSS 생성과 JS 실행 비용을 같은 말로 묶으면 그 결과를 설명하기 어렵다.
스타일 파일을 컴포넌트 옆에 둔다는 것도 자동 삭제를 보장하지는 않는다. 이 구성의 PostCSS 수집 범위는 src/**/*.{js,jsx,ts,tsx}다. 컴포넌트만 지우고 별도 *.styles.ts를 남기면 그 파일은 수집 대상에 남는다. 짧은 정의는 컴포넌트 안에 두고, 긴 정의는 인접 파일에 두되 함께 관리하는 규칙을 정했다. 소유 위치를 가까이 두는 것과 사용하지 않는 파일을 자동으로 제거하는 것은 구분했다.
전역 CSS를 여러 파일로 나눈 뒤에는 import 처리 순서도 맞췄다. postcss-imports.cjs가 로컬 import를 먼저 펼치고, StyleX 규칙 수집과 색상 최적화를 뒤에서 실행한다. 파일을 나눴다는 이유로 최적화가 별도의 경로를 타거나 캐스케이드 순서가 달라지는 것을 피하기 위해서다. 컴포넌트 코드를 바꾸는 작업이었지만, 결과를 같게 유지하려면 CSS가 만들어지는 빌드 경로까지 함께 다뤄야 했다.
마지막으로, 이 규모를 손으로 옮길 생각은 없었다. 최종 변경은 163개 파일에 25,700줄이다. 시각 결과가 같은지 기계로 증명할 수 있다면 기계적인 대량 이식은 해볼 만하다고 판단했고, 그래서 코드보다 비교 하네스를 먼저 만들었다. 아래에서는 전환 단계마다 CSS 크기와 전체 전송량, 페인트 지표가 어떻게 달라졌는지 살펴본다.
같은 선언이 한 번만 생성되고 재사용되니 스타일시트가 작아질 것이라는, 원자 CSS의 기본 주장이다. 먼저 JSX의 유틸리티 클래스만 옮기고, 미사용 스타일을 지우고, 파일 구조와 이름을 정리한 중간 상태에서 쟀다. tailwind.css의 컴포넌트 CSS는 아직 그대로 남아 있다.
| 지표 | Tailwind | StyleX | 변화 |
|---|---|---|---|
| 홈 FCP | 4,796 ms | 4,596 ms | -4.17% |
| 홈 LCP | 5,748 ms | 5,572 ms | -3.06% |
| 글 FCP | 5,928 ms | 5,724 ms | -3.44% |
| CSS 전송 | 28.17 KiB | 26.24 KiB | -6.85% |
| 전체 전송(홈) | 808.93 KiB | 811.45 KiB | +0.31% |
CSS는 6.85% 줄었고 LCP는 5쌍 모두 StyleX 쪽이 낮았다. FCP도 두 경로에서 줄었다. 이 중간 상태에서는 CSS가 줄어든다는 예상에 맞는 결과가 나왔다. 다만 유틸리티 전환과 미사용 스타일 정리를 함께 했으므로, 감소량 전부를 원자 클래스 전환의 효과로 분리해 말할 수는 없다.
전체 전송량은 다른 결과였다. JS 전송이 홈에서 3,671 bytes, 글에서 5,090 bytes 늘었고, 홈의 전체 전송량은 0.31% 증가했다. CSS가 줄었다는 결과를 뒤집는 수치는 아니다. CSS 감소가 전체 전송량 감소까지 보장하지는 않는다는 뜻이다. AnnouncementBanner의 StyleX 선언을 컴포넌트와 같은 파일에 두면 stylex.props 호출이 산출물에 남지 않는데 별도 스타일 파일로 옮기면 15개가 남았다. 파일을 나누면 정적 변환 범위가 줄어드는 비용이 있다. 다만 JS 증가량 전부를 이 요인으로 분리해 측정한 것은 아니다.
이 관찰은 파일 배치 규칙에도 반영했다. 스타일 정의를 나눌 필요가 있더라도, 미리 조합할 수 있는 변형은 정의한 모듈에서 stylex.props로 합성하고 결과를 내보내도록 했다. 예를 들어 최근 글 목록의 시리즈 행은 home/recent.styles.ts에서 공통 행 스타일과 시리즈용 변형을 합성한다. 소비하는 컴포넌트는 그 결과를 고른다. 스타일 객체를 여러 파일에 흩어 놓고 JSX에서 경쟁하는 클래스 문자열을 합치는 일을 줄이려는 방식이다.
이 첫 결과에는 좋은 변화와 비용이 같이 있었다. CSS와 페인트 지표가 줄었다는 이유로 전체 전환도 같은 방향일 것이라고 예상할 수는 없었다. 아직 큰 전역 파일의 컴포넌트 CSS와 본문 스타일을 옮기기 전이었기 때문이다. 다음 단계는 그 남은 범위를 같은 문법으로 표현했을 때 어떤 산출물이 생기는지 확인하는 일이었다.
절반만 옮긴 상태는 애초에 목표가 아니었으니 남은 컴포넌트 CSS와 본문 타이포그래피, 테마, 키프레임, About과 Resume의 SCSS까지 전부 옮겼다(fb88ef70). 선택자를 조건 키로 받아주는 문법이 있으니 기존 CSS를 거의 그대로 옮겨 담을 수 있었다. 결과가 이 작업에서 가장 당황스러운 지점이었다.
| 지표 | Tailwind | StyleX | 변화 |
|---|---|---|---|
| 홈 FCP | 4,800 ms | 7,644 ms | +59.25% |
| 글 FCP | 5,944 ms | 8,828 ms | +48.52% |
| CSS 전송 | 28.17 KiB | 65.75 KiB | +133.43% |
| 글 HTML 문서 | 54.40 KiB | 102.65 KiB | +88.70% |
| 글 RSC 응답 | 320.25 KiB | 471.57 KiB | +47.25% |
| 스타일 재계산(글) | 1,090 ms | 2,155 ms | +97.63% |
| 레이아웃(글) | 2,057 ms | 1,489 ms | -27.62% |
FCP는 두 경로 모두 실행 범위가 겹치지 않았다. 레이아웃 시간은 줄고 홈의 CLS도 0.0025에서 0으로 내려갔지만, 그 정도로 상쇄될 차이가 아니었다.
LCP는 일부러 표에 넣지 않았다. 원본 홈은 첫 카드의 썸네일 이미지, 원본 글은 제목 h1이 마지막 후보였는데 전환 후에는 두 경로 모두 하단 공지 배너의 텍스트가 마지막 후보로 잡혔다. 각 버전 5회 모두 같은 대상이었으니 잡음은 아니지만, 대상이 다른 두 값을 나란히 놓고 "+139%"라고 쓰면 같은 이미지가 그만큼 늦게 그려졌다는 뜻으로 읽힌다. 그렇게 쓰지 않는 편이 맞다고 판단했다.
산출물을 열어 보니 표본 글 HTML의 class 속성 합계가 251,678 bytes였고 중복 클래스 토큰이 2,087개였다. 같은 원자 클래스가 한 문서에서 수백 번 반복되고 있었다. 원인은 세 갈래였다. 컴포넌트마다 '@layer site' 블록을 속성 단위로 반복해 감싸 규칙 수가 불어난 것, 본문 Markdown 노드에 클래스를 주입하려고 넣은 rehype 변환기, MDX 래퍼의 중복 연결이다. 세 가지를 정리한 결과는 이렇다.
| 항목 | 정리 전 | 정리 후 | 변화 |
|---|---|---|---|
| StyleX 정의와 CSS 줄 수 | 27,606줄 | 11,513줄 | -58.30% |
| 주 CSS(gzip) | 61,240 B | 36,578 B | -40.27% |
| 표본 글 HTML(gzip) | 102,689 B | 63,859 B | -37.81% |
| 글 HTML의 class 속성 | 251,678 B | 34,036 B | -86.48% |
| 중복 클래스 토큰 | 2,087개 | 24개 |
여기서 CSS 파일 크기만 줄이려고 했다면 문제의 상당 부분을 놓쳤을 것이다. 원자 CSS는 선언을 재사용해도 그 클래스를 사용하는 요소에는 클래스 문자열이 필요하다. Markdown의 모든 문단과 링크, 코드 토큰에 긴 클래스 목록을 붙이면 CSS 정의가 한 번이어도 HTML 안의 문자열은 노드 수만큼 반복된다. HAST에 클래스를 주입하는 변환기는 바로 그 반복을 만들고 있었다.
MDX 래퍼에서도 입력으로 받은 클래스와 직접 추가하는 클래스가 겹쳤다. 최종 DOM에 실제로 필요한 스타일이 무엇인지 확인하고, 래퍼는 자신이 소유하는 표현만 더하도록 정리했다. 예를 들어 표 래퍼는 가로 스크롤을 맡고 table에는 전달받은 속성을 유지한다. 본문 타이포그래피 전체를 래퍼와 HAST 양쪽에서 다시 붙일 필요는 없었다.
HTML과 RSC도 따로 셌다. 서버 컴포넌트를 쓴다고 스타일을 표현하는 문자열의 전송 비용까지 사라지는 것은 아니다. 이 실험에서는 초기 문서뿐 아니라 Next.js의 RSC 요청과 링크 prefetch도 관찰 구간에 들어온다. 따라서 표의 RSC 전송량은 눈앞의 글 본문 하나만의 크기가 아니다. 다른 글과 태그, 시리즈를 위한 응답까지 포함한 실제 요청 목록을 기준으로 봤다.
세 정리를 함께 적용한 뒤 클래스 속성은 251,678 bytes에서 34,036 bytes로 줄었다. 이는 작성 방식을 고치면서 HTML 표현도 작아졌다는 결과다. 세 변경의 기여도를 각각 분리해 잰 수치는 아니므로, 감소량을 전부 특정 변환기 하나의 효과로 돌리지는 않았다.
가설 2는 이 정리 과정에서 죽었다. 전부 옮기는 것은 가능하지도 않고 바람직하지도 않다. StyleX는 요소에 붙는 스타일을 다루는 도구라서, 요소에 붙일 수 없는 것은 CSS로 남는 편이 옳다.
@property 등록, 선택 영역: 전역 선택자 API가 없다. 리셋은 Tailwind 4.3.3의 것을 그대로 가져와 라이선스 주석과 함께 남겼다.next-themes가 붙이는 마커나 문서 전환에 반응하는 규칙들이다.<canvas>: 래퍼만 클래스를 받으므로 그 두 규칙은 about/hero-fx.css에 둔다.성능 측정을 돌리던 중에 코드 토큰과 수식에 불필요한 클래스가 붙는 것을 발견해 측정을 중단하고 변환기를 걷어냈다. 시각 검증을 다시 통과시킨 뒤 20회를 처음부터 다시 돌렸다.
전환을 마친 뒤에도 post-article, series-thread, markdown-exempt 같은 이름은 남았다. 이런 이름은 원자 스타일의 대체물이 아니라, 본문 영역과 시리즈 문맥을 구분하거나 일부 내용을 타이포그래피에서 제외하는 표시다. 클래스 이름을 전부 없애는 것이 목표가 되면 Markdown이 생성하는 요소나 문서 전역 효과까지 억지로 컴포넌트 스타일에 넣게 된다. 최종 구조에서는 이런 문맥 표시와 요소에 직접 붙이는 StyleX 스타일을 함께 사용한다.
정리 후에도 main과의 FCP 격차는 홈 +26.3%, 글 +24.9%였다. 절반 이상을 줄였는데 여전히 느렸으니 남은 것은 작성 방식의 문제라고 봤다. 실제로 문제가 있었다. 조건 키에 조상 선택자를 그대로 옮겨 담은 자리가 751개였다.
post_card_thumb_img: {
'@layer site': {
transition: {
default: null,
':is(.post-card .thumb img)':
'transform 700ms cubic-bezier(0.2, 0.9, 0.2, 1)',
},
transform: {
default: null,
':is(.post-card:hover .thumb img)': 'scale(1.05)',
},
},
},
이렇게 쓰면 옮긴 것은 문법뿐이다. 조상을 감싸는 선택자가 규칙마다 다시 등장하면서 파일 단위 캐스케이드가 그대로 돌아오고, 컴파일러는 그 규칙마다 특이도 패딩을 덧붙인다. 스타일을 요소가 소유하게 만들려고 옮긴 것인데 결과는 반대가 된다. 전부 걷어냈고(ca05500f), 대체 규칙은 세 가지로 정리됐다.
부모의 상태가 자식을 바꿀 때는 부모가 커스텀 속성을 세팅하고 자식이 원래 값을 폴백으로 읽는다.
post_card: {
'@layer site': {
'--post-card-thumb-transform': {default: null, ':hover': 'scale(1.05)'},
},
},
post_card_thumb_img: {
'@layer site': {
objectFit: 'cover',
transition: 'transform 700ms cubic-bezier(0.2, 0.9, 0.2, 1)',
transform: 'var(--post-card-thumb-transform, none)',
},
},
부모에 따라 값이 달라지는 공용 스타일은 부모별 변형으로 쪼개 각 사용처가 고른다. element_a, element_p, element_li처럼 요소 이름으로 뭉쳐 있던 스타일이 여기 해당했고, 쪼개다 보니 어느 문맥에도 속하지 않는 요소가 값을 받아가던 자리도 드러났다. 상태를 React가 들고 있을 때는 CSS에서 속성을 읽지 말고 컴포넌트에서 스타일을 고른다. 헤더가 활성 메뉴를 고르는 방식이 그렇다.
썸네일의 예에서는 부모 카드가 hover 상태일 때 커스텀 속성 값만 바꾸고, 이미지는 자신에게 적용할 transform을 읽는다. 자식 스타일 안에 .post-card:hover .thumb img라는 전체 경로가 들어가지 않는다. 부모가 어떤 상태에서 값을 공급하는지와 이미지가 그 값을 어디에 사용하는지가 각각의 정의에 드러난다. 커스텀 속성의 이름은 부모와 자식 사이에서 공유하므로, 그 관계까지 없어진 것은 아니다.
시리즈 배지도 같은 이유로 사용처별 변형을 나눴다. 카드 안에 있을 때와 한 줄짜리 목록 안에 있을 때 필요한 크기와 배치가 다른데, 요소 이름 하나로 묶으면 조상 선택자가 다시 필요해진다. series 공통 정의에 series_in_post_row, series_in_post_card를 더하고 소비하는 쪽에서 골랐다. 공통 스타일을 무조건 늘리는 대신, 문맥이 다른 부분을 명시적으로 표현한 것이다.
접근성 스타일은 반대로 공용 위치로 옮겼다. 라벨을 화면에서 숨기는 스타일을 쓰려고 다른 경로의 About 스타일 파일까지 가져오는 관계는 끊고, styles/accessibility.styles.ts에 뒀다. 파일을 잘게 나누는 것 자체보다 어느 컴포넌트가 어느 영역을 알아야 하는지 정리한 것이 이 단계의 변화였다.
재작성 도중 축약 속성이 어긋나는 문제를 만났다. StyleX의 기본값은 축약을 롱핸드로 펼쳐 속성 특이도로 승자를 정하는 방식인데, 기존 CSS가 border와 borderColor를 섞어 쓰던 자리에서 결과가 달라졌다. styleResolution: 'application-order'로 바꿔 축약을 축약으로 유지하고 작성 순서로 해결하게 하니 맞았다(374d6b36).
이 문제는 클래스 문자열의 순서를 바꾸는 것으로 해결할 수 없었다. StyleX가 스타일 객체를 어떤 규칙으로 해석하는지가 먼저 정해지기 때문이다. 현재 Babel 설정에 styleResolution을 명시하고 PostCSS에서도 같은 설정을 사용한다. 한편 상태에 따라 테두리 일부만 바꾸는 정의는 롱핸드를 일관되게 쓰도록 작성 규칙에 남겼다. 옵션 하나가 기존 CSS의 모든 조합을 자동으로 안전하게 바꿔준다고 가정하지는 않았다.
| 항목 | 정리 후 | 재작성 후 | 변화 |
|---|---|---|---|
*.styles.ts 줄 수 | 8,579줄 | 6,138줄 | -28.5% |
| 주 CSS(압축 전) | 229,057 B | 169,234 B | -26.1% |
| 주 CSS(gzip) | 36,578 B | 28,710 B | -21.5% |
:is(조상) 조건 키 | 751개 | 0개 |
그리고 다시 쟀다. 홈 FCP +10.1%, 글 FCP +12.1%. 격차는 절반 이하로 줄었지만 여전히 main이 빠르다. 반면 CPU 쪽은 손해가 아니었다. 메인 스레드 누적 시간은 홈에서 0.7% 줄고 레이아웃 시간은 20.0% 줄었다.
남은 것은 전송량이었다. 400 Kbps를 문서와 폰트와 이미지가 나눠 쓰는 조건에서 주 스타일시트는 양쪽 모두 초당 약 8.7 KB로 내려오므로 늘어난 바이트가 거의 그대로 FCP에 실린다. 그래서 선택자 바이트를 셌다. main이 31,656 bytes, 재작성본이 91,193 bytes다. 원자 클래스는 선언 하나당 선택자 하나를 요구하므로 선언을 한 선택자에 묶어 쓴 수작업 CSS보다 커진다. 한 번만 쓰이는 맞춤 스타일이 많은 코드베이스에서는 재사용으로 상쇄되지도 않는다. 특이도 패딩(:not(#\#))을 전부 제거해도 gzip 26,876 bytes로 main의 23,471 bytes 아래로 내려가지 않는다.
여기서 가설 3이 죽었다. 작성 방식을 고치는 것으로는 이 구조적 차이를 넘을 수 없다.
전체 바이트를 못 줄이면 경로별로 필요한 만큼만 내려보내면 된다. 셋을 재봤고, 셋 다 접었다.
StyleX의 useCSSLayers 켜기. 공식 설치 문서가 권하는 기본 구성은 이 옵션을 켜는 쪽이다. 특이도 패딩이 사라져 gzip 1.6 KB가 줄지만, 생성 레이어가 내가 선언한 레이어 순서 밖으로 나가면서 82개 조건 중 16개가 깨진다. 미니멀 모드가 컴포넌트 애니메이션을 끄지 못하고 본문 max-width가 뒤집힌다. 이 블로그는 레이어 순서를 CSS에서 직접 선언하고 StyleX 스타일을 그중 하나에 넣는 구조여서, 생성 레이어가 그 위계를 가로지르면 사용자 설정 기능이 먼저 무너진다.
@layer properties, theme, base, typography, typographyFinal, utilities, site, preferences;
preferences는 팔레트와 필름 그레인, 미니멀 모드처럼 사용자가 선택하는 설정을 담는다. 컴포넌트가 자기 애니메이션을 정의했더라도 미니멀 모드에서는 그 동작을 끌 수 있어야 한다. 그러려면 컴포넌트 스타일이 어느 레이어에 들어가고 사용자 설정이 그것을 어떻게 덮는지가 전환 후에도 유지돼야 한다. CSS 파일이 작아지는 옵션이라도 이 관계가 바뀌면 적용할 수 없었다.
이 실험을 StyleX의 CSS 레이어 기능 자체가 쓸모없다는 뜻으로 읽지는 않는다. 이미 작성해 둔 레이어 구조를 유지한 채 옵션을 켠 결과다. 생성 레이어에 맞춰 전체 우선순위를 다시 설계하는 것은 별도의 변경이고, 이번 작업에서는 기존 화면과 사용자 설정의 동작을 유지하는 쪽을 택했다.
본문 타이포그래피의 죽은 규칙 제거. 한 번도 쓰지 않는 kbd, figure, picture 규칙이 1,396 bytes인데 gzip 이득은 0.2 KB다. 파일 전체가 gzip 2.4 KB라 손댈 값이 아니었다.
컴포넌트 스타일을 경로별로 쪼개기. 전역 스타일시트에서 글 경로만 쓰는 규칙은 gzip 463 bytes뿐이었다. 어느 경로의 HTML에도 나타나지 않는 3.1 KB는 메뉴와 검색과 설정 패널처럼 열었을 때만 쓰이는 UI다.
세 번째 실험에서는 여러 경로가 원자 클래스를 공유하고 있어, 글 경로만 따로 떼어내 얻을 수 있는 이득이 작았다. 앞서 유틸리티를 옮긴 중간 상태에서 CSS가 줄었던 것과, 전환 후 컴포넌트 CSS를 경로별로 더 줄이기 어려웠던 것은 서로 다른 결과다. 어느 범위를 바꾸고 무엇을 재는지에 따라 구분해서 읽어야 한다.
전환 단계마다 같은 비교 장치를 사용했다. 스타일 작성 방식만 바꾸는 작업이라 시각 결과가 달라지면 그 자체로 실패이고, 눈으로 훑어서 확인할 규모가 아니었다. 그래서 코드보다 비교 스크립트를 먼저 만들었다.
전환 전 스타일시트를 baseline.css로 동결해 두고 유틸리티 스타일을 계산 스타일로 비교한다. 161개 그룹 × 너비 10개 × 테마 2개 × 모션 설정 2개 × 상태 4개 = 25,760개 조합이다. 이 파일은 stylelint 대상에서 제외했다. 포맷하면 기준 자체가 바뀐다. 같은 명령에 마크다운 CSS 검사 26개 조건을 붙였다. 그중 12개는 인라인 코드 규칙이 블록 코드의 크기나 굵기를 건드릴 수 없다는 것을 너비와 테마, 본문 문맥별로 확인한다. 나머지 14개는 Prism 토큰 7종의 색을 라이트와 다크에서 baseline.css의 원래 클래스와 대조한다. 전환 중에 실제로 깨지기 쉬운 자리였다. 기준을 동결한 뒤 공통 웹 글꼴을 따로 제거했으므로 font-family는 비교에서 뺐다. 대신 두 페이지의 글꼴을 같은 값으로 고정해 폭과 원점 비교는 그대로 둔다.
pnpm --filter blog test:styles # check-style-parity.mjs, check-markdown-styles.mjs
pnpm --filter blog test:styles:pages # check-page-style-parity.mjs
pnpm --filter blog test:styles:motion # check-page-motion-parity.mjs
화면 비교는 전환 전후를 각각 프로덕션 빌드해 두 포트에 띄우고 돌린다. 13개 경로를 모바일과 데스크톱, 라이트와 다크, 메뉴와 검색과 설정 패널, 팔레트 5종, 필름 그레인, 미니멀 모드까지 조합한 90개 조건에서 계산된 속성, 가상 요소, content, 위치와 크기, 스크린샷을 비교한다. 허용 오차는 두지 않았다. 정지 화면으로 잡히지 않는 애니메이션은 키프레임과 재생 설정을 16개 조건에서 따로 비교한다.
이 검사들을 따로 둔 이유는 실패했을 때 살펴볼 범위가 다르기 때문이다. 유틸리티 대조가 깨지면 작은 스타일 정의부터 볼 수 있고, 거기는 통과하는데 페이지 대조가 깨지면 조상 문맥이나 레이어, DOM 구조를 함께 봐야 한다. 화면을 고정한 검사만으로는 애니메이션이 남아 있어야 하는지, 미니멀 모드에서 꺼졌는지를 알 수 없으므로 모션 검사도 분리했다.
| 검사 | 비교하는 것 | 여기서 다루지 않는 것 |
|---|---|---|
| 유틸리티와 코드 블록 | 고정 입력에서 계산된 스타일 | 실제 페이지의 모든 조상 문맥 |
| 페이지 | DOM 내용, 계산 스타일, 가상 요소, 위치와 픽셀 | 실행 중인 WebGL과 JS 애니메이션 프레임 |
| 모션 | CSS 애니메이션의 키프레임과 재생 설정 | 화면에 그려지는 모든 프레임의 픽셀 |
페이지 검사는 스크린샷을 찍기 전에 DOM 노드 수와 태그, 말단 노드의 텍스트를 비교한다. 그 뒤에 getBoundingClientRect()의 위치와 크기, 계산 스타일을 대조한다. 가상 요소는 ::before, ::after뿐 아니라 목록 마커와 placeholder도 확인한다. 본문 앞의 장식이나 입력창의 안내 문구가 사라져도, 일반 요소의 color와 margin만 같으면 통과하는 검사가 되지 않도록 했다.
모션 검사는 document.getAnimations()에서 CSS 애니메이션을 모으고, 각 효과의 getTiming()과 getKeyframes()를 비교한다. 재생을 멈추고 기준 시점으로 맞춰 설정을 읽는다. 서로 다른 순간에 찍힌 화면 두 장을 같다고 주장하는 방식은 아니다. 사용자 설정 때문에 애니메이션이 없어져야 하는 경우도 같은 경로에 포함했다.
기준 커밋은 하나로 고정하지 못했다. About과 Resume은 브랜치에서 내용이 바뀐 경로라 main이 아니라 내용 갱신 커밋 7c4fa58e로 재야 한다. 비교 대상이 다른 두 페이지를 같은 기준으로 재면 그 차이가 스타일 차이로 잡힌다.
성능 쪽에도 같은 원칙을 뒀다. HTTP 에러, 캐시된 응답, JavaScript 에러, 페인트 측정 누락이 있는 실행은 조용히 빼는 대신 측정 자체를 무효로 처리한다. 느린 실행을 골라 버리지 않았다는 말은 그런 코드가 있다는 뜻이어야 한다. 최종 20회는 모두 검사를 통과했다.
측정 환경: Chrome for Testing 153.0.8010.12, macOS arm64 / Apple M5, Node 24.20.0, pnpm 12.1.0, Next.js 16.3.1, Tailwind 4.3.3에서 StyleX 0.19.0으로. CPU 20배 감속, 다운로드와 업로드 400 Kbps, 최소 요청 지연 400 ms, 모바일 390 × 844 DPR 2, 라이트 모드. 경로 2개 × 버전 2개 × 5회 = 20회를 AB/BA 순서로 교차 실행. 매번 새 브라우저 프로세스와 프로필, HTTP 캐시 비활성화, 서비스 워커와 GA 차단. Lighthouse 점수가 아니라 CDP 스로틀링과 실제 트레이스다. 기준은 main
8ea72e21이고 가설 1과 2의 측정만 그 이전 커밋7c4fa58e기준이다. 이 최종 20회의 원시 기록은 저장소에 넣지 않았다. 앞선 라운드의 기록은apps/blog/tests/performance/에 있으므로, 이 표의 값만 재현 절차로 다시 확인해야 한다.
하네스를 만들며 배운 것도 있다. JSX의 공백 텍스트 노드를 건드리면 CSS가 완전히 같아도 글자 위치가 움직인다. 팔레트를 바꾼 직후에는 계산 스타일에서 예전 자동 여백이 간헐적으로 읽히는데, 요소 위치를 먼저 읽어 레이아웃을 강제로 끝낸 뒤 스타일을 수집하니 사라졌다. 한계도 분명하다. 정지 화면 비교는 애니메이션을 고정하고 WebGL 캔버스 픽셀을 마스킹하므로 통과했다는 것은 CSS가 만드는 결과가 같다는 뜻이고 JavaScript와 WebGL이 그리는 프레임까지 같다는 주장은 아니다.
화면 대조에서는 같은 상태를 찍기 위해 모션을 고정한다. 성능 측정에서는 애니메이션과 캔버스를 정상 실행했다. 화면을 멈춘 상태의 CPU 시간을 실제 페이지의 비용처럼 비교하지 않기 위해서다. 두 작업은 같은 페이지를 사용하더라도 확인하는 대상이 다르다.
성능 비교에는 개발 서버를 쓰지 않았다. 양쪽을 프로덕션 빌드하고 서버의 경로와 이미지 캐시는 사전 방문으로 준비했다. 반면 브라우저는 매 실행 새 프로세스와 프로필로 띄우고 HTTP 캐시를 비웠다. 따라서 이 기록은 서버의 완전한 콜드 스타트가 아니라, 준비된 서버에 캐시 없는 브라우저가 방문하는 조건이다. 서버 준비와 브라우저 초기화를 같은 의미의 예열로 묶지 않았다.
400 Kbps와 CPU 20배 감속도 설정값을 기록하는 데서 끝내지 않았다. compare-performance.mjs는 CDP로 네트워크 규칙을 적용하고, 실제 요청에 그 규칙의 id가 붙었는지 검사한다. 모바일 입력 조건과 FCP, LCP가 수집됐는지도 확인한다. 측정은 load와 폰트 준비, network idle을 기다린 뒤 5초 더 관찰하고 끝낸다. 이 시간 동안 Next.js의 prefetch와 정상 애니메이션 작업도 포함된다.
전송량에는 서로 다른 두 종류의 숫자가 나온다. 주 CSS(gzip)은 산출물 파일을 압축한 크기다. 성능 표의 CSS 전송과 전체 전송은 CDP가 기록한 요청별 인코딩 바이트를 합친 값으로, 외부 스타일시트와 프로토콜이 보고한 부가 비용도 포함한다. 이 둘을 같은 파일의 전후 크기처럼 직접 빼지 않았다. CSS, JS, 폰트, 이미지로 나누고, 문서와 Fetch 응답도 요청 목록에서 따로 확인했다.
LCP는 시간과 함께 마지막 후보 요소의 태그, 텍스트, URL을 기록했다. 전환 후 공지 배너가 마지막 후보가 됐다는 판단은 그 기록에서 나왔다. FCP가 늦어졌다는 결과는 비교할 수 있어도, 제목에서 배너로 대상이 바뀐 LCP 차이를 제목 하나의 렌더 지연으로 해석할 수는 없었다. 숫자와 그 숫자가 가리키는 요소를 함께 남긴 이유다.
CPU 수치에도 범위가 있다. 메인 스레드 작업과 스타일 재계산, 레이아웃 시간은 관찰 시작과 끝의 누적 카운터 차이다. 페이지 로드가 길어지면 관찰 구간도 길어지고, 그 사이 애니메이션이 실행한 일도 들어온다. 첫 측정에서 메인 스레드 작업 중앙값은 조금 늘었지만 실행 범위가 겹쳤으므로, 작은 중앙값 차이만으로 CPU 회귀를 확정하지 않았다. INP나 실사용자 성능을 측정한 실험도 아니다.
실행별 지표와 환경은 results.json, 상세 트레이스는 *.trace.json.gz, 마지막 화면은 PNG로 남는다. 전환 중간 상태마다 출력 디렉터리를 나눠서 보관했다. 원본 브랜치의 측정값도 실행마다 달라질 수 있으므로, 첫 단계의 원본 시간에 마지막 단계의 결과를 붙여 하나의 개선율로 만들지는 않았다. 저장소의 측정 코드와 실행 절차는 이 조건을 다시 구성하기 위한 기록이다.
유틸리티만 옮긴 중간 상태에서는 FCP가 줄었지만, 전환 범위를 넓힌 뒤에는 작성 방식을 고쳐도 main보다 느렸다. 다음으로 페이지가 받는 자원 중 불필요한 것을 찾아봤고, 두 가지를 고쳤다.
수식이 없는 글에서 KaTeX를 걷어냈다(98ad842b). MathLoader가 글 페이지에서 조건 없이 렌더돼 폰트 4종과 CDN 스타일시트를 항상 받고 있었다. 최근 2년 글 137개 중 수식을 쓰는 글은 8개다. 나머지 129개가 쓰지 않는 폰트를 받고 있었다는 뜻이다. 검사는 파서보다 일부러 넓게 잡아 산문의 달러 기호나 셸 스니펫도 수식으로 세도록 했다. 잘못 포함하는 쪽이 잘못 빼는 쪽보다 안전하다.
export function hasMath(body: string): boolean {
return /\$\$/.test(body) || /\$[^$\n]+\$/.test(body)
}
본문용 CSS를 전역 스타일시트에서 떼어내 Markdown을 그리는 컴포넌트가 직접 import하게 했다(e271c313). 목록과 태그, 아카이브 경로는 더 이상 받지 않는다. 옮기다 한 번 깨졌다. 타이포그래피 파일에 푸터와 404 페이지가 의존하던 Tailwind space-* 잔재 두 줄이 섞여 있어서, 파일을 통째로 옮기자 모든 목록 경로가 무너졌다. 본문 밖 레이아웃을 잡던 규칙은 spacing.css로 분리해 전역에 남겼다.
CSS 파일을 분리하는 것과 경로별 다운로드를 분리하는 것도 다른 작업이었다. 파일을 여러 개로 나눠도 전역 진입점에서 전부 import하면 모든 경로가 받는 범위는 그대로다. 최종 구조에서는 stylex.css가 문서 기본값과 공용 UI를 맡고, reading.css는 Markdown을 렌더하는 컴포넌트가 가져오게 했다.
reading.css에는 타이포그래피와 글 영역, 코드와 다이어그램, 목차를 위한 CSS가 들어 있다. 반면 푸터와 목록 레이아웃에 필요한 간격 규칙은 spacing.css에 남았다. 파일 이름이 본문용처럼 보이는지만으로 옮기지 않고, 실제로 어떤 경로가 그 규칙을 사용하는지 확인해야 했다. 앞에서 원자 컴포넌트 스타일의 분할 이득이 작았다는 결과와, 여기서 본문 CSS를 통째로 분리한 결과는 대상이 다르다.
KaTeX 조건부 로딩도 수식 파서 자체를 바꾼 작업은 아니다. 수식을 표시하는 데 필요한 브라우저 자원을 그 글에서 받을지 정하는 변경이다. 그래서 파일 크기뿐 아니라 요청 목록에서 폰트와 외부 CSS가 사라지는지 확인하는 것이 중요했다. 사용하지 않는 자원은 더 작게 압축하는 것보다 요청 자체를 없앨 수 있었다.
이 글의 측정은 KaTeX를 사용하던 구성의 기록이다. 이후 마크다운 파이프라인을 WASM으로 옮기는 작업에서 수식을 MathML로 바꾸고 MathLoader를 제거했다. 현재 구성의 수식 글꼴과 전송량에 위 결과를 그대로 적용하지는 않는다. 서버와 브라우저를 모두 새로 시작한 첫 방문 시간과 전체 빌드 시간은 그 글에서 별도로 측정했다.
| 지표 | main | 다이어트 | 변화 |
|---|---|---|---|
| 홈 FCP | 4,948 ms | 4,708 ms | -4.9% |
| 글 FCP | 6,000 ms | 5,748 ms | -4.2% |
| 글 LCP | 12,964 ms | 12,144 ms | -6.3% |
| 글 load | 11,437 ms | 10,553 ms | -7.7% |
| 글 레이아웃 | 2,927 ms | 2,398 ms | -18.1% |
| 글 전체 전송 | 668.9 KiB | 606.9 KiB | -9.3% |
| 글 요청 수 | 33개 | 28개 | -15.2% |
홈은 전체 전송량이 5.5% 늘었는데도 FCP가 빨라졌다. 늘어난 것은 원자 클래스 문자열이 키운 HTML 문서(+5.6 KB)와 RSC prefetch 응답(+31 KB)이고, 둘 다 렌더 차단 경로 밖이다. 경로별 스타일시트는 이렇게 갈렸다.
| 경로 | main | 다이어트 |
|---|---|---|
| 홈, 목록, 태그, 아카이브, 404 | 24,788 B | 24,474 B |
| 글, 시리즈, 소개, 이력서 | 24,788 B | 30,324 B |
정직하게 말하면 이 개선은 StyleX와 관계가 없다. KaTeX 조건부 로딩과 본문 CSS 분리는 Tailwind 위에서도 똑같이 할 수 있었다. 스타일 엔진을 갈아 끼워 만든 적자를 스타일과 무관한 두 가지로 메우고 넘어선 것이 이 전환의 성능 성적표다.
얻은 것은 스타일을 따라가는 경로가 짧아졌다는 점이다. JSX에서 적용하는 정의를 찾으면 해당 요소의 기본값과 상태별 값을 볼 수 있고, 부모와 자식 사이의 관계는 커스텀 속성이나 명시적인 변형으로 남는다. 컴포넌트 내부 정의는 컴포넌트와 함께 없어지며, 인접 스타일 파일도 같은 변경에서 관리할 수 있다. 별도 파일의 자동 삭제까지 보장되는 것은 아니다. 타입과 자동완성이 붙고, 경쟁하는 스타일을 클래스 문자열로 이어 붙이던 자리도 합성으로 바꿨다.
반대로 전역 CSS가 맡는 범위도 더 분명해졌다. 문서 테마와 사용자 설정, 생성된 Markdown의 문맥은 CSS로 남겼다. 새 컴포넌트를 만들 때 어디에 스타일을 둘지, 부모 상태가 자식에게 영향을 줄 때 무엇을 전달할지, 본문 전용 규칙은 어느 진입점에서 가져올지 작성 규칙에 남겼다. 이 작업의 유지보수상 결과는 파일 확장자가 바뀐 것보다 그 경계를 정한 데 있다.
값은 전송량으로 냈다. 선택자 바이트가 31,656에서 91,193으로 늘고, 원자 클래스 문자열이 HTML과 RSC 응답을 키우고, 본문 경로의 스타일시트가 gzip 24,788에서 30,324 bytes가 됐다. 빌드에는 Babel 플러그인 의존이 하나 늘었다. 전역 선택자와 생성 콘텐츠, 조상 상태에 의존하는 효과는 여전히 CSS로 남으니 작성 방식이 하나로 통일되는 것도 아니다. 스타일 정의 줄 수도 3,464줄에서 9,096줄로 늘었다. JSX에서 빠진 유틸리티 클래스 문자열이 이 비교에 계산되지 않았으니 정확한 대조는 아니지만, 짧아지는 방향은 아니었다.
이 숫자를 StyleX 일반의 성능 성적표로 읽으면 안 된다. Linear는 1,000개가 넘는 PR로 같은 도구로 옮기면서 뷰가 많은 페이지의 메인 스레드 작업을 20%에서 35%까지 줄였고 페이지 전환 렌더가 약 30% 빨라졌다고 보고했다. 출발점이 다르다. Linear는 클라이언트가 렌더 도중 스타일을 만들고 규칙을 주입하는 런타임 CSS-in-JS에서 왔으니 걷어낼 런타임 비용이 있었고, 이 블로그는 이미 빌드 타임에 CSS가 확정되는 Tailwind에서 왔으니 걷어낼 런타임이 없었다. 없는 이득은 얻지 못하고 원자 클래스의 바이트 비용만 새로 낸 셈이다. StyleX의 성능 논거는 런타임 CSS-in-JS를 상대로 성립한다고 읽는 편이 맞다.
그래서 성능을 명분으로 StyleX를 고르는 것은 최소한 이 블로그 기준으로는 근거가 약하다고 생각한다. 스타일의 소유 위치를 코드로 강제하고 싶다는 것이 이유라면 값을 치를 만하고, 그 값이 얼마인지는 위의 표들이 말해준다. 한 번만 쓰이는 맞춤 스타일이 많은 사이트일수록 원자화의 이득이 줄어드니, 디자인 시스템이 촘촘한 제품에서 같은 실험을 하면 다른 숫자가 나올 가능성이 높다.
다음에 스타일을 바꿀 때도 이 하네스를 쓸 생각이다. 이번에는 CSS가 줄면서 전체 전송량은 늘기도 했고, 전체 전송량이 늘었는데도 FCP는 줄기도 했다. 하나의 지표만으로 전환 전체의 성패를 판정할 수는 없었다. CSS 크기와 전체 전송량, 실제 화면이 그려지는 시간을 따로 비교한 기록을 남긴 것이 다음 작업에도 도움이 될 것 같다.
여기까지의 수치는 StyleX 전환과 KaTeX 조건부 로딩, 본문 CSS 분리 단계의 결과다. 이후 마크다운 처리를 WASM으로 옮기고 글 목록 메타데이터 재사용까지 적용한 블로그 전체의 결과는 세 번째 편에서 같은 조건으로 다시 측정했다. 최초 전체 비교에서는 홈 FCP가 비슷했고, 수식 글은 글꼴 전송량이 늘면서 LCP가 늦어졌다. 이 글의 홈 FCP 4.9% 감소를 최종 구성의 개선율로 이어 붙일 수는 없었다. 비교 커밋과 감속 조건부터 다른 실험이다.
그래서 수식 전용 글꼴은 유지하고 일반 웹 글꼴인 Inter, JetBrains Mono, Fraunces를 실제 코드에서 제거했다. 시스템 글꼴로 바꾸면서 글자 모양과 줄바꿈은 달라졌지만, 브라우저가 받던 공통 글꼴 약 168KiB를 없앴다. 재빌드 후 수식 글의 FCP 중앙값은 1,514ms에서 758ms, LCP는 5,650ms에서 2,352ms로 줄었다. 이 변경은 StyleX 전환과 별개이므로 홈 LCP가 늦어진 결과도 아래 그래프에 함께 표시했다. 측정 조건과 원인 대조는 세 번째 편에 자세히 적었다. 이 전환에서 얻은 것은 스타일을 관리할 위치가 분명해졌다는 점이다. 첫 화면의 비용을 줄이는 일은 사용하는 자원까지 따로 살펴봐야 했다.
아래 그래프는 위의 StyleX 전환 단계별 표와 다른 실험이다. 일반 웹 글꼴 제거 직전과 직후를 CPU 4배 감속, 지연 150ms, 전송 속도 1,600Kbps에서 경로별 네 번씩 비교했다. 막대는 중앙값이며, 각 경로의 전과 후를 비교하면 된다. 실행 범위는 세 번째 편에 적었다. 수식 전용 글꼴은 양쪽 모두 유지했다.