DOM 요소를 생성하거나 수정할 때 주로 사용하는 방식을 크게 2가지로 나눌 수 있다.
본 글에서는 이 2가지 방식의 차이점과 더 나은 방식은 없을까에 대해 이야기 할 예정이다.
다만 먼저 짚고 넘어가야 할 점이 있다.
어떤 방식이 항상 절대적으로 더 좋다고 말할 수는 없다.
결국 성능은 어떤 상황에서 어떤 방식으로 DOM을 다루느냐에 따라 달라진다.
그래서 이번 글에서는 다음과 같은 상황을 가정했다.
데이터 배열을 받아 화면에 반복적으로 렌더링해야 하는 상황
이 상황을 택한 이유는 간단하다. 실제 웹 개발에서는 서버나 사용자 입력으로 들어온 데이터를 리스트 형태로 반복 렌더링하는 경우가 매우 많기 때문이다.
가장 먼저 살펴볼 방식은 innerHTML을 이용한 렌더링이다.
function renderTickets() {
board.innerHTML = '';
tickets.forEach((ticket, index) => {
board.innerHTML += createTicketMarkup(ticket, index, filterSelect.value);
});
}
tickets라는 데이터 배열을 받아온 후 createTicketMarkup으로 HTML 문자열을 만들고 이를 innerHTML에 추가하는 방식이다.
겉으로 보기엔 단순해 보인다.
하지만 이 방식은 반복 렌더링 상황에서 성능상 매우 좋지 않은 패턴이 될 수 있다.
왜 그런지 알기 위해선 먼저 innerHTML이 어떻게 동작하는지 알 필요가 있다.
innerHTML은 문자열을 받아 브라우저가 이를 HTML로 파싱하고, 이를 또 DOM으로 변환하는 방식으로 동작한다.
이전 코드가 문제가 되었던 이유는 innerHTML += ... 형태라는 점이다.
이 코드는 겉보기엔 단순히 기존 내용 뒤에 새로운 마크업을 추가하는 것처럼 보이지만, 실제로는 그렇게 단순하지 않다.
브라우저는 새로 추가된 부분만 따로 처리하지 않고, 추가되고 난 후에 만들어진 전체 문자열을 기준으로 HTML파싱과 DOM 노드 변환 작업을 실시한다.
구체적인 예시로 확인해보자.
board.innerHTML에 첫 번째 ticket 마크업을 추가했다. 그럼 무슨 일이 일어날까?
해당 마크업이 <div class="ticket">~</div>이라고 해보자. 브라우저는 이를 파싱하고, DOM을 그려야 한다.

생성된 DOM은 위와 같은 모습일 것이다.
하지만 우리는 티켓이 여러개이기 때문에 바로 다음 티켓의 마크업을 innerHTML로 넣어줘야한다.
이 부분을 주의깊게 봐야한다.
기존 1개의 티켓에 단순히 1개의 티켓을 추가로 더해주므로 괜찮지 않나?라고 생각하면 큰 오산이다.
2번째 티켓을 넣은 이후의 상태를 보면 innerHTML에는 <div class="ticket">~</div><div class="ticket">~</div>이런식으로 2개가 존재할 것이다.
브라우저는 두 번째 티켓만 따로 파싱한 뒤 DOM 노드를 생성하는 것이 아니라, 이 전체 문자열을 다시 파싱해서 전체 DOM을 다시 구성하게 된다.
즉, 티켓이 100개라면 단순히 100개의 DOM 노드만 만드는 것이 아니라,
과정을 거쳐 5050번에 가까운 노드 생성 비용이 발생하게 된다
우리 입장에선 DOM 노드 100개를 추가한 것 뿐이지만, 브라우저 입장에선 최종적으로 100개의 노드를 만들기 위해 5050번의 노드 생성 작업을 실시했던 것이다.
Chrome 성능 탭에서 CPU 4배 감속 옵션을 걸고, 티켓을 300개 생성해봤다.

티켓 생성에 약 5303ms가 소요됐다.
이 예시에서는 문자열을 만들고 innerHTML에 넣는 방식 자체가 문제라기보다,
반복문 안에서 innerHTML을 계속 다시 설정하는 방식이 큰 병목이었다.
그럼 이렇게 생각할 수도 있다.
문자열로만 반복문을 돌려 이어붙인다음 마지막에 딱 1번만 innerHTML을 하면 되는 것 아닌가?
맞다. 실제로 그렇게 바꾸면 성능이 크게 개선된다.
function renderTickets() {
let tmpString = '';
tickets.forEach((ticket, index) => {
tmpString += createTicketMarkup(ticket, index, filterSelect.value);
});
board.innerHTML = tmpString;
}
이 경우에는 반복문 동안 문자열만 만들고, 마지막에 딱 한 번만 innerHTML을 설정한다.
결과는 다음과 같았다.

innerHTML을 반복해서 사용했을 때와 비교하면
5,303.5 ms ⇒ 43.3ms
약 99.18%나 빨라진 것을 확인할 수 있다.
이 정도 차이라면 사실상 병목이 어디 있었는지 명확하게 드러난다.
문제는 innerHTML 자체라기보다, 반복해서 innerHTML을 다시 설정한 방식이었다.
그렇다면 자연스럽게 이런 의문이 든다.
innerHTML을 한 번만 써도 충분히 빨라졌는데,
createElement는 또 어떤 차이가 있는 걸까?
아래는 createElement를 사용한 방식이다.
function createTicketElement(ticket, index, filter) {
const highlighted = shouldHighlight(ticket, filter);
const article = document.createElement('article');
article.className = 'ticket';
article.style.backgroundColor = highlighted ? '#EFF6FF' : '#FFFFFF';
const indexDiv = document.createElement('div');
indexDiv.className = 'ticket__index';
indexDiv.textContent = `#${index + 1}`;
const ballsDiv = document.createElement('div');
ballsDiv.className = 'balls';
ticket.forEach((number) => {
const ball = document.createElement('span');
ball.className = 'ball';
ball.textContent = String(number);
ballsDiv.appendChild(ball);
});
function renderTickets() {
board.innerHTML = "";
tickets.forEach((ticket, index) => {
const ticketElement = createTicketElement(ticket, index, filterSelect.value);
board.appendChild(ticketElement);
});
}
이 방식은 innerHTML처럼 문자열을 파싱하고 DOM 객체를 생성하는 것이 아니라 createElement로 DOM 객체를 파싱없이 직접 생성하는 방식이다.
즉, HTML 문자열 전체를 다시 파싱하는 과정 없이 노드를 직접 구성할 수 있다는 점이 차이점이다.
물론 여기서도 조심해야 한다.
“파싱이 없으니 무조건 더 빠르다”
이렇게 단정할 수는 없다.
브라우저 엔진마다 최적화 방식이 다르고, 성능에는 문자열 생성 비용, 노드 생성 비용, 실제 DOM 접근 횟수 등 여러 요소가 영향을 준다.
하지만 적어도 반복 렌더링 상황에서는,
문자열 전체를 다시 파싱하는 방식보다 필요한 노드를 직접 생성하는 방식이 더 유리할 가능성이 크다.
실제로 같은 조건에서 측정해봤을 때, innerHTML을 마지막에 한 번만 사용하는 방식보다도 조금 더 빠른 결과가 나왔다.

약 4ms 정도로 미세한 차이지만 더 빠른 성능을 보였다.
이렇게만 들으면 createElement + appendChild 조합이 꽤 괜찮아 보인다.
하지만 이 방식에도 분명 한계가 존재한다.
appendChild를 할 때마다 실제 DOM에 접근해 자식을 추가하게 되고, 이 과정에서 브라우저의 리플로우(Reflow)와 리페인트(Repaint)가 반복적으로 발생해 렌더링 비용이 급증할 수 있다.
이 과정이 수백 번, 수천 번 반복되면 그 자체로 비용이 커질 수 있다.
즉, DOM 요소를 직접 생성하는 것은 좋지만, 생성된 노드를 실제 DOM에 하나씩 붙이는 것은 다른 부담이 될 수 있다.
그렇다면 이런 생각이 든다.
DOM 노드는 미리 다 만들어두고,
마지막에 실제 DOM에는 한 번만 반영할 수 없을까?
이때 나오는 것이 바로 DocumentFragment다.
먼저 아래 코드가 Fragment를 사용한 코드이다.
function renderTickets() {
board.innerHTML = "";
const fragment = document.createDocumentFragment();
tickets.forEach((ticket, index) => {
fragment.appendChild(createTicketElement(ticket, index, filterSelect.value));
});
board.appendChild(fragment); // 마지막에 1번만 실제 DOM 접근
}
Fragment는 정확히 말하면 DOM 트리에 직접 붙어 있지 않은 임시 컨테이너다.
즉, 실제 화면에 바로 반영되는 DOM이 아니라,
노드들을 잠시 모아두는 중간 임시 공간처럼 사용할 수 있다.
그렇다면 이게 왜 좋을까?
메모리 상에 임시로 생성된 DOM이므로 실제 DOM요소에 접근할 필요도 없고, 자식 요소를 추가하거나 삭제해도 DOM을 다시 그릴 필요가 없어진다.
결국 Fragment에 티켓들을 모두 추가해놓고, 마지막에 Fragment를 1번만 실제 DOM에 붙이면, 그 안에 있던 자식들이 한 번에 옮겨진다는 것이다.
즉, Fragment의 핵심은 실제 DOM 접근 횟수를 줄일 수 있다는 점이다.
이전처럼 Fragment 없이 createElement+appendChild만 사용한 방식과 Fragment를 사용한 방식을 비교해보자.
다른 조건은 유지한 채로 티켓의 수를 300개에서 3000개로 늘린 채로 비교해봤다.
Fragment 사용X

Fragment 사용O

결과를 보면 Fragment를 사용한 쪽이 약 25배 더 좋은 성능을 보여준다.
데이터 개수가 적을 때는 차이가 크게 느껴지지 않을 수도 있다.
하지만 렌더링해야 할 요소 수가 많아질수록, 실제 DOM에 몇 번 접근하느냐의 차이가 확연히 보인다.
이번 실험을 통해 얻은 결론은 생각보다 단순했다.
1. innerHTML += ... 를 반복문 안에서 사용하는 건 매우 비효율적이다
문자열을 계속 이어붙이는 것처럼 보이지만, 실제로는 한 번 더해질 때마다 전체 문자열을 기준으로 DOM 노드를 계속 생성하여 큰 비용이 발생한다.
2. innerHTML도 마지막에 한 번만 사용하면 충분히 빨라질 수 있다
문제는 innerHTML 자체보다는 반복해서 다시 설정하는 것이다.
3. createElement는 DOM 노드를 직접 생성할 수 있다는 장점이 있다
문자열 전체를 다시 HTML로 파싱하지 않고 필요한 노드를 직접 만들 수 있기 때문에, 반복 렌더링 상황에서 더 유리할 수 있다.
4. 하지만 appendChild를 실제 DOM에 계속 수행하는 것도 비용이 된다
노드 생성과 실제 DOM 반영은 별개의 문제다. 파싱없이 노드를 만들어도 실제 DOM에 수천 번 붙이면 또 다른 병목이 생길 수 있다.
5. 많은 요소를 반복 렌더링해야 한다면 Fragment가 매우 효과적이다
노드를 임시 컨테이너에 먼저 모아두고, 마지막에 한 번만 실제 DOM에 반영할 수 있기 때문이다.
이 글의 결론은 단순히
“Fragment가 좋다” 가 아니다.
하지만!
반복 렌더링 상황에서는 "어떤 방식으로 DOM 객체를 만들지"보다 “실제 DOM에 얼마나 접근하는지”가 더 중요하다.
그리고 해당 관점에서 DocumentFragment는 아주 좋은 선택지가 될 수 있다.
앞으로 반복적으로 많은 요소를 렌더링해야 하는 상황이라면,
무작정 appendChild를 사용하기 전에 이 글의 제목을 다시 한 번 상기시켜보자.