JavaScript 웹 컴포넌트로 재사용 가능한 UI 구축하기
들어가며
최근의 프로젝트 환경은 대부분 React, Vue, Angular와 같은 프레임워크를 기반으로 합니다. 이러한 프레임워크들은 컴포넌트 기반 아키텍처를 통해 재사용 가능한 UI를 구성하여 효율적인 개발을 가능하게 했죠.
하지만 프레임워크 환경이 아닌 전통적인 HTML, CSS, JavaScript 환경에서 작업해야 하는 경우도 있습니다. 이때 웹 표준 기술만으로도 프레임워크가 제공하는 강력한 기능을 구현할 수 있는 방법이 있는데요, 그것이 바로 '웹 컴포넌트'입니다.
웹 컴포넌트를 사용하면 HTML, CSS, JavaScript만으로 재사용 가능하고 캡슐화된 커스텀 컴포넌트를 만들 수 있습니다. 이를 통해 특정 프레임워크에 종속되지 않고도 프레임워크와 유사한 컴포넌트 기반 개발을 할 수 있습니다. 웹 컴포넌트의 캡슐화와 재사용성은 웹 개발을 더욱 모듈화 하고 효율적으로 만들어주며, 웹 표준을 준수함으로써 장기적인 호환성과 유지보수성을 보장할 수 있습니다.
오늘은 웹 컴포넌트의 장단점, 예시 코드와 활용법을 알아보도록 하겠습니다.
웹 컴포넌트의 장점
- 재사용성
웹 컴포넌트는 프레임워크 환경에서 개발한 컴포넌트와 같이 독립적인 구조를 가지고 여러 화면(프로젝트)에서 사용할 수 있습니다. 높은 재사용성을 통해 개발 시간을 단축하고 일관성 있는 UI를 유지할 수 있습니다. - 캡슐화
Shadow DOM 기술을 이용하여 컴포넌트의 구조, 스타일, 동작을 캡슐화할 수 있습니다. 이를 통해 독립적인 컴포넌트를 만들 수 있습니다. - 웹 표준 준수
웹 컴포넌트는 웹 표준 기술을 기반으로 합니다. 따라서 특정 프레임워크에 종속되지 않으면서도 순수한 JavaScript만으로 구현이 가능합니다.
웹 컴포넌트의 단점
- 브라우저 호환성
웹 컴포넌트는 ECMAScript 2015 클래스 문법을 사용해 웹 컴포넌트 기능을 명시하는 클래스를 생성합니다. 때문에 일부 구형 브라우저에서는 지원이 제한적일 수 있습니다. - 상태 관리의 복잡성
React나 Vue 같은 프레임워크에 비해 상태 관리와 데이터 흐름 제어가 다소 복잡할 수 있습니다. - 생태계의 성숙도
React나 Angular에 비해 관련 도구와 라이브러리의 생태계가 아직 발전 중에 있습니다.
웹 컴포넌트의 구현
웹 컴포넌트의 핵심 메서드와 생명주기
웹 컴포넌트의 구현 방법을 알아보기 전에 우선 웹 컴포넌트의 핵심 메서드와 생명주기를 알아야 하는데요. 웹 컴포넌트를 만들고 관리하는 데 필요한 주요 메서드와 생명주기 훅은 다음과 같습니다.
- constructor()
컴포넌트의 초기 설정을 담당합니다. 주로 Shadow DOM을 연결하는 데 사용됩니다. - connectedCallback()
컴포넌트가 DOM에 추가될 때 호출됩니다. 렌더링이나 이벤트 리스너 설정 등의 초기화 작업을 수행합니다. - disconnectedCallback()
컴포넌트가 DOM에서 제거될 때 호출됩니다. 주로 정리 작업을 수행합니다. - attributeChangedCallback(name, oldValue, newValue)
컴포넌트의 속성이 변경될 때 호출됩니다. 속성 변경에 따른 컴포넌트 업데이트를 처리합니다. - adoptedCallback()
컴포넌트가 새로운 문서로 이동될 때 호출됩니다. 이는 비교적 드물게 사용됩니다.
웹 컴포넌트로 커스텀 input 컴포넌트 만들기
// CustomInput 클래스를 정의합니다. HTMLElement를 상속받아 새로운 HTML 요소를 만듭니다.
class CustomInput extends HTMLElement {
// 관찰할 속성 목록을 정의합니다. 이 속성들이 변경되면 attributeChangedCallback이 호출됩니다.
static get observedAttributes() {
return ['placeholder', 'value'];
}
// 생성자에서는 Shadow DOM을 생성하고 연결합니다.
constructor() {
super(); // 반드시 super()를 호출하여 HTMLElement의 생성자를 실행해야 합니다.
this.attachShadow({ mode: 'open' }); // open 모드의 Shadow DOM을 생성합니다.
}
// 컴포넌트가 DOM에 연결될 때 호출되는 생명주기 메서드입니다.
connectedCallback() {
this.render(); // 컴포넌트의 내용을 렌더링합니다.
console.log('CustomInput이 페이지에 추가되었습니다.');
}
// 컴포넌트가 DOM에서 제거될 때 호출되는 생명주기 메서드입니다.
disconnectedCallback() {
console.log('CustomInput이 페이지에서 제거되었습니다.');
}
// 관찰 중인 속성이 변경될 때 호출되는 생명주기 메서드입니다.
attributeChangedCallback(name, oldValue, newValue) {
console.log(`${name} 속성이 ${oldValue}에서 ${newValue}로 변경되었습니다.`);
if (name === 'placeholder' || name === 'value') {
this.render(); // placeholder나 value가 변경되면 컴포넌트를 다시 렌더링합니다.
}
}
// 컴포넌트의 내용을 렌더링하는 메서드입니다.
render() {
// Shadow DOM의 내용을 설정합니다. 스타일과 input 요소를 포함합니다.
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
input {
border: 2px solid #ccc;
border-radius: 4px;
padding: 8px;
font-size: 16px;
}
input:focus {
border-color: #007bff;
outline: none;
}
</style>
<input type="text"
placeholder="${this.getAttribute('placeholder') || ''}"
value="${this.getAttribute('value') || ''}">
`;
// input 요소에 대한 참조를 저장하고 이벤트 리스너를 추가합니다.
this.input = this.shadowRoot.querySelector('input');
this.input.addEventListener('input', this.handleInput.bind(this));
}
// input 이벤트 핸들러입니다.
handleInput(event) {
// 커스텀 이벤트를 생성하여 발생시킵니다.
this.dispatchEvent(new CustomEvent('change', {
detail: { value: event.target.value },
bubbles: true, // 이벤트를 버블링시킵니다.
composed: true // 이벤트가 Shadow DOM 경계를 넘어 전파되도록 합니다.
}));
}
// value 속성에 대한 getter입니다.
get value() {
return this.getAttribute('value');
}
// value 속성에 대한 setter입니다.
set value(newValue) {
this.setAttribute('value', newValue);
}
}
// 커스텀 요소를 정의합니다. 'custom-input'이라는 이름으로 사용할 수 있게 됩니다.
customElements.define('custom-input', CustomInput);
이렇게 만들어진 컴포넌트는 아래와 같이 사용 할 수 있습니다.
<!-- custom-input 요소를 사용합니다. -->
<custom-input id="myInput" placeholder="이름을 입력하세요"></custom-input>
<script>
// custom-input 요소에 대한 참조를 가져옵니다.
const input = document.getElementById('myInput');
// change 이벤트에 대한 리스너를 추가합니다.
input.addEventListener('change', (event) => {
console.log('입력값:', event.detail.value);
});
// 5초 후에 value를 프로그래밍 방식으로 변경합니다.
setTimeout(() => {
input.value = '새로운 값';
}, 5000);
</script>
이 예제에서는 아주 간단한 input 커스텀 컴포넌트를 구현해 보았는데요. Shadow DOM을 사용하여 스타일을 캡슐화하고, 생명주기 메서드를 통해 컴포넌트의 상태를 관리하며, 커스텀 이벤트를 통해 외부와 통신하는 방법을 보여줍니다. 웹 컴포넌트는 이러한 방식으로 재사용 가능하고 독립적인 컴포넌트를 만들 수 있습니다.
웹 컴포넌트 스타일링하기
웹 컴포넌트를 활용하여 커스텀 컴포넌트를 구현할 경우 외부에서 Shadow DOM 내부 요소에 접근할 수 없기 때문에, Shadow DOM 내부에 모든 스타일을 작성하게 됩니다. 이로 인해 반복적인 스타일 선언, 공통 스타일 적용의 어려움 등이 있을 수 있습니다. 하지만 다음과 같은 방법을 사용하면 이러한 불편함을 해소할 수 있습니다.
- :part 의사 선택자 사용하기
:part 의사 선택자를 사용하면 Shadow DOM 내부의 특정 요소를 외부에서 스타일링할 수 있습니다.
// javascript로 웹 컴포넌트 구현 class StylableButton extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <button part="button"> <span part="label">Click me</span> // part 속성 추가 </button> `; } } customElements.define('stylable-button', StylableButton);
// ::part 의사 선택자를 사용하여 스타일링 stylable-button::part(button) { background-color: #4CAF50; border: none; color: white; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; } stylable-button::part(label) { font-weight: bold; }
- CSS 변수 활용하기
CSS 변수(사용자 정의 속성)를 사용하면 Shadow DOM 내부의 스타일을 외부에서 동적으로 조정할 수 있습니다.
// javascript로 웹 컴포넌트 구현 class ThemableButton extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> // 스타일에 css 변수 적용 button { background-color: var(--button-bg-color, #4CAF50); color: var(--button-text-color, white); padding: var(--button-padding, 15px 32px); font-size: var(--button-font-size, 16px); } </style> <button>Click me</button> `; } } customElements.define('themable-button', ThemableButton);
// 외부 css 파일에서 변수 선언 themable-button { --button-bg-color: #008CBA; --button-text-color: #FFFFFF; --button-padding: 10px 20px; --button-font-size: 14px; }
- ::slotted 의사 요소
::slotted 의사 요소를 사용하면 슬롯에 삽입된 요소의 스타일을 지정할 수 있습니다.
// javascript로 웹 컴포넌트 구현 class CustomCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> .card { border: 1px solid #ccc; padding: 10px; } ::slotted(h2) { // 각 slotted 스타일 작성 color: #333; font-size: 24px; } ::slotted(p) { // 각 slotted 스타일 작성 color: #666; font-size: 16px; } </style> <div class="card"> <slot name="title"></slot> <slot name="content"></slot> </div> `; } } customElements.define('custom-card', CustomCard);
// HTML에서의 적용 <custom-card> <h2 slot="title">Card Title</h2> <p slot="content">This is the card content.</p> </custom-card>
위와 같은 방법을 활용하면, 웹 컴포넌트의 캡슐화를 유지하면서도 유연하게 스타일 커스터마이징을 할 수 있습니다.
마치며
요즘의 다양한 프레임워크는 편리한 개발 환경을 제공하지만, 동시에 특정 기술에 대한 의존성이 문제가 되기도 합니다. 이에 반해 웹 컴포넌트는 프레임워크의 장점을 수용하면서도 웹 표준을 준수할 수 있는 유연한 접근 방식을 제공합니다. 프레임워크의 제약에서 벗어나 순수한 웹 기술만으로 강력하고 재사용 가능한 UI를 구축하고자 한다면, 웹 컴포넌트는 매력적인 대안이 될 수 있다고 생각합니다. 이제는 너무나 익숙해진 프레임워크가 지겹다면 오늘은 웹 컴포넌트를 활용한 웹을 구축해 보는 건 어떨까요?
오늘도 읽어주셔서 감사합니다. 🙇♀️
참고문헌
웹 컴포넌트 (MDN)
이 글은 pxd XE Group Blog에서도 보실 수 있습니다.