사전 웹 컴포넌트
개념

웹 컴포넌트

gabury1고친 사람 github-actions[bot]

웹 컴포넌트는 라이브러리 없이 브라우저 기능만으로 나만의 태그를 만들어 쓰게 해 줍니다. 한 번 만든 태그는 버튼처럼 어느 웹 페이지에나 넣어 씁니다. 태그 안의 모양과 스타일은 바깥 페이지와 섞이지 않게 감춰 둡니다.

쉽고 빠른 이해

웹 컴포넌트는 브라우저에게 새 태그를 가르치는 방법입니다. <price-tag> 라는 태그를 한 번 정의해 두면 페이지 어디에 적어도 가격표가 그려집니다.

화면 라이브러리로 만든 부품은 그 라이브러리 안에서만 동작합니다. 팀마다 라이브러리가 다르면 같은 버튼을 팀 수만큼 다시 만들게 됩니다. 브라우저가 직접 아는 태그는 어느 라이브러리 위에서나 쓸 수 있습니다.

  1. 태그가 할 일을 자바스크립트 클래스 하나에 적습니다
  2. 그 클래스를 하이픈이 든 태그 이름으로 브라우저에 등록합니다
  3. 태그가 페이지에 나타나면 브라우저가 그 클래스대로 태그를 그립니다
  4. 태그의 속 구조는 바깥과 갈라진 별도 공간에 둡니다

대가는 손이 더 간다는 점입니다. 값이 바뀔 때 화면을 다시 그리는 일을 브라우저가 대신해 주지 않습니다. 바깥 스타일이 안으로 못 들어가서 색 하나를 바꾸는 데도 따로 손을 써야 합니다. 그래서 주로 라이브러리가 서로 다른 여러 팀이 같은 부품을 나눠 쓸 때 씁니다.

상세

가전제품은 어느 집 콘센트에 꽂아도 동작합니다. 집마다 벽지와 가구가 달라도 냉장고는 냉장고로 일합니다. 안의 배선은 덮개 속에 있어서 쓰는 사람은 겉의 버튼만 만집니다.

이 절은 웹 컴포넌트가 무엇으로 이루어지고 어떻게 동작하는지를 다룹니다. 예는 상품 가격을 「3000원」처럼 적어 주는 <price-tag> 태그 하나로 끝까지 갑니다.

태그와 요소와 DOM

웹 페이지는 HTML(HyperText Markup Language)로 적습니다. <button> 처럼 꺾쇠로 감싼 이름이 태그입니다. 브라우저는 태그 하나를 읽을 때마다 객체 하나를 만듭니다. 이 객체가 요소(element)입니다.

브라우저는 만든 요소들을 부모와 자식으로 이어 나무 모양으로 들고 있습니다. 이 구조가 DOM(Document Object Model, 문서 객체 모델)입니다. 자바스크립트 코드는 DOM 을 읽고 고쳐서 화면을 바꿉니다.

브라우저가 아는 태그는 정해져 있습니다. <button> 을 만나면 누를 수 있는 버튼을 그립니다. 처음 보는 이름을 만나면 아무 동작 없는 요소로 둡니다. 웹 컴포넌트는 이 목록에 내 태그를 더하는 방법입니다.

라이브러리 컴포넌트와 다른 점

화면을 조각으로 나눈 부품 하나가 컴포넌트입니다. 상품 카드 하나는 사진 · 이름 · 가격 요소 여러 개를 묶은 컴포넌트 하나입니다. 보통은 화면 라이브러리를 써서 컴포넌트를 만듭니다.

라이브러리로 만든 컴포넌트는 그 라이브러리가 있어야 동작합니다. React 로 만든 버튼을 Vue.js 페이지에 바로 꽂을 수는 없습니다. 회사 안에 팀이 여럿일 수 있습니다. 팀마다 라이브러리가 다르면 같은 버튼을 팀 수만큼 다시 만들게 됩니다.

웹 컴포넌트로 만든 부품은 브라우저가 직접 아는 태그가 됩니다. 그래서 어느 라이브러리 위에서나 동작합니다. 라이브러리 없이 HTML 파일 하나에 넣어도 됩니다.

웹 컴포넌트를 이루는 세 기술

웹 컴포넌트는 기술 하나의 이름이 아닙니다. 브라우저가 기본으로 주는 기술 셋을 묶어 부르는 이름입니다. 셋은 따로 써도 됩니다. 대개는 함께 씁니다.

기술 하는 일 없으면
커스텀 엘리먼트 새 태그 이름을 클래스에 묶어 등록한다 새 태그를 만들 수 없다
섀도 DOM 태그 속 구조를 바깥과 갈라 감춘다 페이지 스타일이 속으로 새어 든다
template 요소 속 구조의 틀을 그리지 않고 담아 둔다 요소마다 속 구조를 새로 짠다

아래 소절이 셋을 하나씩 봅니다. 커스텀 엘리먼트가 뼈대입니다. 나머지 둘은 그 위에 얹힙니다.

커스텀 엘리먼트로 태그 등록하기

커스텀 엘리먼트(custom element)는 새 태그 이름과 자바스크립트 클래스를 묶어 브라우저에 알리는 기술입니다. price-tag 라는 이름과 PriceTag 클래스를 묶어 알리면 브라우저가 <price-tag> 를 알아봅니다.

클래스는 브라우저가 주는 HTMLElement 를 상속해 만듭니다. HTMLElement 는 <button> 같은 기본 요소들이 다 물려받는 바탕 클래스입니다. 그래서 새 태그도 보통 요소가 가진 기능을 다 갖고 시작합니다.

등록은 customElements.define 함수 한 번입니다. 첫 인자는 태그 이름입니다. 둘째 인자는 클래스입니다. 아래 코드는 <price-tag> 를 등록합니다.

JavaScript
class PriceTag extends HTMLElement {
  connectedCallback() {
    const won = this.getAttribute('won');
    this.textContent = won + '원';
  }
}
customElements.define('price-tag', PriceTag);

connectedCallback 은 요소가 페이지에 붙을 때 브라우저가 불러 주는 메서드입니다. 이 메서드는 won 속성을 읽어 요소의 글자(textContent)를 채웁니다.

속성(attribute)은 태그 안에 이름="값" 꼴로 적는 값입니다. <price-tag won="3000"> 이라면 won 속성의 값이 3000 입니다.

아래 코드는 요소를 만들어 페이지에 붙입니다. createElement 가 요소를 만듭니다. setAttribute 는 속성을 넣습니다. append 는 요소를 페이지 본문에 붙입니다.

JavaScript
const el = document.createElement('price-tag');
el.setAttribute('won', '3000');
el.textContent; // ''
document.body.append(el);
el.textContent; // '3000원'

요소를 만들기만 했을 때는 글자가 비어 있습니다. 페이지에 붙인 순간 connectedCallback 이 불려 글자가 채워졌습니다. HTML 에 <price-tag won="3000"></price-tag> 를 적어도 같은 일이 일어납니다.

이름에 하이픈이 드는 까닭

새 태그 이름에는 하이픈(-)이 하나 이상 들어가야 합니다. price-tag 는 등록됩니다. pricetag 는 거절됩니다.

브라우저 기본 태그에는 하이픈이 든 이름이 없습니다. HTML 은 앞으로 새 기본 태그를 더할 때도 하이픈 없는 이름을 씁니다. 그래서 하이픈이 든 이름은 언제까지나 내 태그 몫으로 남습니다. 나중에 같은 이름의 기본 태그가 생겨 부딪힐 일이 없습니다.

요소가 거치는 생명주기

요소는 세 단계를 거칩니다. 만들어지는 때, 페이지에 붙는 때, 페이지에서 떨어지는 때입니다. 이 흐름이 요소의 생명주기입니다.

브라우저는 단계마다 클래스의 정해진 메서드를 불러 줍니다. 개발자는 그 메서드에 할 일만 적어 둡니다.

메서드 브라우저가 부르는 때 흔한 쓰임
constructor 요소 객체가 만들어질 때 섀도 DOM 달기 · 초기값 넣기
connectedCallback 페이지에 붙을 때 속성을 읽어 그리기 · 이벤트 듣기 시작
disconnectedCallback 페이지에서 떨어질 때 타이머와 이벤트 듣기 정리
attributeChangedCallback 지켜보는 속성이 바뀔 때 바뀐 값으로 다시 그리기

마지막 메서드는 모든 속성을 지켜보지 않습니다. 클래스 자체에 붙는(static) 목록 observedAttributes 를 두면 거기 적은 속성만 지켜봅니다.

PriceTag 클래스 안에 아래 두 가지를 더하면 가격이 바뀔 때 글자도 따라 바뀝니다. observedAttributes 목록과 attributeChangedCallback 메서드입니다.

JavaScript
static observedAttributes = ['won'];
attributeChangedCallback(name, oldV, newV) {
  this.textContent = newV + '원';
}

메서드의 세 매개변수는 바뀐 속성 이름 · 이전 값 · 새 값입니다. 여기서는 새 값만 씁니다.

아래 코드는 앞에서 만든 el 의 속성을 바꿉니다. 속성을 바꾸는 순간 브라우저가 이 메서드를 부릅니다.

JavaScript
el.setAttribute('won', '5000');
el.textContent; // '5000원'

등록보다 먼저 나온 태그의 업그레이드

태그가 등록보다 먼저 페이지에 나올 수도 있습니다. 클래스를 담은 스크립트가 늦게 실렸을 때입니다. 그동안 브라우저는 그 태그를 처음 보는 이름으로 여겨 아무 동작 없는 요소로 둡니다. 아래 그림은 이 상태를 「등록 전 요소」라고 부릅니다.

등록이 끝나면 브라우저는 이미 페이지에 있던 그 요소에 클래스를 입힙니다. 이 일이 업그레이드(upgrade)입니다. 업그레이드된 요소는 constructor 와 connectedCallback 을 차례로 거칩니다. 아래 그림은 등록 뒤에 만들어진 요소와 업그레이드된 요소가 지나는 경로를 함께 보여 줍니다.

stateDiagram-v2
    state "등록 전 요소" as 등록전
    state "아직 안 붙은 요소" as 안붙음
    state "페이지에 붙은 요소" as 붙음
    state "떨어진 요소" as 떨어짐
    [*] --> 안붙음: 등록 뒤에 만들어짐 · constructor
    안붙음 --> 붙음: connectedCallback
    [*] --> 등록전: 등록 전에 페이지에 나옴
    등록전 --> 붙음: 업그레이드 · constructor 뒤 connectedCallback
    붙음 --> 떨어짐: disconnectedCallback
    떨어짐 --> 붙음: connectedCallback

섀도 DOM 으로 속 감추기

CSS(Cascading Style Sheets)는 화면을 어떻게 그릴지 적는 스타일 언어입니다. 페이지에 적은 CSS 규칙은 페이지 전체에 걸립니다. 어느 팀이 p { color: gray; } 를 적으면 페이지의 모든 문단이 회색이 됩니다. 내 태그 안의 문단도 예외가 아닙니다.

섀도 DOM(shadow DOM)은 요소 하나에 바깥과 갈라진 작은 DOM 을 따로 달아 주는 기술입니다. 그 안에 적은 스타일은 안에만 걸립니다. 페이지의 스타일은 안으로 들어오지 않습니다.

이렇게 따로 달린 작은 DOM 을 섀도 트리라고 부릅니다. 섀도 트리를 단 요소는 호스트(host)라고 부릅니다. <price-tag> 에 섀도 트리를 달면 <price-tag> 가 호스트입니다.

막히는 것은 스타일만이 아닙니다. 페이지에서 요소를 찾는 코드도 섀도 트리 안은 뒤지지 않습니다. 이렇게 속을 감싸서 바깥이 직접 못 만지게 하는 성질이 캡슐화입니다.

아래는 <price-tag> 를 섀도 DOM 으로 다시 짠 코드입니다. 가격은 속성 대신 태그 사이에 적어 <price-tag>3000</price-tag> 로 씁니다.

JavaScript
class PriceTag extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML =
      '<style>p { color: red; }</style>' +
      '<p><slot></slot>원</p>';
  }
}

attachShadow 가 호스트에 섀도 트리를 답니다. 그리고 섀도 루트를 돌려줍니다. 섀도 루트는 섀도 트리의 맨 위입니다. 섀도 트리에 내용을 넣을 때는 이 섀도 루트에 넣습니다.

innerHTML 은 문자열을 HTML 로 읽어 그 속을 채웁니다. 안에 적은 p { color: red; } 는 이 태그 안의 문단에만 걸립니다. 페이지의 다른 문단은 빨개지지 않습니다.

아래 코드는 바깥에서 문단을 찾습니다. querySelector 는 CSS 선택자에 맞는 요소 하나를 찾아 주는 함수입니다. 못 찾으면 null 을 돌려줍니다.

JavaScript
const el = document.querySelector('price-tag');
el.querySelector('p');            // null
el.shadowRoot.querySelector('p'); // p 요소

호스트에서 바로 찾으면 문단이 안 보입니다. shadowRoot 로 섀도 루트를 거쳐야 찾힙니다. mode: 'closed' 로 달면 shadowRoot 가 null 을 돌려줘서 이 방법도 막힙니다.

슬롯으로 바깥 내용 받기

섀도 트리 안은 바깥에서 못 건드립니다. 그러면 쓰는 사람이 태그 사이에 넣은 「3000」은 어디에 보일까요.

슬롯이 그 답입니다. 섀도 트리 안에 <slot> 을 두면 바깥에서 넣은 내용이 그 위치에 끼워져 보입니다. 위 코드의 <p><slot></slot>원</p> 가 그래서 「3000원」을 그립니다.

바깥에서 넣은 내용은 섀도 트리로 옮겨지지 않습니다. 호스트의 자식으로 남아 있습니다. 화면에서만 슬롯 위치에 보입니다. 그래서 그 글자에는 페이지의 스타일도 걸립니다. 아래 그림이 이 두 갈래를 보여 줍니다.

flowchart TD
    H["price-tag · 호스트"]
    H --> T["3000 · 바깥에서 넣은 글자"]
    H --> R
    subgraph ST["섀도 트리"]
        R["섀도 루트"] --> S["style · 빨간 글자"]
        R --> P["p"]
        P --> SL["slot"]
        P --> W["원"]
    end
    T -. 슬롯 위치에 보임 .-> SL

template 요소로 틀 찍어 내기

위 코드는 섀도 트리의 속을 문자열로 적었습니다. <price-tag> 가 페이지에 열 개면 innerHTML 문자열도 열 번 읽혀 속 구조가 열 번 만들어집니다. 속 구조가 길어지면 자바스크립트 문자열 안에서 HTML 을 다루기도 번거롭습니다.

template 요소는 이 틀을 HTML 안에 미리 적어 두는 태그입니다. <template> 안의 내용은 화면에 그려지지 않고 담겨 있기만 합니다. 클래스는 요소가 생길 때마다 이 틀을 복제해 섀도 루트에 붙입니다.

아래는 HTML 에 적어 두는 틀입니다. 앞 코드의 문자열과 내용이 같습니다. id 는 요소에 붙이는 이름표입니다.

HTML
<template id="price">
  <style>p { color: red; }</style>
  <p><slot></slot>원</p>
</template>

constructor 에서는 innerHTML 두 줄 대신 아래 두 줄을 씁니다. root 는 앞 코드에서 attachShadow 가 돌려준 섀도 루트입니다. '#price' 는 id 가 price 인 요소를 찾는 선택자입니다.

JavaScript
const tpl = document.querySelector('#price');
root.append(tpl.content.cloneNode(true));

content 는 template 요소가 담아 둔 틀입니다. cloneNode(true) 는 그 틀을 속까지 복제합니다. 틀은 한 번만 읽습니다. 요소마다 쓰는 것은 그 복제본입니다.

속성은 글자만 받는다

HTML 에 적는 속성은 언제나 문자열입니다. won="3000" 의 값도 숫자가 아니라 글자 「3000」입니다. 숫자나 참·거짓 정도는 클래스 안에서 바꿔 쓰면 됩니다.

상품 목록 같은 객체나 배열은 문자열로 넘기기 어렵습니다. 이럴 때는 자바스크립트로 요소 객체에 값을 직접 넣습니다. 객체에 딸린 값인 프로퍼티(property)를 쓰는 방법입니다. el.items = [...] 처럼 넣습니다.

값이 들어오는 순간은 세터(setter)로 잡습니다. 세터는 프로퍼티에 값을 넣을 때 불리는 메서드입니다. 클래스에 items 세터를 둡니다. 그 안에서 화면을 다시 그리게 짭니다.

다시 그리기는 직접 짠다

화면 라이브러리는 값이 바뀌면 화면을 알아서 다시 그려 줍니다. 웹 컴포넌트에는 그런 장치가 없습니다. 앞의 예에서도 won 이 바뀔 때 글자를 고치는 코드를 손으로 적었습니다.

값이 몇 개뿐이면 문제가 안 됩니다. 값이 많고 서로 얽힌 화면이면 고칠 곳을 빠짐없이 챙기기 어렵습니다. 그래서 웹 컴포넌트를 짤 때도 다시 그리기를 맡아 주는 작은 라이브러리를 얹곤 합니다.

바깥에서 스타일을 바꾸는 방법

캡슐화에는 대가가 있습니다. 페이지가 내 태그의 색을 바꾸고 싶어도 페이지의 스타일 규칙은 섀도 트리 안으로 못 들어갑니다.

방법이 둘 있습니다. 첫째, 글자 색과 글꼴처럼 부모에게서 물려받는 스타일은 섀도 트리 안으로도 이어집니다. 페이지 전체 글꼴을 바꾸면 내 태그의 글꼴도 바뀝니다.

둘째는 CSS 변수(custom property)입니다. 이름을 붙여 둔 스타일 값입니다. 이 값도 섀도 트리 안으로 이어집니다. 태그를 만드는 쪽이 변수를 읽어 쓰게 짜 두면 바깥은 변수 값만 바꿔 색을 고칩니다.

CSS
/* 페이지 */
price-tag { --price-color: blue; }

/* 섀도 트리 안 */
p { color: var(--price-color, red); }

페이지가 변수를 안 주면 둘째 값인 빨강이 쓰입니다. 위처럼 파랑을 주면 가격이 파랗게 그려집니다.

쓰는 때와 안 쓰는 때

이 소절은 앞에서 본 장점과 대가를 상황별로 모읍니다. 그 전에 두 낱말을 풉니다.

디자인 시스템은 회사가 버튼 · 색 · 글꼴 같은 화면 부품을 한 벌로 정해 모든 서비스에 같이 쓰는 묶음입니다. 서버 사이드 렌더링은 서버가 HTML 을 미리 완성해 브라우저로 보내는 방식입니다.

상황 웹 컴포넌트 까닭
라이브러리가 다른 여러 팀이 디자인 시스템을 나눠 쓴다 맞는다 어느 라이브러리 위에서나 동작한다
남의 페이지에 끼워 넣는 작은 화면 부품을 만든다 맞는다 그 페이지 스타일과 안 섞인다
라이브러리 없는 작은 페이지에 부품 몇 개를 넣는다 맞는다 브라우저만 있으면 된다
값이 많이 얽힌 화면 하나를 처음부터 짓는다 손이 많이 간다 다시 그리기를 직접 짜야 한다
서버 사이드 렌더링으로 화면을 보낸다 손이 더 간다 섀도 트리 속은 대개 스크립트가 실행되어야 생긴다

앞의 셋은 「어디서나 동작한다」와 「안 섞인다」가 값을 하는 상황입니다. 뒤의 둘은 브라우저가 대신해 주지 않는 일을 직접 메워야 하는 상황입니다.

관련 항목

웹 컴포넌트를 이루는 브라우저 기술

커스텀 엘리먼트 · 섀도 DOM · HTML 템플릿 · slot 요소 · DocumentFragment · 선언형 섀도 DOM

웹 컴포넌트가 다루는 브라우저 구조

DOM · HTML · HTML 요소 · 태그 · 속성 · CSS · JavaScript

커스텀 엘리먼트가 거치는 처리 단계

생명주기 · 생명주기 메서드 · 콜백 · 이벤트 · 커스텀 이벤트

웹 컴포넌트의 스타일 경계를 정하는 규칙

캡슐화 · 스타일 격리 · CSS 상속 · CSS 변수 · CSS 선택자

웹 컴포넌트 대신 쓰는 컴포넌트 라이브러리

React · Vue.js · Angular · Svelte · SolidJS

웹 컴포넌트를 짜게 돕는 도구

Lit · Stencil · 폴리필 · 스토리북

웹 컴포넌트를 쓰는 설계 방식

디자인 시스템 · 마이크로 프론트엔드 · 위젯 · iframe · 서버 사이드 렌더링

웹 컴포넌트를 정의하는 표준 문서와 단체

HTML Living Standard · WHATWG · W3C · 웹 표준

웹 컴포넌트가 속하는 상위 분류

프론트엔드 · 컴포넌트 · 브라우저 · 사용자 인터페이스 · 클래스 · 상속

다른 이름: Web Components · web components · Web Component · web component · 웹컴포넌트