코드베이스 어디서나 볼 수 있는 패턴이 하나 있습니다: 개발자들이 DOM 요소에 커스텀 데이터를 붙이고 싶어서 클래스 이름에 인코딩하거나 (class="item item-42 status-active"), 숨겨진 입력 필드에 저장하거나, ID를 키로 하는 JavaScript 객체를 추가하는 방식을 씁니다. 이 모든 방법은 임시방편입니다. data-* 속성이 이 용도에 맞는 올바른 도구이며, HTML Living Standard에 내장되어 있습니다.

data-* 속성이란 무엇인가

data-* 속성 명세를 사용하면 어떤 HTML 요소에든 커스텀 키-값 데이터를 붙일 수 있습니다. 이름은 반드시 data-로 시작해야 하고, 그 뒤에 한 글자 이상이 따라와야 합니다. 값은 항상 문자열입니다.

html
<!-- data-* attributes: custom data right on the element -->
<button
  data-action="delete"
  data-item-id="284"
  data-item-name="Summer Newsletter"
  data-confirm="true"
>
  Delete
</button>

<li
  data-product-id="SKU-9921"
  data-price="29.99"
  data-in-stock="true"
  data-category="electronics"
>
  Wireless Keyboard
</li>

이 속성들은 렌더링에 전혀 영향을 미치지 않습니다. 클래스, 스타일, 레이아웃 동작을 추가하지 않습니다. 순수한 메타데이터로서 JavaScript와 CSS에서 읽을 수 있으며, 브라우저의 렌더링 엔진은 이를 무시합니다.

명명 규칙

데이터 속성 이름은 HTML에서 대소문자를 구별하지 않으며(명세에서 소문자로 변환), JavaScript의 dataset API에서는 camelCase로 매핑됩니다. 이 변환은 자동으로 이루어집니다:

html
<!-- HTML attribute names use kebab-case -->
<div
  data-user-id="42"
  data-first-name="Alice"
  data-account-type="premium"
  data-last-login-at="2026-04-16T09:00:00Z"
>
</div>
js
// JavaScript dataset uses camelCase (automatic conversion)
const el = document.querySelector('div');

el.dataset.userId;       // "42"       (from data-user-id)
el.dataset.firstName;    // "Alice"    (from data-first-name)
el.dataset.accountType;  // "premium"  (from data-account-type)
el.dataset.lastLoginAt;  // "2026-04-16T09:00:00Z" (from data-last-login-at)

규칙: HTML의 하이픈은 JavaScript에서 camelCase가 됩니다. data-item-countdataset.itemCount. 이 변환은 양방향입니다 — dataset.itemCount에 값을 쓰면 DOM의 data-item-count가 업데이트됩니다.

dataset으로 읽기 및 쓰기

dataset 프로퍼티는 DOMStringMap입니다 — 읽기와 쓰기에서 일반 JavaScript 객체처럼 동작하며, DOM이 실시간으로 업데이트됩니다:

js
const card = document.querySelector('.product-card');

// Reading
const productId  = card.dataset.productId;  // "SKU-9921"
const price      = parseFloat(card.dataset.price); // 29.99 (remember: always a string, parse as needed)
const inStock    = card.dataset.inStock === 'true'; // convert string to boolean

// Writing (updates the DOM attribute in real time)
card.dataset.price   = '24.99';       // sets data-price="24.99"
card.dataset.inStock = 'false';       // sets data-in-stock="false"
card.dataset.badgeText = 'Sale';      // adds data-badge-text="Sale" (new attribute)

// Deleting
delete card.dataset.badgeText;        // removes data-badge-text from the element

// Checking existence
if ('productId' in card.dataset) {
  console.log('This is a product card');
}
기억하세요: HTML에서 모든 data-* 값은 문자열입니다. 숫자는 항상 parseInt() 또는 parseFloat()로 파싱하고, 불리언은 문자열 'true'와 비교해서 변환하세요. 파싱 없이 원래 타입으로 취급하는 것은 버그의 흔한 원인입니다.

data-* 속성을 활용한 이벤트 위임

데이터 속성의 가장 실용적인 활용 중 하나: 이벤트 위임입니다. 수십 개의 버튼에 개별 이벤트 리스너를 붙이는 대신, 부모 요소 하나에 리스너를 붙이고 요소의 dataset에서 액션을 읽어옵니다:

html
<ul id="task-list">
  <li data-task-id="101" data-status="pending">
    Write article draft
    <button data-action="complete" data-task-id="101">Complete</button>
    <button data-action="delete"   data-task-id="101">Delete</button>
  </li>
  <li data-task-id="102" data-status="pending">
    Review pull request
    <button data-action="complete" data-task-id="102">Complete</button>
    <button data-action="delete"   data-task-id="102">Delete</button>
  </li>
</ul>
js
const taskList = document.getElementById('task-list');

// One listener handles all buttons — even ones added dynamically later
taskList.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action]');
  if (!button) return; // click was somewhere else in the list

  const action = button.dataset.action;
  const taskId = button.dataset.taskId;

  switch (action) {
    case 'complete':
      markTaskComplete(taskId);
      break;
    case 'delete':
      deleteTask(taskId);
      break;
  }
});

function markTaskComplete(id) {
  const item = taskList.querySelector(`li[data-task-id="${id}"]`);
  item.dataset.status = 'complete'; // CSS can react to this change
  console.log(`Task ${id} marked complete`);
}

function deleteTask(id) {
  const item = taskList.querySelector(`li[data-task-id="${id}"]`);
  item.remove();
  console.log(`Task ${id} deleted`);
}

이 패턴은 아이템 수에 관계없이 확장됩니다. event.target.closest()는 클릭된 요소에서 DOM을 올라가며 가장 가까운 일치하는 조상을 찾습니다 — 버튼 텍스트(자식 텍스트 노드)를 클릭해도 버튼을 찾아냅니다. event.target.tagName을 비교하는 것보다 깔끔하고 정확한 요소 매칭보다 더 신뢰할 수 있습니다.

CSS attr()와 data-* 함께 사용하기

CSS는 content 선언에서 attr() 함수를 사용해 데이터 속성 값을 읽을 수 있습니다. 이는 마크업이 아닌 데이터에서 오는 툴팁, 배지, 레이블에 유용합니다:

html
<!-- Tooltip text from data attribute -->
<span class="tooltip" data-tip="Saves automatically every 30 seconds">
  Auto-save enabled
</span>

<!-- Status badge text from data attribute -->
<li class="task" data-status="in-progress">Review PR #412</li>
<li class="task" data-status="complete">Write unit tests</li>
css
/* Tooltip using attr() in CSS */
.tooltip {
  position: relative;
  cursor: help;
  text-decoration: underline dotted;
}

.tooltip::after {
  content: attr(data-tip);
  position: absolute;
  bottom: 100%;
  left: 0;
  background: #1a1a2e;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.85rem;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
}

.tooltip:hover::after,
.tooltip:focus::after {
  opacity: 1;
}

/* Status badge via attr() */
.task::before {
  content: '[' attr(data-status) '] ';
  font-size: 0.75rem;
  font-weight: bold;
  text-transform: uppercase;
}

.task[data-status="complete"] { text-decoration: line-through; color: #6b7280; }
.task[data-status="in-progress"] { color: #d97706; }

attr()는 현재 content 속성에서만 동작합니다 — width, color, 또는 다른 CSS 속성에는 아직 사용할 수 없습니다(단, CSS Values Level 5 명세에서 이를 확장하는 작업이 진행 중입니다). 지금은 속성 선택자 ([data-status="complete"])가 데이터 속성 값에 따라 스타일을 변경하는 실용적인 방법입니다.

완전한 인터랙티브 예제 — data-*를 활용한 아코디언

다음은 상태 관리를 전적으로 데이터 속성으로 구동하는 실제 아코디언 컴포넌트입니다. 동작을 위한 클래스 토글링 없이 데이터 속성만 사용합니다 — 클래스는 스타일링 전용으로 유지됩니다:

html
<div id="faq-accordion" role="list">
  <div class="accordion-item" role="listitem" data-open="false">
    <button
      class="accordion-trigger"
      data-target="faq-1"
      aria-expanded="false"
      aria-controls="faq-1"
    >
      What is the difference between article and section?
    </button>
    <div id="faq-1" class="accordion-panel" data-panel hidden>
      <p>An <code>article</code> is self-contained content that could stand alone.
      A <code>section</code> is a thematic grouping within a larger context.</p>
    </div>
  </div>

  <div class="accordion-item" role="listitem" data-open="false">
    <button
      class="accordion-trigger"
      data-target="faq-2"
      aria-expanded="false"
      aria-controls="faq-2"
    >
      When should I use ARIA roles?
    </button>
    <div id="faq-2" class="accordion-panel" data-panel hidden>
      <p>Only when no native HTML element conveys the same semantics.
      Native elements should always be preferred.</p>
    </div>
  </div>
</div>
js
const accordion = document.getElementById('faq-accordion');

accordion.addEventListener('click', (event) => {
  const trigger = event.target.closest('.accordion-trigger');
  if (!trigger) return;

  const targetId = trigger.dataset.target;
  const panel    = document.getElementById(targetId);
  const item     = trigger.closest('.accordion-item');
  const isOpen   = item.dataset.open === 'true';

  // Toggle this item
  item.dataset.open       = isOpen ? 'false' : 'true';
  trigger.ariaExpanded    = isOpen ? 'false' : 'true';
  panel.hidden            = isOpen;
});
css
.accordion-trigger {
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 1rem;
  font-size: 1rem;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

/* Arrow rotates based on data-open state */
.accordion-item[data-open="false"] .accordion-trigger::after {
  content: '▼';
  transition: transform 0.2s;
}

.accordion-item[data-open="true"] .accordion-trigger::after {
  content: '▲';
}

.accordion-panel {
  padding: 0 1rem 1rem;
}

.accordion-panel[hidden] {
  display: none;
}

상태는 컨테이너의 data-open에 저장됩니다. CSS는 해당 속성을 읽어 화살표 표시기를 회전시킵니다. JavaScript는 속성을 토글하고 aria-expandedhidden을 관리합니다. 각 레이어가 정확히 하나의 역할을 하며, 깔끔하게 조합됩니다.

성능 및 접근성 참고 사항

  • 성능. data-* 속성은 DOM의 일부이며 읽기 속도가 매우 빠릅니다 — 다른 속성을 읽는 것과 본질적으로 동일합니다. 매우 빈번한 코드 경로(프레임당 수천 번 읽기)에서는 요소 참조를 키로 하는 JavaScript Map이 더 빠릅니다. 하지만 일반적인 UI 코드에서는 dataset으로 충분합니다.
  • data-*는 스크립트용이지 의미론용이 아닙니다. 이 속성들은 명시적으로 ARIA 상태에 바인딩하지 않는 한 스크린 리더에 보이지 않습니다. 의미론을 전달하기 위해 data-role이나 data-label을 사용하려 하지 마세요 — 그 목적으로는 적절한 HTML 요소와 WAI-ARIA 속성을 사용하세요.
  • 보안. 데이터 속성에 민감한 데이터(토큰, 비밀번호, 개인정보)를 절대 넣지 마세요. 브라우저의 요소 검사기에서 보이며, 서드파티 스크립트를 포함한 페이지의 모든 JavaScript에서 읽을 수 있습니다.
  • 쿼리. CSS 속성 선택자로 데이터 속성을 가진 요소를 선택할 수 있습니다: document.querySelectorAll('[data-action="delete"]') — 예상대로 동작하며 대량 작업에 매우 유용합니다.

HTML 개발 도구

HTML 작업 시, HTML 포맷터는 속성이 많은 마크업을 읽기 좋게 유지해 주고, HTML 유효성 검사기aria-controls 연결을 깨뜨릴 수 있는 중복 ID 같은 오류를 잡아줍니다. 위의 아코디언 같은 컴포넌트 패턴을 실시간으로 편집하고 미리 보려면, HTML 편집기를 사용하면 빌드 단계 없이 실시간으로 변경 사항을 확인할 수 있습니다.

마무리

데이터 속성은 DOM 요소에 커스텀 메타데이터를 붙이는 깔끔하고 공식적인 방법입니다. 대부분의 사용 사례에서 클래스명 해킹, 숨겨진 입력 필드, 외부 JavaScript 맵보다 훨씬 낫습니다. dataset API는 읽기와 쓰기를 간단하게 만들고, camelCase 변환은 자동으로 이루어지며, CSS 속성 선택자와 이벤트 위임 패턴과 자연스럽게 조합됩니다. 기억하세요: 데이터 속성은 스크립트에서 읽을 수 있는 메타데이터용이지 접근성 의미론용이 아닙니다 — 그것은 ARIA의 역할입니다.