Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 Web Components 구현하기

Claude Code로 Web Components를 구현하는 실전 가이드. Custom Elements, Shadow DOM, React/Vue 연동, 테스트까지 다룹니다.

Claude Code로 Web Components 구현하기

Claude Code와 Web Components가 잘 맞는 이유

Web Components는 브라우저 표준으로 재사용 가능한 HTML 요소를 만드는 기술입니다. Custom Elements는 직접 만든 태그를 등록하고, Shadow DOM은 내부 DOM과 CSS를 바깥 페이지에서 격리하며, template과 slot은 반복 구조를 안정적으로 만들게 해 줍니다. 핵심은 React나 Vue를 대체하는 것이 아니라, 여러 프레임워크에서 공유할 수 있는 작은 UI 계약을 만드는 것입니다.

Claude Code는 이 작업에 잘 맞습니다. Web Components는 코드 몇 줄로 시작할 수 있지만, 공개 API가 흐리면 금방 망가집니다. attribute와 property를 어떻게 맞출지, 이벤트 이름과 payload를 어떻게 정할지, Shadow DOM 스타일을 어디까지 열어둘지, 접근성 테스트를 어떻게 할지 먼저 정해야 합니다. 이 기준을 prompt에 넣으면 Claude Code가 단순 생성기가 아니라 구현자와 리뷰어 역할을 같이 할 수 있습니다.

Masa가 겪은 실제 문제는 React 관리 화면의 작은 수량 선택 컴포넌트를 CMS 페이지와 Vue 화면에도 쓰고 싶었던 상황입니다. React runtime을 CMS에 싣는 것은 과했고, 복사해서 다시 만들면 디자인과 이벤트가 달라졌습니다. Web Component로 분리하자 정적 HTML, CMS, React, Vue에서 같은 태그를 사용할 수 있었습니다. 다만 CSS 격리, 이벤트 설계, 버전 관리가 느슨하면 나중에 더 큰 비용이 생겼습니다.

이 글에서는 quantity-stepper를 예제로 Custom Element 최소 구현, attribute 감시, CustomEvent, Shadow DOM 스타일, React/Vue 사용법, Claude Code 리뷰 지시, Vitest 테스트를 한 번에 정리합니다. 디자인 시스템 전체 흐름은 Claude Code 디자인 시스템 가이드와 함께 보면 좋습니다.

공식 기준은 MDN Web Components, MDN Using custom elements, MDN CustomEvent를 확인하세요.

적합한 사용 사례

Web Components는 모든 UI에 맞는 선택지가 아닙니다. 페이지 전체 상태, 라우팅, 서버 렌더링 흐름을 소유하는 기능은 앱 프레임워크 안에 두는 편이 자연스럽습니다. 반대로 작고 독립적인 UI를 여러 환경에 배포해야 한다면 강력합니다.

사용 사례적합한 이유Claude Code에 줄 조건
디자인 시스템 배포React, Vue, 정적 사이트가 같은 부품을 쓸 수 있음토큰, 크기, 상태, 접근성 규칙
CMS 임베드CMS에 작은 인터랙션을 넣어도 앱 전체가 필요 없음전역 CSS 의존 금지, script 로딩 순서
여러 프레임워크 공통 UI마이그레이션 중 구 화면과 신 화면이 같은 태그 사용attribute API, 이벤트명, 버전 정책
폼 부품수량, 평점, 자동완성 같은 입력 보조를 독립 배포label, 오류 상태, 키보드 조작
사내 위젯관리자 화면과 포털에 같은 작은 도구 삽입secret 금지, 로그, 패키지 버전

복사 버튼, 평점 선택, 수량 조절, 검색 입력, 헬프 버튼은 좋은 후보입니다. 반면 전체 결제 페이지나 복잡한 에디터를 Web Component 하나로 만들면 책임이 너무 커집니다. Claude Code에게는 컴포넌트가 맡을 일과 맡지 않을 일을 함께 전달하세요.

flowchart LR
  A["Design tokens"] --> B["Web Component package"]
  B --> C["React app"]
  B --> D["Vue app"]
  B --> E["CMS page"]
  B --> F["Static HTML"]
  B --> G["Internal widget"]

Custom Element 최소 구현

아래 코드를 quantity-stepper.ts로 저장합니다. HTML attribute는 문자열이므로 내부에서 숫자로 정규화하고, 클릭으로 값이 바뀌면 attribute와 property를 맞춘 뒤 quantity-change 이벤트를 보냅니다.

const toNumber = (value: string | null, fallback: number) => {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : fallback;
};

class QuantityStepper extends HTMLElement {
  static observedAttributes = ["value", "step", "label"];

  #root = this.attachShadow({ mode: "open" });
  #value = 0;
  #step = 1;

  connectedCallback() {
    this.#syncFromAttributes();
    this.#render();
  }

  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
    if (oldValue === newValue || !this.isConnected) return;
    this.#syncFromAttributes();
    this.#render();
  }

  get value() {
    return this.#value;
  }

  set value(nextValue: number) {
    const normalized = Number.isFinite(nextValue) ? nextValue : 0;
    if (normalized === this.#value) return;

    this.#value = normalized;
    this.setAttribute("value", String(normalized));
    this.#emitChange();
    this.#render();
  }

  #syncFromAttributes() {
    this.#value = toNumber(this.getAttribute("value"), 0);
    this.#step = toNumber(this.getAttribute("step"), 1);
  }

  #update(direction: -1 | 1) {
    this.value = this.#value + this.#step * direction;
  }

  #emitChange() {
    this.dispatchEvent(
      new CustomEvent("quantity-change", {
        detail: { value: this.#value },
        bubbles: true,
        composed: true,
      }),
    );
  }

  #render() {
    const label = this.getAttribute("label") || "Quantity";

    this.#root.innerHTML = `
      <style>
        :host {
          --quantity-accent: #2563eb;
          --quantity-border: #cbd5e1;
          --quantity-bg: #ffffff;
          --quantity-text: #0f172a;
          display: inline-flex;
          font-family: system-ui, sans-serif;
        }
        .control {
          display: inline-grid;
          grid-template-columns: 2.5rem minmax(3rem, auto) 2.5rem;
          align-items: center;
          border: 1px solid var(--quantity-border);
          border-radius: 8px;
          background: var(--quantity-bg);
          color: var(--quantity-text);
          overflow: hidden;
        }
        button {
          min-width: 2.5rem;
          min-height: 2.5rem;
          border: 0;
          background: transparent;
          color: var(--quantity-accent);
          font: inherit;
          cursor: pointer;
        }
        button:focus-visible {
          outline: 2px solid var(--quantity-accent);
          outline-offset: -2px;
        }
        output {
          min-width: 3rem;
          text-align: center;
          font-weight: 700;
        }
        .sr-only {
          position: absolute;
          width: 1px;
          height: 1px;
          padding: 0;
          margin: -1px;
          overflow: hidden;
          clip: rect(0, 0, 0, 0);
          white-space: nowrap;
          border: 0;
        }
      </style>

      <div class="control" role="group" aria-label="${label}">
        <button part="button decrement" data-action="decrement" type="button">
          <span aria-hidden="true">-</span>
          <span class="sr-only">Decrease ${label}</span>
        </button>
        <output part="value" aria-live="polite">${this.#value}</output>
        <button part="button increment" data-action="increment" type="button">
          <span aria-hidden="true">+</span>
          <span class="sr-only">Increase ${label}</span>
        </button>
      </div>
    `;

    this.#root
      .querySelector('[data-action="decrement"]')
      ?.addEventListener("click", () => this.#update(-1));

    this.#root
      .querySelector('[data-action="increment"]')
      ?.addEventListener("click", () => this.#update(1));
  }
}

if (!customElements.get("quantity-stepper")) {
  customElements.define("quantity-stepper", QuantityStepper);
}
<script type="module" src="/src/quantity-stepper.ts"></script>

<quantity-stepper value="2" step="1" label="Seats"></quantity-stepper>

Attribute, property, CustomEvent

attribute는 HTML 문자열이고 property는 JavaScript 값입니다. 이 차이를 무시하면 React에서는 값이 바뀌는데 CMS에서는 바뀌지 않거나, 화면은 갱신되지만 이벤트가 안 나가는 문제가 생깁니다.

이 구현은 observedAttributesvalue, step, label을 감시합니다. 외부에서 attribute가 바뀌면 내부 값을 동기화하고 다시 렌더링합니다. 내부 클릭은 value setter를 지나며 attribute 반영, 이벤트 발행, 렌더링을 같은 순서로 처리합니다.

const stepper = document.querySelector("quantity-stepper");

stepper?.addEventListener("quantity-change", (event) => {
  const { value } = (event as CustomEvent<{ value: number }>).detail;
  console.log("Selected quantity:", value);
});

이벤트 이름은 change보다 quantity-change처럼 구체적인 이름이 좋습니다. Shadow DOM 밖의 앱에서 이벤트를 받아야 하므로 bubbles: truecomposed: true도 공개 API로 봐야 합니다.

Shadow DOM 스타일링

Shadow DOM 내부에는 바깥 페이지의 CSS가 쉽게 들어오지 않습니다. 그래서 CMS나 오래된 사내 포털에 넣어도 안전하지만, 테마 변경 지점이 없으면 디자인 시스템과 맞추기 어렵습니다.

실무에서는 CSS Custom Properties와 part를 같이 씁니다. Custom Properties는 색상과 간격 같은 토큰을 전달하고, part는 내부 버튼과 값 표시처럼 제한된 부분만 외부에서 스타일링하게 해 줍니다.

quantity-stepper {
  --quantity-accent: #16a34a;
  --quantity-border: #94a3b8;
  --quantity-bg: #f8fafc;
}

quantity-stepper::part(button) {
  font-weight: 700;
}

quantity-stepper::part(value) {
  min-width: 4rem;
}

Claude Code에는 전역 CSS에 의존하지 말 것, 하지만 테마 hook은 공개할 것이라고 함께 지시하세요. focus ring을 없애지 않는지, label과 aria-live가 있는지도 Claude Code 접근성 가이드 기준으로 확인합니다.

React와 Vue에서 사용하기

React에서는 custom event를 ref로 구독하는 방식이 가장 예측 가능합니다.

import { useEffect, useRef, useState } from "react";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import "./quantity-stepper";

type QuantityStepperElement = HTMLElement & { value: number };

declare global {
  namespace JSX {
    interface IntrinsicElements {
      "quantity-stepper": DetailedHTMLProps<
        HTMLAttributes<QuantityStepperElement>,
        QuantityStepperElement
      > & {
        value?: string;
        step?: string;
        label?: string;
      };
    }
  }
}

export function QuantityField() {
  const [quantity, setQuantity] = useState(2);
  const ref = useRef<QuantityStepperElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleQuantityChange = (event: Event) => {
      setQuantity((event as CustomEvent<{ value: number }>).detail.value);
    };

    element.addEventListener("quantity-change", handleQuantityChange);
    return () => {
      element.removeEventListener("quantity-change", handleQuantityChange);
    };
  }, []);

  return (
    <div>
      <quantity-stepper ref={ref} value="2" step="1" label="Seats" />
      <p>Selected: {quantity}</p>
    </div>
  );
}

Vue에서는 template ref와 lifecycle hook으로 처리합니다.

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import "./quantity-stepper";

const quantity = ref(2);
const stepper = ref<HTMLElement | null>(null);

const handleQuantityChange = (event: Event) => {
  quantity.value = (event as CustomEvent<{ value: number }>).detail.value;
};

onMounted(() => {
  stepper.value?.addEventListener("quantity-change", handleQuantityChange);
});

onBeforeUnmount(() => {
  stepper.value?.removeEventListener("quantity-change", handleQuantityChange);
});
</script>

<template>
  <quantity-stepper ref="stepper" value="2" step="1" label="Seats" />
  <p>Selected: {{ quantity }}</p>
</template>
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag === "quantity-stepper",
        },
      },
    }),
  ],
});

Claude Code 리뷰 지시

You are editing only src/components/quantity-stepper.ts and its tests.

Goal:
Build a framework-agnostic Web Component named quantity-stepper.

Public API:
- attributes: value, step, label
- property: value as number
- event: quantity-change with detail { value: number }
- event must bubble and cross Shadow DOM with composed: true

Implementation rules:
- Use Custom Elements and Shadow DOM.
- Do not depend on React or Vue.
- Expose theme hooks with CSS Custom Properties.
- Expose ::part(button) and ::part(value).
- Keep keyboard and screen reader behavior usable.
- Do not remove visible focus styles.

Review before finishing:
- Attribute/property synchronization
- Event naming and detail shape
- Shadow DOM styling boundaries
- Accessibility labels and aria-live
- Test coverage for click, attribute update, and event emission
Review this Web Component as production code.
Prioritize bugs over style preferences.
Report issues in this order:
1. Broken public API or breaking change risk
2. Accessibility defects
3. Shadow DOM styling leaks
4. React/Vue integration problems
5. Missing tests

For every finding, include the file, line, why it fails, and a minimal fix.

이 prompt는 Claude Code가 외형보다 공개 계약을 검토하게 만듭니다. 팀에서는 CLAUDE.md에 축약해서 넣어 두면 새 컴포넌트 추가 때 기준이 흔들리지 않습니다.

테스트로 계약 보호하기

import { beforeEach, describe, expect, it } from "vitest";
import "./quantity-stepper";

describe("quantity-stepper", () => {
  beforeEach(() => {
    document.body.innerHTML = "";
  });

  it("increments by step and emits a composed event", () => {
    const element = document.createElement("quantity-stepper") as HTMLElement & {
      value: number;
    };

    element.setAttribute("value", "3");
    element.setAttribute("step", "2");
    element.setAttribute("label", "Seats");
    document.body.append(element);

    const events: Array<CustomEvent<{ value: number }>> = [];
    element.addEventListener("quantity-change", (event) => {
      events.push(event as CustomEvent<{ value: number }>);
    });

    element.shadowRoot
      ?.querySelector<HTMLButtonElement>('[data-action="increment"]')
      ?.click();

    expect(element.getAttribute("value")).toBe("5");
    expect(element.value).toBe(5);
    expect(events).toHaveLength(1);
    expect(events[0].detail.value).toBe(5);
    expect(events[0].bubbles).toBe(true);
    expect(events[0].composed).toBe(true);
  });

  it("updates rendering when the value attribute changes", () => {
    const element = document.createElement("quantity-stepper");
    document.body.append(element);

    element.setAttribute("value", "9");

    expect(element.shadowRoot?.querySelector("output")?.textContent).toBe("9");
  });
});

테스트는 내부 HTML 전체보다 공개 API를 보호해야 합니다. attribute 변경, 클릭, 이벤트 payload, Shadow DOM 바깥 전파를 확인하면 실제 앱에 붙였을 때의 사고가 줄어듭니다.

흔한 함정

첫째, Shadow DOM과 CSS입니다. 전역 CSS에 기대면 호스트 페이지에 깨지고, 완전히 닫으면 테마 적용이 어렵습니다. CSS 변수와 part를 명시하세요.

둘째, attribute와 property 동기화입니다. HTML은 문자열이고 JS는 숫자나 객체가 될 수 있습니다. 세 경로가 같은 상태로 수렴하는지 테스트해야 합니다.

셋째, 이벤트 설계입니다. 이벤트명, detail 구조, composed 여부는 공개 API입니다. 나중에 바꾸면 breaking change입니다.

넷째, 접근성입니다. custom tag 자체에는 버튼 의미가 없습니다. 내부에 native button을 쓰고 label, focus, keyboard, live region을 확인하세요.

다섯째, SSR과 hydration입니다. 정의 파일이 로드되기 전까지는 업그레이드되지 않은 HTML입니다. 결제나 가입 흐름에는 fallback이 필요합니다.

여섯째, 버전 관리입니다. attribute, CSS 변수, part, 이벤트 payload는 README나 Storybook에 기록하고 semver로 관리합니다.

정리와 검증 메모

Web Components는 프레임워크 대체제가 아니라 여러 환경에서 공유할 작은 UI 계약입니다. Claude Code에는 공개 API, 스타일 경계, 이벤트, 접근성, 테스트를 먼저 주고 구현을 맡기는 방식이 가장 안정적입니다. Lit을 쓰더라도 이 원칙은 그대로 유지됩니다.

ClaudeCodeLab은 Web Components 기반 디자인 시스템, CMS 임베드, 사내 위젯 라이브러리, Claude Code 리뷰 워크플로를 실제 저장소 기준으로 정리할 수 있습니다. 팀 도입이 필요하면 Claude Code 교육 및 상담을 확인하세요.

검증 메모: 이 글의 코드는 quantity-stepper 하나로 attribute, property, Shadow DOM 버튼, quantity-change 이벤트, React/Vue 구독을 확인하도록 구성했습니다. 운영 전에는 Playwright 키보드 테스트와 실제 브라우저 focus 표시 확인을 추가하는 것을 권장합니다.

#Claude Code #Web Components #Custom Elements #Shadow DOM #Lit
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.