본문 바로가기

CS

[JavaScript] 브라우저 렌더링과 DOM

브라우저는 어떻게 화면을 그릴까? 

렌더링은 크게 4단계로 이루어집니다. 

1. 브라우저가 HTML과 CSS를 파싱하여 DOM과 CSSOM 생성

2. DOM과 CSSOM을 결합해 렌더 트리 생성

3. 렌더 트리를 기반으로 각 요소의 위치와 크기를 계산하는 레이아웃 수행 

4. 계산된 결과를 화면에 색을 입혀 그려내는 페인트 수행 


HTTP/1.1과 HTTP/2 

브라우저가 서버와 통신할 때 사용하는 규약이 바로 HTTP입니다. 현재 가장 많이 쓰이는 버전은 HTTP/1.1과 HTTP/2이고, 이 둘의 차이가 실제 성능에 큰 영향을 미칩니다. 

 

HTTP/1.1의 한계 

HTTP/1.1은 하나의 커넥션에서 요청과 응답을 하나씩 처리합니다. CSS파일, 자바스크립트 파일, 이미지 파일 등 모든 리소스 요청이 개별적으로 순차 처리됩니다. 

개발자 도구 Network 패널에서 Queing 시간이 길게 잡히는 것을 확인할 수 있습니다. 

이는 연결 제한 때문에 요청이 줄 서서 기다리고 있다는 것을 의미합니다. 

같은 도메인에 동시 연결 수를 최대 6개로 제한하고 있기 때문에 이런 상황이 벌어지는거죠.. 

 

HTTP/2로 해결 

HTTP/2는 하나의 커넥션에서 여러 요청과 응답을 동시에 처리할 수 있습니다. 연결 제한이 없어지기 때문에 Queing 대기 시간이 사라지고, HTTP/1.1 대비 페이지 로드 속도가 약 50% 빠릅니다

현실적으로 문제를 해결하기 위해서는 HTTP/2를 지원하는 CDN을 사용하는 것입니다. 

(대부분의 클라우드 CDN가 기본적으로 HTTP/2를 지원)

▶ 이미지가 많은 페이지에서 느리다면 HTTP 버전을 확인해보세요 !

 

HTTP/2로 당장 바꾸기 어렵다면, preconnect만으로도 첫 요청의 DNS 조회 + TCP + TLS 핸드셰이크 시간을 없앨 수 있습니다. 

<link rel="preconnect" href="https://이미지서버도메인.com" crossorigin />

자바스크립트는 언제 실행될까? 

브라우저는 HTML을 위에서 아래로 순서대로 파싱합니다. 그러다가 <script> 태그를 만나면 HTML 파싱을 멈추고 자바스크립트를 먼저 실행합니다. 

<head>
  <script>
    const $apple = document.getElementById('apple'); // null
    $apple.style.color = 'red'; // TypeError
  </script>
</head>
<body>
  <li id="apple">Apple</li> 
</body>

브라우저 입장에서 <head>를 먼저 읽고 <script>를 발견해서 자바스크립트를 바로 실행합니다. 

자바스크립트가 DOM안에서 id가 apple인 요소를 찾으려고 하지만 

아직 <body> 아래의 id="apple"을 읽지 못했기 때문에 apple이라는 요소를 찾지 못해서 null을 출력합니다. 

null.style.color가 되어 TypeError가 발생합니다. 

> 일반 스크립트는 JS 다운로드 시간동안 HTML 읽기를 멈춤.

> 자바스크립트는 DOM에 이미 존재하는 요소만 찾을 수 있다! 

 

async와 defer로 해결하기 

JS 때문에 DOM을 만드는 작업이 중간에 멈춰서 HTML5부터 이 문제를 해결하기 위해 async와 defer 속성이 추가되었습니다.

async는 HTML 파싱과 JS 파일 로드를 동시에 진행하되, 로드가 완료되는 순간 HTML 파싱을 멈추고 즉시 실행합니다. (여러 스크립트가 있을 경우 실행 순서 보장X)

> JS 다운로드 끝나는 순간 바로 실행 

defer는 HTML 파싱과 JS 파일 로드를 동시에 진행하되, HTML 파싱이 완전히 끝난 후 실행합니다. DOM이 완성된 뒤 실행이 보장되므로, 대부분의 경우 defer가 권장됩니다. 

> DOM 다 만들고 JS 실행 

<script defer src="app.js"></script>

리플로우와 리페인트 

자바스크립트로 DOM을 변경하면 브라우저는 렌더링을 다시 수행합니다. 

리플로우: 요소의 크기, 위치, 레이아웃이 바뀔 때 발생

> 레이아웃 계산을 처음부터 다시 하기 때문에 비용이 큼 

- 요소 추가나 삭제 시

- 요소 크기나 위치 변경 시 

- 윈도우 리사이징

- offsetWidth, scrollTop 등 레이아웃 관련 값 읽을 때 

리페인트: 색상, 배경 등 레이아웃에 영향 없는 스타일만 바뀔 때 발생 

> 리플로우 없이 페인트만 재진행 

 

DOM 조작을 반복하면 그때마다 리플로우가 일어나기 때문에 아래 코드처럼 변경을 한 번에 모아서 처리하면 리플로우 횟수를 줄일 수 있습니다.

// 리플로우 3번 발생
element.style.width = '100px';
element.style.height = '100px';
element.style.margin = '10px';

// 리플로우 1번 발생 - good~
element.style.cssText = 'width: 100px; height: 100px; margin: 10px;';
// 또는 클래스를 한 번에 바꾸기
element.className = 'resized';

스크롤 이벤트와 리플로우 

scrollTop, scrollLeft, offsetHeight, getBoundingClientRect() 같은 값은 읽는 것만으로도 브라우저가 최신 레이아웃을 계산해야 해서 리플로우를 강제로 발생시킵니다. 

// bad: 스크롤할 때마다 리플로우 발생
window.addEventListener('scroll', () => {
  const scrollY = window.scrollY;        // 레이아웃 읽기 → 리플로우
  element.style.opacity = scrollY / 300; // 스타일 변경 → 또 리플로우
});

// good: requestAnimationFrame으로 묶기
window.addEventListener('scroll', () => {
  requestAnimationFrame(() => {
    const scrollY = window.scrollY;
    element.style.opacity = scrollY / 300;
  });
});

// good: transform/opacity 사용 (리플로우 없이 GPU 처리)
element.style.transform = `translateY(${scrollY}px)`;
element.style.opacity = scrollY / 300;

top, left, width, height를 바꾸면 리플로우가 발생하지만, transform과 opacity는 레이아웃에 영향을 주지 않아 리플로우 없이 GPU에서 처리됩니다.

https://inpa.tistory.com/entry/%F0%9F%8C%90-requestAnimationFrame-%EA%B0%80%EC%9D%B4%EB%93%9C

 

🌐 웹 애니메이션 최적화 requestAnimationFrame 가이드

자바스크립트 웹 애니메이션 웹페이지의 애니메이션을 구현할때 CSS의 animatoin , transition , transform 속성을 통해 구현할 수도 있지만, 보다 사용자와의 복잡한 상호작용을 구현하게 하기 위해 Javasc

inpa.tistory.com

여기서 rAF와 setTimeout의 차이를 잘 설명해주고 있으니 한 번 확인하면 좋을 것 같습니다. 

setTimeout은 단순히 일정 시간 뒤 실행되는 반면, rAF는 브라우저의 다음 렌더링 프레임 직전에 실행됩니다. 

그래서 rAF는 브라우저의 화면 그리기 타이밍과 동기화되어 프레임 누락이 적고 더 부드러운 애니메이션을 만들 수 있습니다. 


DOM이란? 

DOM은 HTML 문서를 브라우저가 이해할 수 있는 자료구조로 변환한 것입니다. HTML 요소들은 노드 객체로 변환하고, 이 노드들을 계층적인 트리 구조로 표현합니다. 

<div class="greeting">Hello</div>

// DOM에서는..
div 노드 (요소 노드)
├── class="greeting" (어트리뷰트 노드)
└── "Hello" (텍스트 노드)

HTML 문서 전체는 이런 노드들이 부모-자식 관계로 연결된 트리 구조를 가지고 있습니다. 

자바스크립트는 이 DOM 트리를 DOM API를 통해 읽고 수정할 수 있습니다. 


DOM 요소를 가져오고 만들고 바꾸기 

요소 가져오기 

// id로 가져오기
const apple = document.getElementById('apple');

// CSS 선택자로 가져오기 
const first = document.querySelector('.item');       
const all   = document.querySelectorAll('.item');

 

텍스트 바꾸기 

apple.textContent = '사과';  // 텍스트만 변경 
apple.innerHTML = '<b>사과</b>';  // HTML 태그 포함 변경

 

노드 만들어서 추가하기 

// 1. 요소 생성
const li = document.createElement('li');

// 2. 내용 채우기
li.textContent = 'Mango';

// 3. DOM에 추가
document.querySelector('ul').appendChild(li);

 

innerHTML은 간편하지만 기존 자식 노드를 모두 제거하고 다시 파싱하기 때문에 자주 변경되는 요소에는 성능 부담이 있습니다. 또한 사용자 입력값을 그대로 넣으면 XSS 공격에 취약해지기 때문에

동적으로 요소를 추가할 때는 createElement + appendChild 방식을 권장한다고 하네요,, 


XSS(Cross-Site Scripting)란?

공격자가 악의적인 스크립트를 페이지에 주입하는 공격 

innerHTML에 사용자 입력값을 그대로 넣었을 때 주의해야합니다. 

// 사용자가 검색창에 아래와 같이 입력하면?
const userInput = '<img src="x" onerror="document.cookie를 탈취하는 코드">';

// 그대로 실행돼서 위험하다..
searchResult.innerHTML = userInput; 

// 안전한 방법 - 태그로 해석하지 않고 문자열로 처리
searchResult.textContent = userInput;

해결방법은 사용자 입력을 화면에 표시할 땐 innerHTML 대신 textContent를 쓰기!

그렇게 하면 태그가 있어도 그냥 텍스트로 화면에 표시됩니다.