DOM Attribute Mapping
React에서 JSX를 작성할 때 className, htmlFor, onClick 같은 속성명을 사용한다. 그런데 실제 HTML DOM에서는 class, for, onclick이다. JSX에서 쓰는 속성명과 실제 DOM 속성명이 다른 이유가 뭘까? 그리고 이 변환은 내부적으로 어떻게 처리될까?
왜 속성명이 다른가
HTML 속성 중 일부는 JavaScript 예약어와 충돌한다. 대표적인 게 class와 for다.
// 이건 문법 에러다 — class는 JS 예약어
const props = { class: "container" };
// 이건 괜찮다
const props = { className: "container" };
class는 JavaScript에서 클래스 선언에 쓰이는 키워드이고, for는 반복문 키워드다. JSX가 결국 JavaScript 코드로 변환되기 때문에, 이런 충돌을 피하려면 다른 이름을 써야 한다.
그래서 React는 DOM API의 프로퍼티명을 따르기로 했다. element.className, element.htmlFor처럼 DOM 프로퍼티 이름이 이미 camelCase로 존재하기 때문에, JSX에서도 이 이름을 그대로 사용하는 것이다.
이건 예약어 충돌뿐 아니라 일관성 문제이기도 하다. HTML attribute 이름은 규칙이 제각각이다.
| HTML Attribute | 규칙 |
|---|---|
tabindex | 전부 소문자 |
accept-charset | kebab-case |
contenteditable | 소문자 합성어 |
crossorigin | 소문자 합성어 |
JavaScript에서는 관례적으로 camelCase를 쓰기 때문에, 이걸 통일하면 개발자 경험이 훨씬 나아진다. tabIndex, acceptCharset, contentEditable, crossOrigin처럼.
변환이 필요한 속성의 분류
모든 속성이 변환이 필요한 건 아니다. 크게 세 가지로 나눌 수 있다.
1. 그대로 쓸 수 있는 속성
id, name, type, value, src, href 같은 속성은 HTML과 JavaScript에서 이름이 같다. 이런 속성은 변환 없이 그대로 DOM에 설정하면 된다.
2. camelCase → kebab-case 변환
대부분의 다중 단어 속성은 단순 규칙으로 변환할 수 있다. camelCase에서 대문자를 찾아서 앞에 하이픈을 붙이고 소문자로 바꾸면 된다.
const camelToKebab = (str) => {
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
};
camelToKebab("tabIndex"); // "tab-index"
camelToKebab("maxLength"); // "max-length"
camelToKebab("cellPadding"); // "cell-padding"
camelToKebab("autoComplete"); // "auto-complete"
이 규칙 하나로 상당수의 속성을 처리할 수 있다.
3. 특수 케이스 (규칙으로 안 되는 것들)
일부 속성은 단순 camelCase → kebab-case 변환으로 처리할 수 없다. 이런 것들은 별도의 매핑 테이블이 필요하다.
const specialCases = {
className: "class", // JS 예약어 회피
htmlFor: "for", // JS 예약어 회피
acceptCharset: "accept-charset",
httpEquiv: "http-equiv",
};
className → class는 단순 케이스 변환으로는 class-name이 되어버리니까 직접 매핑해야 한다. htmlFor → for도 마찬가지로 html-for가 아니라 for이어야 한다.
여기에 XML 네임스페이스 속성도 특수 케이스에 해당한다.
const xmlSpecialCases = {
xlinkActuate: "xlink:actuate",
xlinkArcrole: "xlink:arcrole",
xlinkHref: "xlink:href",
xlinkRole: "xlink:role",
xlinkShow: "xlink:show",
xlinkTitle: "xlink:title",
xlinkType: "xlink:type",
xmlBase: "xml:base",
xmlLang: "xml:lang",
xmlSpace: "xml:space",
};
이 속성들은 SVG에서 주로 사용된다. camelCase 변환으로는 xlink-href가 되지만 실제로는 xlink:href여야 한다. 콜론이 들어가는 네임스페이스 형식이라 규칙 기반 변환이 불가능하다.
변환 함수 구현
이 세 가지 분류를 합치면 하나의 변환 함수를 만들 수 있다.
const camelToKebab = (str) => {
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
};
const specialCases = {
acceptCharset: "accept-charset",
httpEquiv: "http-equiv",
className: "class",
htmlFor: "for",
xlinkHref: "xlink:href",
// ... 기타 특수 케이스
};
const convertAttribute = (attr) => {
// 1. 특수 케이스면 매핑 테이블에서 바로 반환
if (attr in specialCases) {
return specialCases[attr];
}
// 2. 전부 소문자면 변환 불필요
if (attr === attr.toLowerCase()) {
return attr;
}
// 3. 나머지는 camelCase → kebab-case
return camelToKebab(attr);
};
이 함수의 로직은 단순하다. 먼저 특수 케이스 테이블을 확인하고, 이미 소문자면 그대로 반환하고, 그 외에는 camelCase를 kebab-case로 변환한다.
매핑 테이블 미리 생성하기
변환 함수를 매번 호출하는 것보다, 전체 속성 목록에 대해 매핑 테이블을 미리 만들어두면 런타임 성능이 좋아진다.
const attributes = [
"accept", "acceptCharset", "accessKey", "action",
"allowFullScreen", "alt", "async", "autoComplete",
"autoFocus", "autoPlay", "capture", "cellPadding",
"cellSpacing", "checked", "className", "colSpan",
"cols", "content", "contentEditable", "controls",
// ... 수백 개의 속성
];
const attributeMap = Object.fromEntries(
attributes.map((attr) => [attr, convertAttribute(attr)])
);
이렇게 하면 attributeMap은 다음과 같은 객체가 된다.
{
accept: "accept",
acceptCharset: "accept-charset",
accessKey: "access-key",
allowFullScreen: "allow-full-screen",
className: "class",
htmlFor: "for",
tabIndex: "tab-index",
// ...
}
렌더링 시점에 attributeMap[propName]으로 O(1) 조회만 하면 되니까, 복잡한 변환 로직을 매번 실행할 필요가 없다.
속성 설정: setAttribute vs 프로퍼티 직접 할당
속성 변환 후에 실제로 DOM에 설정하는 방법도 두 가지가 있다.
// 방법 1: setAttribute (HTML attribute)
element.setAttribute("class", "container");
element.setAttribute("tabindex", "0");
// 방법 2: 프로퍼티 직접 할당 (DOM property)
element.className = "container";
element.tabIndex = 0;
이 둘은 미묘하게 다르다. setAttribute는 항상 문자열로 설정되고, 프로퍼티 할당은 타입이 유지된다. 예를 들어 element.checked = true는 boolean이지만 element.setAttribute("checked", "true")는 문자열 "true"다.
React는 내부적으로 상황에 따라 두 방법을 혼용한다. 하지만 직접 구현할 때는 setAttribute를 기본으로 사용하면 대부분의 케이스를 커버할 수 있다.
const setDOMAttribute = (element, name, value) => {
const htmlName = attributeMap[name] || name;
if (value === null || value === undefined || value === false) {
element.removeAttribute(htmlName);
return;
}
if (value === true) {
element.setAttribute(htmlName, "");
return;
}
element.setAttribute(htmlName, String(value));
};
boolean 속성 처리가 포인트다. disabled={true}는 element.setAttribute("disabled", "")로 설정하고, disabled={false}는 element.removeAttribute("disabled")로 제거한다. HTML에서 disabled 속성은 값이 뭐든 존재하기만 하면 활성화되기 때문이다.
style 속성은 왜 특별한가
style은 다른 속성과 완전히 다르게 처리해야 한다. HTML에서 style은 문자열이지만, React에서는 객체로 전달한다.
// React JSX
<div style={{ backgroundColor: "red", fontSize: "16px", marginTop: "10px" }} />
// 실제 HTML
<div style="background-color: red; font-size: 16px; margin-top: 10px;" />
style 객체의 키도 camelCase → kebab-case 변환이 필요하다. 그런데 CSS 속성명의 변환 규칙은 HTML 속성과 같으므로 동일한 camelToKebab 함수를 재사용할 수 있다.
const styleObjectToString = (styleObj) => {
return Object.entries(styleObj)
.map(([key, value]) => `${camelToKebab(key)}: ${value}`)
.join("; ");
};
// { backgroundColor: "red", fontSize: "16px" }
// → "background-color: red; font-size: 16px"
혹은 element.style 프로퍼티에 직접 할당하는 방법도 있다. element.style은 CSSStyleDeclaration 객체인데, camelCase 프로퍼티명을 그대로 받아들인다.
const applyStyle = (element, styleObj) => {
Object.entries(styleObj).forEach(([key, value]) => {
element.style[key] = value;
});
};
// element.style.backgroundColor = "red" — 이건 그냥 동작한다
이 방식이 더 간단하고, 브라우저가 알아서 올바른 CSS 속성으로 변환해주기 때문에 직접 kebab-case 변환을 할 필요가 없다.
이벤트 핸들러 vs 일반 속성
속성 매핑에서 한 가지 더 고려할 것은 이벤트 핸들러의 분리다. onClick, onChange, onSubmit 같은 속성은 DOM attribute로 설정하면 안 되고 addEventListener로 등록해야 한다.
const isEventProp = (name) => name.startsWith("on");
const applyProps = (element, props) => {
Object.entries(props).forEach(([name, value]) => {
if (name === "children") return;
if (name === "style") {
applyStyle(element, value);
return;
}
if (isEventProp(name)) {
const eventName = name.slice(2).toLowerCase(); // onClick → click
element.addEventListener(eventName, value);
return;
}
// 일반 속성
setDOMAttribute(element, name, value);
});
};
on으로 시작하는 속성은 이벤트로 처리하고, style은 객체로 처리하고, 나머지를 속성 매핑 테이블을 통해 DOM에 설정한다. 이렇게 속성의 종류에 따라 처리 방식을 분기하는 게 attribute mapping의 전체 그림이다.
주요 매핑 테이블 요약
자주 쓰이는 속성들의 매핑을 정리하면 다음과 같다.
| JSX (camelCase) | HTML (실제 DOM) | 변환 유형 |
|---|---|---|
className | class | 특수 케이스 |
htmlFor | for | 특수 케이스 |
tabIndex | tabindex | camelCase → 소문자 |
readOnly | readonly | camelCase → 소문자 |
maxLength | maxlength | camelCase → 소문자 |
autoFocus | autofocus | camelCase → 소문자 |
autoComplete | autocomplete | camelCase → 소문자 |
contentEditable | contenteditable | camelCase → 소문자 |
crossOrigin | crossorigin | camelCase → 소문자 |
acceptCharset | accept-charset | 특수 케이스 |
httpEquiv | http-equiv | 특수 케이스 |
colSpan | colspan | camelCase → 소문자 |
rowSpan | rowspan | camelCase → 소문자 |
실제로는 HTML 속성만 해도 150개 이상이고, SVG까지 포함하면 수백 개에 달한다. 하지만 변환 로직 자체는 "특수 케이스 테이블 + camelCase → kebab-case 함수" 두 가지 조합으로 전부 커버할 수 있다.