Stencil로 웹 컴포넌트 만들기: TSX와 데코레이터, 반응성, 스타일·테마, 테스트, 프레임워크 래퍼
이 글의 핵심
Stencil은 런타임 프레임워크가 아니라 컴파일러라서, 개발할 때는 React와 비슷한 컴포넌트 모델을 쓰고 배포할 때는 어느 프레임워크에도 묶이지 않는 웹 컴포넌트를 남깁니다. 여러 앱에서 같은 컴포넌트를 재사용할 때 이득이 크다는 판단 기준, @Prop과 @State의 경계, Shadow DOM 캡슐화의 한계를 함께 짚습니다.
이 글의 핵심
Stencil는 Ionic 팀이 만든 웹 컴포넌트(Web Components) 컴파일러입니다. 브라우저 표준인 Custom Elements, Shadow DOM, HTML Templates 위에 TypeScript·TSX, 반응성, 테스트·번들링을 한 번에 얹어 줍니다. 프레임워크 없이 컴포넌트만 재사용하고 싶거나, 디자인 시스템을 React·Angular·Vue로 된 여러 제품에 동일하게 올리고 싶을 때 잘 맞습니다.
Stencil를 두는 올바른 자리
“컴파일러”라는 말의 의미
Stencil는 소스(*.tsx)를 읽어 표준 Custom Element 클래스와 최적화된 자바스크립트로 변환합니다. 개발자에게는 React와 비슷한 컴포넌트 모델을 주고, 배포 시점에는 프레임워크에 묶이지 않는 위젯을 남기는 구조입니다.
컴파일 단계가 필요한 이유는 간단합니다. 순수 HTMLElement만으로도 컴포넌트를 만들 수 있지만, 템플릿·상태·속성 동기화·이벤트·접근성을 매번 수작업으로 작성하면 생산성과 일관성이 떨어집니다. Stencil는 그 보일러플레이트를 줄이고, 속성과 내부 상태가 어긋나는 식의 실수를 구조적으로 막아 줍니다.
런타임과 번들 크기
Stencil 런타임은 가볍게 유지되도록 설계되어 있습니다. 다만 Shadow DOM·반응성·슬롯 등을 쓰면 당연히 Vanilla JS 몇 줄보다는 큽니다. 한 번 빌드한 컴포넌트를 여러 앱에서 재사용할 때 이득이 큰지로 도입 여부를 판단하면 됩니다.
Ionic·Stencil 관계
Ionic Framework의 UI 컴포넌트가 Stencil로 구현되어 있습니다. 그렇다고 Stencil이 모바일 하이브리드 앱 전용은 아니며, 임의의 웹 프로젝트·디자인 시스템에 쓸 수 있습니다.
핵심 개념
Custom Element와 태그 이름
Stencil 컴포넌트는 @Component의 tag로 커스텀 태그명을 갖습니다. 이름은 my-ui-button처럼 하이픈을 포함한 소문자로, Custom Elements 명명 규칙을 따라야 합니다.
@Component({
tag: 'my-ui-button',
styleUrl: 'my-ui-button.css',
shadow: true,
})
export class MyUiButton {
// ...
}
태그 문자열은 문서 전역의 customElements 레지스트리에 등록되므로, 같은 태그를 두 번 define하면 예외가 발생합니다. 마이크로 프론트엔드처럼 여러 번들이 한 문서에 올라오는 구조에서는 같은 라이브러리의 서로 다른 버전이 함께 로드되지 않도록 주의해야 합니다.
Shadow DOM: 캡슐화와 한계
shadow: true이면 스타일과 DOM 트리가 캡슐화됩니다. 서드파티 위젯이나 디자인 토큰이 고정된 UI에는 유리합니다. 반면 앱의 Tailwind 유틸리티 같은 전역 CSS와 섞기 어렵고, 폼·레이블 연결처럼 Light DOM이 더 편한 경우도 있습니다. 이럴 때는 CSS 변수로 토큰을 주입하거나, scoped: true(Shadow DOM 없이 클래스 접두사로 스타일을 격리)나 shadow를 끈 Light DOM 컴포넌트를 검토합니다.
슬롯(Slot)과 합성
웹 컴포넌트의 합성(composition)은 주로 <slot>으로 이루어집니다. Stencil TSX에서도 name 속성으로 이름 있는 슬롯을 나눌 수 있습니다. 라이브러리를 설계할 때 어디까지를 내부 구현으로 숨기고 어디를 슬롯으로 열지가 API 품질을 가릅니다. 참고로 슬롯은 Shadow DOM의 기능이라, shadow를 끈 컴포넌트에서는 Stencil이 슬롯 동작을 흉내 내는 폴리필 로직을 사용하며 일부 동적 시나리오에서 동작이 다를 수 있습니다.
비동기 로딩과 defineCustomElements
dist 타깃으로 배포한 패키지는 보통 loader 엔트리의 defineCustomElements()를 호출해 태그를 등록합니다. 이 방식은 태그를 먼저 등록해 두고, 실제로 문서에 나타난 컴포넌트의 코드만 지연 로딩합니다. 앱 부팅 시점에 한 번만 호출하면 됩니다.
프로젝트 생성과 디렉터리 구조
공식 스타터로 컴포넌트 라이브러리 뼈대를 만들 수 있습니다.
npm init stencil@latest component my-lib
cd my-lib
npm install
npm start
일반적인 라이브러리 레이아웃은 다음과 비슷합니다. 세부 경로는 버전에 따라 조금 다르지만, src/components에 컴포넌트가 있고 stencil.config.ts에 빌드 설정이 있다는 점은 같습니다.
my-lib/
├── src/
│ ├── components/
│ │ └── my-ui-button/
│ │ ├── my-ui-button.tsx
│ │ └── my-ui-button.css
│ └── index.html # 개발 시 프리뷰
├── stencil.config.ts
├── tsconfig.json
└── package.json
npm start는 로컬 개발 서버를 띄워 핫 리로드로 TSX를 확인하게 해 줍니다. 라이브러리 소비자 입장에서는 dist/ 산출물과 loader 엔트리가 실제 계약이므로, 배포 전에 npm run build 결과물을 직접 열어 보는 것이 좋습니다.
TSX와 데코레이터
TSX의 역할
Stencil는 JSX와 유사한 TSX로 마크업을 작성합니다. HTML 문자열을 조립하는 것이 아니라, 컴파일러가 가상 DOM 렌더링 루틴으로 변환할 수 있는 형태로 쓴다는 뜻입니다. 조건부 렌더링과 리스트 렌더링 패턴은 React와 거의 같습니다.
@Component
클래스에 붙이는 최상위 메타데이터입니다. tag, styleUrl / styleUrls, shadow, scoped 등을 설정합니다. 스타일 파일을 분리해 두면 테마별 CSS를 바꾸기 쉽습니다.
@Prop: 외부에서 주입되는 입력
@Prop()은 호스트가 HTML 속성 또는 DOM 프로퍼티로 넘기는 값입니다. reflect: true를 켜면 프로퍼티 값이 HTML 속성으로 다시 반영되어 CSS 속성 선택자나 외부 스크립트와 연동하기 쉽습니다. Stencil에서 Prop은 기본적으로 컴포넌트 내부에서 바꾸지 않는 값으로 취급되며, 내부에서 값을 바꿔야 한다면 @Prop({ mutable: true })로 명시해야 합니다.
@Prop({ reflect: true }) variant: 'primary' | 'secondary' = 'primary';
@Prop() label: string;
문자열로만 오는 HTML 속성과, 객체도 넘길 수 있는 DOM 프로퍼티의 차이를 이해해야 합니다. 복잡한 객체를 JSON 문자열 속성으로 받아 파싱하기보다는 프로퍼티로만 설정하도록 API를 단순화하는 편이 유지보수에 유리합니다.
@State: 내부 반응형 상태
@State()는 컴포넌트 내부 상태입니다. 값이 바뀌면 Stencil가 재렌더를 스케줄합니다. 변경 감지는 참조 비교로 이루어지므로, 배열이나 객체를 push나 필드 대입으로 직접 고치면 재렌더가 일어나지 않습니다. this.items = [...this.items, item]처럼 새 참조를 대입해야 합니다.
@State() private open = false;
private toggle = () => {
this.open = !this.open;
};
@Event와 @Listen
@Event는 CustomEvent를 발행하기 위한 선언으로, EventEmitter를 통해 자식에서 부모로 메시지를 보냅니다. @Listen은 호스트 요소나 window/document의 이벤트를 메서드에 바인딩할 때 씁니다. 문서 클릭으로 드롭다운을 닫는 패턴처럼 전역 이벤트를 다룰 때 유용합니다.
@Event({ eventName: 'myChange' }) changeEvent: EventEmitter<string>;
emitValue(value: string) {
this.changeEvent.emit(value);
}
@Watch, @Method, @Element
@Watch('propName')은 지정한 @Prop 또는 @State가 바뀔 때 메서드를 실행하며, 파생 데이터 동기화나 외부 라이브러리와의 연결에 씁니다. @Method()는 클래스 메서드를 호스트 요소의 공개 메서드로 노출해 await el.open()처럼 호출할 수 있게 합니다. 노출된 메서드는 반드시 async이거나 Promise를 반환해야 하고, 남용하면 캡슐화가 깨지므로 꼭 필요한 제어 API만 노출합니다. @Element()는 호스트 요소 참조를 주입하며, 포커스 이동이나 크기 측정처럼 실제 DOM이 필요할 때 사용합니다.
render()와 TSX 규칙
클래스에 render() 메서드를 두고 그 안에서 TSX를 반환합니다. Stencil는 @State·@Prop이 바뀔 때 이 함수를 다시 호출합니다. 부수 효과는 render() 안에 두지 말고 라이프사이클 메서드, 이벤트 핸들러, @Watch로 옮기는 것이 안전합니다.
import { Component, Event, EventEmitter, h, Prop, State } from '@stencil/core';
@Component({
tag: 'my-ui-button',
styleUrl: 'my-ui-button.css',
shadow: true,
})
export class MyUiButton {
@Prop() label: string;
@State() private pressed = false;
@Event({ eventName: 'myChange' }) changeEvent: EventEmitter<boolean>;
private handleClick = () => {
this.pressed = !this.pressed;
this.changeEvent.emit(this.pressed);
};
render() {
return (
<button type="button" aria-pressed={this.pressed ? 'true' : 'false'} onClick={this.handleClick}>
<slot name="icon" />
{this.label}
</button>
);
}
}
aria-pressed는 토글 버튼의 켜짐/꺼짐 상태를 뜻하므로 클릭으로 상태가 바뀌는 버튼에만 붙입니다. 마우스를 누르고 있는 동안만 값을 바꾸는 용도로 쓰면 스크린 리더 사용자에게 잘못된 상태가 전달됩니다. h는 Stencil의 JSX 팩토리이므로 TSX를 쓰는 파일에서는 import해야 합니다.
반응성과 상태 관리
Stencil의 반응성 모델
Stencil는 컴포넌트 단위로 상태 변경을 감지하고 렌더링합니다. 거대한 단일 스토어보다 웹 컴포넌트가 지향하는 캡슐화와 잘 맞는 모델입니다. 여러 컴포넌트가 공유하는 상태가 필요하면 공식 패키지인 @stencil/store를 쓰거나, 호스트 프레임워크의 상태 관리와 경계를 명확히 나누는 편이 낫습니다.
@Prop vs @State 경계
보수적으로 잘 작동하는 규칙은 부모가 제어하는 설정과 데이터는 @Prop으로, 열림/닫힘이나 탭 인덱스처럼 위젯 내부 UI에만 의미가 있는 일시 상태는 @State로 두는 것입니다. 경계가 애매하면 이 값이 URL이나 폼, 상위 상태와 동기화되어야 하는지를 기준으로 나눕니다. 동기화되어야 한다면 Prop으로 받고 변경은 이벤트로 알리는 쪽이 맞습니다.
@Watch로 파생 값과 동기화
외부 @Prop이 바뀔 때 내부 정규화나 검증을 하려면 @Watch가 적합합니다. 예를 들어 날짜 문자열 Prop을 받아 내부 Date로 파싱하고, 잘못된 입력이면 기본값으로 되돌리는 경우입니다. @Watch는 초기 값에 대해서는 호출되지 않으므로, 첫 로드 때의 처리는 componentWillLoad에서 같은 함수를 한 번 불러 줘야 합니다.
비동기 데이터
비동기 데이터는 라이프사이클 메서드에서 로드해 @State에 넣습니다. 이때 함정이 하나 있습니다. componentWillLoad가 Promise를 반환하면 Stencil는 그 Promise가 끝날 때까지 첫 렌더를 미룹니다. 그래서 아래처럼 await하면 로딩 표시는 한 번도 그려지지 않고, 요청이 느릴수록 컴포넌트가 통째로 비어 보입니다.
// 첫 렌더가 fetch 완료까지 지연됨
async componentWillLoad() {
this.items = await fetchJson('/api/items');
}
로딩 상태를 먼저 보여 주려면 Promise를 반환하지 않고 요청만 시작합니다.
componentWillLoad() {
this.loading = true;
fetchJson('/api/items')
.then((items) => (this.items = items))
.catch((e) => (this.error = e))
.finally(() => (this.loading = false));
}
어느 쪽이 맞는지는 컴포넌트 계약에 달려 있습니다. 데이터 없이는 의미 없는 컴포넌트라면 첫 렌더를 미루는 쪽이 깜빡임이 없고, 목록처럼 골격부터 보여 줄 수 있다면 두 번째 방식이 낫습니다.
라이프사이클 메서드
Stencil는 커스텀 엘리먼트 수명에 맞춘 메서드를 제공합니다. 대표적인 것만 정리하면 다음과 같습니다.
| 메서드 | 용도 |
|---|---|
connectedCallback | DOM에 연결될 때마다 호출됩니다. 요소가 이동하면 여러 번 호출될 수 있습니다. |
disconnectedCallback | DOM에서 제거될 때 호출됩니다. 구독 해제와 타이머 정리를 여기서 합니다. |
componentWillLoad | 첫 렌더 전에 한 번 호출됩니다. Promise를 반환하면 첫 렌더가 그만큼 지연됩니다. |
componentDidLoad | 첫 렌더 후 한 번 호출됩니다. 포커스 설정이나 DOM 측정에 씁니다. |
componentWillRender / componentDidRender | 매 렌더 직전·직후에 호출됩니다. 무거운 로직은 피합니다. |
componentWillUpdate / componentDidUpdate | 첫 렌더를 제외한 재렌더 전후에 호출됩니다. |
connectedCallback에서 시작한 리스너나 MutationObserver를 disconnectedCallback에서 정리하지 않으면, 요소가 붙었다 떨어질 때마다 누적되어 메모리 누수로 이어집니다. React의 useEffect cleanup과 같은 책임을 disconnectedCallback에 두는 습관이 필요합니다.
스타일링과 테마
Shadow DOM에서의 스타일 전략
Shadow 내부 스타일은 전역 리셋의 영향을 받지 않습니다. 대신 디자인 토큰은 보통 CSS Custom Properties로 바깥에서 주입합니다. CSS 변수는 Shadow 경계를 넘어 상속되므로, 컴포넌트 안에서는 var()의 기본값을 두고 호스트 앱이 상위 요소에서 변수를 정의하면 그 값이 쓰입니다.
button {
background: var(--my-button-bg, #2563eb);
color: var(--my-button-color, #fff);
}
:host에서 --my-button-bg를 직접 선언하면 호스트 요소 자신에 값이 지정되어 상위 요소에서 정의한 값이 상속되지 않습니다. 소비자가 my-ui-button { --my-button-bg: ... }처럼 요소에 직접 지정하면 덮어쓸 수 있지만, :root에서 테마를 바꾸는 방식은 동작하지 않으므로 위처럼 var() 기본값을 쓰는 편이 테마 연동에 유리합니다.
::part로 외부 스타일 허용 범위 열기
캡슐화를 유지하면서 특정 부분만 스타일을 허용하려면 내부 요소에 part 속성을 붙이고 바깥에서 ::part() 선택자로 스타일을 지정하게 합니다. 디자인 시스템에서 공식 커스터마이징 지점을 문서화하기 좋은 방법입니다.
다크 모드·테마 스위칭
테마는 클래스 토글보다는 CSS 변수 값을 바꾸는 방식으로 두는 경우가 많습니다. 상위 요소의 클래스를 보고 스타일을 바꾸는 :host-context()는 Chromium 계열에서만 지원되므로 라이브러리에서 의존하기 어렵습니다. 테마를 Prop으로 받아 reflect로 호스트 속성에 반영한 뒤 :host([theme='dark'])로 스타일을 나누거나, 상위에서 CSS 변수를 바꾸는 방식이 브라우저 호환성 면에서 안전합니다.
SCSS·PostCSS
stencil.config.ts의 plugins에 @stencil/sass, @stencil-community/postcss 같은 플러그인을 연결하면 SCSS나 Autoprefixer를 사용할 수 있습니다. 앱 쪽 빌드와 같은 후처리를 맞춰 두면 브라우저별 차이를 줄일 수 있습니다.
테스트
newSpecPage로 단위 테스트
Stencil 테스트는 실제 Custom Element가 DOM에 마운트되는 경로를 지향합니다. @stencil/core/testing의 newSpecPage는 Node 환경의 모의 DOM에서 컴포넌트를 등록·렌더하고, 비동기 렌더 큐까지 비워 준 뒤 결과를 돌려줍니다.
import { newSpecPage } from '@stencil/core/testing';
import { MyUiButton } from '../my-ui-button';
describe('my-ui-button', () => {
it('renders label', async () => {
const page = await newSpecPage({
components: [MyUiButton],
html: `<my-ui-button label="저장"></my-ui-button>`,
});
expect(page.root.shadowRoot.textContent).toContain('저장');
});
});
클릭이나 키보드 입력은 DOM 이벤트를 디스패치한 뒤 await page.waitForChanges()로 재렌더를 기다려 검증합니다. @Method로 노출한 API는 외부 계약이므로 테스트에 반드시 포함하는 것이 좋습니다.
테스트 설정
Stencil는 Jest 기반 테스트 러너를 내장하고 있으며, stencil.config.ts의 testing 블록에 Jest 옵션을 넣을 수 있습니다.
// stencil.config.ts (발췌)
import { Config } from '@stencil/core';
export const config: Config = {
namespace: 'mylib',
outputTargets: [{ type: 'dist' }, { type: 'docs-readme' }],
testing: {
browserHeadless: true,
transformIgnorePatterns: ['/node_modules/(?!lodash-es)/'],
},
};
모노레포에서는 tsconfig의 paths 별칭과 Jest의 moduleNameMapper를 함께 맞춰야 합니다. 둘이 어긋나면 빌드는 되는데 테스트에서만 import 에러가 나는 상황이 반복됩니다. 또 내장 러너가 지원하는 Jest 메이저 버전이 Stencil 버전마다 정해져 있으므로, Jest를 따로 올리기 전에 호환 범위를 확인해야 합니다.
E2E 테스트(Puppeteer)
E2E는 실제 브라우저에서 포커스 이동, 키보드, 레이아웃을 검증할 때 씁니다. Stencil 내장 E2E는 Puppeteer로 헤드리스 Chromium을 띄웁니다. 최근 Stencil 문서는 WebdriverIO나 Playwright 연동도 안내하고 있으므로, 새 프로젝트라면 공식 문서의 권장 방식을 먼저 확인하는 것이 좋습니다.
// src/components/my-ui-button/my-ui-button.e2e.ts
import { newE2EPage } from '@stencil/core/testing';
describe('my-ui-button e2e', () => {
it('클릭 시 myChange 이벤트가 발생한다', async () => {
const page = await newE2EPage();
await page.setContent('<my-ui-button label="확인"></my-ui-button>');
const el = await page.find('my-ui-button');
const spy = await el.spyOnEvent('myChange');
await el.click();
await page.waitForChanges();
expect(spy).toHaveReceivedEventDetail(true);
});
});
E2E는 느리고 환경에 따라 깨지기 쉬우므로, 사용자 흐름에서 반드시 보장해야 할 것만 남기고 나머지는 newSpecPage로 내립니다. 스냅샷 테스트도 마찬가지로, 마크업 전체를 찍으면 사소한 변경마다 갱신이 필요해 노이즈가 되기 쉽습니다. 의미 있는 서브트리만 스냅샷하거나 시각적 회귀 도구와 역할을 나누는 편이 낫습니다.
빌드와 출력 타깃
stencil.config.ts
Stencil 빌드는 stencil.config.ts에서 결정됩니다. 출력 형식, 프레임워크 래퍼 생성, 문서 출력 등을 한곳에서 조정합니다.
import { Config } from '@stencil/core';
export const config: Config = {
namespace: 'mylib',
srcDir: 'src',
outputTargets: [
{ type: 'dist', esmLoaderPath: '../loader' },
{ type: 'dist-custom-elements' },
{ type: 'docs-readme' },
{ type: 'www', serviceWorker: null },
],
};
dist는 npm 패키지로 배포하는 기본 빌드로, loader를 통해 태그를 한 번에 등록하고 컴포넌트 코드는 지연 로딩합니다. dist-custom-elements는 컴포넌트마다 독립적인 모듈을 만들어, 번들러를 쓰는 앱이 필요한 컴포넌트만 import하고 트리 셰이킹할 수 있게 합니다. 프레임워크 래퍼도 보통 이 출력을 기반으로 합니다. www는 문서·데모 사이트용이므로 라이브러리만 배포한다면 생략해도 됩니다.
폴리필과 extras
예전 버전의 Stencil에는 extras.cssVarsShim, extras.shadowDomShim처럼 구형 브라우저(IE11, 구 Edge)를 위한 shim 옵션이 있었지만, Stencil 3에서 해당 브라우저 지원과 함께 제거되었습니다. 현재 extras에 남은 옵션은 슬롯 폴리필 동작 같은 세부 호환 플래그 위주이므로, 오래된 예제의 cssShim·shadowDomShim 설정을 그대로 복사하면 타입 에러가 나거나 무시됩니다.
렌더 스케줄링
taskQueue 옵션은 렌더 업데이트를 어떻게 배치할지 정합니다. 기본값인 'async'는 DOM 읽기/쓰기를 다음 프레임으로 모아 처리하고, 'immediate'는 가능한 즉시 처리하며, 'congestionAsync'는 프레임 예산을 넘기면 작업을 다음 프레임으로 미룹니다. 대부분은 기본값으로 충분하고, 대량 렌더에서 프레임 드롭이 확인될 때 바꿔 보는 정도면 됩니다.
공개 API와 트리 셰이킹
소비자가 실제로 쓰는 컴포넌트만 포함되도록 package.json의 sideEffects와 배럴 파일(index.ts) 설계를 정리합니다. 배럴 파일이 모든 컴포넌트를 재export하면서 등록 코드까지 실행하면, 번들러는 부수 효과가 있다고 보고 사용하지 않는 컴포넌트도 남깁니다.
사전 렌더링(Prerender)
www 출력에 prerender를 켜면 문서·마케팅 페이지처럼 정적 HTML이 유리한 경우 첫 화면 표시를 앞당길 수 있습니다. 동적 대시보드용 위젯 라이브러리에는 우선순위가 낮습니다.
프레임워크 통합
왜 래퍼가 필요한가
Stencil 산출물은 표준이지만, 프레임워크마다 커스텀 엘리먼트를 다루는 방식이 다릅니다. React 18까지는 커스텀 엘리먼트에 객체 프로퍼티를 넘기거나 커스텀 이벤트를 onXxx로 구독할 수 없었고(React 19에서 개선됨), Vue는 v-model을 위한 이벤트 규칙이 따로 있으며, Angular는 템플릿 타입 체크를 위해 컴포넌트 선언이 필요합니다.
Stencil는 @stencil/react-output-target, @stencil/vue-output-target, @stencil/angular-output-target을 outputTargets에 추가해 각 프레임워크용 래퍼를 생성하는 방식을 공식 문서로 안내합니다. 래퍼는 빌드할 때마다 다시 생성되므로, Stencil이나 출력 타깃 패키지를 올린 뒤에는 생성된 타입과 이벤트 매핑이 바뀌지 않았는지 확인해야 합니다.
React
React 래퍼는 속성을 props로, 커스텀 이벤트를 콜백 props로 매핑하고 타입 정의도 함께 생성합니다. Next.js 같은 서버 컴포넌트 환경에서는 커스텀 엘리먼트가 클라이언트에서만 정의되므로, 래퍼를 클라이언트 컴포넌트 안에서 써야 한다는 점을 문서에 명시해 둡니다.
// 소비 앱: 생성된 React 래퍼 사용
import { MyUiButton } from '@acme/my-lib-react';
export function SaveBar() {
return (
<MyUiButton
label="저장"
onMyChange={(e) => console.log(e.detail)}
/>
);
}
콜백 이름은 @Event의 이벤트 이름에서 생성기 규칙으로 만들어지므로, 라이브러리 문서에 이벤트 목록을 함께 제공하는 것이 좋습니다.
Vue·Angular
Vue 래퍼에서 v-model을 지원하려면 어떤 프로퍼티를 값으로, 어떤 이벤트를 갱신으로 볼지 출력 타깃 설정에 지정해야 합니다. 래퍼 없이 Vue에서 직접 커스텀 엘리먼트를 쓸 때는 Vue 템플릿 컴파일러가 모르는 태그를 Vue 컴포넌트로 해석하려다 경고를 내므로, compilerOptions.isCustomElement로 해당 태그 접두사를 커스텀 엘리먼트로 알려 줘야 합니다.
Angular에서는 CUSTOM_ELEMENTS_SCHEMA를 선언하면 래퍼 없이도 쓸 수 있지만, 그 대가로 해당 템플릿에서 알 수 없는 요소·속성에 대한 컴파일 검사가 꺼집니다. 규모가 큰 팀이라면 생성된 래퍼를 써서 템플릿 타입 체크를 유지하는 편이 장기적으로 비용이 적습니다.
버전 관리와 브레이킹 체인지
라이브러리로 배포할 때는 시맨틱 버저닝과 변경 기록, 마이그레이션 안내가 필요합니다. Custom Elements는 등록된 태그 이름이 곧 계약이므로, 태그 이름을 바꾸는 것은 거의 항상 브레이킹 체인지입니다.
운영 시 자주 만나는 이슈
중복 등록과 버전 충돌
한 페이지에 같은 Stencil 라이브러리가 두 번 로드되면 두 번째 customElements.define이 실패하거나, 먼저 로드된 버전의 구현이 쓰여 예상과 다른 동작을 합니다. 번들러 설정과 마이크로 프론트엔드 경계에서 단일 복사본을 보장해야 합니다.
폼과 접근성
Shadow DOM 내부의 input은 바깥 문서의 <label for>가 id로 찾을 수 없어 기본 연결이 끊어집니다. 또 Shadow 내부 input의 값은 바깥 <form> 제출에 포함되지 않습니다. 라벨은 슬롯으로 받아 내부에서 연결하거나 aria-label·aria-labelledby로 접근성 이름을 정리하고, 폼 제출이 필요하면 Stencil 4의 formAssociated: true와 @AttachInternals()로 폼 연동 커스텀 엘리먼트를 만드는 방법을 검토합니다. 폼 컴포넌트는 나중에 고치려면 사용처 전체를 바꿔야 하므로 설계 초기에 결정하는 편이 낫습니다.
SSR과 하이드레이션
호스트 앱이 SSR을 쓰면 커스텀 엘리먼트는 기본적으로 클라이언트에서만 등록되므로, 서버 HTML에는 빈 태그만 들어가고 등록 후에 내용이 채워집니다. 레이아웃이 밀리지 않도록 자리를 잡아 두거나, Stencil의 hydrate 앱 출력(dist-hydrate-script)으로 서버 렌더링을 구성하는 방법을 프레임워크별 통합 문서에 맞춰 검토합니다.