안녕하세요, 인프랩의 프론트엔드 개발자 홍시입니다.

최근 저희 팀은 기존 기술 스택으로 인프런 서비스를 유지, 발전시킴과 동시에 미래를 위해 한 페이지씩 모던한 기술 스택으로 개선하는 일에 집중하고 있는데요.

모던한 기술 스택으로 새롭게 만드는 웹앱들은 결국 기존 인프런 서비스 위에 올라가기 때문에, 새로운 웹앱과 기존 서비스 사이의 스타일 충돌이 필연적으로 발생했습니다.

이를 shadow DOM을 이용해 해결하는 과정을 공유합니다.

1. 문제

초기 인프런 서비스는 css 프레임워크 Bulma를 이용해 구축되었고 지금까지도 Bulma의 스타일 시트가 전역으로 적용되어 있습니다.

그뿐만 아니라 Bulma의 스타일 시트에서 파생된 다양한 전역 스타일들도 있는데요.

이 전역 스타일들에서는 꽤 일반적인 선택자, 특히 태그 선택자를 많이 이용했기 때문에 새로운 앱에서 사용하는 새로운 디자인 시스템이 어떤 영향을 받아 어떤 혼란스러운 결과가 발생할지 예측하기가 어려웠습니다.

4년 전에 작성된 reset.scss 안의 a 태그에 대한 스타일 코드
4년 전에 작성된 reset.scss 안의 a 태그에 대한 스타일 코드
a 태그에 대한 전역 스타일에 영향을 받아 본래의 스타일이 깨진 버튼 컴포넌트 모습
a 태그에 대한 전역 스타일에 영향을 받아 본래의 스타일이 깨진 버튼 컴포넌트 모습

2. 해결 방법 검토

모던 스택으로 마이그레이션 중인 페이지의 html에 기존 스타일 시트가 아예 적용되지 않게 하는 방법은 처음부터 제외되었습니다.

GNB, Footer를 제외한 영역만 새로운 리액트 앱으로 그려내고 있기 때문에 한 html 안에서 GNB, Footer는 기존 스타일 시트가 필요하기 때문입니다. 화면 레이아웃

결과적으로 신-구 스타일 충돌 문제를 해결하기 위해 검토한 방법은 총 3가지였습니다.

  1. 문제가 되는 부분만 !important로 처리하기.
  2. scss를 이용하여 특정 class name의 요소에는 인프런 서비스의 전역 스타일 시트가 적용되지 않게 하기.
  3. shadow DOM 이용하기.

2-1. 문제가 되는 부분만 !important로 처리하기

단기적으로 가장 빠르게 문제를 덮을 수는 있어도, 근본적인 해결책이 되지는 못합니다.

후에 진짜 !important가 필요할 때는 어떻게 할 것이며, 다른 스타일 간의 우선순위 관리는 어떻게 할 것인지 등등 유지관리가 굉장히 어려워지기 때문입니다.

또한 그 ‘문제가 되는 부분’을 정확하게 찾아내려면 각 요소의 스타일을 개발자가 일일이 확인해야 합니다.
어떤 스타일을 작성할 때마다 눈에 불을 켜고 차이를 찾아낸다는 건 정말 쉽지 않습니다.
gray 600gray 500으로 바뀌는 것과 같은 미세한 차이는 알아차리기 어렵기 때문입니다.

2-2. scss를 이용하여 특정 class name의 요소에는 스타일 시트가 적용되지 않게 하기

sass:meta.load-cssnot 선택자를 이용해서 특정 class를 제외한 나머지 요소에 전역 스타일을 적용하려고 시도해 보았습니다.

meta.load-css를 통해 특정 스타일 시트들을 불러오면 다음과 같이 특정 선택자 하위에 위치하는 요소에만 스타일을 적용할 수 있습니다.

하지만 이렇게 할 경우, 각각의 스타일 시트에서 선언한 스타일 변수에 접근할 수 없는 문제가 발생하기 때문에 사용할 수 없었습니다.

스타일 변수를 읽지 못해 발생하는 에러
스타일 변수를 읽지 못해 발생하는 에러

결국 마지막 방법인 shadow DOM을 시도하게 됩니다.

3. shadow DOM

shadow DOM은 웹 컴포넌트의 핵심 중 하나입니다.

웹 컴포넌트에 대해 자세히 알고 싶다면 여기를 참고하세요.

웹 컴포넌트는 재사용할 수 있는 커스텀 HTML element를 생성하고, 해당 요소를 캡슐화하는 기술입니다.

캡슐화를 통해 마크업, 스타일, 동작을 외부로부터 격리하여, 웹페이지의 다른 구성 요소의 간섭을 방지할 수 있게 도와줍니다.

그중에서도 shadow DOM API는 숨겨지고 분리된 DOM을 기존 HTML 요소에 부착하는 방법을 제공합니다.

그렇기 때문에 shadow DOM이 지금 고민하는 문제를 해결하는 데 도움이 될 거라고 판단했습니다

shadow DOM을 적용했을 때의 dom tree 구조
shadow DOM을 적용했을 때의 dom tree 구조

shadow DOM에 쓰이는 용어는 다음과 같습니다. 후술할 예시와 함께 보겠습니다.

  • Shadow host: shadow DOM이 부착되는 통상적인 DOM 노드, host element입니다.
  • Shadow tree: shadow DOM 내부의 DOM 트리.
  • Shadow boundary: shadow DOM이 끝나고, 통상적인 DOM이 시작되는 장소.
  • Shadow root: shadow 트리의 root 노드.

그리고 shadow DOM은 최신 2 버전 이내의 웬만한 브라우저들이 지원하기 때문에 프로덕트에 사용하기 적합합니다.

4. shadow DOM 적용하기

실제 프로덕트 코드에서 shadow DOM을 적용하는 과정을 소개해드리겠습니다.

4-1. shadow DOM 생성

제가 속한 학습자 셀에서는 account라는 리액트 앱을 개발하고 있습니다.
인프런 서비스 중에서 ‘학습자’에 해당하는 영역들을 모던 스택으로 마이그레이션 하는 일입니다.

이 앱은 빌드된 후 기존 인프런 서비스에서 스크립트 태그로 불러와져 인프런 html 내부, <div id="App">에 그려집니다. 해당 html을 간단하게 표현하면 다음과 같습니다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>인프런 html 간소화</title>
    <style>
      ✨ 전역 스타일을 포함한 인프런 서비스의 기존 스타일들
    </style>
  </head>
  <body>
    <div id="root">
      <nav>GNB</nav>
      <main id="main">
        <div id="App">✨ account 리액트 앱이 그려질 곳</div>
      </main>
      <footer></footer>
    </div>
  </body>
</html>

account 앱이 서빙되는 플로우에 대한 자세한 내용은 다음 포스팅으로 준비될 예정입니다 😇


저희가 원하는 것은 인프런 서비스의 전역 스타일에 리액트 앱이 영향을 받지 않도록, <div id="App">이 shadow DOM을 사용하여 격리되는 것입니다.

shadow DOM을 직접 생성할 수도 있지만, 후술할 emotion 스타일 시트 문제를 좀 더 편리하게 처리하기 위해 react-shadow 라이브러리를 사용했습니다.

// main.tsx
ReactDOM.render(
  <React.StrictMode>
    <AppProvider>
      <App />
    </AppProvider>
  </React.StrictMode>,
  document.getElementById('main'),
);

// AppProvider.tsx
import root from 'react-shadow';

<BrowserRouter>
  // ✨ root.div는 shadow host에 해당합니다
  <root.div id="shadow-root">{children}</root.div>
</BrowserRouter>;

그럼 아래와 같은 돔 트리가 생성됩니다. ▶︎#shadow-root 가 위에서 이야기한 Shadow root에 해당합니다.

Shadow root 하위에 생성된 코드는 외부에 영향을 주지 않습니다.
또한, 외부로부터 영향을 받지도 않습니다.
그렇기에 인프런 서비스의 전역 스타일로부터 안전합니다.

일반적으로 shadow dom 내부 요소를 선택하려고 하면, 위와 같이 null을 반환합니다.

위의 코드는 라이브러리를 이용했지만, 사실 Element.attachShadow()를 이용하면 shadow DOM을 등록하는 것은 굉장히 간단합니다.

이 메서드는 매개변수로 옵션 객체 { mode: 'open' | 'closed' } 를 전달받습니다. mode가 open이면 메인 페이지 (DOM)에서 JavaScript를 사용하여 (Element.shadowRoot 등) shadow DOM에 접근할 수 있습니다.

mode가 open이면 이렇게 shadowRoot에 접근할 수 있고, shadowRoot를 통해 요소를 선택할 수 있습니다.

4-2. 스타일 태그 이동

여기까지 하면 새로운 리액트 앱을 인프런 서비스의 전역 스타일로부터 격리하는 데에는 성공했습니다.

하지만 앱을 로컬에서 실행시켜보면… 리액트 앱의 스타일이 다 깨지는 것을 확인할 수 있습니다 😱

account 리액트 앱에서 생성된 <style/> 태그들이 shadow DOM이 아닌 메인 페이지의 head 안에 있어 shadow DOM에 스타일이 적용될 수 없기 때문입니다.

따라서 원래 DOM의 head 안에 있는 <style> 들을 shadow DOM 안으로 옮겨와야 합니다.

방법은 간단합니다. emotion cache를 이용합니다.

emotion cache에 대해 자세히 알고 싶다면 관련 공식문서 를 참고하세요

emotion cache는 emotion에 의해 생성된 스타일들을 삽입하는 데 쓰입니다.
createCache 함수와 <CacheProvider/> 컴포넌트를 함께 이용하면 원하는 위치에 스타일들을 삽입할 수 있습니다.

createCache 함수를 이용해 emotion cache 인스턴스를 만들고, 해당 인스턴스를 <CacheProvider/> 컴포넌트의 value로 넘겨주면 됩니다.

저희의 경우 <style>이 생성되어야 하는 곳은 shadow DOM 내부 최상단이므로, shadow host를 ref로 선택합니다.

const AppProvider: FC = ({ children }) => {
  const shadowRootRef = useRef<HTMLDivElement>(null);

  return (
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        // ✨ shadow host를 ref로 선택합니다.
        <root.div id="shadow-root" ref={shadowRootRef}>
          {children}
        </root.div>
        {MODE !== 'production' ? <ReactQueryDevtools /> : null}
      </QueryClientProvider>
    </BrowserRouter>
  );
};

export default AppProvider;

createCache 함수 (아래 코드에선 createEmotionCache)는 key와 container를 받을 수 있습니다.
key는 인스턴스를 구분시켜줄 식별자로 쓰이고, container는 스타일 태그가 생성될 위치, 타겟을 의미합니다.
container에 그대로 ref를 넘겨버리면 shadow root와 동일 레벨의 위치에 스타일 태그들이 생성되므로, shadow host의 shadow root를 특정해야 합니다.

여기서 문제는 ref.shadowRootnull인 경우입니다.
컨테이너가 null이면 createCache 함수는 제대로 실행되지 않습니다.
한 번 돔이 그려지고 난 뒤에 setShadowRefs 함수가 실행되어야 하므로, shadowRootRef.current?.shadowRoot 를 구독하는 useEffect로 감싸서 shadowRoot가 null이 아닐 때 createCache 함수가 실행되도록 합니다.

const AppProvider: FC = ({ children }) => {
  const shadowRootRef = useRef<HTMLDivElement>(null);
  const [inflabEmotionCache, setInflabEmotionCache] = useState<EmotionCache>();

  useEffect(() => {
    if (shadowRootRef.current?.shadowRoot) {
      setShadowRefs(shadowRootRef.current);
    }
  }, [shadowRootRef.current?.shadowRoot]);

  function setEmotionStyles(ref: Maybe<HTMLDivElement>) {
    if (!ref?.shadowRoot) {
      return;
    }

    if (ref && !inflabEmotionCache) {
      const createdInflabEmotionWithRef = createEmotionCache({
        key: 'inflab',
        container: ref.shadowRoot, // ✨ shadow host(=ref)로부터 shadow root를 선택해서 container로 삼습니다.
      });

      setInflabEmotionCache(createdInflabEmotionWithRef);
    }
  }

  function setShadowRefs(ref: Maybe<HTMLDivElement>) {
    setEmotionStyles(ref);
  }

  return (
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        <root.div id="shadow-root" ref={shadowRootRef}>
          {inflabEmotionCache && (
            <EmotionCacheProvider value={inflabEmotionCache}>{children}</EmotionCacheProvider>
          )}
        </root.div>
        {MODE !== 'production' ? <ReactQueryDevtools /> : null}
      </QueryClientProvider>
    </BrowserRouter>
  );
};

export default AppProvider;

이제, 컨테이너로 특정했던 shadow root에서 emotion이 생성한 스타일 태그들을 확인할 수 있고 리액트 앱에 스타일이 정상적으로 적용되고 있음을 확인할 수 있습니다.

4-3. react portal 타겟 수정

마지막으로, react portal의 설정이 남았습니다.

저희가 사용하는 디자인 시스템의 modal, tooltip, notification 컴포넌트 등등은 react portal을 기반으로 동작합니다.

해당 컴포넌트들은 portal을 사용하지 않고도 동작할 수 있게 하는 옵션이 존재하기는 하지만, 여러모로 portal을 통해 격리된 root에서 생성되는 것이 깔끔합니다.

예를 들어 position:fixed 등의 스타일이 적용되었을 때 모달이 특정 element 안에만 갇힐 수도 있고, z-index 이슈가 생길 수도 있기 때문입니다.

무엇보다도 현재의 상태로 portal을 기반으로 하는 modal 컴포넌트를 사용하면, shadow DOM 바깥에 요소가 생성되기 때문에 스타일도 적용되지 않고 레이아웃도 깨지는 문제가 발생합니다.

따라서 디자인 시스템에서 사용하는 portal의 타겟을 shadow DOM 안으로 옮겨야 합니다.
다행히, Theme Provider에서 portal의 타겟을 설정할 수 있었습니다.

<ThemeProvider
  theme={{
    components: {
      Portal: {
        defaultProps: {
          // ✨ shadow root를 portal의 기본 target으로 설정합니다.
          target: document.querySelector('#shadow-root')?.shadowRoot,
        },
      },
    },
  }}
>

5. 마무리

이렇게 shadow DOM을 이용함으로써 신-구 스타일을 독립적으로 유지하면서 기존 서비스와 새롭게 개발하는 리액트 앱을 성공적으로 붙일 수 있었습니다.

이 글이 기존 코드와 새로운 코드를 서로 영향을 끼치지 않게끔 독립적으로, 그러나 함께 유지하고 싶은 분들께 도움이 되었으면 좋겠습니다.

감사합니다 🤓