웹 컴포넌트(3) - 쉐도우 돔(#Shadow DOM)


image

이 글은 웹 컴포넌트 소개 연재로 그중 세 번째인 쉐도우 돔에 대한 글이다. 아마도 이전 글의 커스텀 엘리먼트 글을 읽고 온 분은 여러 스펙, API, 기억해 두어야 할 것들로 질렸을지도 모르겠다.(두 번으로 잘라서 글을 썼으면 좋았을 것 같다.) 그러나 이번 시간 쉐도우 돔을 사용하는데 배워야 하는 API는 element.attachShadow()함수 하나뿐이니 가벼운 마음으로 시작해도 된다. 짧은 코드를 따라 해 보고, 조금 더 상세한 내용을 짚어보는 순서로 진행해 보겠다. 오늘의 이야기는 HTML, CSS의 스코프를 주제로 시작한다.

"모든 변수를 글로벌에 넣는 일은 그만둬."

아마도 이 글을 읽을 당신에게는 굳이 해줄 필요 없는 말일 것이다. 누군가는 발끈할지도 모르겠다.

(╬ ಠ益ಠ) "날 뭘로 보고! 어제 컴퓨터 학원 등록한 내 조카도 그 얘기하면 지루해 한다고!"

왜 그런 반응을 하는지 충분히 이해하니 진정하자. 하지만 놀랍게도 당신이 프런트엔드 개발자라면 우리 모두 글로벌로 모든 것을 해결하려는 코드를 만들고 있다. 그럴 리 없다고 성급하게 말하지 말고 우리가 작성한 CSS, HTML, Java Script를 생각해보자. HTML, CSS 모두 하나의 페이지에 모든 엘레먼트, 모든 CSS 룰이 쓰여지고 있다. document.querySelector()하나면 이 페이지의 알파와 오메가를 관통할 수 있다.

"아하! 무슨 얘기인지 알겠네. CSS는 정말 싫지. 하지만 난 SASS 쓰고 있으니 괜찮아. 그리고 HTML을 글로벌로 쓰지 말라는 말은 무슨 말이지?"

맞다. 당신의 잘못이 아니고, HTLM도 CSS도 원래 그렇게 생겼다. 모두 public이고 모두 global이다. 하지만 SASS, LESS 같은 도구는 괴로운 환경을 이겨내기 위한 발버둥이지 근본적인 문제의 해결 방안은 아니다.

image

나라고 처음부터 이러고 싶지는 않았어 ㅜㅜ - css meme

분리: #shadow-root

그러면 쉐도우 돔은 어떻게 HTML과 CSS에 스코프를 줄 수 있다는 말인가? HTML이나 CSS 모두 페이지에 적용되는 것인데 쉽게 이해되지 않는다.

일단 시작하자. 크롬 브라우저를 준비하고 아래의 코드를 개발자 도구를 열어 아무 페이지에서든 붙여 넣고, 어떤 일이 일어나는지 확인해 보자. 아래의 코드는 bodyspan태그를 하나 추가하였고, 그 자식으로 style, div를 다시 추가하였다. 단순히 자식 노드에 추가된 style의 스코프를 알아보기 위함이다.

// 어디까지 녹색으로 보이려나?
document.body.appendChild(document.createElement('span')).innerHTML
  = '<style>div { background-color: #82b74b; }</style><div>야호!</div>';

image

필자는 구글에서 붙여 넣어봤다. 그림에서 볼 수 있듯, style태그는 어디에 붙어있든 글로벌이기 때문에 페이지에 존재하는 모든 div를 풀밭으로 만들어 버렸다.

그러면 이번에는 쉐도우 돔을 사용해서 비슷한 일을 해보자. 아래의 코드가 위의 코드와 달라진 것은 attachShadow({mode: 'open'})함수 실행이 하나 더 추가된 것 뿐이다. 이 함수는 쉐도우 루트를 생성하는데, 이것은 DOM 스코프의 경계선 역할을 하게 된다. {mode: 'open'}은 지금 중요치 않으니 그냥 넘어가자. 페이지를 새로 고침 한 다음 아래의 코드를 다시 넣어보자.

// 이번에야 말로 `야호!`만 녹색으로!
document.body.appendChild(document.createElement('span'))
  .attachShadow({mode: 'open'})
  .innerHTML = '<style>div { background-color: #82b74b; }</style><div>야호!</div>';

image

개발자 도구를 살펴보면 #shadow-root (open)이라는 것이 생겼고, 그 밑에 있는 style은 밖으로 새나가지 않는 다는 것을 확인할 수 있다. 반대로 글로벌에 존재하는 스타일 역시 #shadow-root (open)안에 있는 엘레먼트에는 영향을 미치지 못한다. 이것을 확인해 보기 위해, 이번에는 about:blank 페이지로 이동해서 아래의 코드를 넣어보자.

document.body.appendChild(document.createElement('span')).innerHTML
  = '<style>div { background-color: #82b74b; }</style><div id="non-shadow">야호!</div>';
document.body.appendChild(document.createElement('span'))
  .attachShadow({mode: 'open'})
  .innerHTML = '<div id="shadow">야호!</div>';

image

위에서 언급한 대로 쉐도우 루트에 존재하는 엘리먼트에는 루트 밖에 있는 글로벌 스타일이 적용되지 않는 것을 확인할 수 있다. 위의 예제에서는 스타일을 기준으로 설명하였지만, 쉐도우 돔은 돔 자체의 분리 역할을 한다. 즉 쉐도우 루트를 기준으로 id를 중복해서 써도 되고, 루트 안팎의 동일한 이름의 class역시 전혀 다른 클래스의 역할을 수행한다. 쉐도우 루트 밖에서 쉐도우 돔의 엘리먼트를 셀렉트 할 수도 없다. HTML 문서 하나에 수천 개 되는 엘리먼트의 스타일을 한 번에 모두 관리하기 위해 class 이름을 고민할 필요도, id의 중복이 무서워 쓰지 못하는 일도 필요 없다. 쉐도우 돔 하나당 하나의 문서를 관리하듯, 적절한 id를 배분하면, 혹은 그마저도 필요 없이 짧은 셀렉터로 충분히 그 역할을 수행할 수 있다.

image

큰 기능 없는 페이지에도 스타일 수천줄은 우습게 나온다. 과연 이것이 정말 잘 관리되고 있는 것일까

조합: <slot>

쉐도우 돔을 사용하지 않더라도 iframe을 사용하면 비슷한 기능을 수행할 수 있다. 그러나 iframe을 사용한 DOM의 분리는 다음과 같은 단점이 있다.

  • http 요청이 한차례 더 일어난다
  • 별도의 페이지이기 때문에, 소비되는 리소스도 높고 느리다
  • iframe의 주소가 같은 도메인이 아닌 경우 접근 불가능하다

이와 같은 이유로 얼마 전 트위터는 iframe형식으로 지원하던 기능을 브라우저가 지원하는 경우 쉐도우 돔 방식으로 전환했다.

What does this change mean for you? Much lower memory utilization in the browser, and much faster render times. Tweets will appear faster and pages will scroll more smoothly, even when displaying multiple Tweets on the same page. - Upcoming Change to Embedded Tweet Display on Web

이러한 장점과 더불어 iframe으로는 절대 할 수 없지만, 쉐도우 돔이 할 수 있는 일로 슬롯 조합이 있다. 슬롯은 HTML에서 조합을 지어 나타나 특별한 기능을 수행하는 경우에서 발견할 수 있다. 여러분께 슬롯이 어색할 수 있겠지만, 걱정 마시라. 우리가 이미 잘 알고 있는 개념이다. 바로 ol + li, select + item, form + input 등이 그 예이다.

우리가 쉐도우 돔과 함께 사용할 슬롯 역시 위의 ol, li, select등과 동일한 개념을 가지고 동작한다. olli자식 노드에 숫자를 부여한다거나 하는 식이다. 특별한 마크업을 부여할 수도 있고, 스타일을 달리하거나, 동작을 수행하는 기능을 부여할 수도 있다. 아래 예제는 자식 노드(라이트 돔)을 블럭 엘리먼트로 감싸는 일을 하는 셈이다.

아래의 예제는 개발자 도구에서 편집하여 사용하거나, 작은 HTML 파일을 만들어 테스트해볼 수 있다. 아래의 문법을 설명하기 이전, 앞으로 원활한 소통을 위해 몇가지 정의를 이야기 하고 넘어가자. whatwg - 쉐도우 트리

  • 쉐도우 돔: 아래의 코드에서 h1, p등 쉐도우 루트에 붙어있는 DOM
  • 쉐도우 루트: #shadow-root :)
  • 쉐도우 호스트: 쉐도우 루트의 부모. 아래의 코드에서 div#slot-test
  • 라이트 돔: 도큐먼트의 쉐도우 호스트에 붙어있는 노드들. span

아래의 코드 동작을 위의 정의로 풀이해보면 "쉐도우 돔의 슬롯이 가진 이름에 맞는 라이트 돔의 노드가 각 슬롯에 삽입된다" 라고 할 수 있겠다.

<body>
...
<div id="slot-test">
  <!-- Light DOM -->
  <span slot="title">Hello</span>
  <span slot="desc">world</span>
</div>
...
</body>
// Shadow DOM
document.querySelector('#slot-test')
  .attachShadow({mode: 'open'})
  .innerHTML = `
  <h1>
    <slot name="title"></slot>
  </h1>
  <p>
    <slot name="desc"></slot>
  </p>
  `;

image

슬롯의 이름에 맞는 라이트 돔이 자리를 찾아간다

컴포넌트: 커스텀 엘리먼트 + 쉐도우 돔 = DOM OOP

쉐도우 돔을 사용하지 않고 돔의 분리를 할 수 있는 방법으로 iframe을 사용할 수 있다. 실제 쉐도우 돔 mode: close의 polyfill은 iframe으로 작성되었다. 위에서 작성했던 쉐도우 돔을 예제로 아래처럼 쉐도우 돔의 엘리먼트에 외부에서 접근해 보자.

document.querySelector('#non-shadow'); // <div id="non-shadow">야호!</div>
document.querySelector('#shadow'); // null

우리가 여태까지 알아본 바와 같이, 쉐도우 돔에 있는 노드는 id를 통해 가져올 수 없다. 쉐도우 돔에 존재하는 엘리먼트를 쉐도우 돔 밖에서 얻어오기 위해서는 아래와 같이 조금 더 복잡한 방법을 통해 가능하다.

document.querySelector('span').shadowRoot.querySelector('#shadow'); // <div id="shadow">야호!</div>

"아~ 이런! 쿼리가 저렇게 밖에 안돼서는 쓸 모 없겠군!"

전혀 그렇지 않다. 오히려 이것은 좋은 일이다. 왜 그러한지 설명을 위해 먼저 지난 글에서 알아보았던 커스텀 엘리먼트에 대해 다시 기억을 되살려보자. 커스텀 엘리먼트는 HTML 엘리먼트를 자바스크립트 오브젝트로서 관리할 수 있도록 해준다.

// 자바스크립트와 HTML 엘리먼트를 한몸으로 만들어 준다
class MyElement extends HTMLElement {
  yey() {
    console.log('yey');
  }
}
document.querySelector('my-element').yey() // 'yey'

기억이 조금 돌아왔는가? 그럼 이번에는 커스텀 엘리먼트와 쉐도우 돔을 묶은 코드를 살펴보자.

class MyElement extends HTMLElement {
    static get observedAttributes() {return ['lang']; }

    constructor() {
      super();

      // add shadow root in constructor
      const shadowRoot = this.attachShadow({mode: 'open'});
      shadowRoot.innerHTML = `
        <style>div { background-color: #82b74b; }</style>
        <div>yey</div>
      `;
      this._yey = shadowRoot.querySelector('div');
    }

    attributeChangedCallback(attr, oldValue, newValue) {
      if (attr == 'lang') {
        let yey;
        switch (newValue) {
          case 'ko':
            yey = '만세!';
          break;
          case 'es':
            yey = 'hoora!';
          break;
          case 'jp';
            yey = '万歳!';
          break;
          default:
            yey = 'yey!';
        }

        this._yey.innerText = yey;
      }
    }

    yell() {
      alert(this._yey.innerText);
    }
  }

  window.customElements.define('my-element', MyElement);

이쯤에서 지난 기억이 조금 더 돌아왔기를 바란다. 그리고 아래의 그림과 위의 코드를 서로 대입해 보기로 하자. 아래의 그림은 커스텀 엘리먼트, 그리고 쉐도우 돔을 OOP 오브젝트 개념도와 비슷하게 그려놓은 것이다. 커스텀 엘리먼트는 HTML 엘리먼트를 확장해 오브젝트로 만들어 주고, 쉐도우 돔은 그 오브젝트에 스코프를 제공해준다. 다시 얘기하자면 커스텀 엘리먼트와 쉐도우 돔은 DOM을 OOP의 대상으로 바라볼 수 있게 해준다

image 본인의 의견이 많~이 반영된 그림이다. :)

커스텀 엘리먼트가 가지고 있는 쉐도우 돔 트리의 엘리먼트들은 OOP에서 내부 구현에 해당한다. 외부에서 어떠한 오브젝트의 private 속성을 변경하고 싶다면 그것은 어떠한 상황인가? private를 수정한다는 시도가 먼저 잘못되었고, 오브젝트의 정체성에 맞게 필요하다면 메서드를 새로 추가해야 한다.

웹 컴포넌트(커스텀 엘리먼트 + 쉐도우 돔)에서도 마찬가지이다. 내부 돔을 직접 수정하려 하는 시도가 잘못된 것이고, 웹 컴포넌트의 정체성에 맞게 필요하다면 메서드를 추가해야 한다. 우리가 알아보고 있는 기술은 웹 컴포넌트이다. 그 자체로 독립적이고 완결성 있는 것이어야 한다는 뜻이다.

위에서 언급한 불편한 쿼리문은 우리가 기다리는 자바스크립트 private 속성만큼이나 반갑고 유용한 존재이다. 추후 직접 프로젝트를 시작할 때, 아래의 나쁜 예처럼 셀렉터를 작성하고 있다면 위의 그림을 다시 떠올리기 바란다.

예제 1

<!-- GOOD! DOM OOP! -->
<my-element lang="ko"></my-element>
// BAD IDEA!
  document.querySelector('my-element')
    .shadowRoot
    .querySelector('div')
    .innerText = '만세!'

예제 2

  // GOOD! DOM OOP!
  const myElement = document.querySelector('my-element');
  myElement.yell();
  // BAD IDEA!
  const yey = document.querySelector('my-element')
    .shadowRoot
    .querySelector('div')
    .innerText;
  alert(yey);

DOM을 인터페이스로

구글 폴리머팀의 Rob Dodson은 위 그림에서 DOM에 해당하는 events, attributes로 인터페이스를 구성하라고 권고한다. 엘리먼트는 결국 어떠한 상태를 나타내고 있기에, method를 실행하는 것보다 attributes값을 할당하는 것이 맞다는 말이다. 그 다음, 커스텀 엘리먼트는 attributes값이 변경될 때, 그에 맞는 동작을 수행하면 된다. 또한 중요한 attributes 값이나 상태가 있다면 그것은 events로 내보내, 필요한 곳에서 수행해야 한다고 제안한다.

나도 분명 좋은 방법이라고 생각한다. 그러나 attributes값은 문자열, 존재 유무로 불린 값만을 처리할 수 밖에 없다. 실제 컴포넌트를 구성할 때 얼만큼 효용이 있을지는 모르겠다. 필자 생각에는 위에서 말한바와 같이 DOM의 스코프만 잘 지키면 충분히 좋은 컴포넌트 인터페이스가 만들어 질 것이라 생각한다.

쉐도우 돔 자세한 내용들

다른 충실히 쓰여진 튜토리얼들이 많으므로, 여기서는 간단히 주요한 점들만 언급하고 넘어가겠다.

  • textarea, input, image와 같은 엘리먼트들은 쉐도우 돔을 가질 수 없다. (가질 수 있는 것이 이상하다)
  • 쉐도우 돔은 여러번 중첩될 수 있다. slot도 마찬가지이다.
  • 슬롯에 배포된 엘리먼트는 slot.assignedNodes()를 통해 접근할 수 있다.
  • 슬롯안의 엘리먼트가 변경될 때 slotchange 이벤트를 slot엘리먼트에 리스너를 달아 받아오자.
  • 쉐도우 호스트의 스타일은 :host로 변경한다.
  • 쉐도우 호스트의 클래스에 따른 스타일은 :host-context(.classname)으로 가능하다.
  • 슬롯 스타일은 ::sloted(h1) 방식으로 한다.
  • attachShadow({mode: 'closed'})로 쉐도우 루트를 생성하면, 쉐도우 돔에 접근이 불가능해진다.
  • 쉐도우 돔 내부에서 발생한 이벤트의 target은 외부에서 쉐도우 호스트로 변경된다.

마치며

이전 웹 컴포넌트에 대한 글을 공개하며 받았던 피드백들 중에는 다른 프레임워크(React, Angulr..)와의 비교가 적지 않았다. 웹 컴포넌트는 오늘 알아본 것과 같이 보다 근본적인 문제를 해결하는데 집중되어 있다. 다른 프레임워크처럼 생산성이나 어플리케이션 구조 등에 주안점이 있는 것이 아니다. 때문에 웹 컴포넌트는 다른 프레임워크들과 상호 보완 구조에 가깝지, 대체하는 관계가 아님을 다시 말하고 싶다.

References

최규우2017.07.21
Back to list