프레임워크 없는 프론트엔드 개발 스터디 2장 렌더링

파견근로자·2022년 3월 15일
0
post-thumbnail

2장 렌더링

프롤로그

요즘은 많은 사람들(개발자)이 React.js나 Angular.js와 같은 프레임워크나 라이브러리를 활용하여 개발을 합니다.

이러한 도구들은 상태(데이터)가 변할 때 화면 UI가 바뀌는 경험을 쉽게 할 수 있게 만들었습니다.

전통적인 javascript 방식을 한번 생각해 봅시다.

querySelector나 getElementById와 같은 Element Selector를 이용하여 DOM 객체를 가져오고, 여기에 값을 넣는 형태의 작업이 떠오르지 않으시나요?

만약 이러한 방식이 익숙하시다면 오늘 내용을 읽고 한 번 생각해 보는 시간을 가졌으면 좋겠습니다.

어떻게 브라우저가 데이터를 가지고 UI를 표현하는지 그 원리를 한 번 들여다 봅시다.

브라우저는 어떻게 화면을 보여줄까요?

DOM(Document Object Model)

우선 브라우저 설명에 앞서 DOM에 대해 들여다 보겠습니다.

웹 페이지는 일종의 문서(document)다. 이 문서는 웹 브라우저를 통해 그 내용이 해석되어 웹 브라우저 화면에 나타나거나 HTML 소스 자체로 나타나기도 한다. 동일한 문서를 사용하여 이처럼 다른 형태로 나타날 수 있다는 점에 주목할 필요가 있다. DOM 은 동일한 문서를 표현하고, 저장하고, 조작하는 방법을 제공한다.

(출처 : https://developer.mozilla.org/ko/docs/Web/API/Document_Object_Model/Introduction)

DOM(Document Object Model). Javascript를 공부하면서 수도 없이 접했던 단어입니다.

원래 HTML은 단순한 문서였습니다. 정보를 보여주기 위한 방식인 것이지요. 그러나 여기에 디자인과 동작이 들어가면서 javascript와 css가 탄생했습니다.

이로인해 문서 내부에 있는 정보에 접근하고 표현할 방법이 필요했지요. 따라서 javascript가 등장하는 시점에 이를 표현하기 위한 방법으로 DOM이 등장하게 됩니다.

(출처 : https://upload.wikimedia.org/wikipedia/commons/5/5a/DOM-model.svg)

DOM은 트리구조로 되어있고 이를 탐색해가면서 원하는 Element에 접근합니다.

javascript에서는 DOM을 통해서 원하는 부분에 동작을 입힐 수 있게 되는 것이지요.

정리하면 DOM은 문서의 디자인, 동작을 위해 정의한 문서 객체 모델이라고 할 수 있습니다.

Brouser Rendering Process

그렇다면 실제로 브라우저는 어떻게 동작하게 될까요?

자세한 설명은 조금 나중에 하고 여기에서는 간단하게 동작 과정에 대해 설명드리려고 합니다.

(브라우저 렌더링 과정만 한번 정리해야할 것 같습니다..)

브라우저는 실제로 아래와 같은 방식으로 동작합니다.(브라우저 엔진에 따라 조금씩 차이가 있을 수 있습니다.)

(출처 : https://d2.naver.com/content/images/2015/06/helloworld-59361-2.png)

  1. DOM 트리, CSSOM 트리 생성
    HTML, CSS 파일을 Parsing하여 각각 DOM트리, CSSOM트리를 생성한다.
  2. Render 트리 생성
    생성된 DOM트리, CSSOM트리를 결합하여 Render 트리를 생성한다.
  3. Layout
    생성된 Render 트리를 화면에 맞게 배치한다.
  4. Paint
    배치된 Render 트리를 화면에 그린다.

상세한 렌더링 과정은 링크(https://d2.naver.com/helloworld/59361)를 참고 부탁드리겠습니다.

여기까지가 브라우저 렌더링 과정에 대한 간단한 설명이었습니다.

우리는 이번 시간에 ToDoMVC 템플릿을 이용하여 javascript를 이용한 화면 rendering에 대해 배워보겠습니다.

들어가기에 앞서

우리는 위 화면을 만들어보면서 렌더링의 원리와 개념에 대해 살펴보려고 합니다.

위 화면을 동적으로 만들기 위해서는 어떤 부분을 작성해야 할까요?

이 책에서 제시하는 내용은 아래와 같습니다.

  • 필터링된 todo 리스트를 가진 ul
  • 완료되지 않은 todo 수를 가진 span
  • selected 클래스를 오른쪽에 추가한 필터 유형을 가진 링크

우리는 위 내용을 구현하면서 브라우저 렌더링 함수의 원리를 정리해보는 시간을 가져보려고 합니다.

그럼 천천히 시작해 볼까요?

(위 화면은 ToDo List를 표현하는 예제이며 아래 링크에 소스가 올라와 있으니 참고 부탁드리겠습니다.)

(소스 출처 : https://github.com/Apress/frameworkless-front-end-development/tree/master/Chapter02)

순수 함수 렌더링

// todos.js
const getTodoElement = (todo) => {
  const { text, completed } = todo;

  return `
    <li ${completed ? 'class="completed"' : ''}>
        <div class = "view">
            <input ${
              completed ? 'checked' : ''
            } class="toggle" type="checkbox"/>
            <label>${text}</label>
            <button class="destroy"><button>
        </div>
        <input class="edit" value="${text}"/>
    </li>
  `;
};

export default (targetElement, { todos }) => {
  const newTodoList = targetElement.cloneNode(true);
  const todosElements = todos.map(getTodoElement).join('');
  newTodoList.innerHTML = todosElements;
  return newTodoList;
};
  • To Do List를 만드는 함수입니다.
  • todo data를 받아서 list를 String으로 만들고 innerHTML을 통해 새로운 element에 쓰고 반환합니다.
// app.js

import todosView from './todos.js'
import counterView from './counter.js'
import filtersView from './filters.js'

export default (targetElement, state) => {
  const element = targetElement.cloneNode(true)

  const list = element
    .querySelector('.todo-list')
  const counter = element
    .querySelector('.todo-count')
  const filters = element
    .querySelector('.filters')

  list.replaceWith(todosView(list, state))
  counter.replaceWith(counterView(counter, state))
  filters.replaceWith(filtersView(filters, state))

  return element
}
  • 가져온 컴포넌트를 위치에 맞게 넣어줍니다.
  • 위에서 생성한 todos 컴포넌트의 경우 todo-list DOM에 접근하여 채워주게 됩니다.
  • todo-list는 위에 작성된 내용이고 이외에는 생략하였습니다.
// index.js

import getTodos from './getTodos.js'
import appView from './view/app.js'

const state = {
  todos: getTodos(),
  currentFilter: 'All'
}

const main = document.querySelector('.todoapp')

window.requestAnimationFrame(() => {
  const newMain = appView(main, state)
  main.replaceWith(newMain)
})
  • 작성된 app을 실재 화면에 렌더링 해주는 Controller 입니다.
  • 실제 렌더링 함수이며 앞서 작성한 화면에 데이터를 매핑하여 그려주는 동작을 거치게 됩니다.

requestAnimationFrame은??

위 예제에서 작성한 렌더링 엔진은 requestAnimationFrame을 기반으로 합니다.

모든 DOM조작이나 애니메이션은 이 DOM API를 기반으로 해야합니다. 이 API는 메인 스레드를 차단하지 않으며 다음 repaint가 이벤트 루프에서 스케줄링되기 직전에 실행됩니다.

requestAnimationFrame이란?
window.requestAnimationFrame()은 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 합니다. 이 메소드는 리페인트 이전에 실행할 콜백을 인자로 받습니다.
화면에 새로운 애니메이션을 업데이트할 준비가 될때마다 이 메소드를 호출하는것이 좋습니다. 이는 브라우저가 다음 리페인트를 수행하기전에 호출된 애니메이션 함수를 요청합니다.
(출처 : MDN https://developer.mozilla.org/ko/docs/Web/API/Window/requestAnimationFrame)

순수함수 렌더링은 보통 우리가 사용하는 렌더링 방식을 보여줍니다.

이때 컴포넌트 단위로 어떻게 분리하였고, 이를 조합하는 과정이 어떤지 소스를 보면서 익히시면 좋습니다.

구성 요소 함수

위에서 작성된 코드를 확인해보면 올바른 함수를 수동으로 호출해야하는 것을 볼 수 있습니다.

그렇다면 우리는 이를 추상화하여 선언적 방식을 활용해볼 수 있지 않을까요?

그럼 여기에서 어떤 방식으로 렌더링 과정을 추상화 할 수 있는지 살펴보겠습니다.

<!-- 구성요소를 표현하는 html 파일-->
	<section class="todoapp">
        <header class="header">
            <h1>todos</h1>
            <input 
                class="new-todo" 
                placeholder="What needs to be done?" 
                autofocus>
        </header>
        <section class="main">
            <input 
                id="toggle-all" 
                class="toggle-all" 
                type="checkbox">
            <label for="toggle-all">
                Mark all as complete
            </label>
            <ul class="todo-list" data-component="todos">
            </ul>
        </section>
        <footer class="footer">
            <span 
                class="todo-count" 
                data-component="counter">
                    1 Item Left
            </span>
            <ul class="filters" data-component="filters">
                <li>
                    <a href="#/">All</a>
                </li>
                <li>
                    <a href="#/active">Active</a>
                </li>
                <li>
                    <a href="#/completed">Completed</a>
                </li>
            </ul>
            <button class="clear-completed">
                Clear completed
            </button>
        </footer>
    </section>

위 html 파일에서 특이한 점을 찾을 수 있다면 이미 반 이상 알고 계신 것과 다름 없습니다.

바로 data-component에 값을 매핑해주는 것입니다.

이 속성은 뷰 함수의 필수 호출을 대체하며 이는 registry의 값과 일치 시키게 됩니다.
(registry 값을 찾아가는 index의 역할을 하게됩니다. 렌더링 매핑 키라고 보시면 될 것 같네요)

// registry.js
const registry = {}

const renderWrapper = component => {
  return (targetElement, state) => {
    const element = component(targetElement, state)

    const childComponents = element
      .querySelectorAll('[data-component]')

    Array
      .from(childComponents)
      .forEach(target => {
        const name = target
          .dataset
          .component

        const child = registry[name]
        if (!child) {
          return
        }

        target.replaceWith(child(target, state))
      })

    return element
  }
}

const add = (name, component) => {
  registry[name] = renderWrapper(component)
}

const renderRoot = (root, state) => {
  const cloneComponent = root => {
    return root.cloneNode(true)
  }

  return renderWrapper(cloneComponent)(root, state)
}

export default {
  add,
  renderRoot
}
  • 레지스트리의 key는 data-component 속성 값과 일치하게 됩니다.
    이 메커니즘은 루트 컨테이너 뿐 아니라 향후 생성할 모든 구성 요소에도 적용되니 추상화의 관점에서 지켜봐 주세요.
  • 이렇게 추상화를 위해서는 data-component 속성의 값을 읽고 올바른 함수를 자동으로 호출하는 기본 구성 요소에서 상속되어야 합니다. 하지만 원래 순수함수로 작성되었기 때문에 위 기본 객체에서는 고차함수(HoC)를 생성하여 이를 해결합니다.
  • 이 래퍼 함수는 원래 구성 요소를 가져와 동일한 서명의 새로운 구성 요소를 반환합니다.
    이때 래퍼는 레지스트리에서 data-component속성을 가진 모든 DOM 요소를 탐색하게 됩니다.

고차함수(High-order Function)이란?
함수를 반환할 때 다른 함수를 반환하는 함수입니다.
함수라는 말이 너무 많이 나와서 헷갈릴 것 같아서 예제를 가져왔습니다.
활용법은 무궁무진하나 이번에 사용한 이유는 컴포넌트 함수를 동적으로 넘겨받아야 하기 때문입니다.

// 고차함수 예제
function sayHello() {
   return function() {
      console.log("Hello!");
   }
}

자 그럼 메인 호출문이 어떻게 바뀌는지 한 번 볼까요?

import getTodos from './getTodos.js'
import todosView from './view/todos.js'
import counterView from './view/counter.js'
import filtersView from './view/filters.js'

import registry from './registry.js'

registry.add('todos', todosView)
registry.add('counter', counterView)
registry.add('filters', filtersView)

const state = {
  todos: getTodos(),
  currentFilter: 'All'
}

window.requestAnimationFrame(() => {
  const main = document.querySelector('.todoapp')
  const newMain = registry.renderRoot(main, state)
  main.replaceWith(newMain)
})
  • 위에 미리 작성해둔 registry의 add 한수를 활용하여 동적으로 view를 주입하게 됩니다.
  • todos는 data-component의 값과 일치하며 해당 위치에 미리 작성해놓은 컴포넌트를 주입하는 형태로 작성합니다.
  • 이로인해 view component를 별도로 작성하여 상황에 맞게 동적으로 렌더링 할 수 있게 됩니다.

선언적 렌더링의 문제는 한개의 데이터가 변할 경우 DOM 전체를 갈아줘야하는 문제가 있습니다.

이는 단순한 화면이라면 문제가 없지만 페이스북처럼 DOM이 수백개가 유기적으로 변화해야 한다면 성능 문제를 야기시킬 수 있죠.
(옛날 페이스북 사용할때 캐시 날리거나 페북 껐다 켜본 경험 있으시면 나이가 조금 있으신 겁니다 ㅎ)

그래서 가상 DOM 이란 개념을 도입한 라이브러리를 도입하게 됩니다.

그렇습니다.

그게 바로 리엑트 입니다.

동적 데이터 렌더링

가상 DOM

가상DOM의 개념은 페이스북 속도 개선을 위해 리엑트를 세상에 공개함으로 유명해진 개념입니다.

가상 DOM의 개념은 선언적 렌더링 엔진의 성능을 개선시키기 위한 목적으로 만들어졌죠.

실제 DOM은 가능한 적게 작업을 수행하도록 별도의 가상 DOM을 메모리에 유지시키고 동기화 하는 작업을 거치게 됩니다.

(출처 : https://buyandpray.tistory.com/79 )

가상 DOM 개념에서 가장 중요한 것은 diff 알고리즘입니다.

diff알고리즘은 실제 DOM을 가상의 새로운 DOM 요소의 사본으로 바꾸는 알고리즘으로 node 비교를 통해 변경 여부를 확인하여 교체합니다.

그렇다면 diff 알고리즘은 어떻게 노드의 변경을 감지할까요?

그 해답은 아래 비교하는 로직을 거쳐서 감지할 수 있게 됩니다.

노드 비교 방법
1. 속성 수가 다르다.
2. 하나 이상의 속성이 변경되었다.
3. 노드에는 자식이 없으며, textContent가 다르다.

위 방법에 대한 간단한 코드는 아래 적어놓을테니 개념 이해에 참고 부탁드리겠습니다.

	// applyDiff.js
	const isNodeChanged = (node1, node2) => {
  const n1Attributes = node1.attributes;
  const n2Attributes = node2.attributes;
  if (n1Attributes.length !== n2Attributes.length) {
    return true;
  }

  const differentAttribute = Array.from(n1Attributes).find((attribute) => {
    const { name } = attribute;
    const attribute1 = node1.getAttribute(name);
    const attribute2 = node2.getAttribute(name);

    return attribute1 !== attribute2;
  });

  if (differentAttribute) {
    return true;
  }

  if (
    node1.children.length === 0 &&
    node2.children.length === 0 &&
    node1.textContent !== node2.textContent
  ) {
    return true;
  }

  return false;
};

const applyDiff = (parentNode, realNode, virtualNode) => {
	// 새 노드가 정의되지 않은 경우 실제 노드를 삭제한다.
  if (realNode && !virtualNode) {
    realNode.remove();
    return;
  }

	// 실제 노드가 정의되지 않았지만 가상 노드가 존재하는 경우 부모 노드에 추가한다.
  if (!realNode && virtualNode) {
    parentNode.appendChild(virtualNode);
    return;
  }
	
	// 두 노드가 모두 정의된 경우 두 노드간 차이가 있는지 확인한다.
  if (isNodeChanged(virtualNode, realNode)) {
    realNode.replaceWith(virtualNode);
    return;
  }

  const realChildren = Array.from(realNode.children);
  const virtualChildren = Array.from(virtualNode.children);

  const max = Math.max(realChildren.length, virtualChildren.length);
  for (let i = 0; i < max; i++) {
		// 여기 이부분 재귀돌면서 하위 노드를 탐색한다.
    applyDiff(realNode, realChildren[i], virtualChildren[i]);
  }
};

export default applyDiff;

마치며

이렇게 프레임워크 없는 프론트엔드 개발 2장 렌더링편을 정리해 보았습니다.

실제 현업에서 리엑트와 같은 모던 라이브러리를 사용하시는 분들은 한 번 쯤 궁금해 하실만한 내용인 것 같습니다.

jsx 파일에서 state를 어떻게 화면에 매칭시키는지, 그리고 선언적으로 이를 동작하게 하는지 vanilla javascript로 구현하는 과정이 매우 흥미롭지 않았나 생각됩니다.

비록 위 예제는 리엑트의 동작 원리와 상이할 수 있지만 개념적으로 렌더링 엔진이 이런 방식으로 동작하는구나 하고 이해하시면 80%는 성공이 아닐까 생각합니다.

소스코드를 첨부하였으나 이는 이해를 돕기 위한 내용으로 개념적으로 이해하시고 소스코드를 따라가면서 세부 동작 과정을 이해하신다면 이 책을 내 것으로 만드는데 도움이 될 것이라 생각합니다.

긴 글 읽어주셔서 감사합니다.

내용에 잘못된 부분이 있다면 언제든 피드백 부탁드리겠습니다.

소스코드는 해당 책에서 제공한 github주소에서 발최한 내용임을 밝힙니다.

profile
10년만 개발자

0개의 댓글