styled-components에서 Tailwind CSS로 안전하게 대규모 마이그레이션하기

Jin·2026년 1월 23일

CSS

목록 보기
3/3

안녕하세요.
크리에이트립 프론트엔드 개발자 이승우라고 해요.
이 글에서는 styled-components에서 Tailwind CSS로 안전하게 대규모 마이그레이션한 경험을 공유해보려고 해요.

마이그레이션을 결정하게 된 배경

크리에이트립 프론트엔드는 그동안 styled-components를 주력 스타일링 라이브러리로 사용해왔어요. 하지만 프로젝트가 커지면서 몇 가지 문제점이 대두되었어요.

  1. 런타임 오버헤드: styled-components는 CSS-in-JS 방식이기 때문에 런타임에 스타일을 생성하고 주입해요. 이로 인해 초기 로딩 성능과 런타임 성능에 부담이 갔어요.
  2. 번들 사이즈: 수많은 스타일 컴포넌트들이 쌓이면서 번들 사이즈가 점점 커졌어요.
  3. 개발 생산성: 앞서 Custom Style System 블로그에서 언급했듯이, 비슷한 패턴의 스타일 컴포넌트를 반복적으로 작성하는 것은 생산성 측면에서 아쉬웠어요.
  4. AI 코딩(바이브 코딩)과의 궁합: 이 부분이 최근 들어 크게 체감되었어요. Cursor, Claude Code 같은 AI 코딩 도구들을 활용한 개발이 일상이 되면서, Tailwind CSS가 압도적으로 유리하다는 것을 느꼈어요.

AI 코딩 시대에 Tailwind가 더 적합한 이유

styled-components로 스타일링을 하려면 AI가 별도의 스타일 컴포넌트를 정의해야 해요.

// styled-components 방식
const CardWrapper = styled.div`
  display: flex;
  flex-direction: column;
  padding: 16px;
  border-radius: 8px;
  background-color: #ffffff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

  ${(p) => p.theme.media.tablet} {
    padding: 24px;
  }
`;

const CardTitle = styled.h2`
  font-size: 18px;
  font-weight: 700;
  color: #1a1a1a;
  margin-bottom: 8px;
`;

// 실제 컴포넌트
function Card() {
  return (
    <CardWrapper>
      <CardTitle>제목</CardTitle>
      {/* ... */}
    </CardWrapper>
  );
}

반면 Tailwind는 마크업과 스타일이 한 곳에 있어요.

// Tailwind 방식
function Card() {
  return (
    <div className="flex flex-col p-16 tablet:p-24 rounded-8 bg-white shadow-grey2-12">
      <h2 className="text-18 font-bold text-gray-90 mb-8">제목</h2>
      {/* ... */}
    </div>
  );
}

AI 입장에서 Tailwind 방식은 스타일 컴포넌트를 별도로 정의하고 관리할 필요 없이, 마크업을 작성하면서 동시에 스타일링을 완성할 수 있거든요. 토큰 수도 적게 사용하고, 수정이 필요할 때도 해당 요소만 찾아서 클래스명을 바꾸면 끝이에요.

LLM이 Tailwind를 선호하는 이유

실제로 LLM들은 Tailwind CSS를 더 선호해요. 그 이유를 Claude에게 직접 물어봤어요.

Q: styled-components와 Tailwind CSS 중 어떤 것이 코드 생성에 더 적합해?

Claude: Tailwind CSS가 코드 생성에 더 적합합니다.
몇 가지 이유가 있어요:

  • 단일 파일 완결성: Tailwind는 HTML/JSX 마크업 안에서 스타일링이 완료됩니다. styled-components는 스타일 정의와 컴포넌트 사용이 분리되어 있어 두 곳을 동시에 관리해야 해요.
  • 예측 가능한 출력: Tailwind 클래스명은 표준화되어 있어서 flex, p-4, text-center 같은 클래스가 어떤 스타일을 의미하는지 명확해요. 제가 생성한 코드가 의도대로 동작할 확률이 높습니다.
  • 수정 용이성: 사용자가 "패딩을 더크게 해줘"라고 하면 p-4를 p-6으로 바꾸면 끝이에요. styled-components에서는 해당 스타일 컴포넌트를 찾아서 CSS 속성을 수정해야 합니다.
  • 학습 데이터의 일관성: Tailwind는 공식 문서와 커뮤니티에서 일관된 패턴으로 사용되기 때문에, 제가 학습한 패턴을 그대로 적용하기 좋습니다.

이건 단순히 "AI가 좋아한다"는 수준이 아니에요. 실제로 AI와 페어 프로그래밍을 할 때 Tailwind를 사용하면 한 번의 프롬프트로 원하는 결과물이 나올 확률이 체감상 훨씬 높았어요. styled-components를 사용할 때는 "스타일 컴포넌트 이름을 바꿔줘", "이 스타일을 저 컴포넌트로 옮겨줘" 같은 추가 요청이 자주 필요했거든요.

실제로 AI와 함께 UI를 빠르게 프로토타이핑하거나 반복 작업을 할 때, Tailwind의 생산성 향상이 체감될 정도였어요. 디자인 시스템에 맞춘 피그마 플러그인까지 만들어두니, 피그마에서 요소를 선택하면 바로 Tailwind 클래스를 복사해서 붙여넣을 수 있게 되었고요.

이런 이유들로 Tailwind CSS로의 전환을 결정하게 되었어요. 하지만 문제가 있었어요.

점진적 마이그레이션의 딜레마

저희 프로젝트는 어드민(admin)과 유저웹(web) 두 개의 주요 애플리케이션을 가지고 있어요. 수천 개의 컴포넌트에 적용된 styled-components를 한 번에 모두 Tailwind로 변환하는 것은 현실적으로 불가능했어요.

점진적인 마이그레이션이 필수였고, 이는 곧 styled-components와 Tailwind CSS가 공존해야 한다는 것을 의미했어요.

여기서 핵심 문제가 발생해요.

/* styled-components로 정의된 스타일 */
.sc-dkrFOg { 
  padding: 16px; 
}

/* Tailwind 유틸리티 클래스 */
.p-20 { 
  padding: 20px; 
}

같은 요소에 두 스타일이 적용되면 어떤 것이 우선할까요? CSS 특성상 나중에 선언된 스타일이 이기거나, 특수성(specificity)에 따라 결정돼요. styled-components는 런타임에 동적으로 스타일을 생성하기 때문에 우선순위에서 Tailwind 유틸 클래스를 이겨버려서 불가피하게 styled-components를 계속 써야하는 상황이 발생했어요.

안전한 점진적 마이그레이션을 위해서는 Tailwind 유틸리티 클래스가 항상 최우선순위를 가져야 했어요.

해결책: CSS Cascade Layers

CSS Cascade Layers(@layer)는 CSS의 캐스케이드 순서를 명시적으로 제어할 수 있게 해주는 기능이에요. 레이어 순서를 미리 선언해두면, 각 레이어에 속한 스타일들은 선언 순서와 관계없이 정해진 우선순위를 따라요.

/* 레이어 순서 선언 - 나중에 선언된 레이어가 더 높은 우선순위 */
@layer theme, base, components, utilities;

/* components 레이어의 스타일 */
@layer components {
  .button { padding: 16px; }
}

/* utilities 레이어의 스타일 - components보다 항상 우선 */
@layer utilities {
  .p-20 { padding: 20px; }
}

이 구조에서 utilities 레이어는 components 레이어보다 항상 높은 우선순위를 가져요. 즉, Tailwind 유틸리티 클래스를 utilities레이어에, styled-components 스타일을 components 레이어에 배치하면 원하는 동작을 보장할 수 있어요!

어드민(Admin) 앱에서의 구현

어드민 앱은 Vite 기반의 SPA이고, UI 라이브러리로 Ant Design을 사용하고 있어요. Ant Design 역시 CSS-in-JS를 사용하기 때문에 styled-components와 비슷한 문제가 있었어요.

1단계. 레이어 순서 선언하기

가장 먼저 HTML의 <head>에서 레이어 순서를 선언해주었어요. 이 선언이 다른 어떤 CSS보다 먼저 평가되어야 해요.

<!-- apps/admin/index.html -->
<head>
  <!-- Lock CSS layer order early to prevent runtime reordering -->
  <style>
    @layer theme, base, antd, components, utilities;
  </style>
</head>

antd 레이어를 별도로 두어 Ant Design 스타일도 제어할 수 있게 했어요.

2단계. Ant Design의 CSS-in-JS 레이어 활성화

Ant Design 5.x부터는 자체적으로 CSS Cascade Layers를 지원해요. StyleProvider의 layer prop을 활성화하면 Ant Design이 생성하는 모든 스타일이 지정된 레이어에 포함돼요.

// apps/admin/src/App.tsx
import { StyleProvider } from '@ant-design/cssinjs';

function App() {
  return (
    <StyleProvider layer>
      <ConfigProvider>
        {/* ... */}
      </ConfigProvider>
    </StyleProvider>
  );
}

이렇게 하면 Ant Design의 스타일은 antd 레이어에 들어가고, Tailwind의 utilities 레이어보다 낮은 우선순위를 가지게 돼요.

3단계. styled-components 스타일을 components 레이어로

styled-components로 생성되는 스타일도 components 레이어로 감싸야 했어요. 어드민 앱의 경우 SSR이 없기 때문에 비교적 단순했지만, 유저웹에서는 이 부분이 까다로웠어요.

유저웹(Web)에서의 구현

유저웹은 Next.js 기반의 SSR/CSR 애플리케이션이에요. 여기서는 styled-components의 동작 방식을 깊이 이해해야 했어요.

styled-components의 스타일 주입 방식

styled-components는 실행 환경에 따라 다른 방식으로 스타일을 주입해요.

  1. 서버 (SSR): 서버에는 DOM이 없기 때문에 스타일을 문자열로 수집하여 HTML의 <style> 태그에 인라인으로 포함시켜요.
  2. 클라이언트 (CSR): 브라우저에서 동적으로 컴포넌트가 마운트될 때 스타일을 주입해요. 이때 프로덕션 환경에서는 speedy mode가 활성화되어 CSSStyleSheet.insertRule() API를 사용해 CSSOM에 직접 스타일을 주입해요. (개발 환경에서는 DevTools 호환성을 위해 텍스트 노드 방식을 사용해요.)

서버에서 주입된 스타일은 HTML 내 <style> 태그에 텍스트로 존재하기 때문에 @layer로 감싸기가 비교적 수월해요. 하지만 클라이언트에서 speedy mode로 주입되는 스타일은 insertRule을 통해 CSSOM에 직접 들어가기 때문에 별도의 처리가 필요했어요.

SSR 스타일 처리: wrapCssWithLayer

서버에서 수집된 styled-components 스타일을 @layer components { ... }로 감싸는 유틸리티 함수를 만들었어요.

// wrapCssWithLayer.ts
const DEFAULT_LAYER_NAME = 'components';

const startsWithLayer = (css: string, layerPrefix: string) => {
  const trimmed = css.trimStart();
  return (
    trimmed.startsWith(layerPrefix) ||
    trimmed.startsWith(`${layerPrefix} `) ||
    trimmed.startsWith(`${layerPrefix}{`)
  );
};

export const wrapCssWithLayer = (css: string, layerName = DEFAULT_LAYER_NAME): string => {
  const layerPrefix = `@layer ${layerName}`;
  const trimmed = css.trim();

  // 이미 레이어로 시작하면 그대로 반환 (멱등성 보장)
  if (!trimmed || startsWithLayer(trimmed, layerPrefix)) {
    return css;
  }

  return `${layerPrefix} {\n${css}\n}`;
};

이 함수는 멱등성을 가져요. 이미 @layer로 감싸진 CSS는 다시 감싸지 않아요.

_document.tsx에서 SSR 스타일 래핑

Next.js의 _document.tsx에서 styled-components가 수집한 스타일 태그들을 래핑해주었어요.

// apps/web/domain/common/pages/_document.tsx
import { wrapCssWithLayer } from '@creatrip/style';

export default class CustomDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const sheet = new ServerStyleSheet();
    
    // ... 렌더링 로직 ...

    const styledComponentsStyleTags = sheet.getStyleElement().map((styleElement) => {
      const html = styleElement.props.dangerouslySetInnerHTML?.__html ?? '';
      
      // 서버 렌더링된 styled-components CSS를 @layer components로 감싸기
      return cloneElement(styleElement, {
        dangerouslySetInnerHTML: {
          __html: wrapCssWithLayer(html),
        },
      });
    });

    return {
      ...initialProps,
      styles: (
        <>
          {initialProps.styles}
          {styledComponentsStyleTags}
        </>
      ),
    };
  }
}

이로써 SSR 시점에 생성되는 styled-components 스타일은 모두 @layer components 안에 들어가게 되었어요.

CSR Speedy Mode 처리: insertRule 패치

여기가 가장 트리키한 부분이었어요. 클라이언트에서 동적으로 생성되는 스타일은 CSSStyleSheet.prototype.insertRule()을 통해 주입되는데, 이 메서드를 오버라이딩하여 자동으로 @layer로 감싸도록 했어요.

// apps/web/public/sc-layer.js
const DEFAULT_LAYER_NAME = 'components';
const SC_LAYER_SCRIPT_NAME = 'styledInsertRule';

(function () {
  const proto = window.CSSStyleSheet.prototype;
  
  // 이미 패치되어 있으면 무시 (중복 패치 방지)
  if (proto.insertRule?.name === SC_LAYER_SCRIPT_NAME) {
    return;
  }

  const originalInsertRule = proto.insertRule;

  // styled-components가 소유한 스타일시트인지 판별
  const isStyledComponentsSheet = (sheet) => {
    const owner = sheet?.ownerNode;
    if (!(owner instanceof Element)) return false;
    return owner.hasAttribute('data-styled') || 
           owner.hasAttribute('data-styled-version');
  };

  // 이미 @layer로 시작하는 규칙인지 확인
  const shouldWrapRule = (rule, layerName) => {
    if (typeof rule !== 'string' || !rule.trim()) return false;
    if (rule.trim().indexOf('@layer ') === 0) return false;
    return true;
  };

  // insertRule 패치
  const patched = function creatripStyledInsertRule(rule, index) {
    if (isStyledComponentsSheet(this) && shouldWrapRule(rule, DEFAULT_LAYER_NAME)) {
      const wrappedRule = '@layer ' + DEFAULT_LAYER_NAME + ' {\n' + rule + '\n}';
      return originalInsertRule.call(this, wrappedRule, index);
    }
    return originalInsertRule.call(this, rule, index);
  };

  // 함수 이름 설정 (중복 패치 방지용)
  Object.defineProperty(patched, 'name', { value: SC_LAYER_SCRIPT_NAME });
  proto.insertRule = patched;
})();

이 스크립트는 styled-components가 소유한 스타일시트(data-styled 또는 data-styled-version 속성으로 판별)에 대해서만 동작해요. 다른 라이브러리나 순수 CSS에는 영향을 주지 않아요.

스크립트 로딩 타이밍

이 패치 스크립트는 styled-components가 스타일을 주입하기 전에 실행되어야 해요. Next.js의 beforeInteractive 전략을 사용했어요.

// _document.tsx
<Script src="/sc-layer.js" strategy="beforeInteractive" />

결과

이 접근 방식을 통해 다음을 달성할 수 있었어요.

  1. 안전한 점진적 마이그레이션: Tailwind 유틸리티 클래스가 항상 styled-components보다 높은 우선순위를 가지므로, 한 컴포넌트씩 마이그레이션해도 스타일 충돌이 발생하지 않아요.
  2. 기존 코드 호환성 유지: 아직 마이그레이션되지 않은 styled-components 코드는 그대로 동작해요. 레이어 시스템은 우선순위만 조정할 뿐, 기존 스타일의 동작을 변경하지 않아요.
  3. 롤백 가능성: 만약 특정 컴포넌트의 마이그레이션에 문제가 생기면, 해당 컴포넌트만 롤백할 수 있어요.
  4. 어드민 앱 100% 마이그레이션 완료: 이 레이어 시스템 덕분에 어드민 앱은 styled-components에서 Tailwind CSS로 100% 마이그레이션을 빠르게 완료할 수 있었어요. (유저웹도 상당 부분 진행중)

레이어 우선순위 구조를 정리하면: (어드민 기준)

@layer theme, base, antd, components, utilities;
         ↑                    ↑           ↑
      기본 테마          styled-components  Tailwind
                         Ant Design 스타일   유틸리티
                         (낮은 우선순위)     (가장 높은 우선순위)

마치며

대규모 스타일링 시스템 마이그레이션은 쉽지 않은 작업이에요. 특히 런타임에 동적으로 스타일을 생성하는 라이브러리가 관련되어 있다면 더욱 그래요.

CSS Cascade Layers는 이런 복잡한 상황에서 스타일 우선순위를 명시적으로 제어할 수 있게 해주는 강력한 도구예요. 브라우저 지원도 이제 충분히 안정화되었고요.

이 경험을 통해 "기존 시스템 위에서 안전하게 새로운 시스템으로 전환하는 방법"에 대해 많이 고민해볼 수 있었어요. 혹시 비슷한 마이그레이션을 계획하고 계신 분들께 도움이 되었으면 해요 :)

레퍼런스

profile
배워서 공유하기

0개의 댓글