이래도 Fragment를 쓰지 않겠다고?

정이현·2026년 4월 13일

DOM 요소를 생성하거나 수정할 때 주로 사용하는 방식을 크게 2가지로 나눌 수 있다.

  1. innerHTML에 문자열 형태로 마크업을 넣는 방식
  2. createElement로 DOM 노드를 생성해 appendChild하는 방식

본 글에서는 이 2가지 방식의 차이점과 더 나은 방식은 없을까에 대해 이야기 할 예정이다.

다만 먼저 짚고 넘어가야 할 점이 있다.
어떤 방식이 항상 절대적으로 더 좋다고 말할 수는 없다.

결국 성능은 어떤 상황에서 어떤 방식으로 DOM을 다루느냐에 따라 달라진다.

그래서 이번 글에서는 다음과 같은 상황을 가정했다.

데이터 배열을 받아 화면에 반복적으로 렌더링해야 하는 상황

이 상황을 택한 이유는 간단하다. 실제 웹 개발에서는 서버나 사용자 입력으로 들어온 데이터를 리스트 형태로 반복 렌더링하는 경우가 매우 많기 때문이다.


innerHTML 방식

가장 먼저 살펴볼 방식은 innerHTML을 이용한 렌더링이다.

function renderTickets() {
  board.innerHTML = '';

  tickets.forEach((ticket, index) => {
    board.innerHTML += createTicketMarkup(ticket, index, filterSelect.value);
  }); 
}

tickets라는 데이터 배열을 받아온 후 createTicketMarkup으로 HTML 문자열을 만들고 이를 innerHTML에 추가하는 방식이다.

겉으로 보기엔 단순해 보인다.
하지만 이 방식은 반복 렌더링 상황에서 성능상 매우 좋지 않은 패턴이 될 수 있다.

왜 그런지 알기 위해선 먼저 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 노드만 만드는 것이 아니라,

  • 1개짜리 문자열 파싱
  • 2개짜리 문자열 파싱
  • 3개짜리 문자열 파싱
  • …
  • 100개짜리 문자열 파싱

과정을 거쳐 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 방식

아래는 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 정도로 미세한 차이지만 더 빠른 성능을 보였다.

appendChild도 완벽한 해결책은 아니다.

이렇게만 들으면 createElement + appendChild 조합이 꽤 괜찮아 보인다.
하지만 이 방식에도 분명 한계가 존재한다.

appendChild를 할 때마다 실제 DOM에 접근해 자식을 추가하게 되고, 이 과정에서 브라우저의 리플로우(Reflow)와 리페인트(Repaint)가 반복적으로 발생해 렌더링 비용이 급증할 수 있다.
이 과정이 수백 번, 수천 번 반복되면 그 자체로 비용이 커질 수 있다.

즉, DOM 요소를 직접 생성하는 것은 좋지만, 생성된 노드를 실제 DOM에 하나씩 붙이는 것은 다른 부담이 될 수 있다.

그렇다면 이런 생각이 든다.

DOM 노드는 미리 다 만들어두고,
마지막에 실제 DOM에는 한 번만 반영할 수 없을까?

이때 나오는 것이 바로 DocumentFragment다.


Fragment

먼저 아래 코드가 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를 사용하기 전에 이 글의 제목을 다시 한 번 상기시켜보자.

0개의 댓글