Lit 완벽 가이드 — 네이티브 웹 컴포넌트 개발
이 글의 핵심
Lit는 표준 웹 컴포넌트(Custom Elements, Shadow DOM) 위에 반응형 속성·템플릿·효율적 업데이트를 얹은 경량 라이브러리입니다. 이 글에서는 핵심 개념, @property·@state, html 템플릿과 바인딩, 업데이트 라이프사이클, ::part·테마, React·Vue·Angular 래핑, 토큰·문서화 중심 디자인 시스템까지 실무 관점으로 설명합니다.
이 글의 핵심
Lit는 Google이 유지하는 경량 웹 컴포넌트 라이브러리로, Custom Elements와 Shadow DOM 같은 브라우저 표준 위에 선언적 템플릿(html), 반응형 속성(reactive properties), 효율적인 DOM 업데이트를 제공합니다. React·Vue·Angular가 제공하는 “프레임워크 안에서의 컴포넌트 모델”과 달리, Lit는 프레임워크에 종속되지 않는 UI 조각을 만들기에 적합합니다. 본문은 Lit 3 계열 API를 기준으로 하되, 개념은 이전 메이저와도 크게 충돌하지 않습니다.
이 글을 끝까지 읽으면 다음을 이해할 수 있습니다.
- 웹 컴포넌트 스택(특히 Custom Elements·Shadow DOM)과 Lit가 해결하는 문제
@property와@state, 변환기(reflect, attribute)의 역할html템플릿 리터럴과 바인딩, 조건부·반복 렌더링 패턴- 업데이트 사이클(
requestUpdate,updated,hasUpdated등)과 성능 관점 - Shadow DOM에서의 스타일 캡슐화와
::part, CSS 변수 기반 테마 - React·Vue·Angular에서 커스텀 엘리먼트를 쓰는 실무 패턴
- 토큰·문서·버전 정책을 포함한 디자인 시스템 구축의 현실적인 접근
1. 전제 지식과 Lit의 위치
1.1 왜 웹 컴포넌트인가
오래된 웹 페이지는 HTML/CSS/JS가 한 파일에 뒤섞이기 쉽고, 재사용 가능한 UI 경계가 약했습니다. 웹 컴포넌트는 브라우저 수준에서 다음을 표준화합니다.
| 표준 | 역할 |
|---|---|
| Custom Elements | 새 HTML 태그를 등록하고 생명주기 훅을 갖춤 |
| Shadow DOM | DOM·스타일을 캡슐화한 트리를 붙임 |
| HTML templates | 재사용 가능한 마크업 조각(<template>) |
Lit는 이 위에 작은 런타임을 올려, 매번 innerHTML을 다시 쓰거나 수동으로 DOM을 비교하는 부담을 줄입니다.
1.2 Lit가 하지 않는 것
Lit는 라우터, 글로벌 상태 관리, 서버 렌더링 전략을 강제하지 않습니다. 대규모 앱에서는 보통 앱 셸(React Router, Vue Router, Angular 등)이 네비게이션과 데이터 레이어를 담당하고, 디자인 시스템·공용 위젯은 Lit 웹 컴포넌트로 제공하는 이중 구조가 자주 쓰입니다.
2. Lit의 핵심 개념
2.1 LitElement와 커스텀 엘리먼트
Lit의 기본 클래스는 LitElement입니다. 클래스에 @customElement('my-widget')를 붙이면 <my-widget></my-widget>처럼 선언적으로 사용할 수 있습니다.
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 조작 대신 상태와 템플릿에 집중합니다.
언제 쓰는가: 임베드 위젯, CDN으로 배포되는 공용 UI, 마이크로 프론트엔드의 기술 중립 레이어, 디자인 시스템의 프리미티브에 적합합니다.
주의점: Custom Elements 이름은 하이픈을 포함한 소문자 태그명 규칙을 따라야 하며, 전역 등록 순서와 중복 정의를 팀 규약으로 관리해야 합니다.
2.2 데코레이터 없이 쓰는 방법
빌드 설정에 따라 데코레이터를 쓰지 않을 수 있습니다. 이 경우 static properties와 static styles로 동일한 정보를 선언합니다.
import { LitElement, html, css } 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);
팀에서는 한 스타일로 통일(데코레이터 vs static)하는 것이 유지보수에 유리합니다.
3. Reactive Properties와 State
3.1 @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 속성 문자열과 JavaScript 프로퍼티 객체가 서로 다른 레이어입니다. reflect: true는 JS에서 값이 바뀔 때 attribute에도 반영되어, DevTools·CSS 속성 셀렉터·서버 렌더 마크업과의 일관성을 높입니다.
실무 팁: Boolean 속성은 HTML에서 속성 존재 여부로 해석되는 경우가 많습니다. Lit의 converter로 팀 규칙을 명시하면 혼선이 줄어듭니다.
3.2 @state — 내부 전용
@state는 외부에서 건드리지 말아야 할 내부 상태에 사용합니다. 반응형으로 동작하지만 공개 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>
`;
}
}
함정: 내부 객체나 배열을 제자리에서 변형(mutate)하면 변경 감지가 누락될 수 있습니다. 불변 업데이트(새 참조 할당)나 Lit의 패턴 가이드를 따르는 것이 안전합니다.
3.3 requestUpdate와 명시적 갱신
대부분은 속성·상태 변경만으로 충분하지만, 비동기 작업 후 강제로 한 번 더 그리기가 필요하면 requestUpdate()를 호출합니다. 라이프사이클 절(아래 5장)과 함께 읽으면 흐름이 명확해집니다.
4. 템플릿과 바인딩
4.1 html과 표현식
Lit의 html은 태그된 템플릿 리터럴입니다. ${} 안에는 문자열, 숫자, 다른 템플릿, DOM 노드 등을 넣을 수 있습니다.
render() {
const items = ['alpha', 'bravo'];
return html`
<ul>
${items.map((label) => html`<li>${label}</li>`)}
</ul>
`;
}
왜 중요한가: map 안에서 다시 html을 반환하는 패턴이 반복 리스트의 기본형입니다. 대량 리스트에서는 키가 안정적인지, 불필요한 재생성이 없는지 성능을 점검합니다.
4.2 속성·프로퍼티·불리언·이벤트
- 프로퍼티 바인딩:
.prop=${value}(객체·복합 값에 적합) - 속성 바인딩:
attr=${value}또는attr="literal" - 불리언 속성:
?disabled=${isOff} - 이벤트:
@click=${handler}(Lit의 템플릿 문법)
render() {
return html`
<button
type="button"
?disabled=${this.loading}
@click=${this._onPrimary}
>
${this.loading ? '처리 중…' : '실행'}
</button>
`;
}
실무 시나리오: 폼 컨트롤을 만들 때 어떤 값을 attribute로 노출할지(서버 HTML과의 호환)와 어떤 값을 프로퍼티로만 둘지(객체 상태)를 나누는 설계가 필요합니다.
4.3 조건부 렌더링
삼항 연산자, guard가 필요한지, 혹은 별도 서브 컴포넌트로 분리할지는 가독성과 리렌더 비용의 균형 문제입니다. 복잡한 분기는 작은 컴포넌트로 쪼개는 편이 테스트와 재사용에 유리합니다.
5. 라이프사이클과 업데이트
5.1 업데이트가 도는 순서(개념)
Lit는 대략 다음 순서로 생각할 수 있습니다. (세부 이름은 버전 문서와 함께 확인하십시오.)
- 속성·상태 변경 감지
requestUpdate→ 업데이트 스케줄링render()호출로 템플릿 결과 확정- DOM에 패치
updated등에서 렌더 이후 부수 효과(포커스, 측정, 외부 라이브러리 동기화)
5.2 자주 쓰는 훅
connectedCallback/disconnectedCallback: DOM 연결·해제 시 리스너·타이머·관찰자(observer) 정리에 사용합니다.firstUpdated: 최초 렌더 직후 한 번만 필요한 초기화(레이아웃 측정 등).updated(changedProperties): 이전 값과 비교해 특정 프로퍼티 변화에만 반응합니다.
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) {
super.updated(changed);
if (changed.has('width')) {
// 폭이 바뀔 때만 외부 차트 라이브러리에 알림 등
this._syncChartSize();
}
}
_syncChartSize() {
// 예시: 외부 라이브러리와의 동기화
}
render() {
return html`<div style="width:${this.width}px">content</div>`;
}
}
주의: updated에서 다시 상태를 바꾸면 추가 업데이트가 예약됩니다. 무한 루프를 피하려면 조건을 엄격히 두거나, 레이아웃 읽기/쓰기를 분리하는 패턴을 씁니다.
5.3 성능 관점
- 불필요한
@property노출 줄이기: 외부에 필요 없는 값은@state로 숨깁니다. - 무거운 작업 분리: 렌더마다 큰 배열을 만들거나 정렬하지 말고, 입력이 바뀔 때만 메모이제이션합니다.
- 리스트 안정성: 동적 리스트에서 식별자 기반 키 전략을 세웁니다.
6. 스타일 캡슐화
6.1 static styles와 Shadow DOM
Lit는 기본적으로 Shadow DOM을 사용하며, static styles에 css 태그 리터럴을 모읍니다.
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 커스텀 프로퍼티(변수)와 ::part로 풀어냅니다.
6.2 ::part와 exportparts
part 속성을 붙인 요소는 호스트 페이지에서 fancy-card::part(body)처럼 스타일할 수 있습니다. 노출 범위를 설계하지 않으면 캡슐화가 깨지므로, 디자인 시스템에서는 토큰 + 소수의 part 이름으로 제한하는 편이 안전합니다.
슬롯 내부까지 part를 노출해야 하면 exportparts 속성으로 전달 규칙을 명시합니다. 팀 문서에 허용된 part 목록을 적어 두면 소비자가 예측 가능하게 커스터마이즈할 수 있습니다.
6.3 :host와 상태 위임
:host는 컴포넌트 루트에 스타일을 걸 때 사용합니다. 포커스 링, disabled 상태 등은 호스트에 속성을 반영해(reflect) 일관되게 스타일합니다.
6.4 Light DOM을 쓰는 경우
일부 시나리오(기존 전역 CSS와의 강한 연동, 특정 CMS 제약)에서는 Shadow를 끄거나 하이브리드 전략을 쓰기도 합니다. 이는 캡슐화 이점과 트레이드오프가 있으므로, 디자인 시스템 차원에서 지원 범위를 명시해야 합니다.
7. 프레임워크 통합 (React, Vue, Angular)
웹 컴포넌트는 프레임워크의 컴포넌트 트리에 “이질적인 태그”로 들어갑니다. 통합 시 핵심은 속성은 문자열인가 객체인가, 이벤트는 어떻게 구독하는가, 타입 정의를 어떻게 할 것인가입니다.
7.1 React
React 18+에서는 커스텀 엘리먼트 사용이 비교적 수월해졌지만, 프로퍼티 vs attribute를 여전히 구분해야 합니다. 팀에서는 래퍼 컴포넌트를 두고 useRef로 DOM에 접근하거나, 이벤트 이름 매핑을 한 곳에 모읍니다.
일반적인 패턴은 다음과 같습니다.
- 문자열·숫자 위주의 API는 attribute 친화적으로 설계
- 객체·함수가 필요하면 프로퍼티로만 세팅하는 헬퍼 제공
- 커스텀 이벤트는
addEventListener로 구독하는 얇은 래퍼 작성
// 개념 예시: 얇은 래퍼 (프로젝트별 세부는 팀 표준에 맞게 조정)
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 as EventListener);
return () => el.removeEventListener('selected', handler as EventListener);
}, [props.onSelected]);
useEffect(() => {
if (ref.current) ref.current.variant = props.variant ?? 'neutral';
}, [props.variant]);
return <fancy-card ref={ref} />;
}
실무 함정: React 개발자는 className에 익숙하지만, 커스텀 엘리먼트는 class가 자연스러울 수 있습니다. 래퍼에서 일관 규칙을 정합니다.
7.2 Vue
Vue는 커스텀 엘리먼트를 컴파일 타임에 인지하도록 compilerOptions.isCustomElement 등을 설정하는 경우가 많습니다. v-model을 직접 쓰기 어렵다면 속성 + 이벤트로 Vue식 패턴을 흉내 낸 래퍼를 둡니다.
7.3 Angular
Angular는 CUSTOM_ELEMENTS_SCHEMA를 모듈에 넣어 알 수 없는 태그를 허용합니다. 폼 컨트롤 값 접근은 ControlValueAccessor로 브리지하는 경우가 많습니다. 디자인 시스템 팀은 Angular용 얇은 디렉티브/컴포넌트 래퍼를 제공해 DX를 맞추기도 합니다.
7.4 공통 권장 사항
- 이벤트는
CustomEvent로 명확한detail을 실어 보냅니다. - 공개 속성 이름은 프레임워크 예약어와 충돌하지 않게 합니다.
- SSR이 필요하면 해당 스택의 하이드레이션 전략과 충돌 여부를 초기에 검증합니다.
8. 실전 디자인 시스템 구축
8.1 토큰 우선
색·간격·타이포·레이어(그림자)는 CSS 변수로 먼저 정의하고, Lit 컴포넌트는 그 변수를 읽도록 합니다. 그래야 React·Vue 앱의 루트에서 한 번 테마를 바꿔 전체가 따라오게 만들 수 있습니다.
/* tokens.css (예시) */
:root {
--space-1: 4px;
--space-2: 8px;
--radius-md: 10px;
--color-surface: #ffffff;
--color-text: #111111;
}
8.2 레이어: 프리미티브 → 패턴 → 템플릿
- 프리미티브: 버튼, 입력, 배지처럼 작고 재사용 빈도가 높은 요소
- 패턴: 폼 필드 그룹, 툴바, 카드 레이아웃
- 템플릿/페이지: 앱 셸이 담당하는 경우가 많고, Lit는 여기까지 오지 않을 수 있음
각 레이어의 공개 API 표(properties, events, slots, parts)를 문서화하면 소비 팀이 프레임워크 래퍼를 만들기 쉽습니다.
8.3 문서화와 시각적 회귀
Storybook(Web Components) 또는 Custom Elements Manifest 기반 문서 생성기를 병행하면 버전 업 시 회귀를 줄일 수 있습니다. 스냅샷 테스트는 템플릿 출력 문자열보다는 스토리 기반 시각적 테스트와 병행하는 편이 실무에서 안정적입니다.
8.4 버전 정책과 변경 관리
웹 컴포넌트는 HTML 속성 이름 변경이 곧 breaking change입니다. 시맨틱 버전을 엄격히 하고, deprecation 주기를 문서에 박아 두는 것이 좋습니다. 내부 디자인 시스템이라도 codemod나 호환 래퍼 속성을 준비하면 마이그레이션이 수월합니다.
8.5 접근성(a11y)과 국제화(i18n)
- 키보드 포커스,
role,aria-*는 프리미티브 단계에서 검증합니다. - 문자열은 가능하면 슬롯으로 넘기거나
i18n속성을 두어 앱 측에서 주입합니다.
9. 트러블슈팅 체크리스트
- 스타일이 전혀 안 먹는다: Shadow 경계를 확인하고, 전역 선택자 대신 변수/
::part경로를 사용합니다. - 속성을 바꿨는데 반응이 없다: attribute ↔ property 반영 규칙과
converter를 점검합니다. - 이벤트가 두 번 온다: 개발 모드 Strict Mode 이중 마운트, 또는 리스너 중복 등록 여부를 확인합니다.
- 리스트가 이상하게 갱신된다: 불변 업데이트와 키 안정성, 대량 데이터 시 렌더 비용을 확인합니다.
10. 정리
Lit는 표준 웹 컴포넌트를 현대적인 개발 경험으로 끌어올리는 작고 예측 가능한 레이어입니다. Reactive properties와 state로 API를 명확히 나누고, html 템플릿으로 선언적 UI를 유지하며, 라이프사이클에서 렌더 전후 책임을 분리하고, Shadow DOM 스타일은 변수와 ::part로 확장 가능하게 설계합니다. 프레임워크와 함께 쓸 때는 속성·이벤트 계약을 문서화하고, 디자인 시스템 차원에서는 토큰·레이어링·버전 정책을 함께 가져가야 프로덕션에서 안전하게 스케일합니다.
배포 전에는 git add·git commit·git push 후 npm run deploy를 실행하는 것이 이 저장소의 워크플로입니다.