DOM 생성 유틸리티 함수 추상화
프레임워크 없이 Vanilla JS로 UI를 만들면 document.createElement를 끊임없이 호출하게 된다. 버튼 하나를 만들어도 요소 생성, 클래스 부여, 텍스트 삽입, 속성 설정을 각각 해야 한다.
const button = document.createElement('button');
button.className = 'btn btn-primary';
const span = document.createElement('span');
span.className = 'label';
span.textContent = '저장';
button.appendChild(span);
button.disabled = true;
버튼 하나에 6줄이다. 카드 컴포넌트, 모달, 리스트 아이템까지 만들면 코드의 절반이 DOM 생성 코드가 된다. 문제는 단순히 길이가 아니라, 의도가 묻힌다는 것이다. "저장 버튼을 만든다"라는 의도가 6줄의 저수준 API 호출 속에 파묻혀 있어서 코드를 읽을 때 무엇을 만드는지 한눈에 파악하기 어렵다.
이 문제를 해결하는 방법은 DOM 생성 로직을 유틸리티 함수로 추상화하는 것이다.
범용 요소 생성 함수
가장 기본이 되는 함수는 어떤 태그든 만들 수 있는 범용 createElement다.
const createElement = (tag, className, textContent, attributes = {}) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (textContent) element.textContent = textContent;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
};
이 함수 하나로 대부분의 요소 생성을 커버할 수 있다.
const title = createElement('h2', 'card-title', '할 일 목록');
const input = createElement('input', 'text-field', null, {
type: 'text',
placeholder: '새 항목 입력',
});
const icon = createElement('img', 'icon', null, {
src: '/icons/check.svg',
alt: '완료',
});
한 줄로 의도가 명확하게 드러난다. 더 중요한 것은, DOM API의 세부사항을 호출부에서 신경 쓰지 않아도 된다는 점이다.
왜 className을 별도 파라미터로 빼는가
attributes 객체에 class를 넣어도 되지 않냐는 의문이 들 수 있다. 하지만 실무에서는 거의 모든 요소에 클래스를 부여하기 때문에, 가장 빈번한 파라미터를 별도로 빼는 것이 호출부를 깔끔하게 만든다. setAttribute('class', ...)와 element.className = ...의 동작 차이도 고려 대상이다. className은 기존 클래스를 완전히 대체하고, setAttribute도 마찬가지지만 classList.add와는 다르게 동작한다. 유틸리티 함수에서는 "새 요소 생성 시 초기 클래스 설정"이 목적이므로 className 직접 할당이 가장 직관적이다.
setAttribute vs 프로퍼티 직접 할당
여기서 흔히 마주치는 함정이 있다. textarea나 input의 value를 setAttribute로 설정하면 기대와 다르게 동작한다.
// 의도: textarea에 초기값 "hello" 설정
const textarea = createElement('textarea', 'editor', null, {
value: 'hello',
});
console.log(textarea.value); // "" — 빈 문자열!
setAttribute('value', 'hello')는 HTML 어트리뷰트를 설정하는 것이고, textarea의 실제 값은 DOM 프로퍼티인 element.value로 제어된다. HTML에서 <textarea>의 초기값은 자식 텍스트 노드로 결정되지, value 어트리뷰트가 아니다. <input>은 value 어트리뷰트가 초기값을 설정하지만, 사용자가 입력한 후에는 프로퍼티만 현재 값을 반영한다.
이 차이를 유틸리티 함수 내부에서 처리하면 호출부는 신경 쓸 필요가 없다.
const createElement = (tag, className, textContent, attributes = {}) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (textContent) element.textContent = textContent;
Object.entries(attributes).forEach(([key, value]) => {
if (key === 'value' && (tag === 'textarea' || tag === 'input')) {
element.value = value;
} else {
element.setAttribute(key, value);
}
});
return element;
};
이런 예외 처리가 유틸리티 함수의 핵심 가치다. 한 곳에서 한 번만 처리하면 프로젝트 전체에서 같은 실수를 반복하지 않는다. 이것이 추상화의 진짜 이유다 — 코드를 짧게 만드는 것이 아니라, 실수를 구조적으로 방지하는 것이다.
어트리뷰트 vs 프로퍼티 정리
| 구분 | HTML 어트리뷰트 (setAttribute) | DOM 프로퍼티 (직접 할당) |
|---|---|---|
input.value | 초기값만 설정 (이후 변경 반영 안 됨) | 현재 값을 직접 제어 |
textarea.value | 동작하지 않음 | 현재 값을 직접 제어 |
checkbox.checked | 초기 상태만 설정 | 현재 체크 상태 제어 |
select.value | 동작하지 않음 | 현재 선택값 제어 |
element.className | 문자열로 설정 | 문자열로 설정 (동일) |
element.style | 문자열로 설정 | CSSStyleDeclaration 객체 |
폼 요소의 "현재 상태"를 다룰 때는 프로퍼티 직접 할당이 안전하다. 어트리뷰트는 HTML 마크업의 초기값에 가깝고, 프로퍼티는 런타임의 현재 상태를 반영한다.
특화 팩토리 함수
범용 createElement만으로도 충분하지만, 자주 사용하는 패턴은 별도 함수로 만들면 호출부가 더 간결해진다.
버튼 팩토리
const createButton = (text, className, disabled = false) => {
const button = createElement('button', `btn ${className}`);
const span = createElement('span', 'btn-label', text);
button.appendChild(span);
if (disabled) button.disabled = true;
return button;
};
버튼 안에 span으로 텍스트를 감싸는 것은 스타일링 목적이다. 버튼 내부에 아이콘과 텍스트를 나란히 배치해야 할 때 span이 있으면 CSS로 제어하기 쉽다. 이런 내부 구조를 팩토리 함수에 캡슐화하면 버튼의 HTML 구조가 바뀌어도 호출부를 수정할 필요가 없다.
// 호출부는 의도만 표현
const saveBtn = createButton('저장', 'btn-primary');
const cancelBtn = createButton('취소', 'btn-secondary');
const submitBtn = createButton('제출', 'btn-primary', true);
이미지 팩토리
const createImage = (src, alt, className) => {
return createElement('img', className, null, { src, alt });
};
이미지는 항상 src와 alt가 필요하다. alt를 빼먹는 실수를 팩토리 함수의 시그니처로 방지할 수 있다. 파라미터에 alt가 있으니까 호출할 때 자연스럽게 넣게 된다.
하지만 이 함수의 규모가 작고 createElement로 충분히 표현 가능하다면, 굳이 별도 함수로 분리하지 않는 것도 좋은 판단이다. 나중에 createElement와 통합해서 코드를 줄일 수도 있다.
설계 판단 기준
DOM 유틸리티를 설계할 때 반복적으로 마주치는 판단 지점들이 있다.
얼마나 추상화할 것인가
추상화 수준을 결정하는 기준은 반복 빈도와 내부 구조의 복잡도다.
// 레벨 1: 범용 함수 하나로 충분
createElement('div', 'card', null);
// 레벨 2: 특정 패턴이 3번 이상 반복되면 팩토리로
createButton('저장', 'btn-primary');
// 레벨 3: 복합 구조가 반복되면 컴포넌트 수준으로
createCard({ title: '할 일', content: '내용', status: 'todo' });
레벨 3까지 가면 사실상 컴포넌트 시스템을 만드는 것이므로, 별도의 View 클래스로 분리하는 것이 낫다. DOM 유틸리티는 레벨 1~2에서 멈추는 것이 적절하다.
children 지원 여부
요소를 만들면서 자식도 한 번에 추가하고 싶은 경우가 있다.
const createElement = (tag, className, children = []) => {
const element = document.createElement(tag);
if (className) element.className = className;
children.forEach((child) => {
if (typeof child === 'string') {
element.appendChild(document.createTextNode(child));
} else {
element.appendChild(child);
}
});
return element;
};
// 사용
const card = createElement('div', 'card', [
createElement('h3', 'card-title', ['할 일 목록']),
createElement('p', 'card-desc', ['오늘 할 일을 정리합니다']),
createButton('추가', 'btn-add'),
]);
이 방식은 JSX 없이도 선언적으로 DOM 트리를 구성할 수 있다는 장점이 있다. 하지만 파라미터가 많아지면 오히려 가독성이 떨어질 수 있으므로, 프로젝트 규모와 팀 컨벤션에 따라 판단해야 한다.
이벤트 바인딩을 포함할 것인가
const createElement = (tag, className, textContent, attributes = {}, events = {}) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (textContent) element.textContent = textContent;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
Object.entries(events).forEach(([event, handler]) => {
element.addEventListener(event, handler);
});
return element;
};
// 사용
const deleteBtn = createElement('button', 'btn-delete', '삭제', {}, {
click: () => handleDelete(itemId),
});
편리하지만, 이벤트 바인딩까지 유틸리티에 포함하면 책임이 커진다. 이벤트 해제(removeEventListener)를 누가 관리할 것인지, 이벤트 위임 패턴과 충돌하지 않는지 등을 고려해야 한다. 단순한 DOM 유틸리티는 요소 생성까지만 담당하고, 이벤트는 컴포넌트 레이어에서 처리하는 것이 책임 분리 원칙에 맞다.
React의 createElement와 비교
여기서 만든 DOM 유틸리티와 React의 React.createElement는 비슷해 보이지만 근본적으로 다르다.
// Vanilla JS — 실제 DOM 노드를 즉시 생성
const div = createElement('div', 'card', 'hello');
// div instanceof HTMLDivElement → true
// React — Virtual DOM 객체를 생성 (실제 DOM 아님)
const vnode = React.createElement('div', { className: 'card' }, 'hello');
// vnode = { type: 'div', props: { className: 'card', children: 'hello' } }
Vanilla JS 유틸리티는 document.createElement를 감싸는 편의 함수일 뿐이다. 호출 즉시 실제 DOM 노드가 만들어진다. React의 createElement는 가상 객체(ReactElement)를 반환하고, 실제 DOM 생성은 나중에 Reconciler가 처리한다.
이 차이가 의미하는 것은, Vanilla JS에서는 요소를 만들 때마다 실제 DOM 조작이 발생하므로 대량의 요소를 한 번에 만들 때 성능에 주의해야 한다. DocumentFragment를 사용하거나, 문자열로 한 번에 innerHTML로 삽입하는 방식이 더 빠를 수 있다.
// DocumentFragment 활용
const fragment = document.createDocumentFragment();
items.forEach((item) => {
fragment.appendChild(createElement('li', 'list-item', item.text));
});
container.appendChild(fragment); // DOM 삽입은 한 번만 발생
DocumentFragment에 자식을 추가하는 것은 실제 DOM 트리에 영향을 주지 않으므로 리플로우가 발생하지 않는다. 마지막에 appendChild로 한 번에 삽입하면 리플로우를 최소화할 수 있다.
정리
DOM 유틸리티 함수를 만드는 이유를 다시 정리하면:
- 의도 표현: 저수준 API 호출 대신 "무엇을 만드는지"가 드러남
- 실수 방지:
setAttributevs 프로퍼티 같은 함정을 한 곳에서 처리 - 변경 용이: 내부 HTML 구조가 바뀌어도 호출부는 그대로
- 일관성: 프로젝트 전체에서 동일한 방식으로 요소 생성
프레임워크를 쓰면 이런 고민 자체가 필요 없다. JSX가 이 모든 것을 해결해주기 때문이다. 하지만 Vanilla JS로 작업할 때는 이런 유틸리티 계층이 코드 품질에 직접적인 영향을 준다. 핵심은 과도한 추상화를 피하고, 프로젝트에서 실제로 반복되는 패턴만 함수로 만드는 것이다.