Custom Elements로 커스텀 태그 만들기

아더에러·2026년 4월 20일

프론트엔드

목록 보기
13/15
post-thumbnail

HTML을 사용하다보면 <div>나 <button> 같은 기본 HTML태그가 아닌
커스텀 태그를 사용할 수 없는가에 대해 의문이 생긴다.

Custom Elements를 활용하면 아래와 같은 커스텀 태그를 만들 수 있다.

<user-card></user-card>
<my-modal></my-modal>
<app-header></app-header>

이를 통해 의미 있는 이름으로 태그를 사용 할 수 있으며 재사용이 가능해진다.
즉, 프레임워크 없이도 컴포넌트처럼 사용이 가능해진다.

Custom Elements 사용 방법 (최소 구성)

class UserCard extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<p>User</p>`;
  }
}

customElements.define("user-card", UserCard);
  1. user-card라는 태그 이름을 키로 저장
  2. 연결된 클래스(UserCard)를 기억
<user-card></user-card>
  1. HTML 파싱 중 <user-card>를 읽는다.
  2. HTMLElement를 상속한 인스턴스를 생성한다.
  3. 아래와 같이 렌더링 된다.
<user-card>
	<p>User</p>
</user-card>

Custom Elements 생명주기

Custom Element의 생명주기란, 한 요소가 다음 과정을 거치는 흐름이다.

  1. 정의된다 (define)
  2. 생성된다 (constructor)
  3. DOM에 연결된다 (connected)
  4. 속성이 변한다 (attribute changed)
  5. DOM에서 제거된다 (disconnected)

이 과정마다 브라우저가 자동으로 호출해주는 함수가 있고,
그 함수들을 “약속된 이름”으로 구현하면 된다.

class MyElement extends HTMLElement {
  constructor() {}
  connectedCallback() {}
  disconnectedCallback() {}
  attributeChangedCallback(name, oldValue, newValue) {}
  static get observedAttributes() {}
}

이 다섯 개가 Custom Elements의 전 생명주기다.

  • 이 함수들은 우리가 호출하지 않는다
  • 브라우저가 정확한 시점에 자동으로 호출한다

1단계: constructor

<user-card></user-card>

브라우저가 HTML을 파싱하다가 이 태그를 발견하고 customElements.define을 통해 정의했다면
브라우저는 내부적으로 인스턴스를 생성하는데 이때 constructor()가 호출된다.

constructor의 특징

  • 요소 인스턴스가 생성되는 순간
  • 아직 DOM에 붙었는지는 모른다
  • document, parentNode 접근을 보장하지 않는다

즉, 아직 "화면에 나타났다"고 볼 수 없다.

→ DOM 연결 여부가 불확실하므로, innerHTML과 같은 DOM 조작은 constructor 내부에서 실행하지 않아야한다.

2단계: connectedCallback

<body>
  <user-card></user-card>
</body>

요소가 실제로 DOM 트리에 들어갔을 때 실행된다. 사실상 렌더링 단계라고 할 수 있다.

connectedCallback() {
  this.innerHTML = `<p>User</p>`;
}

<user-card> 요소 내부의 자식 DOM을 구성한다 그 결과 DOM은 아래와 같아진다.

<user-card>
  <p>User</p>
</user-card>

3단계: attributeChangedCallback

<user-card name="민준" age="27"></user-card>

Custom Element에 attribute가 부여되더라도,
브라우저는 이 값이 화면에 쓰일 데이터인지, 내부 상태인지,
혹은 단순한 정보인지를 알 수 없다.

그래서 기본적으로 브라우저는
attribute가 추가되거나 변경되더라도 아무 반응도 하지 않는다.

만약 모든 커스텀 요소의 모든 attribute 변화를
자동으로 감시하고 자동으로 반응하도록 만들었다면,
불필요한 연산이 늘어나 성능 문제가 발생하게 된다.

이 때문에 Custom Elements에서는
어떤 attribute가 중요한지 개발자가 직접 명시하도록 되어 있다.

observedAttributes — 감시할 attribute를 명시하는 단계

static get observedAttributes() {
  return ['name', 'age'];
}

이 코드는 브라우저에게 다음과 같이 전달된다.

이 요소에서는
name과 age attribute의 변화만 감시하겠다

배열에 포함된 attribute 중 하나라도 변화가 발생하면,
브라우저는 해당 변화를 감지하게 된다.

하지만 이 시점까지는
아직 실제 동작은 정의되지 않은 상태다.
단지 “어떤 변화를 감시할지”만 정해졌을 뿐이다.

attributeChangedCallback — 감지된 변화를 전달받는 지점

observedAttributes에 의해 감시가 설정된 attribute가 변경되면,
브라우저는 아래 메서드를 자동으로 호출한다.

attributeChangedCallback(name, oldValue, newValue) {
  // ...
}

이 메서드는 우리가 직접 호출하지 않는다.
브라우저가 정확한 시점에 호출해준다.

attributeChangedCallback은 언제 호출될까

이 메서드는 아래 모든 경우에 호출된다.

1) HTML에서 처음 등장할 때

<user-card name="민준" age="27"></user-card>

요소가 생성되면서 attribute가 처음 설정되는 순간,
브라우저는 이를 “변화”로 인식한다.

name null 민준
age  null 27

2) JavaScript로 attribute 값을 변경할 때

el.setAttribute('name', '우혁');
name 민준 우혁

3) attribute를 제거할 때

el.removeAttribute('age');
age 27 null

attributeChangedCallback 인자의 의미

attributeChangedCallback(name, oldValue, newValue)

각 인자는 다음 의미를 가진다.

  • name
    → 변화가 발생한 attribute의 이름

  • oldValue
    → 변경 이전의 값
    → 이전에 존재하지 않았다면 null

  • newValue
    → 변경 이후의 값
    → attribute가 제거된 경우 null

그래서 이 함수에서 해야 할 일

중요한 점은,
attributeChangedCallback이 화면을 자동으로 바꾸지 않는다는 것이다.

브라우저의 역할은 여기까지다.

"이 attribute가 이렇게 바뀌었다."

그 이후 동작은 개발자의 책임이다.

보통 이 메서드에서는 다음 흐름을 따른다.

  1. 어떤 attribute가 바뀌었는지 확인
  2. 내부 상태를 갱신하거나
  3. 필요한 경우 다시 렌더링

예시:

class UserCard extends HTMLElement {
  static get observedAttributes() {
    return ['name'];
  }

  attributeChangedCallback(attr, oldVal, newVal) {
    if (attr === 'name') {
      this.render();
    }
  }

  render() {
    this.innerHTML = `<p>${this.getAttribute('name')}</p>`;
  }
}

즉, attributeChangedCallback은 트리거일 뿐이며,
실제 화면 변경 로직은 직접 작성해야 한다.

4단계: disconnectedCallback

요소가 DOM에서 빠질 때 호출된다.

element.remove();

또는 부모가 제거될 때도 호출된다.

다음과 같은 일을 맡는다.

  • 이벤트 해제
  • 타이머 정리
  • observer 해제

Custom Elements 이름

Custom Elemtns 태그 이름에는 반드시 하이픈이 필요하다.
→ 미래의 HTML 표준 태그와 충돌하지 않게 하기 위한 안전장치

HTML 표준 태그는 계속해서 생겨나고 있는데, 미래에 HTML 표준으로 추가된 태그인지 Custom Elements인지 브라우저가 구분 할 수 없기에 하이픈을 사용해야한다.

하이픈을 사용하지 않고 customElements.define 을 사용하면 에러가 발생한다.

Custom Elements 문제점

Custom Elements는 태그 수준의 컴포넌트화를 가능하게 하지만,
DOM과 스타일이 전역에 그대로 노출된다는 구조적 한계를 가진다.

이 문제는 "재사용 가능한 단위"로 사용하려 할수록 명확해진다.

1. 스타일이 전역에 노출된다

Custom Element 내부에서 작성한 스타일은
기본적으로 전역 CSS 규칙의 영향을 그대로 받는다.

p {
  color: red;
}

이 스타일은 다음 요소에 모두 영향을 미친다.

<user-card>
  <p>User</p>
</user-card>

<article>
  <p>Post</p>
</article>

이로 인해 외부 스타일에 의해 스타일이 깨지는 문제가 발생한다.

2. 내부 구현이 그대로 노출된다

Custom Element는 단순한 HTML 요소이기 때문에
그 내부 DOM 구조가 그대로 외부에 노출된다.

<user-card>
  <p class="name">민준</p>
</user-card>

이 상태에서 외부에서는 다음과 같은 접근이 가능하다.

user-card .name {
  color: blue;
}

또는

document.querySelector('user-card .name')

이는 곧 다음을 의미한다.

  • 내부 DOM 구조가 사실상 public API가 됨
  • 내부 마크업을 바꾸면 외부 코드가 깨질 수 있음

마무리

내부 스타일은 외부 CSS에 의해 언제든지 바뀔 수 있고
내부 DOM 구조는 외부 코드에서 직접 접근 할 수 있다.

즉, Custom Elements는 "이 요소를 어떻게 쓰는가"는 분리해주지만,
"이 요소가 어떻게 만들어졌는가"까지 숨겨주지는 못한다.

이러한 문제를 해결하기 위해 DOM 구조와 스타일 자체를 전역 환경으로부터 분리하려는 시도가 등장했고,
그 역할을 맡는 것이 바로 Shadow DOM이다.

0개의 댓글