Ver2.5 타입스크립트, 웹 컴포넌트 개발의 슈퍼파워 부스터 🚀

타입스크립트, 웹 컴포넌트 개발의 슈퍼파워 부스터 🚀
바닐라 JS의 자유로움과 타입스크립트의 안정성, 두 마리 토끼를 모두 잡는 힙스터 개발자 되기 프로젝트!
안녕! 우리 같이 웹 컴포넌트 레벨업 좀 해볼까? 😉
요즘 프론트엔드 씬, 정말 정신없지 않아? 매일같이 새로운 프레임워크가 쏟아져 나오고, 어제 배운 게 오늘 구식이 되기도 하잖아. 이런 '프레임워크 전쟁' 속에서 "어떤 기술을 파야 진짜 내 실력이 될까?" 고민해 본 적, 다들 한 번쯤은 있을 거야.
바로 그럴 때, 우리에게 한 줄기 빛처럼 다가오는 게 바로 웹 컴포넌트(Web Components)야. 특정 프레임워크에 종속되지 않고, 웹 표준 기술만으로 재사용 가능한 UI 부품을 만드는 기술이지. 마치 레고 블록처럼 말이야! 🧩 한번 제대로 만들어두면 React, Vue, Angular, 심지어 그냥 순수 자바스크립트 프로젝트 어디서든 갖다 쓸 수 있으니, 이거야말로 진정한 '가성비 갑' 기술 아니겠어?
그런데... 솔직히 바닐라 자바스크립트(Vanilla JS)로만 웹 컴포넌트를 만들다 보면 좀 답답할 때가 있어. 코드가 길어질수록 "이 변수 타입이 뭐였더라?", "이 함수는 어떤 인자를 받았지?" 헷갈리기 시작하고, 예상치 못한 곳에서 에러가 빵! 터지면서 야근의 그림자가 드리우기도 하지. 😱
바로 이 순간, 우리의 구원투수 타입스크립트(TypeScript)가 등판할 시간이야! 타입스크립트는 자바스크립트에 '타입'이라는 든든한 갑옷을 입혀주는 슈퍼세트(Superset) 언어야. 컴파일 단계에서부터 오류를 싹 잡아주니까, 런타임 에러 걱정은 줄고 코드의 안정성과 가독성은 수직 상승하게 돼.
이 글에서는 웹 컴포넌트라는 레고 블록에 타입스크립트라는 초강력 본드를 발라서, 누가 봐도 감탄할 만큼 견고하고 확장성 있는 컴포넌트를 만드는 여정을 함께 떠나보려고 해. 친구한테 설명해주듯 쉽고 재미있게, 하지만 핵심은 절대 놓치지 않고 꼼꼼하게 짚어줄게. 준비됐어? 그럼, 지금 바로 시작해보자!
웹 컴포넌트의 세계에 처음 발을 들인 친구들을 위해 핵심 개념 세 가지만 빠르게 짚고 넘어갈게.
1. Custom Elements (커스텀 엘리먼트): `<my-button>`이나 `<profile-card>`처럼 나만의 HTML 태그를 만들고, 그에 맞는 동작(자바스크립트 클래스)을 정의할 수 있게 해줘.
2. Shadow DOM (섀도 DOM): 컴포넌트의 내부 구조(HTML, CSS, JS)를 외부로부터 완벽하게 캡슐화하는 기술이야. 외부 CSS가 내부 스타일을 망가뜨리거나, 내부 스타일이 바깥으로 새어 나가는 '스타일 오염'을 막아주는 마법의 방어막이지! 🛡️
3. HTML Templates (<template> & <slot>): 렌더링되기 전까지 화면에 보이지 않는 HTML 조각을 정의하는 태그야. 이걸 복제해서 커스텀 엘리먼트의 구조로 사용하면 성능상 이점을 얻을 수 있어. `<slot>`을 사용하면 컴포넌트 사용자가 원하는 콘텐츠를 특정 위치에 끼워 넣을 수도 있구.
🛠️ 개발 환경 세팅: 장비부터 제대로 갖추자고!
전쟁터에 나가려면 총이랑 총알이 있어야 하는 법! 타입스크립트로 웹 컴포넌트를 개발하기 위한 최소한의, 하지만 가장 힙하고 효율적인 개발 환경을 세팅해 보자. 옛날처럼 Webpack으로 복잡하게 설정할 필요 없어. 요즘 대세는 바로 Vite니까!
Vite는 번개처럼 빠른 개발 서버와 빌드 속도를 자랑하는 차세대 프론트엔드 도구야. 설정도 엄청나게 간단해서 우리 같은 '설정 지옥'을 싫어하는 개발자들에게는 축복과도 같지. ✨
1단계: 프로젝트 폴더 만들고 npm 초기화
일단 작업할 폴더를 하나 만들고, 그 안에서 터미널을 열어 아래 명령어를 입력해 줘.
mkdir ts-web-components-project
cd ts-web-components-project
npm init -y
`npm init -y`는 `package.json` 파일을 기본값으로 빠르게 생성해 주는 명령어. 이제 우리 프로젝트의 정보가 담긴 신분증이 생긴 셈이야.
2단계: 타입스크립트와 Vite 설치
이제 프로젝트에 필요한 핵심 패키지들을 설치할 차례. 개발할 때만 필요한 도구들이니까 `--save-dev` 또는 `-D` 옵션을 붙여서 설치하자.
npm install typescript vite -D
이 한 줄이면 타입스크립트 컴파일러와 Vite 개발 서버가 모두 설치돼. 정말 간단하지?
3단계: 타입스크립트 설정 파일 (tsconfig.json) 만들기
타입스크립트 컴파일러에게 "이 프로젝트는 이런 규칙으로 컴파일해 줘!"라고 알려주는 설정 파일, `tsconfig.json`을 만들어야 해. 프로젝트 루트 폴더에 `tsconfig.json` 파일을 만들고 아래 내용을 붙여넣어 봐.
{
"compilerOptions": {
/* 기본 옵션 */
"target": "ESNext", // 최신 ECMAScript 버전으로 컴파일
"module": "ESNext", // 모듈 시스템도 최신 표준 사용
"lib": ["ESNext", "DOM"], // 사용할 라이브러리 (DOM API 포함 필수!)
"moduleResolution": "Node", // 모듈 해석 방식
"strict": true, // 모든 엄격한 타입 체크 활성화 (강력 추천!)
"esModuleInterop": true, // CommonJS 모듈과의 호환성 지원
"skipLibCheck": true, // 라이브러리 파일의 타입 체크는 건너뛰기
"forceConsistentCasingInFileNames": true, // 파일 이름의 대소문자 구분 강제
/* 웹 컴포넌트 개발을 위한 추가 옵션 */
"experimentalDecorators": true, // 데코레이터 사용 (나중에 고급 패턴에서 다룰 거야)
"useDefineForClassFields": false // 클래스 필드 정의 방식 (true면 Lit 등에서 문제 발생 가능)
},
"include": ["src"] // 타입스크립트 컴파일 대상 폴더
}
- `"target": "ESNext"`: 웹 컴포넌트는 대부분 최신 브라우저를 타겟으로 하므로, 굳이 구형 자바스크립트(ES5)로 변환할 필요가 없어. 최신 문법을 그대로 사용해서 코드도 깔끔하게 유지하자!
- `"lib": ["ESNext", "DOM"]`: 이게 진짜 중요해. `"DOM"`이 빠지면 타입스크립트가 `document`, `HTMLElement` 같은 브라우저 내장 객체들을 전혀 알지 못해서 온갖 에러를 뿜어낼 거야.
- `"strict": true`: 타입스크립트를 쓰는 가장 큰 이유! `null`이나 `undefined` 체크를 강제하고, `any` 타입을 암시적으로 사용하는 걸 막아줘. 처음엔 좀 귀찮아도, 나중엔 버그의 90%를 막아주는 든든한 방패가 될 거야.
4단계: 프로젝트 구조 잡고 파일 만들기
이제 실제 코드를 작성할 파일들을 만들어보자. 보통 소스 코드는 `src` 폴더 안에 모아두는 게 국룰이지.
/ts-web-components-project
├── node_modules/
├── src/
│ ├── my-counter.ts // 우리의 첫 웹 컴포넌트 파일
│ └── main.ts // 앱의 진입점 파일
├── index.html // 메인 HTML 파일
├── package.json
└── tsconfig.json
각 파일에 아래와 같이 기본 코드를 채워 넣어 줘.
index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TS + Web Components</title>
</head>
<body>
<h1>타입스크립트로 만든 웹 컴포넌트!</h1>
<my-counter></my-counter>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
`<script>` 태그에 `type="module"`을 꼭 넣어줘야 Vite가 제대로 동작하고, 타입스크립트 파일을 브라우저가 이해할 수 있게 변환해 줘.
src/my-counter.ts
class MyCounter extends HTMLElement {
// # (해시)는 ES2022부터 지원하는 진짜 private 필드!
#count = 0;
#shadow: ShadowRoot;
constructor() {
super(); // HTMLElement의 생성자를 먼저 호출해야 해
this.#shadow = this.attachShadow({ mode: 'open' });
}
// 컴포넌트가 DOM에 추가될 때 호출되는 라이프사이클 콜백
connectedCallback() {
this.render();
this.setupEventListeners();
}
render() {
this.#shadow.innerHTML = `
<style>
button {
font-size: 1.5rem;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
border: 2px solid #6e8efb;
background-color: white;
margin: 5px;
}
span {
font-size: 2rem;
font-weight: bold;
margin: 0 20px;
min-width: 50px;
display: inline-block;
text-align: center;
}
</style>
<button id="decrement">-</button>
<span>${this.#count}</span>
<button id="increment">+</button>
`;
}
setupEventListeners() {
// 타입 단언(as)을 사용해 쿼리한 요소의 정확한 타입을 알려주자
const incrementBtn = this.#shadow.querySelector('#increment') as HTMLButtonElement;
const decrementBtn = this.#shadow.querySelector('#decrement') as HTMLButtonElement;
incrementBtn.addEventListener('click', () => {
this.#count++;
this.render();
});
decrementBtn.addEventListener('click', () => {
this.#count--;
this.render();
});
}
}
// 'my-counter'라는 태그 이름으로 우리가 만든 클래스를 등록
customElements.define('my-counter', MyCounter);
src/main.ts
// my-counter.ts 파일을 임포트해서
// customElements.define 코드가 실행되도록 해야 해.
import './my-counter';
`main.ts`의 역할은 간단해. 우리가 만든 컴포넌트 파일들을 임포트해서 브라우저에 등록시키는 거야.
5단계: 개발 서버 실행! 🚀
마지막으로 `package.json` 파일에 개발 서버를 실행할 스크립트를 추가해주자.
{
...
"scripts": {
"dev": "vite"
},
...
}
이제 터미널에 아래 명령어를 입력하면...
npm run dev
짜잔! ✨ 터미널에 나타난 주소(보통 `http://localhost:5173`)로 접속하면 우리가 만든 카운터 컴포넌트가 멋지게 동작하는 걸 볼 수 있을 거야. 코드를 수정하면 화면이 자동으로 새로고침되는 마법도 경험할 수 있지!
🕵️♂️ 타입스크립트로 웹 컴포넌트 속살 파헤치기
자, 기본 세팅은 끝났어. 이제부터가 진짜 본게임이야. 타입스크립트가 웹 컴포넌트 개발의 어떤 부분들을 얼마나 우아하게 만들어주는지, 그 디테일을 하나하나 파헤쳐 보자.
프로퍼티와 속성(Properties & Attributes) 타입 정의하기
웹 컴포넌트에서는 자바스크립트 프로퍼티(e.g., `myCounter.value = 5`)와 HTML 속성(e.g., `<my-counter value="5">`)을 함께 사용하는 경우가 많아. 이 둘을 동기화하고 타입을 안전하게 관리하는 게 핵심이야.
예를 들어, 우리 카운터 컴포넌트가 시작 값을 속성으로 받을 수 있게 개선해 보자.
// my-counter.ts
class MyCounter extends HTMLElement {
#count = 0;
#shadow: ShadowRoot;
// 1. 관찰할 속성 목록을 정적(static) 게터로 정의
static get observedAttributes(): string[] {
return ['initial-value'];
}
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
// 2. 속성이 변경될 때 호출되는 라이프사이클 콜백
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
console.log(`속성 변경! ${name}: ${oldValue} -> ${newValue}`);
if (name === 'initial-value') {
// HTML 속성은 항상 문자열(string)이야. 숫자로 바꿔줘야 해.
const initialValue = Number(newValue);
// isNaN으로 유효한 숫자인지 체크하는 습관!
if (!isNaN(initialValue)) {
this.#count = initialValue;
this.render(); // 값이 바뀌었으니 다시 렌더링
}
}
}
// ... render, setupEventListeners 메서드는 동일 ...
}
customElements.define('my-counter', MyCounter);
이제 `index.html`에서 이렇게 사용할 수 있어.
<my-counter initial-value="10"></my-counter>
여기서 타입스크립트의 진가가 드러나. `attributeChangedCallback`의 매개변수인 `name`, `oldValue`, `newValue`에 자동으로 타입이 지정되지? 덕분에 우리는 `newValue`가 `string` 또는 `null`이라는 걸 명확히 알고, `Number()` 함수를 사용해 안전하게 숫자로 변환하는 로직을 짤 수 있어. 만약 타입스크립트가 없었다면 `newValue`가 어떤 타입인지 기억에 의존하거나, 런타임에 에러를 만나고 나서야 깨닫게 됐을지도 몰라.
이 둘은 종종 헷갈리는데, 명확히 구분해야 해.
- 속성 (Attribute): HTML 태그에 문자열로 작성하는 값. (`<my-comp value="hello">`)
- 프로퍼티 (Property): 자바스크립트 객체의 키-값. (`myComp.value = 'hello'`)
HTML 속성은 항상 문자열이라는 점이 핵심이야. 그래서 `boolean`이나 `number` 타입의 데이터를 다룰 땐, 프로퍼티와 속성 간의 변환 로직을 잘 짜줘야 해.
프로퍼티와 속성을 더 세련되게 동기화하려면 게터(getter)와 세터(setter)를 활용하는 게 좋아.
// my-counter.ts 내부에 추가
get value(): number {
return this.#count;
}
set value(newValue: number) {
if (typeof newValue !== 'number' || isNaN(newValue)) {
console.error('value는 반드시 숫자여야 합니다.');
return;
}
this.#count = newValue;
// 프로퍼티가 바뀌면 속성도 업데이트 (선택 사항)
this.setAttribute('initial-value', String(newValue));
this.render();
}
이제 외부 자바스크립트에서 `myCounter.value = 20;` 처럼 프로퍼티를 직접 조작할 수 있게 됐어. 세터에 `newValue: number`라고 타입을 명시했기 때문에, 만약 누군가 `myCounter.value = '스무살'` 같은 코드를 작성하면 타입스크립트 컴파일러가 "야, 너 지금 뭐하는 거야! 숫자 넣으라니까!" 하고 바로 에러를 띄워줄 거야. 든든하지? 😎
섀도 DOM 내부 요소 타입 안전하게 다루기
웹 컴포넌트의 꽃, 섀도 DOM! 캡슐화는 정말 좋지만, 내부 요소에 접근할 때 `querySelector`를 쓰면 반환 타입이 `Element | null`이라서 좀 귀찮아.
// 바닐라 JS라면...
const span = this.#shadow.querySelector('span');
// span이 진짜 있는지, button이 맞는지 확신할 수 없음
if (span) {
span.textContent = this.#count;
}
타입스크립트에서는 제네릭(Generic)이나 타입 단언(Type Assertion)을 사용해서 이 문제를 깔끔하게 해결할 수 있어.
// 방법 1: 제네릭 사용 (더 안전하고 추천!)
const span = this.#shadow.querySelector<HTMLSpanElement>('span');
if (span) {
// 이제 span은 HTMLSpanElement 타입. textContent 속성을 바로 쓸 수 있음
span.textContent = String(this.#count);
}
// 방법 2: 타입 단언 사용 (as 키워드)
const incrementBtn = this.#shadow.querySelector('#increment') as HTMLButtonElement;
// incrementBtn이 null일 가능성을 체크하지 않으면 위험할 수 있음!
// "개발자인 내가 보증할게, 이건 무조건 버튼이야!" 라는 의미.
incrementBtn.addEventListener('click', this.#increment);
제네릭 `
이벤트 핸들링: 타입과 함께 춤을! 💃
컴포넌트는 외부와 소통해야 해. 가장 일반적인 방법이 바로 커스텀 이벤트(Custom Event)를 발생시키는 거야. 예를 들어, 카운터 값이 변경될 때마다 `change` 이벤트를 발생시켜서 부모 요소가 그 값을 알 수 있게 해보자.
타입스크립트의 `CustomEvent` 제네릭을 사용하면 이벤트에 담아 보낼 데이터(payload)의 타입을 지정할 수 있어. 이게 진짜 꿀이야!
// my-counter.ts 내부에 추가
#dispatchChange() {
// 이벤트에 담을 데이터의 타입을 { value: number }로 지정
const event = new CustomEvent<{ value: number }>('change', {
bubbles: true, // 이벤트가 상위로 전파되도록 설정
composed: true, // 섀도 DOM 경계를 넘어 전파되도록 설정
detail: {
value: this.#count
}
});
this.dispatchEvent(event);
}
// #increment, #decrement 메서드 수정
#increment = () => {
this.value++; // 세터를 호출해서 렌더링과 속성 업데이트가 일어나게 함
this.#dispatchChange(); // 값이 바뀔 때마다 이벤트 발생
};
#decrement = () => {
this.value--;
this.#dispatchChange();
};
// setupEventListeners에서 이벤트 핸들러 연결 부분 수정
setupEventListeners() {
// ...
incrementBtn.addEventListener('click', this.#increment);
decrementBtn.addEventListener('click', this.#decrement);
}
이제 이 컴포넌트를 사용하는 쪽에서는 이벤트 리스너의 `event` 객체 타입을 정확히 알 수 있어.
// index.html에 추가
<p>현재 카운터 값: <span id="current-value">10</span></p>
// main.ts에 추가
const myCounter = document.querySelector('my-counter');
const valueDisplay = document.querySelector('#current-value');
if (myCounter && valueDisplay) {
// 이벤트 리스너의 event 타입을 명시적으로 지정
myCounter.addEventListener('change', (event: Event) => {
// CustomEvent 타입으로 단언해야 detail 프로퍼티에 접근 가능
const customEvent = event as CustomEvent<{ value: number }>;
console.log('이벤트 받음!', customEvent.detail);
valueDisplay.textContent = String(customEvent.detail.value);
});
}
`event.detail`에 접근하려고 할 때, 타입스크립트는 기본 `Event` 타입에는 `detail` 프로퍼티가 없다고 알려줄 거야. 이때 `as CustomEvent<{ value: number }>`처럼 타입 단언을 사용하면, `event.detail.value`에 접근할 때 자동완성 기능이 활성화되고 타입 체크의 보호를 받을 수 있게 돼. 실수로 `event.detail.val` 같은 오타를 치면 바로 에러를 띄워주니 얼마나 좋아!
🔥 실전! 투두리스트(To-Do List) 컴포넌트 만들기
이론은 충분히 익혔으니, 이제 배운 걸 총동원해서 실용적인 예제를 만들어보자. 개발자 면접 단골 질문이자, 컴포넌트 구조화 연습에 최고인 '투두리스트' 앱을 웹 컴포넌트와 타입스크립트로 조립해 볼 거야.
우리는 3개의 컴포넌트를 만들 거야.
1. `todo-app`: 전체 앱의 컨테이너. 할 일 목록(state)을 관리하고, 다른 컴포넌트들을 자식으로 둬. 2. `todo-input`: 새로운 할 일을 입력하고 추가하는 역할을 해. '추가' 버튼을 누르면 `add` 이벤트를 발생시켜. 3. `todo-item`: 개별 할 일 항목을 표시해. 완료/미완료 토글, 삭제 기능을 가지며, 각각 `toggle`과 `remove` 이벤트를 발생시켜.
이런 식으로 컴포넌트를 잘게 쪼개면 각자의 역할이 명확해져서 유지보수가 훨씬 쉬워져. 이런 전문적인 컴포넌트 설계 능력은 **재능넷** 같은 플랫폼에서 당신의 가치를 높여줄 강력한 무기가 될 수 있지. 😉
1. 타입 정의 (interfaces.ts)
먼저 프로젝트 전체에서 사용할 데이터 타입을 정의하는 파일을 만들자. `src/interfaces.ts` 파일을 만들고 아래 내용을 추가해.
// src/interfaces.ts
export interface Todo {
id: number;
text: string;
completed: boolean;
}
이렇게 타입을 중앙에서 관리하면, 여러 컴포넌트에서 일관된 데이터 구조를 유지할 수 있어.
2. `todo-input` 컴포넌트
`src/todo-input.ts` 파일을 만들자.
// src/todo-input.ts
class TodoInput extends HTMLElement {
#shadow: ShadowRoot;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.#setupEventListeners();
}
render() {
this.#shadow.innerHTML = `
<style>
:host { display: block; margin-bottom: 10px; }
input { width: 200px; padding: 8px; }
button { padding: 8px 12px; }
</style>
<input type="text" placeholder="새로운 할 일을 입력하세요">
<button>추가</button>
`;
}
#setupEventListeners() {
const input = this.#shadow.querySelector<HTMLInputElement>('input');
const button = this.#shadow.querySelector<HTMLButtonElement>('button');
if (!input || !button) return;
const addTodo = () => {
if (input.value.trim() === '') return;
const event = new CustomEvent<{ text: string }>('add', {
bubbles: true,
composed: true,
detail: { text: input.value }
});
this.dispatchEvent(event);
input.value = '';
input.focus();
};
button.addEventListener('click', addTodo);
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
addTodo();
}
});
}
}
customElements.define('todo-input', TodoInput);
이 컴포넌트는 `add` 이벤트에 `{ text: string }` 타입의 `detail`을 담아 보낸다는 점이 핵심이야.
3. `todo-item` 컴포넌트
`src/todo-item.ts` 파일을 만들자. 이 컴포넌트는 외부에서 `text`, `completed`, `id` 프로퍼티를 받아와서 화면에 그려줄 거야.
// src/todo-item.ts
class TodoItem extends HTMLElement {
#shadow: ShadowRoot;
#id = 0;
#completed = false;
static get observedAttributes(): string[] {
return ['text', 'completed', 'item-id'];
}
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
attributeChangedCallback(name: string, _: string | null, newValue: string | null) {
if (name === 'item-id' && newValue) {
this.#id = Number(newValue);
}
if (name === 'completed') {
this.#completed = newValue !== null; // 'completed' 속성이 존재하면 true
}
this.render();
this.#setupEventListeners();
}
get text(): string {
return this.getAttribute('text') || '';
}
set text(value: string) {
this.setAttribute('text', value);
}
get completed(): boolean {
return this.#completed;
}
set completed(value: boolean) {
this.#completed = value;
if (value) {
this.setAttribute('completed', '');
} else {
this.removeAttribute('completed');
}
}
get itemId(): number {
return this.#id;
}
set itemId(value: number) {
this.#id = value;
this.setAttribute('item-id', String(value));
}
render() {
this.#shadow.innerHTML = `
<style>
div {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.completed {
text-decoration: line-through;
color: #999;
}
button { margin-left: auto; }
</style>
<div class="${this.completed ? 'completed' : ''}">
<input type="checkbox" ${this.completed ? 'checked' : ''}>
<span>${this.text}</span>
<button>삭제</button>
</div>
`;
}
#setupEventListeners() {
const checkbox = this.#shadow.querySelector<HTMLInputElement>('input[type="checkbox"]');
const removeBtn = this.#shadow.querySelector<HTMLButtonElement>('button');
checkbox?.addEventListener('change', () => {
this.dispatchEvent(new CustomEvent('toggle', {
bubbles: true,
composed: true,
detail: { id: this.itemId }
}));
});
removeBtn?.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('remove', {
bubbles: true,
composed: true,
detail: { id: this.itemId }
}));
});
}
}
customElements.define('todo-item', TodoItem);
이 컴포넌트는 `toggle`과 `remove` 이벤트에 `{ id: number }` 타입의 `detail`을 담아 보내. 게터/세터를 사용해서 프로퍼티와 속성을 깔끔하게 관리하는 것도 눈여겨봐 줘.
4. `todo-app` 컴포넌트 (대장 컴포넌트)
이제 모든 것을 총괄할 `src/todo-app.ts` 파일을 만들자.
// src/todo-app.ts
import { Todo } from './interfaces';
import './todo-input'; // 자식 컴포넌트들을 임포트해야 등록됨
import './todo-item';
// TodoItem 클래스의 타입을 명시적으로 사용하기 위해 임포트
// 하지만 define만 할거라면 위처럼 임포트만 해도 됨.
// import TodoItem from './todo-item';
class TodoApp extends HTMLElement {
#shadow: ShadowRoot;
#todos: Todo[] = [
{ id: 1, text: '타입스크립트 공부하기', completed: true },
{ id: 2, text: '웹 컴포넌트 만들기', completed: false },
];
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.#setupEventListeners();
}
render() {
this.#shadow.innerHTML = `
<style>
:host {
display: block;
max-width: 500px;
margin: 2rem auto;
border: 1px solid #ccc;
padding: 20px;
border-radius: 10px;
}
h2 { margin-top: 0; }
</style>
<h2>My To-Do List</h2>
<todo-input></todo-input>
<div id="todo-list"></div>
`;
this.#renderTodoList();
}
#renderTodoList() {
const todoListContainer = this.#shadow.querySelector('#todo-list');
if (!todoListContainer) return;
todoListContainer.innerHTML = ''; // 기존 목록 비우기
this.#todos.forEach(todo => {
const item = document.createElement('todo-item');
// 프로퍼티로 데이터 전달
// item.itemId = todo.id; // 세터가 있다면 이렇게도 가능
// item.text = todo.text;
// item.completed = todo.completed;
// 속성으로 데이터 전달
item.setAttribute('item-id', String(todo.id));
item.setAttribute('text', todo.text);
if (todo.completed) {
item.setAttribute('completed', '');
}
todoListContainer.appendChild(item);
});
}
#setupEventListeners() {
this.addEventListener('add', this.#addTodo);
this.addEventListener('toggle', this.#toggleTodo);
this.addEventListener('remove', this.#removeTodo);
}
#addTodo = (event: Event) => {
const e = event as CustomEvent<{ text: string }>;
const newTodo: Todo = {
id: Date.now(),
text: e.detail.text,
completed: false,
};
this.#todos = [...this.#todos, newTodo];
this.#renderTodoList();
};
#toggleTodo = (event: Event) => {
const e = event as CustomEvent<{ id: number }>;
this.#todos = this.#todos.map(todo =>
todo.id === e.detail.id ? { ...todo, completed: !todo.completed } : todo
);
this.#renderTodoList();
};
#removeTodo = (event: Event) =>
{
const e = event as CustomEvent<{ id: number }>;
this.#todos = this.#todos.filter(todo => todo.id !== e.detail.id);
this.#renderTodoList();
};
}
customElements.define('todo-app', TodoApp);
`todo-app` 컴포넌트는 `#todos`라는 private 필드에 상태를 저장해. 그리고 자식 컴포넌트(`todo-input`, `todo-item`)에서 올라오는 이벤트를 리스닝해서 상태를 변경하고, 상태가 변경되면 `#renderTodoList`를 호출해서 화면을 다시 그려. 이것이 바로 '상태는 위에서 아래로(Props down), 이벤트는 아래서 위로(Events up)'라는 현대적인 컴포넌트 아키텍처의 기본 원리야!
5. 최종 조립
마지막으로 `main.ts`와 `index.html`을 수정해서 `todo-app`을 화면에 띄워주자.
main.ts
// src/main.ts
import './todo-app';
index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TS + Web Components To-Do App</title>
<style>body { font-family: sans-serif; }</style>
</head>
<body>
<todo-app></todo-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
이제 `npm run dev`로 개발 서버를 실행하면, 타입스크립트로 무장한 견고하고 잘 구조화된 투두리스트 앱이 완성된 걸 볼 수 있을 거야! 🥳
🚀 다음 레벨로: 라이브러리와 함께 날아오르기
지금까지 우리는 순수 타입스크립트와 웹 표준 API만으로 웹 컴포넌트를 만들어봤어. 이걸 '바닐라 웹 컴포넌트'라고 부르지. 바닐라 방식은 의존성이 없어서 가볍고, 웹 표준을 깊게 이해하는 데 도움이 된다는 큰 장점이 있어.
하지만 솔직히, 매번 `render` 함수를 직접 호출하고, 속성과 프로퍼티를 수동으로 동기화하는 건 좀 번거롭기도 해. 그래서 이런 반복적인 작업을 자동화해주고, 더 편한 개발 경험을 제공하는 라이브러리들이 존재해. 타입스크립트와 찰떡궁합을 자랑하는 대표 주자들을 만나보자.
정답은 없어! 상황에 따라 달라.
- 바닐라가 좋을 때: 프로젝트 의존성을 최소화하고 싶을 때, 아주 가벼운 단일 컴포넌트를 만들 때, 웹 표준 자체를 깊게 학습하고 싶을 때.
- 라이브러리가 좋을 때: 복잡한 상태 변화에 따른 렌더링을 자동으로 처리하고 싶을 때(반응성), 여러 컴포넌트로 구성된 큰 애플리케이션을 만들 때, 생산성을 높이고 싶을 때.
1. Lit (from Google)
Lit은 구글에서 만든, 웹 컴포넌트 개발을 위한 작고 빠른 라이브러리야. Polymer의 후속작으로, 타입스크립트를 완벽하게 지원하며 현대적인 웹 개발 패러다임을 많이 채용했어.
Lit의 핵심 특징은 두 가지야.
- 반응형 프로퍼티(Reactive Properties): `@property` 데코레이터를 붙인 프로퍼티 값이 바뀌면, 컴포넌트가 알아서 다시 렌더링돼. 더 이상 수동으로 `render()`를 호출할 필요가 없어! - 선언적 템플릿 (`lit-html`): 자바스크립트 템플릿 리터럴을 사용해서 HTML을 효율적으로 렌더링해. 변경된 부분만 DOM을 업데이트해서 성능이 아주 뛰어나.
아까 만들었던 카운터 컴포넌트를 Lit으로 다시 만들어보면 얼마나 코드가 간결해지는지 바로 체감될 거야.
// lit-counter.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('lit-counter')
export class LitCounter extends LitElement {
// CSS도 컴포넌트 안에 깔끔하게 정의
static styles = css`
button { font-size: 1.5rem; /* ... */ }
span { font-size: 2rem; /* ... */ }
`;
// @property 데코레이터: 이 프로퍼티가 바뀌면 자동으로 리렌더링!
// { type: Number } 옵션으로 속성(string)을 프로퍼티(number)로 자동 변환
@property({ type: Number, attribute: 'initial-value' })
count = 0;
// render 메서드는 템플릿을 반환하기만 하면 됨
render() {
return html`
<button @click=${this.#decrement}>-</button>
<span>${this.count}</span>
<button @click=${this.#increment}>+</button>
`;
}
#increment() {
this.count++;
// 이벤트 디스패치는 동일
this.dispatchEvent(new CustomEvent('change', { detail: { value: this.count } }));
}
#decrement() {
this.count--;
this.dispatchEvent(new CustomEvent('change', { detail: { value: this.count } }));
}
}
어때? 🤯 `connectedCallback`, `attributeChangedCallback`, 섀도 DOM 수동 조작, `render` 함수 직접 호출 같은 보일러플레이트 코드가 싹 사라졌지? `@property` 데코레이터와 `render` 함수에만 집중하면 되니까 훨씬 깔끔하고 직관적이야. 타입스크립트의 데코레이터 문법을 적극적으로 활용한 좋은 예시지.
2. Stencil (from Ionic Team)
Stencil은 라이브러리라기보다는 '웹 컴포넌트 컴파일러'에 가까워. React처럼 JSX 문법으로 컴포넌트를 작성하면, Stencil이 최적화된 표준 웹 컴포넌트(바닐라) 코드를 생성해줘.
Stencil의 특징은 다음과 같아.
- JSX 지원: React 개발자에게 익숙한 JSX 문법으로 템플릿을 작성할 수 있어. - 타입스크립트 우선: 처음부터 타입스크립트를 기반으로 설계되었어. - 성능 최적화: 비동기 렌더링, 지연 로딩(Lazy Loading) 등 다양한 성능 최적화 기법이 내장되어 있어.
Stencil로 카운터를 만든다면 이런 모습일 거야.
// stencil-counter.tsx
import { Component, Prop, State, h } from '@stencil/core';
@Component({
tag: 'stencil-counter',
styleUrl: 'stencil-counter.css',
shadow: true,
})
export class StencilCounter {
// @Prop: 외부에서 받는 프로퍼티 (React의 props와 유사)
@Prop() initialValue: number = 0;
// @State: 내부 상태 (React의 state와 유사)
@State() count: number;
// 컴포넌트가 로드되기 전에 호출
componentWillLoad() {
this.count = this.initialValue;
}
private increment = () => {
this.count++;
}
private decrement = () => {
this.count--;
}
render() {
return (
<div>
<button onClick={this.decrement}>-</button>
<span>{this.count}</span>
<button onClick={this.increment}>+</button>
</div>
);
}
}
React에 익숙하다면 Stencil의 코드가 훨씬 더 친숙하게 느껴질 거야. 어떤 도구를 선택할지는 결국 팀의 기술 스택이나 개인적인 선호도에 따라 달라지겠지. 중요한 건, 어떤 라이브러리를 쓰든 타입스크립트가 그 기반을 튼튼하게 받쳐준다는 사실이야.
마치며: 타입스크립트 갑옷을 입고, 웹 컴포넌트의 세계로!
와, 정말 긴 여정이었어! 😅 우리는 웹 컴포넌트의 기본 개념부터 시작해서, 타입스크립트로 개발 환경을 세팅하고, 프로퍼티, 속성, 섀도 DOM, 이벤트를 타입 안전하게 다루는 법을 깊이 있게 파고들었어. 그리고 실전 예제인 투두리스트 앱을 만들면서 배운 지식을 종합해 봤지. 마지막으로 Lit, Stencil 같은 생태계의 멋진 도구들까지 맛봤어.
이제 너는 더 이상 바닐라 자바스크립트의 불확실성 속에서 헤매지 않아도 돼. 타입스크립트라는 든든한 갑옷을 입었으니까! 🛡️
- 코드를 작성하는 동안 자동완성과 타입 힌트가 너의 훌륭한 가이드가 되어줄 거야.
- 코드를 실행하기 전에 컴파일러가 어이없는 실수들을 미리 잡아줄 거야.
- 몇 달 뒤에 네 코드를 다시 보거나, 동료가 네 코드를 이어받아도 훨씬 쉽게 이해하고 유지보수할 수 있을 거야.
웹 컴포넌트와 타입스크립트의 조합은 특정 프레임워크의 유행을 넘어, 오랫동안 너의 커리어를 빛내줄 강력한 기술 스택이 될 거라고 확신해. 프레임워크에 종속되지 않는 재사용 가능한 컴포넌트를 만드는 능력은 어떤 프로젝트에서도 환영받는 귀한 기술이니까.
오늘 배운 내용들을 바탕으로 자신만의 웹 컴포넌트를 하나씩 만들어봐. 작고 사소한 것이라도 괜찮아. 그렇게 하나씩 만들다 보면, 어느새 너만의 멋진 컴포넌트 라이브러리를 갖게 될지도 몰라.
자, 이제 키보드를 잡고 너의 첫 타입스크립트 웹 컴포넌트를 만들어 볼 시간이야. 행운을 빌어! Happy coding! 💻💖
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.