Linear가 성능과 더 엄격한 스타일링 기반을 위해 styled-components에서 StyleX로 점진적으로 이전한 이유, 접근 방식, 그리고 진행 상황을 다룹니다.
2026년 6월 23일·6분 읽기
이 마이그레이션에 내가 인정하고 싶은 것보다 더 많은 토큰을 썼다.
지난 몇 달 동안 우리는 점점 더 반복 가능해지는 에이전트 보조 워크플로를 사용해 Linear의 React 애플리케이션을 StyleX 쪽으로 마이그레이션해 왔다. 이 마이그레이션의 주된 이유는 성능이다. 런타임 CSS-in-JS는 클라이언트가 렌더링하는 동안 사용자가 스타일 생성과 규칙 주입 비용을 치르게 만든다.
하지만 성능은 이야기의 절반일 뿐이다.
styled-components는 놀랄 만큼 Linear에 잘 맞았다. 스타일을 컴포넌트 가까이에 둘 수 있었고, CSS의 모든 힘을 제공했으며, 제품 표면에 특정한 무언가가 필요할 때 빠르게 움직이기 쉬웠다.
그 자유에는 대가가 따른다. CSS는 이미 멀리서 스타일링하기 쉽게 만들고, styled(Button)은 컴포넌트의 스타일링 계약을 명시적으로 만드는 대신 바깥에서 컴포넌트를 다시 여는 일을 당연하게 만든다. 우리는 UI를 깊이 신경 쓰기 때문에 Linear는 여전히 일관된 느낌을 주지만, 어디서든 무엇이든 쉽게 덮어쓸 수 있을 때 단지 신경 쓰는 것만으로는 확장에 한계가 있다.
StyleX는 우리에게 다음 단계다. 스타일링 작업을 렌더 경로 밖으로 옮기고 다음 단계의 기반을 위해 더 엄격한 토대를 구축하는 것이다.
우리를 끝내 선을 넘게 만든 것은 styled-components가 유지보수 모드로 들어간 일이었다. React 18로 업그레이드한 뒤 우리는 그것을 직접 체감했다. React는 CSS-in-JS 라이브러리가 성능 문제를 피하도록 돕기 위해 useInsertionEffect를 도입했지만, styled-components는 이를 끝내 채택하지 않았다.
useInsertionEffect를 채택하기 위한 정체된 PR을 추적하던 중, 나는 그 작성자인 Sanity의 Cody Olsen과 연결되었고, 결국 그들이 최적화한 포크를 함께 테스트하게 되었다. Sanity의 글은 이를 “최후의 수단”으로 설명한다. 그 포크는 구명보트이지 장기 계획이 아니다.
styled-components에서 벗어나는 이번 마이그레이션은 몇 가지 타협할 수 없는 기준에 의해 이끌렸다.
> * 같은 결합자 선택자는 큰 규모에서 매우 비용이 클 수도 있다(이에 대한 Maciek의 글 참고).우리는 React와 호환되는 스타일링 라이브러리 대부분을 살펴보았다. 가장 가까운 대안은 vanilla-extract였다. 탄탄한 정적 추출과 타입 안정성을 갖추고 있었지만, API는 파편화되어 느껴졌고 별도의 스타일링 파일이 필요하다는 점도 우리가 선호하는 작업 방식과 맞지 않았다.
StyleX는 스타일을 컴포넌트 로컬에 유지하고, 작은 API를 제공하며, 결정론적인 스타일 해석, 타입 안전한 스타일링 계약, 그리고 바깥에서 컴포넌트를 다시 스타일링하기 어렵게 만드는 엄격한 가드레일을 제공한다. 또한 Meta가 활발히 유지보수하고 있고, 그들의 웹 표면 대부분에서 사용되며, Figma나 Cursor 같은 회사들도 채택하고 있다.
이 엄격함은 공짜가 아니다. CSS 템플릿 리터럴의 완전한 유연성에서 제약 있는 원자적 시스템으로 옮겨가면 어떤 패턴은 더 어려워진다. 부모에 의존하는 선택자, 전역 선택자, 그리고 래퍼를 통한 컴포넌트 재스타일링이 그렇다.
하지만 바로 그것이 핵심이기도 하다. 마이그레이션하기 고통스러운 패턴은 종종 규모가 커졌을 때 스타일링을 추론하기 더 어렵게 만들던 바로 그 패턴이기 때문이다.
2026년 1월 이 프로젝트를 시작했을 때, 나는 코딩 에이전트가 우리 코드베이스를 그냥 자동으로 마이그레이션해 주면 좋겠다고 정말 바랐다(아, 시도는 해 봤다). 하지만 styled-components는 튜링 완전한 언어와 CSS의 모든 힘, 그리고 완전히 열린 API를 손에 쥐여 준다. 사람들이 같은 의도를 표현하는 방식은 엄청나게 많고, 겉보기에 맞아 보이지만 미묘하게 그렇지 않은 출력을 만들어 내기 너무 쉽다.
그리고 Linear에 디자인 시스템이 없다는 사실도 마이그레이션을 훨씬 더 어렵게 만들었다. 우리는 공유 컴포넌트를 가지고 있지만, 명확한 스타일링 계약보다는 활짝 열린 API를 갖고 있다. 마이그레이션 비용의 상당 부분은 바로 그 부채를 갚는 일이다. 그 유연성의 일부를 제거하고, API를 더 엄격하게 다듬고, 바깥에서 컴포넌트를 다시 스타일링하기 어렵게 만드는 것이다.
결정론적인 codemod는 좋은 투자처럼 느껴졌다. 같은 입력이면 같은 출력이다. 내가 마지막으로 더 큰 codemod를 만들었던 것은 10년 전 테스트 파일을 테스트 프레임워크 사이에서 옮기던 jest-codemods였다. styled-components-to-stylex-codemod는 훨씬 더 복잡하다. 수많은 테스트 케이스와 많은 아키텍처로 시작했고, 그다음 에이전트를 풀어 놓자 계속 커져 나갔다.
이 codemod는 이제 500개가 넘는 PR, 대략 100,000줄의 마이그레이션 도구, 온라인 플레이그라운드, 파일 간 선택자 처리, 그리고 대부분의 예외 상황에 대한 회귀 커버리지를 갖추게 되었다.
이 마이그레이션은 점진적이어야 한다. styled-components와 StyleX는 한동안 공존할 것이고, 애플리케이션을 구동하는 스타일링 라이브러리를 바꾸는 동안 제품 작업을 동결하고 싶지는 않다.
우리의 접근 방식은 다음과 같다.
먼저 스타일링 기반을 정의한다. codemod가 유용한 작업을 하려면, 먼저 마이그레이션된 컴포넌트가 가리킬 StyleX 변수, 상수, 공유 프리미티브를 정의해야 했다. Linear는 단순한 평면 토큰 파일이 아니라 정교한 중첩 런타임 테마 설정을 갖고 있어서, 커스텀 ThemeProvider가 제공하는 범위 지정된 StyleX 변수를 사용해 이를 지원하는 몇 가지 도구도 추가했다.
명확한 범위로 에이전트를 띄운다. 에이전트에게 좁은 범위, codemod 실행기, 예제, 검증 스크립트, 그리고 명확한 체크리스트를 제공하라. 에이전트는 렌더된 UI를 살펴보고 생성된 CSS를 원본과 비교할 수 있을 때 훨씬 더 유용하지만, 시각적 정확성은 여전히 어려운 부분이다. 복잡한 hover 상태, 테마 분기, 작은 레이아웃 차이는 여전히 세심한 수동 테스트가 필요하다.
리프 노드부터 시작한다. 먼저 다른 styled-components를 감싸거나 다시 스타일링하지 않는 컴포넌트를 마이그레이션한다. diff는 더 작고, 캐스케이드 상호작용은 더 적으며, 공유 프리미티브로 들어가기 전에 트리의 더 낮은 위험 구간에서 배울 수 있다.
공격적으로 lint를 적용한다. 커스텀 lint 규칙은 원하는 경로를 분명하게 만들고, 오래된 패턴이 퍼지기 전에 잡아낸다. Oxlint를 도입한 것은 정말 큰 도움이 되었다. 회귀가 나타날 때 커스텀 규칙으로 확장하기 쉽고 빠르다.
탈출구를 둔다. 우리는 여전히 CSS 형태의 문제, 즉 진짜 전역 선택자와 서드파티 DOM 재스타일링에는 범위가 제한되고 명시적인 탈출구로 CSS Modules를 사용한다.
이 글을 쓰는 시점에 우리는 파일의 58%를 조금 넘게 변환했지만, 지금까지의 결과는 고무적이다. 런타임 CSS 생성과 주입을 제거하면서, 페이지 사이를 탐색할 때 렌더링 속도가 대략 30% 빨라지는 것을 보고 있다.
StyleX 도입의 실시간 진행 상황.
솔직히 말해, 이 프로젝트는 진행 내내 많은 스타일링 회귀를 동반한 까다로운 작업이었다. 하지만 이것이 우리에게 맞는 방향이다. 애플리케이션을 더 빠르게 만드는 동시에, 우리가 성장함에 따라 변경을 더 안전하게 만들어 준다.
이 프로젝트는 또한 나에게 약간의 안도감을 주었다. 에이전트는 놀라울 정도로 많은 일을 할 수 있지만, 망가진 hover 상태를 알아차리고 에이전트가 생산적으로 일할 수 있게 만드는 시스템을 구축하는 데에는 여전히 인간이 아주 많이 필요하다.