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

크리에이트립 프론트엔드는 그동안 styled-components를 주력 스타일링 라이브러리로 사용해왔어요. 하지만 프로젝트가 커지면서 몇 가지 문제점이 대두되었어요.
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 CSS를 더 선호해요. 그 이유를 Claude에게 직접 물어봤어요.
Q: styled-components와 Tailwind CSS 중 어떤 것이 코드 생성에 더 적합해?
Claude: Tailwind CSS가 코드 생성에 더 적합합니다.
몇 가지 이유가 있어요:
이건 단순히 "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(@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 레이어에 배치하면 원하는 동작을 보장할 수 있어요!
어드민 앱은 Vite 기반의 SPA이고, UI 라이브러리로 Ant Design을 사용하고 있어요. Ant Design 역시 CSS-in-JS를 사용하기 때문에 styled-components와 비슷한 문제가 있었어요.
가장 먼저 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 스타일도 제어할 수 있게 했어요.
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 레이어보다 낮은 우선순위를 가지게 돼요.
styled-components로 생성되는 스타일도 components 레이어로 감싸야 했어요. 어드민 앱의 경우 SSR이 없기 때문에 비교적 단순했지만, 유저웹에서는 이 부분이 까다로웠어요.
유저웹은 Next.js 기반의 SSR/CSR 애플리케이션이에요. 여기서는 styled-components의 동작 방식을 깊이 이해해야 했어요.
styled-components는 실행 환경에 따라 다른 방식으로 스타일을 주입해요.
<style> 태그에 인라인으로 포함시켜요.CSSStyleSheet.insertRule() API를 사용해 CSSOM에 직접 스타일을 주입해요. (개발 환경에서는 DevTools 호환성을 위해 텍스트 노드 방식을 사용해요.)서버에서 주입된 스타일은 HTML 내 <style> 태그에 텍스트로 존재하기 때문에 @layer로 감싸기가 비교적 수월해요. 하지만 클라이언트에서 speedy mode로 주입되는 스타일은 insertRule을 통해 CSSOM에 직접 들어가기 때문에 별도의 처리가 필요했어요.
서버에서 수집된 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는 다시 감싸지 않아요.
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 안에 들어가게 되었어요.
여기가 가장 트리키한 부분이었어요. 클라이언트에서 동적으로 생성되는 스타일은 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" />
이 접근 방식을 통해 다음을 달성할 수 있었어요.
레이어 우선순위 구조를 정리하면: (어드민 기준)
@layer theme, base, antd, components, utilities;
↑ ↑ ↑
기본 테마 styled-components Tailwind
Ant Design 스타일 유틸리티
(낮은 우선순위) (가장 높은 우선순위)
대규모 스타일링 시스템 마이그레이션은 쉽지 않은 작업이에요. 특히 런타임에 동적으로 스타일을 생성하는 라이브러리가 관련되어 있다면 더욱 그래요.
CSS Cascade Layers는 이런 복잡한 상황에서 스타일 우선순위를 명시적으로 제어할 수 있게 해주는 강력한 도구예요. 브라우저 지원도 이제 충분히 안정화되었고요.
이 경험을 통해 "기존 시스템 위에서 안전하게 새로운 시스템으로 전환하는 방법"에 대해 많이 고민해볼 수 있었어요. 혹시 비슷한 마이그레이션을 계획하고 계신 분들께 도움이 되었으면 해요 :)