학습 관리 솔루션의 백오피스 개발 중에, 사용자 애플리케이션의 배너 관리 기능을 구현하게 되었습니다. 이 기능의 요구사항은 다음과 같습니다:
배너 이미지 업로드와 실시간 미리보기
사용자가 배너 이미지를 업로드하면, 적용된 홈 화면을 미리 볼 수 있어야 합니다.
반응형 디자인 제어
미리보기 화면은 데스크톱, 태블릿, 모바일 등 다양한 화면 크기를 확인할 수 있어야 합니다.
사용자 앱의 배너를 포함한 홈 컴포넌트를 그대로 하드코딩하면 단순하게 구현할 수 있지만, 이 방식은 변화에 유연하지 못하고, 유지보수 측면에서 비효율적입니다. 예를 들어, 디자인에 변화가 생긴 경우, 백오피스 시스템도 동일한 변경을 반영해야 합니다. 반응형 디자인의 경우도 마찬가지입니다.
이러한 문제를 해결하기 위해, 이미 개발된 사용자 앱의 컴포넌트를 재사용할 수 있는 Module Federation을 도입하였습니다. Module Federation은 런타임에 원격 앱의 컴포넌트를 동적으로 로드할 수 있는 기능을 제공함으로써, 유연하고 효율적인 솔루션을 가능하게 해줍니다.
Module Federation은 웹팩(Webpack)의 한 기능으로, 여러 JavaScript 앱 간에 코드 모듈을 동적으로 공유할 수 있게 해주는 기술입니다. 이 기능은 마이크로 프론트엔드 아키텍처에서 런타임 통합을 구현할 때 사용합니다. 쉽게 말해 A앱에서 작업한 컴포넌트를 B앱에서 재사용할 수 있습니다.
위 프로젝트에서는 webpack의 Module federation을 vite에서 사용할 수 있게 만든 플러그인: @originjs/vite-plugin-federation를 사용했습니다.
Module Federation을 활용하는 구조에서는, 모듈을 내보내는 원격 앱(Remote App)
과 이 모듈을 사용하는 호스트 앱(Host App)
두 부분으로 나뉩니다.
첫 번째 단계로, 원격 앱에서 내보낼 컴포넌트를 작성합니다. 이 예시에서는 사용자의 배너가 포함된 홈 컴포넌트
를 원격 앱에서 내보냈습니다.
// 원격 앱: vite.config.ts
import federation from "@originjs/vite-plugin-federation";
export default {
...
plugins: [
react(),
federation({
name: 'remote-app',
filename: 'remoteEntry.js',
exposes: {
'./홈': './src/홈',
},
shared: [
'react',
'react-dom',
'react-router-dom',
...의존성
],
}),
],
}
이제 원격 앱을 빌드한 후에 remoteEntry.js 파일이 생성됩니다. 이 파일은 호스트 앱에서 Expose된 원격 모듈을 로딩할 수 있게 하는 인터페이스를 정의합니다.
빌드된 파일의 URL, 예를 들어 https://{빌드파일_URL}/assets/remoteEntry.js
로 접속했을 때, 해당 파일이 정상적으로 보인다면 모듈이 성공적으로 내보내진 것입니다.
로컬에서 확인하려면
yarn build && yarn preview
후 위 경로로 접근할 수 있습니다.
이제 원격 앱의 컴포넌트를 사용할 호스트 앱의 vite.config.ts 파일에 다음과 같은 구성을 추가합니다:
// 호스트 앱: vite.config.ts
...
plugins: [
react(),
federation({
name: 'host-app',
remotes: {
remoteApp: 'https://{빌드파일_URL}/assets/remoteEntry.js'
},
shared: [
'react',
'react-dom',
'react-router-dom',
...의존성
],
}),
],
이 설정을 통해, 호스트 앱은 원격 앱의 모듈을 불러와 사용할 수 있게 됩니다. 이제 React의 lazy 함수를 이용하여 원격 앱의 'Home' 컴포넌트를 동적으로 불러오고, 해당 컴포넌트를 관리자 미리보기 영역에 통합합니다:
const 홈 = lazy(() => import('remoteApp/홈'));
export default function 관리자_미리보기_영역() {
...
return (
<S.PreviewWrapper style={{
pointerEvents: 'none' // 기존 홈 컴포넌트의 클릭 이벤트를 막기 위함
}}>
<ErrorBoundary
fallback={
<미리보기_불러오기_실패_UI />
}>
<Suspense
fallback={
<로딩_UI />
}>
<홈 {...props}/>
</Suspense>
</ErrorBoundary>
</S.PreviewWrapper>
)
}
관리자가 새로운 이미지를 업로드하면, 이를 Props로 전달하고 변경된 배너를 미리보기 홈 화면에서 확인할 수 있습니다.
아래는 호스트 앱에서 원격 앱의 'Home' 컴포넌트를 성공적으로 불러와 미리보기하는 과정을 보여주는 예시입니다:
관련 검색 중 찾은 적절한 사진.. (출처: https://tenosiswono.medium.com/micro-frontends-fixing-duplicate-class-name-in-styled-component-in-7d4486017925)
마이크로 프론트엔드를 통합한 후, 백오피스의 일부 스타일이 깨지는 문제가 발생했습니다. 원인을 조사해본 결과, styled-components에서 자동 생성된 클래스 네임스페이스가 중복되는 문제가 확인되었습니다.
No class name bugs: styled-components generates unique class names for your styles. You never have to worry about duplication, overlap or misspellings. - styled-components 공식문서 발췌
styled-components 공식 문서에 따르면, 스타일마다 고유한 클래스 이름을 생성하여 중복, 중첩, 오탈자 걱정이 없다고 합니다.
그러나 여러 마이크로 프론트엔드가 각각 독립적으로 빌드되면서 동일한 클래스 이름이 생성됨으로써 사용자 앱과 백오피스 간에 클래스 이름이 중복되는 문제가 발생하였습니다.
이 문제는 바벨 플러그인을 활용하여 Styled-Components의 네임스페이스에 접두사를 추가하여 마이크로 프론트엔드간 스타일을 격리할 수 있습니다.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [
react({
babel: {
plugins: [
[
'babel-plugin-styled-components',
{
namespace: 'admin',
},
],
],
},
}),
...
사용자 앱 홈 컴포넌트는 미디어쿼리를 통해 반응형으로 구현되어 있습니다. 기존의 미디어 쿼리는 뷰포트의 크기에 따라 스타일을 조정합니다. 그러나, 요구사항은 뷰포트 크기와 관계없이 홈 컴포넌트의 반응형 스타일을 독립적으로 제어해야 합니다.
우선 스타일 컴포넌트에서 반응형 스타일을 변수로 분리하고 클래스 네임을 사용하여 동일한 css 변경이 이루어지도록 코드를 수정했습니다.
이를 통해 스타일 변경 시 '미디어 쿼리 반응형'과 '클래스 기반의 반응형' 모두 동일하게 적용될 수 있습니다.
// as-is
export const 컨테이너 = styled.div`
width: 387px;
@media screen and (max-width: 500px) {
width: 250px;
}
`
// to-be
export const 컨테이너 = styled.div`
width: 387px;
@media screen and (max-width: 500px) {
${컨테이너_모바일_스타일}
}
&.fake-mobile {
${컨테이너_모바일_스타일}
}
`;
const 컨테이너_모바일_스타일 = css`
width: 250px;
`;
하지만, 모든 엘리먼트에 클래스 이름을 수동으로 추가하는 것은 비효율적입니다. 이를 해결하기 위해, 자식 요소들에 자동으로 새로운 클래스 이름을 주입할 수 있는 ClassInjectionWrapper
컴포넌트를 구현했습니다. 이 컴포넌트는 재귀적으로 자식 요소를 탐색하며 새로운 className을 매핑합니다.
interface ClassInjectionWrapperProps {
children: React.ReactNode;
newClassName?: string | undefined;
}
export default function ClassInjectionWrapper({
children,
newClassName,
}: ClassInjectionWrapperProps) {
if (!newClassName) {
return <>{children}</>;
}
return <>{addClassToChildrenRecursive(children, newClassName)}</>;
}
function addClassToChildrenRecursive(
children: React.ReactNode,
newClassName?: string | undefined
) {
return React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
const newProps = {
...child.props,
className: `${child.props.className || ''} ${newClassName}`.trim(),
};
if (child.props.children) {
newProps.children = addClassToChildrenRecursive(
child.props.children,
newClassName
);
}
return React.cloneElement(child, newProps);
}
return child;
});
}
이제 홈 컴포넌트를 ClassInjectionWrapper
로 감싸 원하는 반응형 상태를 props로 전달할 수 있습니다.
<ClassInjectionWrapper newClassName={previewDevice}>
<Container >
<Header />
<Banner />
...
</Container>
</ClassInjectionWrapper>
예를 들어, 데스크탑 환경에서 모바일 뷰를 확인 할 때, fake-mobile 클래스를 props로 넘기면 내부의 모든 태그에 클래스네임이 추가됩니다.
이제 뷰포트의 크기에 상관없이 반응형 스타일을 동일하게 변경할 수 있게 되었습니다.
Module Federation이라는 개념은 2023 우아콘: 프론트엔드 개발의 미래, Module Federation의 적용 세션을 통해 처음 접했는데, 현재 진행 중인 프로젝트에서 Module Federation을 도입하기에 좋은 기회였습니다.
이번 프로젝트를 통해, 하드코딩을 피하고 런타임에 동적으로 외부 컴포넌트를 불러오는 솔루션을 성공적으로 구현했습니다. 실제로 홈의 디자인이 변경된 이후에도 백오피스 코드에는 한줄의 코드 변경없이 그대로 배너 관리 기능을 사용할 수 있었습니다.