Tailwindcss with MFA

November 26, 2023

오늘은 MFA(feat.Module Federation)에서
tailwindcss 를 좀더 자연스럽게 사용하는 팁을 적어본다.

하나의 프로젝트를 여러 모듈로 나뉘어 개발할때 중요한 부분 중 하나는
각 모듈이 하나의 기능을 가지고 독립적으로 실행 가능하도록 개발하는 것이 좋다.

module federation 역시 양방향으로 사용가능하기 때문에
remote 로 내보내는 모듈들 역시 host 가 되었든 host 에서 사용하게 되든
독립적인 실행이 가능해야 한다.

비즈니스 로직이나 상태 관리는 js 로 컨트롤이 가능하기 때문에
각 모듈별 요구사항을 충족하게 만드는데 어렵지는 않다.

다만 style 은 조금 다르다.

웹을 기준으로 보면 remote 모듈은 결국 하나의 browser 에서 실행되는데
우리의 browser 에서 style 을 처리할때는
css 라는 이름처럼 cascading 하게 처리하게 되는데 여기서 같은 Specificity 를
가지게 되면 나중에 들어오는 style 이 앞의 style 을 덮어 쓰게 된다.

이런 css의 알고리즘은 일반적으로는 문제가 되지 않지만 tailwind 에서는
약간의 문제가 생긴다.

Tailwind 의 Specificity

예를 들어

function CustomInput(props) { const className = `border rounded px-2 py-1 ${props.className || ""}` return <input {...props} className={className} /> } function MyInput(props) { return <CustomInput {...props} className="p-3" /> }

위 예에서 MyInput 의 classname 은 border rounded px-2 py-1 p-3 이 생성된다.
하지만 px-2py-1 그리고 p-3 은 같은 특이성을 가진다.

그리고 tailwind 에서 classname 을 생성할때 css 의 순서는 아래 순서대로 생성된다.

className 에 적힌 class 의 순서는 아무의미 없다.

.p-3 { padding: 0.75rem/* 12px */; } .px-2 { padding-left: 0.5rem/* 8px */; padding-right: 0.5rem/* 8px */; } .py-1 { padding-top: 0.25rem/* 4px */; padding-bottom: 0.25rem/* 4px */; }

결국 px-2py-1 이 적용되고 p-3 은 무시 되는 효과가 나타난다.
이문제는 MFA 가 아닌 일반적인 프로젝트에서도 볼 수 있는 문제 중 하나이다.
이 문제를 해결하기 위한 라이브러리가 존재한다.

Tailwind-merge 라는 라이브러리를 사용하면 깔끔하게 충돌되는 className 을 제거해주고, 위 문제를 해결할 수 있다.

function CustomInput(props) { const className = twMerge("border rounded px-2 py-1", props.className) return <input {...props} className={className} /> } // -> className => border rounded p3

자세한 사용법은 해당 github 을 참조하자. 그럼 이제 2번째 문제로 가보자.

Utility className 의 독립성

Tailwind 의 장점 중 하나는 className 에 유틸리티 클래스를 사용하여 style 을
빠르게 작성하고, 공유할 수 있다는 점이 존재한다.

Tailwind 의 트리쉐이킹인 purge 동작은 해당 모듈에서 사용하지 않는 css 를 제거해주는
아주 좋은 최적화 기능이다.
하지만 이 부분이 결국 MFA 를 만들때 걸림돌이 된다.

예를 들어 Host, Module A, Module B 3개의 모듈이 존재한다고 할때,
우리가 원하는건 각 모듈이 독립적인 style 을 가져가고, 어떤 모듈에서 remote 로 호출되어도
style 을 유지하기를 바랄 것이다.

만약 remote 에서 tailwind 가 없다면? remote 를 부르는 host 에서 해당 remote 모듈의
style 을 가지고 있지 않는 한 remote 모듈의 style 을 정상적으로 적용되지 않는다.

예를 들어,

// Remote A const RemoteA = () => { ;<div className="grid grid-cols-1">RemoteA</div> } // Remote B const RemoteB = () => { ;<div className="grid grid-cols-2">RemoteB</div> } // Host import "./styles.tw.css" // tailwindcss const HostComponent = () => { return ( <> <div className="flex items-center"> // Remote A 와 Remote B 를 가져온다. <RenderRemoteA /> <RenderRemoteB /> </div> </> ) }

여기서 RemoteARemoteB 는 생성된 css style 없이 Host 로 가게 된다.
하지만 Host 가 가지고 있는 class 는 tailwind 의 최적화로 인해 flex items-center
두개의 css style 만 가지고 있으므로 remote 들은 style 이 적용되지 않는다.

결국 Remote 들이 각자의 style 을 가지고 있기 위해서는 Host 처럼 각각 import './styles.tw.css' 이런식으로 tailwind 를 가지고 있어야 한다.

그럼 문제가 해결되었나? 해결 된듯 해 보이지만 아직도 문제가 존재한다.
일반적인 classname 은 중복이 되어도 다른 모듈의 css 항목이 가지고 있기 때문에,
문제가 되지 않지만 @media 와 같은 항목을 사용할때는 문제가 생긴다.

/* ModuleA CSS */ @media (min-width: 640px) { .sm\:hidden { display: none; } } @media (min-width: 768px) { .md\:block { display: block; } } /* ModuleB CSS */ @media (min-width: 640px) { .sm\:hidden { display: none; } }

이런식으로 Module A, B 가 각각 @media 항목을 사용할때, 둘의 특이성은
동일하기 때문에 뒤늦게 로드되는 style 이 먼저 로드된 style 을 덮어 버리게 된다.
위 media 쿼리의 정렬 문제는 postcss-sort-media-queries 로 해결할 수는 있다.

그럼 이제 모두 해결된건가? 해치웠나?
아직 문제가 남아있다.

Utility className 의 중복

remote 모듈이 몇개 없을때는 문제가 되지 않지만, remote 모듈이 많아지면
각 remote 모듈마다 동일한 classname 을 가진 stylesheet 가 생성되기 때문에
같은 classname 의 중복이 무자비하게 생성된다.

classname

이런식으로 쌓이게 된다.

classname 이 계속 쌓이게 되면 이는 결국 성능저하를 가지고 오게 되며
해당 Element 의 style 을 디버깅 할때 걸림돌이 될 수 있다.

이를 해결하기 위해서 몇가지 방법이 존재한다.

해결법

  1. 첫번째는 Tailwind 를 Host 나 Root 에서 한번 포함한다.

remote 모듈에서 각각 tailwind 를 불러오지 않고, root 에서 한번만 불러오거나,
해당 remote 를 사용하는 Host 에서만 생성하면 이런 중복을 줄일 수 있을 것이다.

만약 root 에서 한번 불러온다면, 해당 Root 에는 모든 tailwind class 가 포함되어야
하며, 이는 tailwind 의 최적화 기능을 사용할 수 없기 때문에 좋은 방법이 아니다.

그럼 특정 Host 에서 불러온다면?
style sheet 는 빌드 타임에 생성된다. 그런데 Host 에서는 자신이 불러오는 Remote 모듈에서
사용되는 class 를 과연 알수 있을까?

Host 의 tailwind content 설정에 사용하는 Remote 모듈의 path 를 등록한다면
빌드타임에 알수는 있을 것이다.

하지만 MFA 나 ModuleFederation 에서 remote 모듈은 독립적으로 개발하고 배포된다.
만약 remote 의 style 이 변경되어 배포 된다면 그 변경사항을 Host 가 알수 있을까?

Host 가 변경사항을 적용하기 위해서는 사용하는 remote 모듈이 변경될때 Host 본인도
다시 빌드가 되어야 한다.

이는 Host 와 Remote 에 거대한 의존성이 생기게 되고, MFA 와 개발원칙과 어울리지 않는
방식으로 변하게 된다.
추가로 해당 의존성은 어떻게 관리할 것인가?
만약 해당 Host 도 또 다른 Host 에 연결되어 있다면?

  1. 두번째는 twin.macro 와 같은 css-in-Js 를 사용한다.

css-in-js 는 여기서 좋은 해결법이 될 수 있다.
런타임에 난독화된 classname 의 style 을 inject 하기때문에
각 모듈간 classname 의 충돌도 없고
twin.macro 같은 경우 tailwind 문법을 그대로 사용할 수 있기 때문이다.

하지만 이를 위해선 className 에서 별도의 함수로 매번 감싸줘야 하는 불편함이 있고,
추가로 css-in-js 의 동작방식으로 인한 추가 런타임 비용이 들어가게 된다.

  1. 세번째는 tailwind 자체적으로 prefix 를 붙여준다.

Tailwind 에서는 자체적으로 className 의 충돌을 막기 위해 prefix 옵션을 제공한다.

module.exports = { prefix: "custom1-", }

이렇게 하면 실제로 개발 할때

<div className="custom1-grid custom1-grid-cols-1"> <span>Custom</span> </div>

이런식으로 prefix 를 제공할 수 있고, 생성되는 css 에서도 prefix 가 붙어 나오게 된다.
추가로 vscode 같은 ide 에서도 자동완성 역시 지원된다.

다만 이 방식의 단점은 가뜩이나 긴 classname 을 더 길게 만들어 DX 가 저하되고,
classname 을 복사해서 다른 모듈에서 사용하기가 아주 불편해지는 점이 생긴다.

  1. 네번째는 ShaodwDom 을 사용해 dom 을 캡슐화 시킨다.

ShaodwDom 을 활용해서 해당 모듈을 dom 트리에서 별도로 격리 시켜 버리면, 해당 모듈들은
같은 className 이라도 독립적인 style 을 활용할 수 있게 된다.

하지만 이는 또다른 문제를 야기한다.
말 그대로 격리되어 있기 때문에 디자인 시스템과 같은
공유 컴포넌트를 사용하기 위해서는 해당 remote 모듈에서 공유 컴포넌트의 style 을
전부 가지고 있어야 한다.
배보다 배꼽이 커질 수 있는 방법이다.

이 모든 해결법에는 모두 tradeoff 가 존재한다.
실제로 moduleFederation 에서 알려진 큰 문제점 중 하나이다.

Nx Git Issue 해당 링크를 살펴보면 Nx 의 개발자가 이 문제에 대해 설명을 잘 해준다.

새로운 해결법

결국 개발 할때는 기존 Tailwind 를 사용하듯이 일반적으로 사용하고,
빌드 될때는 모든 classname 에 prefix 가 붙어서 빌드 된다면 이문제는 해결된다.

이를 해결하기 위해 몇가지 트릭을 사용했다.
먼저 postcss 의 postcss-prefix plugin 을 사용해 빌드될때 styleSheet 의 모든
className 에 고유 prefix 를 붙여준다.
가장 좋은 prefix 는 해당 package 또는 모듈의 이름이다.

prefix 가 붙은 styleSheet 의 style 을 적용하기 위해서는 실제 컴포넌트에서도
해당 prefix 가 붙은 className 을 써야한다.

예를들어 custom1-flex custom2-items-center 이런식으로 써야 하는데
위에 적었듯이 개발할때는 일반적인 tailwind 를 사용하고
빌드될때 해당 부분을 변경해줘야 한다.

이를 위해 Webpack 의 string-replace-loader 를 사용해서 빌드타임때 dom 의 className에 있는 class 앞에 모두 prefix 를 붙여줄 것 이다.

예를들면 이런식이다.

const twClassnameTransForm = prefix => { return { test: /\.tsx$/, loader: "string-replace-loader", options: { search: /className=['"]([^'"]*)['"]/g, replace(_, p1) { const classNames = p1.trim().split(" ") const prefixedClassNames = classNames.map(className => { if (className.startsWith(prefix)) { return className } else { return `${prefix}-${className}` } }) return `className="${prefixedClassNames.join(" ")}"` }, }, } }

해당 loader 를 사용하면 실제로 작성한 tailwind className 앞에 내가 원하는 prefix 가
모두 붙어서 빌드 된다.

<div className="grid grid-cols-1"> <span>Custom</span> </div> // 빌드 후 <div className="custom1-grid custom1-grid-cols-1"> <span>Custom</span> </div>

이런식으로 추가된다.
이제 각 모듈간 독립적으로 className 을 가지게 되며, 개발 할때는 일반적인
tailwind 의 사용을 활용할 수 있게 된다.

TwMerge 를 포함한다면?

위 방법은 단순히 className 을 string 으로만 적었을때 적용할 수 있다.
하지만 TwMerge 나 props 로 className 을 받아서 적용할때는 어떻게 해야 할까?

<div className={twMerge("grid grid-cols-1", props)}> <span>Custom</span> </div> // 또는 <div className={`grid grid-cols-1 ${props}`}> <span>Custom</span> </div>

string-replace-loader 는 단순하게 string 찾아서 변경시켜 주기 때문에 이런식의 className 은 변환 시킬 수 없다.

이를 해결하기 위해서는 별도의 Util 이 필요하다.
먼저 TwMerge 에서 옵션으로 prefix 설정을 추가하고 별도의 함수로 추출해야 한다.

const extendsTwMergeConfig = ... // twMerge theme 관련 custom 설정 const twMerge = (prefix: string) => { return isDev ? extendTailwindMerge(extendsTwMergeConfig) : extendTailwindMerge({ ...extendsTwMergeConfig, prefix: `${prefix}-`, }); }; export const twCombine = twMerge(packageName);

이런식으로 twMerge 를 개발환경일때는 prefix 를 붙이지 않고 빌드 환경일때만 prefix 가 적용 되도록 하여 twCombine 이라는 함수를 유틸로 노출한다.

그리고 이 역시 string-replace-loader 를 사용해서 변경해주는 loader 가 필요하다.

// transform util const twMergeClassNameAddPrefix = (classNames, prefix) => { const transCls = classNames.map(className => { const pattern = /[\'\"]+/g if (!pattern.test(className)) { return className } const trimClassName = className.replace(pattern, "") if (trimClassName.startsWith(prefix)) { return trimClassName } else { return `"${prefix}-${trimClassName}"` } }) return transCls } // 실제 Loader const twMergeClassnameTransForm = prefix => { return { test: /\.tsx$/, loader: "string-replace-loader", options: { search: /className={twCombine\((.*?)\)}/g, replace(_, p1) { const classNames = p1.trim().split(",") const prefixedClassNames = classNames.map(className => { const trimed = className.trim() const splitClass = trimed.split(/\s+/) if (splitClass.length > 1) { return twMergeClassNameAddPrefix(splitClass, prefix) } const pattern = /[\'\"]+/g if (!pattern.test(trimed)) { return trimed } const trimClassName = trimed.replace(pattern, "") if (trimClassName.startsWith(prefix)) { return trimClassName } else { return `"${prefix}-${trimClassName}"` } }) return `className={twCombine(${prefixedClassNames.join(", ")})}` }, }, } }

twCombine 의 변형은 조금 더 복잡하다.
중첩되는 className 과 ’,’ 로 구분되는 className 으로 인해 로직이 더 생겼다.

추가로 props 로 받는 className 에 대해서도 별도의 util 을 만들어서 자동으로 prefix 가
들어가도록 수정한다.

const cxSet = (prefix: string) => { return function (cls: string) { const result: string[] = [] const clsDest = cls.split(" ") for (const className of clsDest) { if (className !== "undefined") { result.push(`${prefix}-${className}`) } } return result.join(" ") } } const twProps = (pkgname: string) => { const cx = cxSet(pkgname) return (className: string) => (isDev ? className : cx(className)) }

twProps 같은 경우 twCombine 과 마찬가지로 개발환경에서는 일반 className 을
return 하고 빌드타임때는 prefix 를 붙이도록 되어있다.
실제로 사용할때는 이런식으로 사용이 가능하다.

const className = "flex" const App = () => { const ref = false return ( <div className={twCombine( "grid grid-cols-1", twProps(ref ? " px-0" : "py-2"), twProps("p-3") )} > Template </div> ) } // 또는 TwProps With Combine <div className={twCombine( twProps("grid grid-cols-1"), twProps(ref ? " px-0" : "py-2"), twProps("p-3") )} > twProps </div> // 단순히 props 로 className 을 받았을때 <span className={twProps(`grid grid-cols-[1fr_1fr] ${className}`)}> Span </span> // 일반적인 사용 <span className="grid grid-cols-[1fr_1fr]">Center</span>

이제 위 모든 className 이 build 할때 지정한 prefix 가 들어가게 된다.

주의할 점은 twCombine 사용시 아래처럼 string className 이 twProps
사이에 있으면 안된다.
string className은 처음 시작되거나, 그게 아니면 twProps 로만
사용되어야 한다.

// OK <div className={twCombine( twProps("grid grid-cols-1"), twProps(ref ? " px-0" : "py-2"), twProps("p-3") )} > twProps </div> // OK <div className={twCombine( "grid grid-cols-1", twProps(ref ? " px-0" : "py-2"), twProps("p-3") )} > twProps </div> // FAIL!! <div className={twCombine( twProps("grid grid-cols-1"), "flex", twProps("p-3") )} > twProps </div>

외부 모듈끼리의 className 주입

추가로 만약 module 내에서가 아닌 remote module 끼리 className 을
props 로 주고 받는다면?

twProps 는 들어오는 className 을 받는다면 예외없이 해당 모듈의 prefix 가 붙게 된다.

이러면 외부 모듈의 className 을 적용할 수 없기 때문에 외부 모듈의 className은
twProps 내부가 아닌 별도로 className 을 받아야 한다.

그리고 외부 모듈에서는 반드시 twProps 로 className 을 감싸서 반드시 prefix 를
붙여서 주입해야 한다.

밑의 예를 보자.

// module A const ModuleA = () => { const externalClassName = twProps("flex items-center") // 이때 externalClassName 은 module A 의 prefix 를 적용하고 있다. // ex) moduleA-flex moduleA-items-center return ( // ModuleB 로 moduleA 의 prefix 가 적용된 채로 className 을 주입한다. <ModuleB externalClassName={externalClassName} /> ) } // module B const ModuleB = ({ externalClassName }: { externalClassName: string }) => { // twProps 외부에 externalClassName 을 배치한다. // 이렇게 하면 module B 의 최종 className 은 // moduleB-grid moduleB-grid-cols-1 moduleA-flex moduleA-items-center // 이런식으로 양쪽 모듈의 css 를 모두 사용할 수 있다. return ( <div className={`${twProps("grid grid-cols-1")} ${externalClassName ?? ""}`} > test </div> ) }

마무리

webpack 의 loader 와 별도의 util 함수를 활용해 DX 저하는 없애고,
실제로 빌드된 모듈에서는 독립적인 className 을 갖도록 하여 className 의 중복과
충돌을 없어지도록 만들었다.

물론 여기서도 tradeoff 가 존재한다.

twProps 라는 함수로 감싸야 하는 점과 별도의 공통 util 함수를 추출해야 하는점,
webpack 을 사용해야 한다는점, 그리고 React 기준이라는 부분.
twCombine 사용시 주의해야 할 부분 등.

위 방법도 어디서 언제 문제가 생길지 모르고, 그냥 tailwind 를 moduleFederation 에
적용하기 위한 또 하나의 방법이라고 봤으면 좋겠다.
다행히 아직까지는 문제가 없다.

다른 번들러에서도 string-replace-lorder 와 같은 플러그인이 있어서
방법만 안다면 충분히 활용해볼만 하다.

해당 방법을 사용하기 위한 함수와 설명은 충분히 한 것 같아, 별도의 디테일한 설정은
따로 기술하지 않겠다.

그리고 혹시 tailwind 를 module federation 에서 사용하고 있고,
위와 같은 문제를 겪은 뒤 해결한 경험이 있다면 연락해줬으면 한다. 🥹

다음 글은 아마 Signal 에 대한 글로.