Lit로 웹 컴포넌트 만들기: 반응형 속성, 템플릿 바인딩, 라이프사이클, 프레임워크 통합

이 글의 핵심

웹 컴포넌트는 프레임워크에 묶이지 않는 대신 Shadow DOM 경계 때문에 스타일 커스터마이징과 폼 연동이 까다롭습니다. Lit가 브라우저 표준 위에서 해 주는 일과 하지 않는 일을 구분하고, Vue의 isCustomElement나 Angular의 CUSTOM_ELEMENTS_SCHEMA 같은 통합 설정, 토큰 기반 디자인 시스템과 버전 관리까지 실무 관점으로 다룹니다.

이 글의 핵심

Lit는 Google이 유지하는 작은 웹 컴포넌트 라이브러리입니다. Custom Elements와 Shadow DOM이라는 브라우저 표준 위에 선언적 템플릿(html), 반응형 속성(reactive properties), 바뀐 부분만 갱신하는 렌더링을 얹어 줍니다. React·Vue·Angular가 자기 프레임워크 안에서만 동작하는 컴포넌트 모델을 제공하는 것과 달리, Lit로 만든 컴포넌트는 어떤 페이지에서든 HTML 태그처럼 쓸 수 있습니다. 본문은 Lit 3 API를 기준으로 합니다.


전제 지식과 Lit의 위치

왜 웹 컴포넌트인가

웹 컴포넌트는 브라우저 수준에서 재사용 가능한 UI 경계를 만드는 표준 묶음입니다.

표준역할
Custom Elements새 HTML 태그를 등록하고 생명주기 콜백을 갖게 함
Shadow DOMDOM과 스타일을 캡슐화한 별도 트리를 붙임
HTML templates재사용 가능한 마크업 조각(<template>)

표준 API만으로도 컴포넌트를 만들 수 있지만, 상태가 바뀔 때마다 innerHTML을 다시 쓰거나 직접 DOM을 비교해 고쳐야 합니다. Lit는 이 부분을 작은 런타임으로 대신합니다.

Lit가 하지 않는 것

Lit는 라우터, 전역 상태 관리, 서버 렌더링 전략을 제공하거나 강제하지 않습니다(SSR은 실험 단계의 @lit-labs/ssr 패키지로 따로 존재합니다). 그래서 대규모 앱에서는 앱 셸(React Router, Vue Router, Angular 등)이 네비게이션과 데이터 레이어를 맡고, 디자인 시스템이나 공용 위젯만 Lit 웹 컴포넌트로 제공하는 이중 구조가 자주 쓰입니다.


Lit의 핵심 개념

LitElement와 커스텀 엘리먼트

Lit의 기본 클래스는 LitElement입니다. 클래스에 @customElement('hello-lit')를 붙이면 <hello-lit></hello-lit>처럼 선언적으로 사용할 수 있습니다.

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('hello-lit')
class HelloLit extends LitElement {
  render() {
    return html`<p>Hello, Lit</p>`;
  }
}

render()는 현재 상태에 대한 뷰 설명을 반환하는 함수에 가깝습니다. Lit는 템플릿의 정적인 부분을 한 번만 DOM으로 만들고, 이후에는 ${} 표현식 자리의 값만 비교해 바뀐 곳만 갱신합니다. Virtual DOM 트리 전체를 비교하지 않는다는 점이 React와 다릅니다.

임베드 위젯, CDN으로 배포되는 공용 UI, 마이크로 프론트엔드에서 기술 중립적인 레이어, 디자인 시스템의 프리미티브가 Lit가 잘 맞는 자리입니다.

커스텀 엘리먼트 이름에는 반드시 하이픈이 하나 이상 들어가야 하고 소문자로 시작해야 합니다. 또 같은 이름을 두 번 customElements.define()하면 예외가 발생하므로, 여러 번들이 같은 컴포넌트를 각자 포함하는 마이크로 프론트엔드에서는 누가 등록을 책임질지 정해 두어야 합니다.

데코레이터 사용 시 빌드 설정

위 예제와 아래 예제들은 TypeScript 데코레이터를 씁니다. TypeScript의 experimentalDecorators를 쓴다면 useDefineForClassFields를 false로 두어야 합니다. 그렇지 않으면 클래스 필드가 Lit가 만든 접근자를 덮어써서 값이 바뀌어도 다시 렌더링되지 않습니다. TC39 표준 데코레이터(TypeScript 5.0+ 기본 동작)를 쓴다면 반응형 필드에 accessor 키워드가 필요합니다(@property() accessor count = 0;).

데코레이터 없이 쓰는 방법

빌드 도구 없이 쓰거나 데코레이터를 피하고 싶다면 static properties와 static styles로 같은 정보를 선언합니다.

import { LitElement, html } from 'lit';

class HelloPlain extends LitElement {
  static properties = {
    name: { type: String },
  };

  constructor() {
    super();
    this.name = 'world';
  }

  render() {
    return html`<p>Hello, ${this.name}</p>`;
  }
}

customElements.define('hello-plain', HelloPlain);

이 방식에서는 기본값을 클래스 필드(name = 'world')가 아니라 생성자에서 할당해야 합니다. 클래스 필드로 선언하면 앞에서 말한 것과 같은 이유로 반응형 접근자가 가려집니다. 한 코드베이스 안에서는 데코레이터와 static 중 하나로 통일하는 편이 유지보수에 유리합니다.


Reactive Properties와 State

@property: 외부와의 계약

@property로 선언한 필드는 컴포넌트의 공개 API입니다. HTML attribute와 어떻게 동기화할지, 어떤 타입으로 변환할지를 옵션으로 정합니다.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('badge-count')
class BadgeCount extends LitElement {
  @property({ type: Number })
  count = 0;

  @property({ type: String, reflect: true })
  variant = 'neutral';

  render() {
    return html`<span data-variant=${this.variant}>${this.count}</span>`;
  }
}

웹 컴포넌트에서 HTML attribute(항상 문자열)와 JavaScript 프로퍼티(임의 타입)는 서로 다른 레이어입니다. 기본적으로 Lit는 attribute가 바뀌면 type에 맞게 변환해 프로퍼티에 넣지만, 반대 방향은 하지 않습니다. reflect: true를 주면 JS에서 값이 바뀔 때 attribute에도 반영되므로, :host([variant='danger']) 같은 CSS 셀렉터를 쓸 수 있습니다. 다만 반영할 때마다 DOM attribute를 쓰므로 자주 바뀌는 값에는 켜지 않는 편이 좋습니다.

type: Boolean은 HTML 관례대로 attribute가 있으면 true, 없으면 false로 변환합니다. 그래서 <my-el open="false">도 true가 됩니다. 이 동작이 팀 규칙과 맞지 않으면 converter 옵션으로 변환 규칙을 직접 정의합니다.

@state: 내부 전용

@state는 외부에서 건드리지 말아야 할 내부 상태에 씁니다. 반응형으로 동작하지만 attribute와 연결되지 않고, 공개 API 문서에도 넣지 않습니다.

import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('counter-box')
class CounterBox extends LitElement {
  @property({ type: Number })
  step = 1;

  @state()
  private _value = 0;

  private _bump() {
    this._value += this.step;
  }

  render() {
    return html`
      <button type="button" @click=${this._bump}>+${this.step}</button>
      <output>${this._value}</output>
    `;
  }
}

@click=${this._bump}처럼 메서드를 그대로 넘겨도 되는 이유는 Lit가 템플릿 이벤트 리스너를 호스트 엘리먼트를 this로 해서 호출하기 때문입니다.

주의할 점은 변경 감지 방식입니다. Lit는 기본적으로 이전 값과 새 값을 !==로 비교합니다. 배열에 push()하거나 객체 필드를 직접 바꾸면 참조가 같으므로 변경으로 인식되지 않습니다. this.items = [...this.items, item]처럼 새 참조를 할당하거나, 제자리 변경 뒤 requestUpdate()를 호출해야 합니다.

requestUpdate와 명시적 갱신

대부분은 반응형 필드에 값을 할당하는 것만으로 충분합니다. 반응형으로 선언하지 않은 값에 의존하거나 위처럼 객체를 제자리에서 바꿨다면 requestUpdate()로 업데이트를 직접 예약합니다.


템플릿과 바인딩

html과 표현식

Lit의 html은 태그된 템플릿 리터럴입니다. ${} 안에는 문자열, 숫자, 다른 템플릿, DOM 노드, 그리고 이것들의 배열을 넣을 수 있습니다.

render() {
  const items = ['alpha', 'bravo'];
  return html`
    <ul>
      ${items.map((label) => html`<li>${label}</li>`)}
    </ul>
  `;
}

map 안에서 다시 html을 반환하는 패턴이 반복 렌더링의 기본형입니다. 이 방식에는 키가 없어서, 항목 순서가 바뀌면 Lit는 기존 DOM 노드를 위치대로 재사용하고 내용만 바꿉니다. 각 항목이 입력 포커스나 애니메이션 상태처럼 DOM에 붙은 상태를 가진다면 lit/directives/repeat.js의 repeat(items, keyFn, template) 디렉티브에 (item) => item.id 같은 키 함수를 넘겨 키 기반으로 노드를 이동시켜야 합니다.

속성·프로퍼티·불리언·이벤트 바인딩

바인딩 종류는 접두사로 구분합니다.

문법의미
attr=${value}attribute 설정(문자열로 변환됨)
.prop=${value}프로퍼티 설정(객체·배열 등 복합 값에 사용)
?disabled=${flag}불리언 attribute를 추가/제거
@click=${handler}이벤트 리스너 등록
render() {
  return html`
    <button
      type="button"
      ?disabled=${this.loading}
      @click=${this._onPrimary}
    >
      ${this.loading ? '처리 중…' : '실행'}
    </button>
  `;
}

폼 컨트롤 같은 컴포넌트를 설계할 때는 어떤 값을 attribute로 노출해 서버가 만든 HTML에서도 지정할 수 있게 할지, 어떤 값을 객체 상태로서 프로퍼티로만 받을지를 먼저 나눠 두어야 합니다.

조건부 렌더링

간단한 분기는 삼항 연산자나 lit/directives/when.js의 when()으로 충분합니다. 분기가 복잡해지면 별도 컴포넌트로 쪼개는 편이 테스트와 재사용에 유리합니다. 숨겼다 다시 보여줄 때 하위 DOM을 보존해야 한다면 cache() 디렉티브를 고려합니다.


라이프사이클과 업데이트

업데이트가 도는 순서

반응형 필드가 바뀌면 Lit는 즉시 렌더링하지 않고 마이크로태스크로 업데이트를 예약합니다. 그래서 같은 동기 코드 안에서 여러 필드를 바꿔도 렌더링은 한 번만 일어납니다. 예약된 업데이트는 다음 순서로 진행됩니다.

  1. shouldUpdate(changedProperties): false를 반환하면 이번 업데이트를 건너뜁니다.
  2. willUpdate(changedProperties): 렌더링 전에 다른 필드에서 파생값을 계산하는 곳입니다. 여기서 반응형 필드를 바꿔도 추가 업데이트가 생기지 않습니다.
  3. update(): 반영할 attribute를 쓰고 render()를 호출해 DOM을 갱신합니다.
  4. firstUpdated(changedProperties): 첫 업데이트 후 한 번만 호출됩니다.
  5. updated(changedProperties): 매 업데이트 후 호출됩니다. DOM 측정, 포커스 이동, 외부 라이브러리 동기화 같은 부수 효과를 둡니다.
  6. updateComplete 프로미스가 resolve됩니다.

자주 쓰는 훅

connectedCallback/disconnectedCallback은 엘리먼트가 문서에 붙고 떨어질 때 호출되며, window 리스너·타이머·ResizeObserver 같은 외부 구독을 등록하고 해제하는 곳입니다. 둘 다 super 호출을 빠뜨리면 Lit의 내부 초기화가 깨집니다. 엘리먼트는 DOM에서 빠졌다가 다시 붙을 수 있으므로, 구독은 생성자가 아니라 connectedCallback에서 걸어야 재연결 시에도 동작합니다.

updated(changedProperties)의 인자는 이번 업데이트에서 바뀐 필드 이름과 이전 값을 담은 Map이라서, 특정 프로퍼티 변화에만 반응할 수 있습니다.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('sized-box')
class SizedBox extends LitElement {
  @property({ type: Number }) width = 0;

  updated(changed: Map<string, unknown>) {
    super.updated(changed);
    if (changed.has('width')) {
      // 폭이 바뀔 때만 외부 차트 라이브러리에 알림
      this._syncChartSize();
    }
  }

  private _syncChartSize() {
    // 외부 라이브러리와의 동기화
  }

  render() {
    return html`<div style="width:${this.width}px">content</div>`;
  }
}

updated에서 반응형 필드를 바꾸면 업데이트가 한 번 더 예약되고, 개발 빌드에서는 Lit가 경고를 출력합니다. 다른 필드에서 계산되는 값이라면 willUpdate로 옮기는 것이 정석이고, DOM을 측정한 결과를 상태로 저장해야 하는 경우에만 updated에서 조건을 엄격히 걸어 씁니다.

성능 관점

외부에 필요 없는 값은 @property 대신 @state로 두어 attribute 처리와 공개 API 부담을 줄입니다. 렌더링마다 큰 배열을 정렬하거나 필터링하지 말고 willUpdate에서 입력이 바뀔 때만 계산해 필드에 저장합니다. 항목이 많은 동적 리스트는 앞에서 설명한 repeat로 노드 재사용을 안정시킵니다.


스타일 캡슐화

static styles와 Shadow DOM

Lit는 기본적으로 shadow root에 렌더링하며, static styles에 css 태그 리터럴로 스타일을 선언합니다. 이 스타일은 지원 브라우저에서 adoptedStyleSheets로 한 번만 파싱되어 같은 컴포넌트의 모든 인스턴스가 공유합니다.

import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('fancy-card')
class FancyCard extends LitElement {
  static styles = css`
    :host {
      display: block;
      border-radius: 12px;
      padding: 16px;
      background: var(--fancy-bg, #fff);
      color: var(--fancy-fg, #111);
      box-shadow: var(--fancy-shadow, 0 8px 24px rgba(0, 0, 0, 0.08));
    }
    ::slotted([slot='actions']) {
      display: flex;
      gap: 8px;
    }
  `;

  render() {
    return html`
      <section part="body">
        <slot></slot>
        <slot name="actions"></slot>
      </section>
    `;
  }
}

shadow 트리 안의 클래스 이름은 전역 CSS와 충돌하지 않습니다. 대신 외부에서 일부만 바꾸고 싶다는 요구는 두 가지 경로로 풀어야 합니다. CSS 커스텀 프로퍼티는 shadow 경계를 넘어 상속되므로 색이나 간격 같은 값은 --fancy-bg처럼 변수로 받습니다. 구조적인 스타일은 ::part로 엽니다.

::part와 exportparts

part 속성을 붙인 요소는 바깥 페이지에서 fancy-card::part(body)처럼 스타일할 수 있습니다. part를 많이 열수록 내부 구조가 사실상 공개 API가 되므로, 디자인 시스템에서는 토큰과 소수의 part 이름으로 제한하는 편이 안전합니다.

exportparts는 컴포넌트의 shadow 안에 다른 컴포넌트가 중첩되어 있을 때, 안쪽 컴포넌트의 part를 바깥 문서까지 전달하는 속성입니다(<inner-button exportparts="label: button-label">). 슬롯으로 넘겨받은 Light DOM 자식은 원래 바깥 문서의 스타일을 그대로 받으므로 exportparts와 관계가 없습니다. 허용된 part 목록을 컴포넌트 문서에 적어 두면 사용하는 쪽이 예측 가능하게 커스터마이즈할 수 있습니다.

:host와 상태 표현

:host는 컴포넌트 루트 자체에 스타일을 걸 때 씁니다. disabled나 variant 같은 상태를 reflect: true로 attribute에 반영해 두면 :host([disabled])처럼 셀렉터로 일관되게 스타일할 수 있습니다.

Light DOM을 쓰는 경우

기존 전역 CSS(예: 유틸리티 클래스 프레임워크)를 그대로 적용해야 하거나 CMS 제약이 있다면 createRenderRoot() { return this; }로 shadow root 없이 렌더링할 수 있습니다. 이 경우 static styles가 적용되지 않고 <slot>도 동작하지 않으며, 페이지 CSS가 컴포넌트 내부에 그대로 영향을 줍니다. 캡슐화를 포기하는 선택이므로 디자인 시스템 차원에서 어떤 컴포넌트가 이 방식을 쓰는지 명시해야 합니다.


프레임워크 통합 (React, Vue, Angular)

웹 컴포넌트는 프레임워크의 컴포넌트 트리에 낯선 태그로 들어갑니다. 통합할 때 따져야 할 것은 값을 attribute로 넘기는지 프로퍼티로 넘기는지, 커스텀 이벤트를 어떻게 구독하는지, 타입 정의를 어떻게 제공하는지입니다.

React

React 18 이하는 커스텀 엘리먼트에 넘긴 props를 모두 문자열 attribute로 설정하고, onSelected 같은 커스텀 이벤트 핸들러도 연결하지 않습니다. 그래서 객체를 넘기거나 커스텀 이벤트를 받으려면 ref로 DOM에 직접 접근하는 래퍼가 필요합니다. React 19부터는 엘리먼트에 같은 이름의 프로퍼티가 있으면 프로퍼티로 설정하고 on 접두사 props를 커스텀 이벤트 리스너로 연결하므로 래퍼 필요성이 크게 줄었습니다. React 18을 써야 한다면 Lit 팀이 제공하는 @lit/react의 createComponent()가 아래와 같은 래퍼를 자동으로 만들어 줍니다.

// React 18용 수동 래퍼 예시
import { useEffect, useRef } from 'react';

type Props = {
  variant?: string;
  onSelected?: (detail: unknown) => void;
};

export function FancyCardReact(props: Props) {
  const ref = useRef<HTMLElement & { variant?: string }>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const handler = (e: Event) =>
      props.onSelected?.((e as CustomEvent).detail);
    el.addEventListener('selected', handler);
    return () => el.removeEventListener('selected', handler);
  }, [props.onSelected]);

  useEffect(() => {
    if (ref.current) ref.current.variant = props.variant ?? 'neutral';
  }, [props.variant]);

  return <fancy-card ref={ref} />;
}

TSX에서 <fancy-card>를 쓰려면 JSX.IntrinsicElements에 해당 태그 타입을 선언해야 컴파일 오류가 나지 않습니다. 또 onSelected를 인라인 함수로 넘기면 렌더링마다 리스너를 다시 등록하므로, 호출하는 쪽에서 useCallback으로 고정하는 편이 좋습니다.

Vue

Vue는 템플릿 컴파일러가 모르는 태그를 Vue 컴포넌트로 해석하려다 경고를 냅니다. compilerOptions.isCustomElement: (tag) => tag.includes('-')처럼 커스텀 엘리먼트를 알려 주어야 합니다(Vite라면 @vitejs/plugin-vue의 template.compilerOptions). Vue는 엘리먼트에 해당 프로퍼티가 있으면 프로퍼티로, 없으면 attribute로 값을 넘기고, .prop 수식어로 프로퍼티 바인딩을 강제할 수 있습니다. v-model은 기본적으로 value 프로퍼티와 input 이벤트를 기대하므로, 컴포넌트가 다른 이벤트를 쓴다면 프로퍼티 바인딩과 @change 같은 이벤트 리스너를 직접 조합합니다.

Angular

Angular는 CUSTOM_ELEMENTS_SCHEMA를 NgModule이나 standalone 컴포넌트의 schemas에 넣어야 모르는 태그를 허용합니다. Angular의 [prop]="value" 바인딩은 프로퍼티로 설정되므로 객체 전달이 자연스럽고, (selected)="onSelected($event)"로 커스텀 이벤트도 받을 수 있습니다. Angular 폼(ngModel, Reactive Forms)과 연결하려면 ControlValueAccessor를 구현한 디렉티브로 브리지해야 합니다.

공통 권장 사항

이벤트는 CustomEvent에 명확한 detail을 실어 보내고, shadow 경계 밖까지 전달해야 한다면 bubbles: true, composed: true를 함께 지정합니다. composed가 없으면 이벤트는 shadow root에서 멈춥니다. 공개 프로퍼티 이름은 title, hidden처럼 HTMLElement에 이미 있는 이름과 겹치지 않게 정합니다. SSR이 필요한 앱이라면 해당 프레임워크의 하이드레이션과 Lit SSR(Declarative Shadow DOM 기반)이 함께 동작하는지 초기에 검증해야 합니다.


디자인 시스템 구축

토큰 우선

색·간격·타이포·그림자는 CSS 변수로 먼저 정의하고, Lit 컴포넌트는 그 변수를 읽도록 합니다. 커스텀 프로퍼티는 shadow 경계를 넘어 상속되므로, React·Vue 앱의 루트에서 변수 값만 바꿔도 모든 컴포넌트가 함께 테마를 바꿉니다.

/* tokens.css (예시) */
:root {
  --space-1: 4px;
  --space-2: 8px;
  --radius-md: 10px;
  --color-surface: #ffffff;
  --color-text: #111111;
}

레이어 나누기

버튼·입력·배지 같은 프리미티브는 작고 재사용 빈도가 높아 웹 컴포넌트로 만들기 가장 좋습니다. 폼 필드 그룹, 툴바, 카드 레이아웃 같은 패턴 컴포넌트는 프리미티브를 조합해 만듭니다. 페이지 템플릿은 보통 앱 셸이 담당하므로 Lit가 여기까지 맡지 않는 경우가 많습니다. 각 컴포넌트의 공개 API(properties, events, slots, parts)를 표로 문서화해 두면 사용하는 팀이 프레임워크 래퍼를 만들기 쉽습니다.

문서화와 시각적 회귀

Storybook의 Web Components 지원이나 Custom Elements Manifest(custom-elements.json)를 생성하는 analyzer를 쓰면 API 문서를 코드에서 뽑아낼 수 있습니다. 템플릿 출력 문자열을 비교하는 스냅샷 테스트는 사소한 마크업 변경에도 깨지기 쉬워서, 스토리 기반 시각적 회귀 테스트와 함께 쓰는 편이 유지하기 쉽습니다.

버전 정책과 변경 관리

웹 컴포넌트에서는 attribute 이름, 이벤트 이름, part 이름 변경이 모두 breaking change입니다. 이 값들은 HTML 문자열이나 CSS에 박혀 있어서 타입 검사로 잡히지 않습니다. 시맨틱 버전을 엄격히 지키고, 이전 이름을 한동안 함께 지원하는 deprecation 기간을 문서에 명시해 두면 마이그레이션이 수월합니다.

접근성과 국제화

키보드 포커스, role, aria-*는 프리미티브 단계에서 검증해야 합니다. shadow 경계 때문에 aria-labelledby나 <label for>가 바깥 문서의 id를 가리킬 수 없다는 제약이 있고, 폼 연동이 필요한 컴포넌트는 ElementInternals(static formAssociated = true)로 폼 값과 유효성을 노출해야 합니다. 사용자에게 보이는 문자열은 가능하면 슬롯으로 받아 앱 쪽에서 번역된 텍스트를 넣도록 합니다.


자주 겪는 문제

전역 CSS가 컴포넌트 안에 전혀 적용되지 않는 것은 shadow 경계 때문이며 정상 동작입니다. 변수나 ::part로 열어 둔 경로를 쓰거나 컴포넌트에 해당 경로를 추가해야 합니다.

attribute를 바꿨는데 반응이 없다면 attribute 이름(기본값은 프로퍼티 이름의 소문자형이므로 maxItems는 maxitems), type과 converter, 그리고 값이 객체라서 프로퍼티 바인딩이 필요한 경우인지를 확인합니다.

React 래퍼에서 이벤트가 두 번 들어온다면 개발 모드 Strict Mode의 이중 마운트 과정에서 cleanup 없이 리스너를 등록했는지 확인합니다.

리스트가 엉뚱하게 갱신되거나 입력 포커스가 다른 항목으로 옮겨 간다면 배열을 제자리에서 변경했는지, 키 없는 map 대신 repeat가 필요한 상황인지 확인합니다.


같이 보면 좋은 글