
이 포스트는 Linear의 개발자 Kenneth Skovhus가 리니어 팀 블로그에 올린 Styling Linear for the future with StyleX 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.
예상보다 훨씬 오래 걸렸고, 때로는 추진력을 유지하기가 버거웠다. 하지만 1,000개가 넘는 PR 끝에 Linear의 React 애플리케이션을 styled-components에서 StyleX로 옮겼다.
styled-components는 놀랄 만큼 오랫동안 Linear에 잘 맞았다. API는 아주 유연했고, 스타일은 컴포넌트와 함께 둘 수 있었으며, 더 특수한 일이 필요하면 일반 CSS를 꺼내 쓸 수 있었다. 가끔 성가신 border radius 같은 사소한 것에도 유난히 민감한 우리 팀에는 이 방식이 잘 맞았다.
하지만 결국 끝없는 설정 가능성은 부담이 됐다.
CSS는 이미 멀리 떨어진 곳에서 스타일을 건드리기 너무 쉽게 만든다. styled(Button) 같은 패턴은 컴포넌트의 스타일링 계약을 명시하는 대신, 외부에서 컴포넌트를 다시 열어 스타일을 입히는 일을 자연스러운 것으로 만들었다. 몇 달 전 Linear를 리디자인한 뒤 우리는 이 문제를 아주 선명하게 겪었고, 지금까지도 긴 UI 회귀의 꼬리를 쫓고 있다.
팀이 커지면서 UI를 조합하는 방식의 더 단단한 기반을 만들고 싶었다. 에이전트가 코드베이스에 점점 더 많이 기여하는 지금, 이 경계를 정의하는 일은 특히 중요해지고 있다.
좀 더 현실적인 이유는 성능이었다. 우리는 Linear가 빠른 상태를 유지하는 데 신경을 쓰고, CSS-in-JS는 렌더링 중에 스타일을 생성하고 CSS 규칙을 주입하는 일을 추가한다. React 18로 올리면서 동시성 렌더링이 들어온 뒤 성능 회귀를 겪은 것이 마지막 계기였다. 그때 styled-components도 유지보수 모드에 들어갔다.
그러면 질문은 이렇다. 제품 개발을 멈추지 않고, 크고 활발하게 바뀌는 코드베이스를 어떻게 옮길 것인가. 마이그레이션은 반복 작업이 많으니 코딩 에이전트에 잘 맞아 보인다. 그러나 이번 작업에는 통째로 맡기기에는 너무 많은 엣지 케이스가 있었다.
마이그레이션이 길어지는 동안 모델의 능력도 달라졌다. 프로젝트 중간에 Fable과 Sol이 출시되면서 에이전트는 더 나은 작업을 할 수 있게 됐다. 이 글에서는 결정론적 도구, 에이전트, 사람의 판단을 함께 쓴 마이그레이션에서 배운 것을 정리한다. 프로젝트가 진행되는 동안 셋이 맡는 비중도 계속 달라졌다.
StyleX를 선택한 이유
대안을 찾으면서 다음 라이브러리에는 양보할 수 없는 조건이 생겼다. 애플리케이션이 렌더링하는 동안이 아니라 빌드 시점에 스타일 생성 대부분을 처리해야 했다. 멀리 떨어진 곳에서 스타일을 입히는 일을 관례로만 말리지 말고, 의도적으로 어렵게 만들어야 했다. 그러면서도 컴포넌트가 명확한 스타일링 계약을 노출할 방법은 있어야 했다. 특이성 싸움 없이 파일을 넘나들어도 스타일 병합이 예측 가능해야 했고, React와 잘 맞으며 활발히 유지보수되고 장기적으로 안전한 선택지처럼 보여야 했다.
개발자 경험도 중요했고, 이제는 에이전트 경험도 점점 중요해지고 있다. 흔한 경우는 단순하게 남아야 하고, 스타일은 컴포넌트 가까이에 있어야 하며, 필요할 때는 현실적인 탈출구도 있어야 한다.
React에서 쓸 수 있는 주요 스타일링 선택지는 대부분 살펴봤다. 가장 가까운 대안은 정적 추출과 강한 타입 안전성을 제공하는 vanilla-extract였다. 하지만 API가 더 조각나 있다는 느낌이었고, 컴포넌트와 스타일 파일을 나누는 방식도 우리가 일하는 방식과 맞지 않았다.
Meta의 StyleX는 더 나은 균형을 보여줬다. 스타일은 컴포넌트와 함께 남고, API는 작으며, 스타일 해석은 결정적이다. 타입 안전한 스타일링 계약을 제공하면서도 컴포넌트 내부를 파고들어 외부에서 재스타일링하기는 의도적으로 더 어렵게 만든다.
이 제약에는 트레이드오프가 있다. 완전히 유연한 CSS 템플릿 리터럴에서 제한된 원자적 시스템으로 옮기면 부모에 의존하는 선택자, 전역 선택자, 래퍼를 통한 컴포넌트 재스타일링 같은 패턴은 더 어려워진다. 다만 조금 아이러니하게도, 그게 바로 목적이다. 옮기기 가장 어려운 패턴 상당수가 기존 시스템을 규모 있게 추론하기 어렵게 만든 패턴이기 때문이다.
거대한 마이그레이션을 감당 가능한 단위로 만들기
지금 이 마이그레이션을 시작한다면 Fable과 Sol에 직접 작업을 훨씬 더 많이 맡길 것이다. 하지만 시작 당시 styled-components는 자동화하기 유난히 어려운 대상이었다. 튜링 완전한 언어와 완전한 CSS의 힘, 완전히 열린 API를 제공하기 때문이다. 같은 의도를 표현하는 방법이 셀 수 없이 많고, 당시에는 에이전트가 그럴듯하지만 실제로는 틀린 결과를 내기가 불편할 정도로 쉬웠다.
Linear에 정식 디자인 시스템이 없었다는 점도 일을 어렵게 했다. 공통 컴포넌트는 있었지만, 그중 많은 것은 명확한 스타일링 계약 대신 폭넓은 API를 가진 채로 발전해 왔다.
이 제약 아래에서 나는 결정론적 코더모드부터 만들었다. styled-components-to-stylex-codemod는 큰 테스트 모음과 꽤 의도적인 아키텍처에서 시작했고, 이후 에이전트가 구현을 확장하는 데 도움을 줬다. 온라인 플레이그라운드, 파일 간 선택자 처리, 지금까지 만난 대부분의 엣지 케이스를 위한 회귀 방어를 포함한다.
마이그레이션 중 위험 줄이기
제품 개발을 동결하고 싶지 않았으므로 styled-components와 StyleX는 한동안 공존해야 했다. 그 중간 기간의 위험은 몇 가지 방식으로 줄였다.
- 먼저 스타일링 기반을 정의한다. 코더모드가 쓸모 있는 일을 하기 전에, 옮겨 갈 컴포넌트가 사용할 StyleX 변수·상수·공통 프리미티브를 정의해야 했다.
- 범위가 명확한 에이전트를 띄운다. 에이전트는 좁은 범위, 코더모드 실행기, 예시, 검증 스크립트, 명확한 체크리스트가 있을 때 더 잘 일한다. 에이전트에게 렌더링된 UI를 살피고 생성된 CSS를 원본과 비교하게 했지만, 복잡한 hover 상태나 테마 분기, 작은 레이아웃 차이 같은 시각적 차이를 검증하려다 자주 막혔다. Fable과 Sol이 나오기 전까지는 수동 테스트가 많이 필요했다.
- 리프 노드부터 시작한다. 다른
styled-components를 감싸거나 재스타일링하지 않는 컴포넌트부터 옮겼다. 이렇게 하면 diff가 작아지고 캐스케이드와의 상호작용도 줄어든다. 공통 프리미티브를 건드리기 전에 컴포넌트 트리의 위험이 낮은 곳에서 배울 수도 있다.
팀 전체에서 채택을 끌어내기
마이그레이션의 기술적 세부 사항 외에도, 사람들을 styled-components에서 멀어지게 할 필요가 있었다. 진행 상황을 보이게 만들고, 코드베이스의 나머지 부분도 StyleX로 옮기는 데 추진력을 만들고 싶었다.
개발 도구 막대에 현재 페이지에서 남아 있는 styled-components 수를 추적하는 카운터를 추가했다. Linear를 돌아다니면 숫자가 올라가거나 내려갔다.

또 어떤 컴포넌트가 styled-components를 쓰고 어떤 컴포넌트가 StyleX를 쓰는지 강조하는 도구도 만들었다. 코드베이스의 특정 부분이 옮겨졌는지 쉽게 볼 수 있었고, 어쩌면 더 중요한 일은 사람들이 styled-components에서 벗어나도록 자극했다는 점이다.
코드베이스 전체의 진행률을 추적하고 마침내 styled-components에 작별을 고할 수 있는 날을 추정하기 위해 그래프도 계속 만들었다. 실제로 Zoom에서 술을 들고 축하했다.


마지막으로 PR에서 새 styled-components 사용을 알리는 봇을 붙였다. 이 덕분에 모두가 책임을 의식하게 됐고, 회귀가 몰래 들어오는 일도 더 어려워졌다.
규모 있는 StyleX 강제하기
마이그레이션 이후 코드베이스가 가져야 할 관례를 강제하는 도구도 만들었다.
커스텀 린트 규칙은 의도한 경로를 분명하게 만들고, 오래된 패턴이 다시 스며드는 일을 막는다. Oxlint는 흔한 작성 실수, 디자인 토큰 사용, React prop이 생성된 StyleX 결과를 덮어쓸 수 있는 경우를 포함한 빠른 로컬 검사를 맡는다.
파일 단위 린터보다 더 많은 맥락이 필요한 규칙도 있다. 그래서 컴포넌트, import, 스타일을 파일 사이에서 따라가는 타입 인식 저장소 검사기도 만들었다. 이 도구는 스타일링 API를 검증하고, StyleX의 속성 단위 병합 때문에 기본 스타일이나 조건부 스타일이 조용히 사라질 수 있는 경우를 찾는다.
규칙은 몇 가지 큰 범주로 나뉜다.
- 마이그레이션과 레거시 정리: 기존 스타일링 시스템이 돌아오는 일을 막고, 마이그레이션 잔재를 지운다. 예를 들어
styled-componentsimport를 금지하고, 아직도 쓰이지 않는className또는styleprop을 노출하는 컴포넌트를 찾는다. - 컴포넌트 스타일링 API와 파일 간 전달:
sxprop을 컴포넌트 사이의 표준 스타일링 인터페이스로 만들고, 스타일링 지원이 컴포넌트 경계를 지나도 유지되게 한다. StyleX 기반 컴포넌트가sx를 노출하는지, 로컬 스타일 뒤에 병합하는지, 조용히 버리지 않는지, 실제로 적용하지 않는 스타일링 prop을 광고하지 않는지 검사한다. 저장소 검사기는 래퍼와 다형성 컴포넌트를 지나며 StyleX 지원을 추적하고,sx가 올바르게 실체화되는지, 조건부 스타일이 경계를 넘으며 사라지지 않는지도 확인한다. - 스타일 조합과 우선순위:
className,style, JSX spread,sx,stylex.props(),mergedSx()가 서로를 덮어쓰는 경우를 잡는다. 호출자 쪽의 평평한 override가 컴포넌트의 모든 조건 상태를 바꿔 버리는 문제 같은 StyleX 특유의 병합 문제도 찾는다. 여러 값을 한 번에 쓰는 margin·padding 단축 속성을 금지하고, 조건부 단축 속성과 긴 속성을 섞어 StyleX의 속성 우선순위가 결과를 조용히 바꾸는 경우도 막는다. - 디자인과 상호작용의 일관성: 색, 타이포그래피, 그림자, 애니메이션, 커서, 얇은 테두리의 하드코딩 값을 공통 변수로 바꾼다. hover, press, 링크 동작도 표준화한다. 예를 들어
highlightMixin은 포인터 기기에서는:hover, 터치 기기에서는:active에 대응한다. 외부 링크 밑줄도 공통 mixin 하나를 써서 사용자의 링크 밑줄 설정과 호환되게 한다. suppression은 허용하지만 범위가 좁고 이유가 있어야 한다. - 컴파일러·런타임·테마 안전성:
defineConsts값에 산술 연산을 하는 일을 막고, 정의되지 않은 동적 스타일 값을 방어하며, 원시 CSS 변수 대신 타입이 있는defineVars()참조를 쓰게 한다. 테마 토큰을 루트 수준defineVars()기본값으로 쓰지 못하게 하고, 현재 DOM 하위 트리 밖에 렌더링할 때는 테마를 입힌 portal 컴포넌트를 요구한다.
마지막으로 포인터와 터치 기기에서 일관된 hover·press 피드백처럼 StyleX가 직접 지원하지 않는 동작에는 Vite용 빌드 시점 확장도 몇 개 추가했다.
StyleX는 닫힌 시스템이 아니다
탈출구를 완전히 없애려 하지는 않았다. 전역 선택자나 서드파티 DOM 재스타일링처럼 여전히 본질적으로 CSS 모양을 한 문제에는 CSS Modules를 범위가 분명하고 명시적인 대안으로 쓴다.
다른 곳에서는 StyleX를 확장했다. 가장 대표적인 것은 테마다. Linear의 테마는 사람이 직접 작성하는 대신 생성된다. 사용자 정의 테마는 기본 색, 강조 색, 대비 같은 몇 가지 입력값에서 시작하고, LCH 공간에서 100개가 넘는 색 변수를 만든다. 보통 사람들이 생각하는 배경색 교체보다 훨씬 더 깊다. 행이 선택되면 선택된 배경을 새 기본색으로 삼아 전체 테마를 다시 만들고, 레이블·테두리·컨트롤도 모두 다시 계산한다. 포커스, 메뉴, 떠 있는 표면도 마찬가지이며 각각 중첩된 ThemeProvider를 통해 자기 하위 트리에 범위를 둔다.

이 모델은 StyleX의 기본 테마 기능과 특히 잘 맞지는 않는다. 컴포넌트는 여전히 평범한 stylex.defineVars() 토큰을 기준으로 스타일을 작성하지만, 우리는 이 토큰을 자리표시자로 취급한다. ThemeProvider는 StyleX가 생성한 해시된 커스텀 속성 이름을 뽑아 내고, 생성된 테마마다 provider 경계에 범위를 둔 CSS 규칙 하나를 런타임에 주입한다. 결과적으로 테마 모델은 그대로 두면서, 스타일링 시스템의 나머지는 렌더링 경로 밖으로 옮겼다.
얻은 것
마이그레이션으로 Linear의 성능은 좋아졌지만, 계속되는 성능 최적화 작업과 얽혀 있어 정확한 종단 간 효과를 분리하기는 어렵다.
스타일 비용을 최대한 분리해 보기 위해 Fable로 3월의 프로파일과 현재 클라이언트 프로파일을 비교하고, 스타일 생성과 주입에 관련된 작업을 따로 봤다. 화면 요소가 많은 페이지에서는 런타임 스타일 주입을 제거해 메인 스레드 CPU 작업이 대략 20~35% 줄었다. 테스트에서는 중간급 기기에서 약 30% 더 빨라졌다. 화면 이동 중 수백 개 CSS 규칙을 주입하던 클라이언트는 이제 0개를 주입한다.
하지만 어쩌면 더 가치 있는 변화는 Linear의 스타일링 경계가 훨씬 더 선명해졌다는 점이다. 컴포넌트는 명시적인 스타일링 계약을 갖고, 우선순위는 결정적이며, 예전에는 관례나 캐스케이드에 의존했던 많은 패턴이 이제 도구로 강제된다. 1,000개가 넘는 PR 뒤에 남은 것은 런타임에서 더 빠르고, 잘못 쓰기 더 어렵고, 에이전트가 점점 더 많이 작성하는 코드베이스에 더 잘 맞는 스타일링 시스템이다.
댓글
댓글을 불러오는 중...