우리는 여전히 제대로 된 CSS 프레임워크를 가지고 있지 않습니다
요약
현재의 CSS 유틸리티 라이브러리들이 진정한 프레임워크로서의 계약(contract)이 부족함을 지적합니다. Tailwind CSS와 같은 도구들이 제공하는 유틸리티 방식의 본질을 분석하고, 타입 지정 및 컴파일러 강제가 가능한 차세대 CSS 레이어의 필요성을 제안합니다.
핵심 포인트
- 현재 CSS 생태계는 진정한 프레임워크가 아닌 유틸리티 라이브러리 중심임
- Tailwind CSS는 빌드 단계가 추가된 자동화된 OOCSS의 형태임
- 진정한 프레임워크는 타입 지정된 입력과 정의된 출력을 가져야 함
- SCSS 루프를 통해 Tailwind와 유사한 유틸리티 패턴 구현 가능
우리는 유틸리티 라이브러리(utility libraries), 클래스 명명 규칙(class-naming conventions), 그리고 프레임워크 브랜딩으로 포장된 방법론(methodologies)들을 가지고 있습니다. 하지만 우리가 갖지 못한 것은 다른 생태계의 프레임워크처럼 작동하는 CSS 레이어입니다. 즉, 타입이 지정된 입력(typed input), 정의된 출력(defined output), 그리고 컴파일러(compiler)가 실제로 강제할 수 있는 계약(contract)이 있는 레이어 말입니다. 이것은 제가 수년 동안 사석에서 해온 불평의 긴 버전입니다. 또한 이것은 진정한 CSS 프레임워크가 무엇을 해야 하는지에 대한 제 생각을 밝히고, 제가 그 방향으로 구축해 온 작업 스케치(working sketch)를 보여줍니다.
Tailwind는 자동화된 OOCSS입니다. 현재 가장 영향력 있는 것은 Tailwind이므로, 그것이 실제로 무엇인지부터 시작해 봅시다. 빌드 단계(build step)를 제거하면 유틸리티 우선 방법론(utility-first methodology)은 새로운 것이 아닙니다. 2008년 Nicole Sullivan에 의해 대중화된 객체 지향 CSS (Object-Oriented CSS, OOCSS)는 이미 동일한 주장을 하고 있었습니다: 마크업 레이어(markup layer)에서 조합하는 작고 단일 목적을 가진 클래스들 말입니다. Tailwind가 존재하기 전에도 많은 팀이 SCSS 루프(loops)를 사용하여 자신만의 유틸리티 스케일(utility scales)을 생성했습니다. 2012년에도 작동했을 법한 몇 줄의 SCSS로 Tailwind의 간격(spacing) 및 플렉스(flex) 유틸리티의 의미 있는 일부를 재구축할 수 있습니다:
// 디자인 시스템 토큰 정의
$spacing-map : (
"1" : 0 .25rem , // 4px
"2" : 0 .5rem , // 8px
"4" : 1rem , // 16px
"8" : 2rem // 32px
);
// 패딩(padding) 및 마진(margin) 유틸리티 생성을 위한 루프
@each $key , $value in $spacing-map {
.p- #{ $key } { padding : $value ; }
.pt- #{ $key } { padding-top : $value ; }
.m- #{ $key } { margin : $value ; }
.mt- #{ $key } { margin-top : $value ; }
}
// 컴파일 결과:
// .p-1 { padding: 0.25rem; }
// .pt-1 { padding-top: 0.25rem; }
// ...
플렉스 정렬(Flex alignments)? 동일한 패턴입니다:
$flex-alignments : (
"start" : flex-start ,
"end" : flex-end ,
"center" : center ,
"between" : space-between
);
@each $name , $value in $flex-alignments {
.justify- #{ $name } { justify-content : $value ; }
.items- #{ $name } { align-items : $value ; }
}
변형(Variants) (반응형(responsive), 호버(hover), 다크 모드(dark mode))도 중첩된 @media, :hover, 부모 선택자(parent selectors)와 같은 동일한 형태를 따랐습니다.
SCSS는 이미 중단점(breakpoints)과 가상 클래스(pseudo-classes)를 루프(looping)함으로써 md:p-4 / hover:bg-blue-500과 같은 패턴을 생성할 수 있었습니다. Tailwind가 진정으로 추가한 것은 빌드 단계(build step)입니다. JIT 컴파일러(JIT compiler)는 코드베이스를 스캔하여 실제로 사용하는 유틸리티 클래스(utility classes)만을 출력하며, 일회성 용도를 위한 임의 값 지원(arbitrary-value support, 예: p-[17px])도 제공합니다. 이것은 진정한 인체공학적(ergonomic) 승리입니다. 번들 크기(bundle sizes)는 줄어들었고 작성 속도는 향상되었습니다. 그에 따른 하류 비용(downstream cost)은 논하기가 더 어려운데, 이는 작성 단계가 아닌 유지보수 단계에서 나타나기 때문입니다. Tailwind 코드베이스에서는 페이지의 무언가가 이상해 보여서 그것을 스타일링한 것을 찾으러 갈 때, 종종 찾을 수 없는 경우가 발생합니다. 여러분이 검색할 클래스(flex, text-center, p-4)는 프로젝트 전체에 걸쳐 수천 번 나타납니다. 레이아웃 결정 사항은 grep으로 찾거나, 바로 이동(jump-to)하거나, 한 곳에서 이름을 바꿀 수 있는 선언문이 아니라, 모든 컴포넌트 파일에 흩어진 문자열 파편이 되어버립니다. 보통 코드베이스 탐색을 도와주는 도구들은 대부분 도움이 되지 않습니다. 결과적으로 우리가 가진 것은 이렇습니다: 2012년 SCSS에서도 가능했던 유틸리티 클래스, 그것들을 인체공학적으로 만들어주는 빌드 단계, 그리고 프로젝트가 커질수록 악화되는 검색 및 이름 변경 문제 말입니다. 이것이 정말 우리가 프레임워크에 원하는 모습일까요? AI가 이 지점에서 망가뜨린 것은 Tailwind를 망가뜨린 것이 아닙니다. 그것은 Tailwind가 결코 가져본 적 없는 계약(contract)을 드러냈을 뿐입니다. Tailwind 시대의 대부분 동안, 클래스 이름은 인간이 작성했습니다. 여기에는 암묵적인 사회적 계약(social contract)이 있었습니다: 여러분은 대부분 간격 스케일(spacing scale)을 유지하고, 이유 없이 임의 값(arbitrary values)을 사용하지 않으며, 문자열이 터무니없이 길어지면 이를 알아차리고 컴포넌트로 분리했습니다. 프레임워크는 이 중 그 어떤 것도 강제하지 않았습니다. 그저 여러분을 신뢰했을 뿐입니다. 그 신뢰라는 가정이 깨지고 있습니다. 이제 AI가 프로덕션 CSS의 상당 부분을 작성하고 있으며, AI에게는 암묵적인 사회적 계약이 없습니다. AI는 언뜻 보기에 그럴싸해 보이는 유틸리티 문자열을 생성하지만, 이는 뷰포트(viewport)에서 깨지거나, 중복되거나, 스케일에서 조용히 벗어나 버립니다. class 속성은 30개의 유틸리티까지 늘어나고 그중 절반은 중복되지만, 스택 내의 그 어떤 것도 이러한 이탈을 잡아내지 못합니다.
결과물을 수동으로 검토하는 것이 병목 현상(bottleneck)이 됩니다. 임의 값 탈출구(arbitrary-value escape hatch). 시니어 개발자가 리뷰 과정에서 제동을 걸어줄 때는 p-[17px] 같은 코드가 용인될 수 있었습니다. 하지만 이제는 코드 생성기(code generator)의 기본 동작이 되었고, 누구도 지적할 수 있는 계약(contract)이 존재하지 않습니다. "스케일(scale)을 사용하세요"라는 말은 규칙이 아니라 분위기(vibe)에 불과합니다. 체계적으로 보이는 문자열. p-[var(--spacing-3)]는 체계적으로 보입니다. 그 안에 토큰 이름이 들어있고 컨벤션(convention)을 따르는 것처럼 보여서, 리뷰어들은 그냥 통과시킵니다. 하지만 프레임워크의 스캐닝(scanning) 단계는 실제로 이를 이해하지 못합니다. 변수(var)가 헬퍼 API(helper API)가 아닌 문자열 파편(string fragment) 안에 살고 있기 때문입니다. 변수 이름을 오타 내더라도 그대로 배포될 수 있습니다. 아무것도 이를 잡아내지 못합니다. 하드코딩(Hardcoding)은 정직한 부정직함입니다. 토큰으로 감싼 문자열은 부정직한 부정직함입니다. 해결책은 CSS에서 AI를 금지하거나 임의 값(arbitrary values)을 금지하는 것이 아닙니다. 해결책은 코드베이스에 AI가 실제로 그 안에서 실행할 수 있는 계약을 제공하는 것입니다. 즉, 빌드(build) 시 검증할 수 있는 타입이 지정된 입력값(typed inputs), 유효한 CSS를 생성하는 헬퍼(helpers), 그리고 스케일 범위 밖의 값(off-scale values)이 스케일 범위 내의 값과 구별되지 않고 눈에 띄게 드러나도록 만드는 것입니다.
CSS를 위한 프레임워크가 의미해야 하는 것. 다른 어떤 생태계에서도 프레임워크는 계약(contract)을 제공합니다. 정의된 형태의 입력을 전달하면, 프레임워크가 내부에서 작업을 수행하고, 정의된 출력을 돌려줍니다. Rails는 라우트 정의(route definitions)를 받아 요청 라이프사이클(request lifecycle)을 제공합니다. React는 컴포넌트(components)와 프롭스(props)를 받아 조정된 트리(reconciled tree)를 제공합니다. 프레임워크가 구현(implementation)을 소유하고, 사용자는 설정(configuration)과 조합(composition)을 소유합니다. 그 기준에 따르면, CSS 프레임워크는 디자인 토큰(design tokens)을 입력으로 받아 유효한 CSS를 출력으로 내놓아야 합니다. 계약은 함수 시그니처(function signatures)가 될 것입니다. 이 값들을 전달하면, 이 타입이 지정된 객체(typed object)를 돌려받는 식입니다. 단위가 일치하지 않으면 컴파일 타임(compile time)에 실패할 것입니다. 스케일 범위를 벗어난 값도 가능은 하겠지만, 다른 모든 것과 동일한 구문으로 세탁되지 않고 눈에 띄게 범위를 벗어난 것으로 표시될 것입니다. 또한 이는 CSS 스펙(spec)의 일부가 아닌 전체를 다루어야 합니다.
CSS는 유동적입니다. 새로운 속성들이 끊임없이 브라우저에 도입되고 있으며, @container, view-timeline, field-sizing과 같은 실험적인 기능들은 어떤 프레임워크가 이를 타입화(type)하기를 기다리지 않고 등장합니다. 사용 가능한 기능을 제한하는 프레임워크는 그 제작자들이 모델링할 시간이 있었던 범위 내로 작업을 제한하게 됩니다. CSS 스펙(spec)은 스펙 그 자체입니다. 프레임워크의 역할은 당신이 전달하는 값을 타입화하고 유효한 CSS를 출력(emit)하는 것이지, 어떤 속성이 허용될지를 결정하는 것이 아닙니다. 제가 계속해서 되돌아오는 형태는 '가장자리(edges)는 주관적(opinionated)이고, 중간(middle)은 느슨한(loose)' 형태입니다. 프레임워크는 엄격함이 필요한 곳에서 엄격해야 합니다. 즉, 토큰 레이어(token layer)로부터의 타입화된 입력과 출력 시점의 유효한 CSS 말입니다. 중간 부분(어떻게 조합하는지, 파일을 어떻게 조직하는지, 헬퍼(helper)를 사용할지 아니면 생(raw) CSS를 작성할지 등)은 당신의 영역입니다. 프레임워크는 그것을 소유하려 하지 않습니다. 이는 대부분의 CSS 프레임워크가 작동하는 방식의 역(inverse)입니다. Tailwind는 중간 부분을 소유하며(모든 클래스는 그들의 것입니다), 가장자리는 느슨하게 유지됩니다(어떤 문자열이든 클래스 속성에 들어갈 수 있습니다). CSS-in-JS 라이브러리들은 중간 부분의 템플릿 구문(template syntax)을 소유하지만, 템플릿 내부의 값들은 여전히 타입이 지정되지 않은 상태입니다. vanilla-extract는 출력은 타입화하지만 입력은 그렇지 않습니다. 양방향 모두 가장자리가 엄격하고 중간이 느슨한 프레임워크는 CSS에서 드물지만, 이는 다른 모든 곳에서 타입 시스템(typed systems)이 작동하는 방식입니다. 작동 가능한 스케치: CSS-Calipers. 이것이 코드로 구현된 원칙입니다. CSS-Calipers는 제가 작년에 작성한 작은 TypeScript 라이브러리입니다. 이 라이브러리의 이면에는 제가 Vanilla Forums에서 일하던 시절로 거슬러 올라가는 아이디어들이 담겨 있습니다. 이는 제가 계속 원해왔던 프레임워크의 '측정 및 수학(measurement-and-math)' 부분을 다룹니다. 토큰이 입력되면 타입화된 CSS가 출력됩니다. 헬퍼(helpers)가 계약(contract) 역할을 합니다. 빌드 타임(build time)에 사용하는 것이 가장 좋지만, 가끔 런타임(runtime) 사용도 가능합니다. 측정값은 조합(composition) 과정 내내 불투명(opaque)하게 유지됩니다. 경계(boundary)에서 .css()를 호출하기 전까지는 아무것도 문자열을 출력하지 않습니다. 수학적 계산은 마지막 단계뿐만 아니라 모든 단계에서 확인됩니다. 일치하지 않는 단위는 즉시 실패(fail fast)합니다. 위의 코드 조각에서 볼 수 있듯이, paddingBase.add(rotation)은 메시지에 px 대 deg가 명시된 명확한 에러를 던집니다. 당신은 프로덕션(production) 환경에서 각도(degrees)에 픽셀(pixels)을 더했다는 사실을 뒤늦게 알게 되지 않습니다.
측정 코어(measurement core)는 토대입니다. 그 위에 저는 제 포트폴리오에서 보조 도구(helpers) 레이어를 구축했습니다: 테두리(borders), 패딩(paddings), 마진(margins), 그림자(shadows)입니다. 각 헬퍼(helper)는 측정값을 소비하여 타입이 지정된 스타일 객체(typed style objects)를 출력합니다. 실제 사용 중인 테두리(borders) 헬퍼는 다음과 같습니다:
// 토큰 레이어의 기본값 사용
export const cardBase = style ( borders ());
// 특정 값을 인라인(inline)으로 재정의
export const cardEmphasis = style ({
... borders ({
width : m ( 2 ),
radius : { south : m ( 8 ) }, // 컴퍼스 스타일(compass-style): south = bottom
}),
});
// 또는 전체 토큰 설정(token config)을 전달
export const cardThemed = style ({
... borders ( theme . cardBorders ),
});
세 가지 호출 형태가 있으며, 모두 유효합니다: 기본값, 인라인 재정의, 전체 토큰 설정입니다. 세 번째 방식에서 비로소 이것이 프레임워크(framework)처럼 느껴지기 시작합니다. 토큰 파일에서 토큰을 가져온다고 가정해 봅시다:
// tokens/cardBorders.ts — 현재
export const cardBorders = {
width : m ( 1 ),
color : theme . colors . surface ,
};
// components/Card.styles.ts
import { cardBorders } from " @/tokens/cardBorders " ;
export const cardStyles = style ({
... borders ( cardBorders ),
});
이제 디자인 팀에서 더 두꺼운 상단 강조(accent top)와 둥근 하단 모서리를 원합니다. 당신은 오직 토큰 파일만 수정하면 됩니다:
// tokens/cardBorders.ts — 디자인 수정 후
export const cardBorders = {
width : m ( 1 ),
color : theme . colors . surface ,
top : { width : m ( 3 ), color : theme . colors . accent },
radius : { south : m ( 8 ) },
};
컴포넌트 파일은 변경되지 않습니다. 헬퍼는 새로운 토큰 형태를 수용하여 더 많은 CSS를 출력하며, 나중에 키(key)를 제거하면 더 적은 CSS를 출력합니다. 디자인에 borderTopWidth를 추가한다는 것은 토큰 객체에 새로운 키를 추가하는 것을 의미하며, 마크업(markup)에 새로운 클래스(class)를 추가하는 것이 아닙니다. 호출부(call site)는 불변(invariant)이며, 디자인 토큰(design tokens)이 변화가 일어나는 지점입니다. 간격(spacing) 헬퍼도 동일한 방식으로 작동합니다: 균일한 간격을 위한 paddings(m(16)), 축 의도(axis-intent)를 위한 paddings({ block: m(8), inline: m(16) }), 그리고 형태를 완전히 토큰에 위임하는 paddings(theme.cardPadding)가 있습니다. 레이어 전반에 걸쳐 동일한 호출 패턴이 적용됩니다.
헬퍼(Helper) 이름은 의도적으로 생성되는 CSS 속성의 복수형으로 지정되었습니다: paddings, borders, margins, shadows. 이는 검색(Grep)이 용이하고, 스타일 객체 내의 가공되지 않은(raw) padding 속성과 시각적으로 구분되며, 레이어 전반에 걸쳐 일관성을 유지합니다. 만약 Tailwind의 p-1 / p-4 / p-8과 같은 축약형(shorthand)이 그립다면, 동일한 기본 요소(primitives) 위에 세 줄짜리 스케일 함수(scale function)를 작성하면 됩니다:
const space = ( n : number ) => m ( 4 ). multiply ( n );
space ( 4 ).css (); // "16px" (Tailwind의 p-4)
paddings ( space ( 4 )); // p-4의 타입이 지정된(typed) 등가물, 스타일 객체에 바로 적용됨
타입이 지정된 프레임워크 내부에서 Tailwind 스타일의 인체공학적(ergonomics) 사용성을 제공합니다. 스케일의 기준은 사용자의 선택에 달려 있습니다: 폰트 크기를 존중하는 간격(spacing)을 위해 rem으로 전환하거나, 카테고리별(space, radius, fontSize)로 별도의 스케일을 정의하거나, 유동적 타이포그래피(fluid-typography) 스케일을 위해 비율을 조합할 수 있습니다. 수학적 계산은 사용자의 몫이지만, 타입(typing)은 끝까지 유지됩니다. 그리고 미디어 쿼리(media-queries) 모듈은 문자열 템플릿 대신 타입이 지정된 중단점(breakpoints)을 사용함으로써 이를 하나로 묶어줍니다:
import { m } from " css-calipers ";
import { makeMediaQueryStyle } from " css-calipers/mediaQueries ";
const media = makeMediaQueryStyle ({
mobile : { maxWidth : m ( 639 ) },
tablet : { minWidth : m ( 640 ), maxWidth : m ( 1023 ) },
desktop : { minWidth : m ( 1024 ) },
});
const cardWidth = m ( 320 ).clamp ( m ( 260 ), m ( 360 )); // 타입이 지정된 유동적 크기 조절 (fluid sizing)
const cardStyles = {
width : cardWidth.css (),
...media ({
mobile : { padding : m ( 12 ).css () },
tablet : { padding : m ( 16 ).css () },
desktop : { padding : m ( 24 ).css () },
}),
};
clamp는 명시적으로 비교해 볼 만한 가치가 있습니다. 네이티브 CSS clamp(260px, 100vw, 360px)는 순수 문자열입니다. 인자 순서가 바뀌거나, 단위 이름에 오타가 나거나, 단위 계열이 일치하지 않아도 아무런 불평 없이 그대로 전달됩니다. 반면 타입이 지정된 버전은 측정(Measurement) 객체를 받아 단위를 검증하고, 계속해서 조합할 수 있는 측정 객체를 반환합니다. 토큰으로부터 경계값(bounds)을 유도하거나, clamp된 결과에 값을 더하거나, 다른 헬퍼에 전달한 뒤, 맨 마지막 단계에서만 CSS 문자열을 생성할 수 있습니다.
CSS의 clamp()와 유사한 개념이지만, 호출 시점에 타입이 소실되는 대신 모든 단계에서 타입이 유지됩니다. 미디어 쿼리 팩토리 (media-query factory) 또한 동일한 방식으로 타입이 지정된 브레이크포인트 (breakpoint) 값을 받습니다. 쿼리 맵 (query map)에서 키가 누락되면 페이지가 태블릿에서 잘못 렌더링될 때가 아니라, 호출 시점에 즉시 발견됩니다. 동일한 패턴이 나머지 헬퍼 (helper) 레이어 전반에 걸쳐 확장됩니다: @supports 폴백 (fallbacks)은 타입이 지정된 객체로, 접근성 변형 (accessibility variants)은 실제 CSS 기능 세트에 맞춰 타입이 지정되며, 색상 조작 (color manipulation)은 타입이 지정된 메서드를 통해 이루어집니다. CSS 값들은 일반적인 CSS-in-JS 작업에서는 거의 불가능했던 곳에서 타입 체크 (type-checking)를 받게 됩니다. 모든 경계 지점에서 이를 선택적으로 적용하거나 제외할 수 있습니다. 정밀한 간격 계산 (spacing math)이 필요한 경우에는 measurements를 사용하고, 유틸리티 우선 (utility-first) 방식이 유리한 레이아웃 프리미티브 (layout primitives)에는 가공되지 않은 Tailwind 클래스를 작성하며, 컴포넌트 범위 (component-scoped) 작업에는 CSS Module, vanilla-extract 또는 styled-components를 사용하세요. CSS-Calipers 자체는 컴파일러에 구애받지 않습니다 (compiler-agnostic). 이 도구는 위 도구들 중 무엇과도, 혹은 순수 스타일 객체 (style objects)나 wh
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기