프론트엔드 뜯어보기

CSS를 언제 만드나

빌드 타임 CSS와 런타임 CSS, 그리고 동적 값

4장 · 파이프라인의 스타일 구간 · 읽는 시간 약 25분

목차 · 진행 0%

    브라우저가 스타일 단계에서 요소마다 어떤 CSS 규칙이 걸리는지 계산한다는 것을 0장에서 봤다. 그런데 그 규칙은 어디서 오는가? 손으로 쓴 .css 파일일 수도 있고, Sass가 바꿔 준 결과일 수도 있고, JavaScript가 렌더링 도중에 만들어 문서에 끼워 넣은 문자열일 수도 있다.

    브라우저 개발자 도구로 이 페이지의 요소를 하나 골라 보면 class에 library-module__Qy0mLa__reader 같은 낯선 이름이 붙어 있다(이 사이트는 개발 서버와 운영 빌드 모두 이 모양이다). 사람이 지은 이름이 아니다. 누가, 언제 이 이름을 만들었을까? 이 장은 스타일링 방식들을 "CSS가 언제 만들어지나"라는 기준 하나로 나눠 본다. 언제가 정해지면 어디서(개발자 컴퓨터, 서버, 사용자 브라우저)도 따라 정해진다.

    CSS가 만들어지는 세 시점

    CSS가 만들어질 수 있는 때는 크게 셋이다.

    1. 빌드 때: 개발자 컴퓨터나 CI에서 번들러가 돌 때. 결과는 .css 파일이 되어 배포된다.
    2. 서버 렌더링 때: 요청이 들어와 서버가 HTML을 만들 때. 그 렌더링에 쓰인 스타일을 모아 HTML에 함께 실어 보낸다.
    3. 브라우저 실행 중: JavaScript가 컴포넌트를 렌더링하면서 스타일 문자열을 만들고 <style> 태그나 스타일시트에 넣는다.

    빌드 때 CSS를 만들면 사용자 기기에서 만들 일이 없다. 브라우저는 받은 파일을 파싱하기만 하면 된다. 브라우저에서 CSS를 만들면 그 일을 사용자 기기의 메인 스레드가 맡는다. 스타일을 만드는 코드도 번들에 실려 내려간다.

    방식예CSS가 만들어지는 때동적 값 처리
    전처리기·후처리기Sass, PostCSS빌드CSS 변수
    CSS Modules*.module.css빌드CSS 변수
    런타임 CSS-in-JSstyled-components, Emotion렌더링 중(서버·브라우저)값마다 새 규칙
    제로 런타임 CSS-in-JSvanilla-extract, Linaria, Panda CSS빌드CSS 변수
    유틸리티 생성Tailwind CSS빌드미리 만든 클래스 고르기, CSS 변수

    이 장에서 눈여겨볼 것은 표의 마지막 열이다. 빌드 때 CSS를 다 만들어 두는 방식들은 실행 중에 바뀌는 값(사용자가 고른 색, 드래그한 위치)을 직접 규칙으로 만들 수 없다. 그래서 대부분 규칙은 빌드 때 고정하고, 값만 CSS 변수로 넘기는 길로 간다.

    전처리기와 CSS Modules: 빌드 때 CSS가 되는 방식

    Sass는 $변수, 중첩, 믹스인 같은 문법으로 쓴 파일을 빌드 때 평범한 CSS로 바꾼다. PostCSS는 CSS를 받아 플러그인으로 고친 CSS를 낸다(벤더 접두사 붙이기, 최신 문법 낮추기). 어느 쪽이든 브라우저는 결과 CSS만 받는다. 둘의 차이는 Sass와 PostCSS 노트에 있다.

    CSS Modules는 "클래스 이름과 애니메이션 이름이 기본적으로 지역 스코프인 CSS 파일"이다. 파일은 평범한 CSS처럼 쓰지만, 빌드 도구가 클래스 이름마다 겹치지 않는 이름을 새로 지어 준다. 그래서 두 파일에 똑같이 .button을 써도 충돌하지 않는다. Next.js 문서도 CSS Modules가 "고유한 클래스 이름을 만들어 CSS를 지역 스코프로 만든다"고 설명한다.

    import s from './04.module.css'
    
    export function Probe() {
      return <div className={s.probe}>.probe</div>
    }
    예측먼저 답해 보세요

    import s from "./04.module.css"의 s는 브라우저에서 실행될 때 무엇일까?

    실험에서 찾은 규칙의 선택자가 .\30 4-module…처럼 보인다면 이름이 숫자로 시작해서다. CSS 식별자는 숫자로 시작할 수 없어서 첫 글자 0을 \30 으로 이스케이프한다. 브라우저가 받은 것은 결국 평범한 클래스 선택자다.

    이름 모양은 빌드 도구와 설정에 따라 다르지만, 어느 도구든 사람이 지은 이름이 아니라 파일 경로나 내용에서 만든 이름을 붙인다. Next.js는 운영 빌드(next build)에서 CSS 파일을 합치고 줄여 페이지 경로(라우트)마다 필요한 만큼만 나눠 싣는다. CSS 순서는 코드에서 스타일을 import한 순서를 따르고, 개발 모드에서는 순서가 다르게 보일 수 있다. 그래서 Next.js 문서는 순서가 중요한 스타일을 운영 빌드 결과로 확인하라고 권한다. 순서가 꼬였을 때 무엇이 이기는지는 CSS 명시도와 캐스케이드 레이어 노트에서 다룬다.

    런타임 CSS-in-JS: 렌더링 중에 만드는 방식

    styled-components와 Emotion은 컴포넌트 코드 안에 CSS를 쓴다(CSS-in-JS 노트).

    // $로 시작하는 prop은 DOM 속성으로 넘기지 않는다(styled-components의 transient prop)
    const Swatch = styled.div<{ $hue: number }>`
      width: 8px;
      height: 8px;
      background: hsl(${p => p.$hue} 70% 55%);
    `

    이 코드는 빌드 때 CSS가 되지 않는다. 컴포넌트가 렌더링될 때 라이브러리가 템플릿을 실제 값으로 채우고, 그 문자열에서 클래스 이름을 만든다. 처음 보는 스타일이면 문서의 스타일시트에 규칙을 넣는다. 넣는 시점은 라이브러리마다 다르다. styled-components v6(정식판 6.5.3)은 렌더링 도중에 바로 넣고, Emotion과 styled-components v7 프리릴리스는 React의 useInsertionEffect로 화면 반영 직전에 넣는다. $hue에 다른 값이 들어올 때마다 새 규칙이 생긴다. 이 방식이 치르는 비용은 이렇다.

    • 런타임 코드: 스타일을 만드는 라이브러리 자체가 번들에 실려 사용자 기기로 내려간다.
    • 렌더링 비용: 템플릿을 채우고 이름을 만드는 일은 렌더링할 때마다 일어나고, 새 규칙을 넣는 일은 렌더링 중이나 화면 반영 직전에 일어난다. 둘 다 메인 스레드가 맡는다. 규칙이 문서에 들어가면 브라우저는 스타일을 다시 계산한다.
    • 서버 렌더링 준비: 서버에서 렌더링할 때 쓰인 스타일을 모아 HTML에 실어야 한다. 그렇지 않으면 스타일 없는 화면이 잠깐 보인다. Next.js App Router 문서는 렌더링 중에 CSS 규칙을 모으는 레지스트리를 두고, useServerInsertedHTML 훅으로 그 규칙을 HTML에 끼워 넣는 설정을 안내한다.
    • 서버 컴포넌트: Next.js 문서는 이런 라이브러리를 클라이언트 컴포넌트에서 쓸 수 있다고 적는다. 서버 컴포넌트와 함께 쓰려면 라이브러리가 최신 React 기능을 지원해야 한다는 경고도 있다. Emotion은 같은 문서에 "지원 작업 중"으로 올라 있고, 해당 이슈는 2026-10-11 기준 열려 있다.

    styled-components는 사정이 조금 다르다. 메인테이너가 2025년 3월 유지보수 모드를 공지했다. 그 뒤 6.3.0에서는 'use client' 없이 서버 컴포넌트에서 쓰는 모드가 생겼다. 이 모드는 인라인 <style> 태그를 내보내고, React 19가 그 태그를 <head>로 옮기고 중복을 없앤다. 대신 ThemeProvider가 아무 일도 하지 않으므로, styled-components는 테마를 빌드 때 만든 CSS 변수로 다루라고 안내한다. 결국 서버 컴포넌트에서 쓰는 길도 "동적인 값은 줄이고 CSS 변수로"라는 같은 결론에 닿는다. 한편 메인테이너는 2026년 5월부터 v7 알파(프리릴리스)를 내기 시작했고, 7월 말 프리릴리스부터 클라이언트의 규칙 삽입을 useInsertionEffect로 옮겼다(릴리스 노트).

    아래 실험은 라이브러리를 쓰지 않고 그 핵심 동작만 흉내 낸다. 칸마다 다른 색을 주는데, 한쪽은 값마다 새 규칙을 insertRule로 넣고 다른 쪽은 규칙 하나에 칸마다 CSS 변수 값만 바꾼다. 실제 라이브러리는 같은 스타일을 캐시하므로 잰 시간 자체보다 문서에 무엇이 쌓이는지를 보자.

    실험규칙을 만들어 넣기와 CSS 변수만 바꾸기

    칸마다 다른 색을 줍니다. "규칙 주입"은 값마다 새 CSS 규칙을 만들어 문서에 넣고, "CSS 변수"는 규칙 하나를 두고 칸의 변수 값만 바꿉니다. 시간은 이 브라우저에서 실제로 잰 값입니다.

    칸 수
    색 바꾸기

    버튼을 눌러 재 보세요.

    • 해볼 것
    • 규칙 주입(런타임 CSS-in-JS 흉내)으로 색을 바꿔 본다
    • CSS 변수로 같은 일을 해 본다
    • 규칙 주입을 세 번 이상 눌러 쌓인 규칙 수를 본다

    "규칙 주입"을 누를수록 문서에 규칙이 쌓인다. styled-components와 Emotion도 컴포넌트 스타일로 한 번 넣은 규칙은 컴포넌트가 사라져도 지우지 않는다(전역 스타일은 예외). 남겨 둔 규칙은 같은 스타일이 또 쓰일 때 다시 넣지 않게 해 주는 캐시 역할도 한다. 규칙이 많아지면 스타일 계산에서 맞춰 볼 규칙도 늘어난다. CSS 변수 방식은 규칙 수가 그대로이고, 바뀌는 것은 요소의 style 속성 값뿐이다.

    제로 런타임: CSS-in-JS인데 런타임이 없다

    "컴포넌트 옆에 타입이 붙은 스타일을 쓰고 싶지만, 브라우저에서 CSS를 만들고 싶지는 않다." 이 요구에서 나온 것이 제로 런타임 CSS-in-JS다. 스타일은 TypeScript나 JavaScript로 쓰지만, 빌드 도구가 그 코드를 미리 실행하거나 분석해서 CSS 파일로 빼낸다.

    • vanilla-extract는 스스로를 "TypeScript로 쓰는 제로 런타임 스타일시트"라고 소개한다. .css.ts 파일을 빌드 때 실행해 정적 CSS 파일을 만든다. 실행 중에 바뀌는 값은 @vanilla-extract/dynamic의 assignInlineVars로 CSS 변수에 넣는다.
    • Linaria는 styled-components와 비슷한 문법을 쓴다. props에 따라 바뀌는 값은 내부에서 CSS 변수로 바꾸고, 요소의 style 속성으로 그 값을 넘긴다.
    • Panda CSS는 소스를 정적으로 분석해 빌드 때 아토믹 CSS를 만든다. 그래서 Panda 문서는 "값이 소스에 보여야 한다"고 말한다. 실행 중에만 알 수 있는 값은 빌드 때 뽑아낼 수 없다.
    비교런타임 CSS-in-JS ↔ 제로 런타임 CSS-in-JS
    런타임styled-components: 렌더링할 때 $hue 값으로 규칙을 만듭니다.
    제로 런타임Linaria·vanilla-extract: 규칙은 빌드 때 hsl(var(--h) …)로 고정하고, 렌더링 때는 --h 값만 넘깁니다.
    같은 점둘 다 컴포넌트 파일 근처에 스타일을 두고, props로 모양을 바꿉니다.
    다른 점동적인 부분이 CSS 규칙이냐, 변수 값이냐가 다릅니다. 그래서 제로 런타임은 소스만 보고 알 수 없는 조합(실행 중에 만든 선택자 등)을 표현하기 어렵습니다.

    Tailwind CSS: 쓴 것만 만든다

    Tailwind CSS는 flex, text-red-600처럼 미리 정한 유틸리티 클래스를 HTML에 직접 붙인다. 가능한 모든 클래스를 담은 CSS를 내려보내지는 않는다. 빌드 때 소스 파일을 훑어 실제로 쓴 클래스만 만든다. v3는 tailwind.config.js의 content에 훑을 경로를 적고, v4는 소스 파일을 자동으로 찾는다.

    여기서 중요한 것은 훑는 방식이다. Tailwind 문서는 "소스 파일을 일반 텍스트로 다루고, 코드로 파싱하지 않는다"고 말한다. 클래스가 될 수 있는 문자열 조각을 찾아, 아는 유틸리티에 해당하면 CSS를 만든다. 그래서 문자열을 이어 붙여 만든 클래스 이름은 알아보지 못한다.

    함정 찾기AI가 준 상태 배지 컴포넌트, 그대로 받아도 될까?
    내가 AI에게 한 요청
    “상태(ok, warn, error)에 따라 배지 색을 바꾸는 컴포넌트 만들어 줘. Tailwind 써서.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 2개입니다.

    정말 실행 중에야 알 수 있는 값(사용자가 고른 임의의 색)이라면 Tailwind도 다른 방식과 같은 답을 쓴다. 값은 style의 CSS 변수로 넘기고, 클래스는 그 변수를 읽게 한다.

    고르기 전에 물을 것

    방식이 많아 보여도 물어볼 것은 두 가지다.

    1. CSS를 언제 만드나: 빌드 때 만들면 사용자 기기는 파싱만 한다. 렌더링 중에 만들면 사용자 기기가 런타임 코드를 내려받고, 스타일을 만드는 비용까지 치른다. 서버 렌더링·서버 컴포넌트와 함께 쓰려면 추가 설정이나 라이브러리 지원이 필요하다.
    2. 동적 값을 어떻게 넘기나: 대부분의 빌드 타임 방식은 "규칙은 고정, 값은 CSS 변수"로 간다. 규칙과 값을 이렇게 나누는 방식을 이해하면 어느 도구를 써도 같은 원리로 다룰 수 있다.

    Next.js 문서는 이렇게 나눠 쓰라고 권한다. 전역 스타일은 정말로 전역이어야 하는 CSS(Tailwind의 기본 스타일 등)에만 쓰고, 컴포넌트 스타일은 Tailwind로, Tailwind로 부족한 부분은 CSS Modules로 쓴다. 이 사이트도 그렇게 나뉘어 있다. 아티클 페이지는 Tailwind로, 이 도서관은 CSS Modules로 그린다. 앞의 "들여다보기" 실험에서 이 페이지의 class 속성이 빌드 도구가 지은 CSS Modules 이름이었던 것이 그 증거다.

    0장에서 펼친 렌더링 파이프라인(브라우저가 코드를 픽셀로 바꾸는 길)을 1장의 레이아웃·페인트부터 이 장의 스타일까지 한 구간씩 지나왔다. 이 책의 장은 여기까지다. 새 기술을 만나면 "파이프라인의 어느 단계에서, 어느 스레드가, 언제 일하나"부터 물어보자.

    확인 문제장을 덮기 전에 떠올려 보기

    1. import s from "./a.module.css"로 받은 s.button에는 무엇이 들어 있나?

    2. 런타임 CSS-in-JS에서 props로 받은 색이 렌더링마다 다른 값이면?

    3. Tailwind에서 클래스 이름을 text-${color}-600처럼 실행 중에 조합하면 동작하지 않는 이유는?

    『프론트엔드 뜯어보기』 4장. CSS를 언제 만드나 · younggeun0. Claude와 함께 썼습니다. 사실 주장은 공식 문서로, 데모 결과는 실제 브라우저로 확인했습니다.

    보기 옵션