[JS Deep Dive 스터디] 12회차 - 브라우저의 렌더링 과정

장효정·2025년 10월 26일

38장. 브라우저의 렌더링 과정

2009년, 구글이 개발한 V8 JavaScript 엔진을 기반으로 한 Node.js가 등장하면서, JavaScript는 브라우저 안에서만 동작하는 언어가 아니라 서버 사이드 애플리케이션을 만들 수 있는 범용 언어로 발전했다. 이제는 웹 서버, 데스크톱 앱, 심지어 모바일 앱 개발에도 사용될 만큼 영역이 넓어졌다.
하지만 여전히 JavaScript가 가장 많이 사용되는 곳은 브라우저 환경이다. 대부분의 다른 프로그래밍 언어, 예를 들면 Python, Java, C++ 같은 언어들은 운영체제나 가상머신 위에서 실행된다. 그런데 웹 애플리케이션의 JavaScript는 조금 다르다. 브라우저 안에서 HTML, CSS와 함께 실행되기 때문에 브라우저의 구조나 동작 원리를 이해하면 훨씬 효율적으로 코드를 작성할 수 있다.

브라우저는 어떻게 우리 코드를 화면으로 만들어낼까?

우리가 작성한 HTML, CSS, JavaScript 파일은 사실 단순한 텍스트일 뿐이다. 그런데도 브라우저는 이 텍스트를 해석해서 마치 하나의 완성된 시각적 페이지처럼 보여준다. 이 과정을 바로 렌더링(Rendering)이라고 한다.

그럼 브라우저는 이 복잡한 일을 어떤 순서로 처리할까? 렌더링은 단 한 번에 끝나는 일이 아니라, 여러 단계를 거쳐 순차적으로 진행되는데, 크게 보면 HTML 파싱 → CSS 파싱 → 렌더 트리 생성 → 레이아웃 → 페인트 순서로 이루어진다.

이 과정을 이해하면 우리가 작성한 코드가 브라우저에서 실제로 어떤 영향을 주는지, 그리고 성능 최적화를 어떻게 해야 하는지를 명확하게 알 수 있다. 특히 script 태그를 어디에 배치해야 하는지, CSS는 왜 head에 넣는지 같은 실무적인 질문들에 답을 얻을 수 있다.

그럼 브라우저의 렌더링 과정을 단계별로 차근차근 살펴보자.


38.1 요청과 응답

모든 것은 서버와의 통신에서 시작된다

브라우저가 화면에 무언가를 그려내려면 가장 먼저 무엇이 필요할까? 바로 HTML, CSS, JavaScript 같은 리소스가 필요하다. 이 리소스들은 우리 컴퓨터에 있는 게 아니라 어딘가의 서버에 있다. 그래서 브라우저의 렌더링사실 서버에 리소스를 요청하고 응답받는 것에서 시작된다.

브라우저에 URL을 입력하면 무슨 일이 일어날까?

우리가 주소창에 URL을 입력하는 순간부터 이미 여러 가지 일들이 벌어지고 있다.

먼저 브라우저는 URL의 호스트 이름, 예를 들어 www.example.com 같은 걸 DNS(Domain Name System)를 통해 IP 주소로 변환한다. 우리가 기억하기 쉬운 도메인 이름을 컴퓨터가 이해할 수 있는 IP 주소로 바꾸는 것이다. 그리고 나서 그 IP 주소를 가진 서버에게 요청을 전송하게 된다.

서버는 무엇을 보내줄까?

일반적으로 루트 요청, 그러니까 www.example.com처럼 특정 파일을 지정하지 않으면 서버는 암묵적으로 index.html을 응답한다. 그리고 HTML만 보내는 것이 아니다. CSS 파일, JavaScript 파일, 이미지, 폰트 같은 다양한 정적 파일들도 함께 보내줄 수 있다.

브라우저는 리소스를 어떻게 요청할까?

여기서 중요한 점이 있다. 브라우저는 처음부터 필요한 모든 리소스를 미리 다 요청하는 게 아니다. HTML을 읽어가다가 외부 리소스를 참조하는 태그를 만나면 그때그때 요청하는 방식으로 동작한다.

예를 들어 HTML을 파싱하다가 link 태그로 CSS 파일을 발견하면 그 순간 CSS를 요청하고, img 태그로 이미지를 발견하면 HTML의 파싱을 일시 중단하고 해당 리소스 파일을 서버로 요청하는 것이다. script 태그도 마찬가지다. 이렇게 필요할 때마다 즉시 요청하는 방식이다.


38.2 HTTP 1.1과 HTTP 2.0

브라우저와 서버는 어떤 방식으로 대화할까?

38.1에서 브라우저가 서버에 리소스를 요청하고 응답받는다는 걸 배웠는데, 이 요청과 응답이 정확히 어떤 방식으로 이루어질까?

브라우저와 서버가 통신할 때는 HTTP(HyperText Transfer Protocol)라는 프로토콜을 사용한다. 그런데 이 HTTP에도 버전이 있고, 버전에 따라 성능 차이가 꽤 크다. 특히 웹 페이지 로딩 속도에 직접적인 영향을 미치기 때문에 렌더링 과정을 이해하는 데 중요하다.

예전 방식은 어떤 문제가 있었을까?

HTTP 1.1에는 사실 꽤 큰 문제가 있었다. 커넥션(connection) 하나당 요청 하나, 응답 하나만 처리할 수 있었기 때문이다. 즉, 한 번에 하나씩만 주고받을 수 있었던 것이다.

그러니까 웹 페이지에 CSS 파일 10개, JavaScript 파일 5개, 이미지 20개가 필요하다면? 이 35개를 하나씩 차례로 요청하고 받아야 했다. 당연히 리소스가 많으면 많을수록 전체 로딩 시간이 길어질 수밖에 없었다. 동시에 여러 개를 받을 수가 없었으니까.

HTTP 2.0은 어떻게 개선했을까?

HTTP 2.0에서는 멀티플렉싱(multiplexing)이라는 기술을 도입했다. 이제 하나의 커넥션으로 여러 개의 요청과 응답을 동시에 처리할 수 있게 된 것이다. 여러 리소스를 병렬로 전송할 수 있게 되면서 페이지 로드 속도가 HTTP 1.1에 비해 약 50% 정도 빨라졌다. 정말 큰 성능 향상이다!


38.3 HTML 파싱과 DOM 생성

브라우저는 HTML을 어떻게 이해할까?

서버로부터 HTML 문서를 받으면, 브라우저는 이걸 단순히 읽는 게 아니라 파싱(parsing)을 시작한다. 파싱이란 텍스트를 해석해서 컴퓨터가 이해할 수 있는 구조화된 데이터로 바꾸는 과정이다.

HTML이 DOM이 되기까지의 여정

HTML이 DOM으로 변환되는 과정은 5 단계를 거친다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <ul>
      <li id="apple">Apple</li>
      <li id="banana">Banana</li>
      <li id="orange">Orange</li>
    </ul>
    <script src="app.js"></script>
  </body>
</html>

STEP 01 : 바이트! 서버가 보낸 HTML은 사실 2진수, 그러니까 0과 1로 이루어진 바이트 형태로 네트워크를 통해 전달된다.

STEP 02 : 문자! 브라우저는 이 바이트를 읽을 수 있는 문자열로 변환해야 한다. 이때 HTML 파일에 적혀있는 인코딩 방식을 따른다. 보통 meta 태그의 charset 어트리뷰트에 UTF-8 같은 방식이 지정되어 있는데, 이 인코딩 방식에 따라 바이트를 우리가 읽을 수 있는 문자로 변환하는 것이다.

STEP 03 : 토큰! 문자열로 변환된 HTML을 문법적으로 의미 있는 최소 단위로 분해한다. 이게 바로 토큰(token)이다. 예를 들어 <html>, </html>, <head>, <body>, <div> 이런 것들이 각각 하나의 토큰이 된다. 시작 태그 토큰, 종료 태그 토큰, 속성 토큰, 텍스트 토큰 등으로 구분된다.

STEP 04 : 노드! 각 토큰을 객체로 변환해서 노드(node)를 생성한다. 토큰의 내용에 따라 문서 노드, 요소 노드, 어트리뷰트 노드, 텍스트 노드가 생성된다. 이 노드가 바로 DOM을 구성하는 기본 요소가 된다. 예를 들어 <div> 토큰은 div 노드 객체가 되고, 텍스트 토큰은 텍스트 노드 객체가 되는 것이다.

STEP 05 : DOM! 마지막으로 이 노드들 사이의 부모-자식 관계를 파악해서 계층적인 트리 구조로 만든다. html 노드 안에 head와 body가 있고, head 안에는 meta와 link가 있고, body 안에는 ul 같은 요소들이 있는 식으로 말이다. 이 노드들로 구성된 트리 자료구조를 DOM이라고 한다.

DOM이란 무엇일까?

이렇게 완성된 것이 바로 DOM(Document Object Model)이다. DOM은 HTML 문서를 파싱한 결과물이면서 동시에 브라우저가 이해하고 관리할 수 있는 자료구조다.

우리가 JavaScript로 document.querySelectorgetElementById 같은 걸 사용할 수 있는 건 바로 이 DOM이 있기 때문이다. DOM은 "문서가 어떻게 구성되어 있는가"를 표현하는 모델이라고 보면 된다.


38.4 CSS 파싱과 CSSOM 생성

CSS는 언제 파싱될까?

브라우저가 HTML을 파싱하다가 CSS를 로드하는 link 태그나 style 태그를 만나면, 그 순간 DOM 생성을 잠시 중단하고, CSS 파싱이 시작된다.
link 태그의 href에 지정된 CSS 파일을 서버에 요청해서 받아오거나 style 태그 안의 CSS를 가져와서 파싱하기 시작한다.

🗣️ 나도 파싱 과정 있어!

CSS 파싱도 HTML과 마찬가지로 바이트에서 문자로, 문자에서 토큰으로, 토큰에서 노드로, 노드에서 CSSOM으로 변환된다.

CSSOM은 CSS Object Model의 약자다. CSS를 트리 구조로 만든 것이다. 그런데 여기서 특별한 점이 있다. CSSOM은 CSS의 상속 특성을 반영해서 만들어진다는 것이다.

CSS의 상속이 반영된다는 게 무슨 뜻일까?

예를 들어, body 요소에 font-size를 18px로 지정했다고 해보자. 그럼 body의 모든 자식 요소들도 별도로 font-size를 지정하지 않는 한 18px로 상속받는다. 이런 상속 관계가 CSSOM 트리에 그대로 반영되는 것이다. 그래서 CSSOM을 보면 어떤 요소가 어떤 스타일을 상속받는지 한눈에 알 수 있다.

파싱이 끝나면?

CSS 파싱이 완료되고 CSSOM이 만들어지면, 그제야 중단되었던 HTML 파싱이 다시 재개된다. 그리고 나머지 DOM을 계속 만들어가는 것이다.


38.5 렌더 트리 생성

DOM과 CSSOM, 이제 합쳐질 시간입니다

이제 DOM도 있고 CSSOM도 있다. 렌더링 엔전은 이 둘을 결합해서 렌더 트리라는 걸 만든다. 렌더 트리는 렌더링을 위한 트리 구조의 자료구조다. 그런데 DOM에 있는 모든 노드가 렌더 트리에 포함되는 건 아니다.

어떤 요소들이 렌더 트리에서 제외될까?

브라우저 화면에 실제로 표시되지 않는 노드들은 렌더 트리에 포함되지 않는다. 예를 들어 meta 태그나 script 태그는 화면에 보이지 않으니까 당연히 제외되는 대상이다. 또 중요한 게, CSS로 display가 none으로 설정된 요소도 렌더 트리에서 제외된다. 화면에 표시되지 않으니까.

그런데 여기서 주의할 점이 있다. visibility가 hidden인 요소는 어떨까? 이건 보이진 않지만 공간은 차지하지 않는가? 그래서 렌더 트리에는 포함된다.

드디어 화면에 그려질 준비가 됐어요

렌더 트리가 완성되면 이제 본격적으로 화면에 그릴 준비를 하는데, 두 단계를 거친다.

STEP 01 : 레이아웃 단계! 리플로우라고도 부른다. 이 단계에서는 렌더 트리의 각 노드가 화면의 어디에 위치할지, 크기는 얼마나 될지를 정확하게 계산한다. 예를 들어, "이 div는 화면 왼쪽에서 100px, 위에서 50px 떨어진 곳에 위치하고, 너비는 300px, 높이는 200px이다" 이런 식으로 말이다.

STEP 02 : 페인트 단계! 레이아웃에서 계산한 정보를 바탕으로 실제로 픽셀을 화면에 그려내는 것이다. 렌더 트리를 순회하면서 각 노드를 화면에 그려나간다.

이 모든 과정이 끝나면 드디어 우리가 만든 웹 페이지가 브라우저 화면에 나타나게 된다-! 🎉


38.6 자바스크립트 파싱과 실행

JavaScript를 만나면 또 멈춰요..😳

HTML을 열심히 파싱하다가 script 태그를 만나면? 또 멈춘다. DOM 생성이 일시 중단된다.

왜 그럴까? 그 이유는 JavaScript가 DOM을 변경할 수 있기 때문이다. 만약 JavaScript가 DOM을 수정하는데 동시에 DOM을 만들고 있으면 문제가 생길 수 있지 않느냐. 그래서 일단 JavaScript를 처리하고 나서 다시 DOM을 만들기로 한 것이다.

제어권이 넘어가요

이 시점에서 재미있는 일이 벌어진다. 제어권이 브라우저의 렌더링 엔진에서 JavaScript 엔진으로 넘어가는 것이다. JavaScript 엔진은 Chrome의 V8이나 Firefox의 SpiderMonkey, Safari의 JavaScriptCore 같은 것들이 있다.

그럼 JavaScript는 어떻게 실행될까?

JavaScript 엔진도 일련의 파싱 과정을 거친다.

먼저 토크나이징(Tokenizing)을 한다. JavaScript 소스코드를 의미 있는 최소 단위인 토큰으로 쪼개는 것이다. function, return, 변수명, 연산자 이런 것들이 각각 토큰이 된다.

그 다음은 파싱이다. 토큰들을 분석해서 AST라는 걸 만든다. 근데 AST, 너무 생소하지 않나? 좀 헷갈릴 수 있으니 자세히 알아보자.

AST를 왜 만들까?

AST는 Abstract Syntax Tree의 약자로, 추상 구문 트리라고 한다. 그럼 이걸 왜 만들어야 하는 걸까? 생각해보자. 우리가 작성한 JavaScript 코드는 그냥 텍스트일 뿐이다. 컴퓨터가 이 텍스트를 바로 실행할 수는 없다. 코드의 구조와 의미를 정확히 이해해야 실행할 수 있기 때문이다. (당연한 말) 예를 들어 이런 간단한 코드가 있다고 해보자.

const x = 1 + 2;

우리는 이걸 보고 "x라는 변수에 1과 2를 더한 값을 할당하는구나"라고 한눈에 이해한다. 하지만 컴퓨터는 이게 단순한 문자열일 뿐이다. "const x = 1 + 2;" 이 문자열이 무슨 의미인지 파악해야 한다.

AST는 코드의 구조를 나타내요

AST는 이 코드의 구조와 의미를 트리 형태로 표현한 것이다. 위의 코드를 AST로 만들면 대략 이런 구조가 된다.

변수 선언 (VariableDeclaration)
├─ 종류: const
├─ 변수명: x
└─ 초기값: 이항 연산 (BinaryExpression)
    ├─ 연산자: +
    ├─ 왼쪽: 숫자 리터럴 1
    └─ 오른쪽: 숫자 리터럴 2

이렇게 트리로 표현하면 코드의 구조가 명확해진다. "아, 이건 변수 선언이고, const 키워드를 사용했고, 변수 이름은 x고, 초기값은 덧셈 연산의 결과구나. 그 덧셈은 1과 2를 더하는 거구나" 이런 식으로 정확히 파악할 수 있는 것이다.

AST는 코드를 분석하고 변환하는 데 필수예요

AST가 있으면 코드를 분석하고 변환하는 게 훨씬 쉬워진다. 트리 구조로 되어 있으니까 각 부분을 찾아가고 수정하기가 편하다. 예를 들어 "코드에서 모든 var를 const로 바꾸고 싶어"라고 하면, AST를 순회하면서 VariableDeclaration 노드를 찾아서 종류를 var에서 const로 바꾸면 된다. 단순 텍스트 치환이 아니라 정확하게 문법 구조를 이해하고 바꿀 수 있는 것이다.

AST와 JavaScript의 관계

JavaScript 엔진은 이 AST를 만든 다음, 이걸 기반으로 바이트코드를 생성한다. 바이트코드는 JavaScript 엔진이 실행할 수 있는 중간 언어다. 그리고 인터프리터가 이 바이트코드를 한 줄씩 실행하는 것이다.

그러니까 JavaScript 코드가 실행되려면 반드시 AST를 거쳐야 한다. 텍스트 → 토큰 → AST → 바이트코드 → 실행, 이런 순서로 진행되는 것이다. AST는 JavaScript를 실행 가능한 형태로 만드는 중간 단계라고 생각하면 된다.

AST는 어디에 쓰일까?

AST는 JavaScript 실행뿐만 아니라 정말 많은 곳에서 사용된다.

Babel은 최신 JavaScript 문법을 구형 브라우저에서도 동작하는 ES5 문법으로 변환해주는 도구다. 예를 들어 화살표 함수를 일반 function으로 바꾸는 것. Babel은 코드를 AST로 만든 다음, AST를 수정해서 다시 코드로 변환한다.

// 변환 전
const add = (a, b) => a + b;

// Babel이 AST를 수정해서 변환
// 변환 후
var add = function(a, b) { return a + b; };

TypeScript 컴파일러도 마찬가지다. TypeScript 코드를 AST로 만들고, 타입 정보를 체크한 다음, JavaScript로 변환한다.

ESLint 같은 코드 검사 도구는 AST를 분석해서 코드에 문제가 없는지 확인한다. 예를 들어 "xxx is defined but never used" 같은 경고를 주는 것도 AST를 보고 판단하는 것이다. (이거 쫌 놀라움..)

Prettier 같은 코드 포맷터는 코드를 AST로 만든 다음, 정해진 규칙에 따라 다시 예쁘게 정렬해서 코드로 만든다.

정리하면

AST는 코드를 컴퓨터가 이해할 수 있는 구조화된 형태로 표현한 것이다. JavaScript를 실행하기 위해서는 반드시 AST를 만들어야 하고, AST를 통해서 코드를 분석하고, 변환하고, 최적화하고, 검사할 수 있다. 우리가 매일 사용하는 많은 개발 도구들이 실제로는 AST를 활용해서 작동하고 있는 것이다. 그래서 AST는 현대 JavaScript 개발 생태계에서 정말 중요한 개념이다!

아무튼 그래서, JavaScript 실행이 끝나면?

JavaScript 파싱과 실행이 모두 끝나면 렌더링 엔진으로 제어권이 다시 돌아온다. 그제야 중단됐던 HTML 파싱이 재개되고, 나머지 DOM을 계속 만들어가게 된다.


38.7 리플로우와 리페인트

JavaScript가 DOM을 바꾸면 어떻게 될까?

JavaScript로 DOM이나 CSSOM을 변경하면 어떤 일이 벌어질까? 변경된 내용을 화면에 반영해야 하니까 렌더링 과정을 다시 거쳐야 한다.

변경된 DOM과 CSSOM은 다시 렌더 트리로 결합되고, 레이아웃 계산과 페인트 과정을 거쳐 브라우저 화면에 다시 렌더링된다. 이 과정을 리플로우(reflow)와 리페인트(repaint)라고 부른다.

리플로우는 무엇일까?

리플로우는 레이아웃을 다시 계산하는 것이다. 쉽게 말해서 "이 요소가 화면의 어디에 있고, 크기는 얼마인가"를 다시 계산하는 것이다.

언제 리플로우가 일어날까? 노드를 추가하거나 삭제할 때, 요소의 크기나 위치를 바꿀 때, 윈도우 크기를 조절할 때 같은 상황에서 발생한다. width, height, padding, margin, position, display 같은 속성을 변경하면 리플로우가 일어난다.

리플로우가 일어나면 변경된 노드뿐만 아니라 그 자식 노드들, 심지어 형제 노드들까지 영향을 받을 수 있다. 하나를 바꿨는데 여러 개가 다시 계산되는 셈.

리페인트는 무엇일까?

리페인트는 말 그대로 다시 페인트하는 것이다. 재결합된 렌더 트리를 기반으로 화면을 다시 그리는 것이다.

그런데 모든 변경이 리플로우를 일으키는 건 아니다. 색상을 바꾸거나 투명도를 조절하는 것처럼 레이아웃에는 영향을 주지 않는 변경은 리페인트만 발생한다. color, background-color, visibility, outline 같은 속성을 바꿀 때 말이다.

⭐️ 성능을 위해 알아둬야 할 것

여기서 정말 중요한 포인트가 있다. 리플로우는 리페인트보다 훨씬 비용이 크다. (리플로우 > 리페인트) 왜냐하면 리플로우가 일어나면 레이아웃을 다시 계산하고 페인트도 다시 해야 하거든요! 반면 리페인트만 일어나면 페인트만 하면 되니까 상대적으로 빠르다. (집 리모델링할 때 리플로우는 철거 공사부터 페인트 칠하는 것까지 다 진행하는 거고, 리페인트는 페인트 칠만 하는 거로 비유하면 되려나)

그래서 성능 최적화를 할 때는 리플로우를 최소화하는 게 정말 중요하다. 예를 들어 여러 스타일을 바꿔야 한다면, 하나씩 따로 바꾸는 것보다 한꺼번에 바꾸는 게 좋다. 그래야 리플로우가 한 번만 일어나니까요~

// 이렇게 하면 리플로우가 3번 발생
element.style.width = '100px';
element.style.height = '100px';
element.style.margin = '10px';

// 이렇게 하면 리플로우가 1번만 발생
element.style.cssText = 'width: 100px; height: 100px; margin: 10px;';

38.8 자바스크립트 파싱에 의한 HTML 파싱 중단

JavaScript의 양날의 검

JavaScript는 정말 강력하다. DOM을 자유자재로 조작할 수 있고, 동적인 기능을 구현할 수 있다. 하지만 이 강력함이 때로는 문제가 될 수도 있다. 특히 script 태그를 어디에 배치하느냐에 따라 심각한 문제가 생길 수 있는데, 왜 그런지 함께 알아보자.

script 태그의 위치가 왜 중요할까?

JavaScript는 DOM을 조작할 수 있는 권력이 있다. 그런데 만약 DOM이 아직 만들어지지 않은 상태에서 JavaScript가 그 DOM을 찾으려고 하면 어떻게 될까? (당연히 에러 나겠지~ㅋㄴㅋ)

실제로 어떤 문제가 생길까?

예를 들어 head 태그 안에 script를 넣고, 그 안에서 body의 어떤 요소를 찾으려고 한다고 해보자. 그 시점에는 아직 body가 파싱되지 않았다. DOM에 아직 그 요소가 없는 것이다. 그래서 document.getElementById를 해도 null이 반환되거나 에러가 발생한다.

<!DOCTYPE html>
<html>
  <head>
    <script>
      // 이 시점에 body는 아직 파싱 안 됨!
      const myDiv = document.getElementById('myDiv'); // null
      console.log(myDiv); // null
    </script>
  </head>
  <body>
    <div id="myDiv">Hello</div>
  </body>
</html>

블로킹 문제도 있어요

또 다른 문제는 블로킹이다. JavaScript의 로딩, 파싱, 실행이 진행되는 동안 HTML 파싱이 계속 중단되어 있지 않느냐. 그 시간 동안 화면에는 아무것도 나타나지 않는다. 사용자는 흰 화면(또는 검은 화면)만 보게 되는 것이다.

특히 JavaScript 파일이 크거나 네트워크가 느리면 이 문제가 더 심각해진다. JavaScript를 다 받을 때까지 사용자는 계속 기다려야 하기 때문. 페이지 로딩 시간이 길어지는 거다.

어떻게 해결할까?

가장 간단한 해결 방법은 script 태그를 body의 맨 아래에 배치하는 것이다.

<body>
  <div id="myDiv">Hello</div>
  
  <!-- body 맨 아래에 script 배치 -->
  <script>
    const myDiv = document.getElementById('myDiv'); // 정상 작동!
  </script>
</body>

이렇게 하면 두 가지 장점이 있다.

첫째, HTML 파싱이 거의 다 끝난 후에 JavaScript가 실행되니까 DOM을 안전하게 조작할 수 있다.
둘째, HTML 요소들이 먼저 렌더링되니까 사용자가 빈 화면을 보는 시간이 줄어든다.

하지만 더 좋은 방법이 있는데, 바로 다음에 설명할 async와 defer 어트리뷰트를 사용하는 것이다.


38.9 script 태그의 async/defer 어트리뷰트

JavaScript 로딩, 더 똑똑하게 할 수는 없을까?

38.8에서 script 태그의 위치가 중요하다는 걸 배웠다. body 맨 아래에 배치하면 DOM 접근도 안전하고 블로킹 문제도 어느 정도 해결된다. 하지만 이것도 완벽한 해결책은 아니다. JavaScript가 로드될 때까지 여전히 기다려야 하니까 말이다.

더 똑똑한 방법이 있어요

JavaScript의 블로킹 문제를 근본적으로 해결하기 위해 HTML5에서는 script 태그에 async와 defer라는 두 가지 어트리뷰트를 추가했다. 이 둘을 사용하면 JavaScript의 로드와 실행을 훨씬 효율적으로 제어할 수 있다.

한 가지 주의할 점은, 이 어트리뷰트들은 src 어트리뷰트가 있는 외부 JavaScript 파일에만 사용할 수 있다는 것이다. script 태그 안에 직접 코드를 쓴 인라인 스크립트에는 적용되지 않는다.

async는 어떻게 동작할까?

async 어트리뷰트를 사용하면 어떻게 될까?

<script async src="script.js"></script>

HTML 파싱과 JavaScript 파일의 로드가 동시에 진행된다. 병렬로 일어나는 것이다. 그러다가 JavaScript 로드가 완료되면 그 순간 HTML 파싱이 잠시 중단되고, JavaScript가 바로 실행된다. 실행이 끝나면 다시 HTML 파싱이 재개된다.

async의 특징과 주의점

async의 중요한 특징은 실행 순서가 보장되지 않는다는 것이다.

만약 script1.js, script2.js, script3.js를 순서대로 작성했더라도 script2.js가 먼저 로드되면 먼저 실행된다. 로드가 완료된 순서대로 실행되는 것이다.

그래서 스크립트들 사이에 의존성이 있으면 문제가 생길 수 있다. script2.js가 script1.js에 정의된 변수를 사용하는데, script2.js가 먼저 실행되면 에러가 나겠지요.

async는 언제 사용할까?

그럼 async는 언제 쓰는 게 좋을까? 다른 스크립트나 DOM과 독립적으로 동작하는 스크립트에 적합하다. 예를 들어 Google Analytics나 광고 스크립트 같은 것들이다. 이런 건 순서가 중요하지 않고 독립적으로 동작하니까 async를 쓰기 좋다.

그럼 defer는 어떻게 동작할까?

defer 어트리뷰트는 async와 비슷하면서도 다르다.

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

defer도 HTML 파싱과 JavaScript 로드가 병렬로 진행된다. 여기까지는 async와 같다.

하지만 큰 차이가 있다. defer를 사용하면 JavaScript 로드가 완료되더라도 바로 실행되지 않는다. HTML 파싱이 완전히 끝날 때까지 기다린다. 그리고 HTML 파싱이 완료되면, 그때 JavaScript가 실행되는 것이다.

defer의 장점

defer의 가장 큰 장점은 세 가지다.

첫째, HTML 파싱이 중단되지 않는다. JavaScript 로드가 완료되어도 실행을 미루니까 HTML 파싱이 쭉 진행될 수 있다. 그래서 페이지 로딩이 빠르다.

둘째, DOM이 완전히 생성된 후에 JavaScript가 실행된다. 그래서 DOM을 안전하게 조작할 수 있다. body 맨 아래에 script를 배치한 것과 비슷한 효과지요.

셋째, 여러 개의 defer 스크립트가 있으면 작성된 순서대로 실행이 보장된다. async와 달리 순서가 지켜진다. 그래서 스크립트 간의 의존성 문제가 없다.

type="module"은 어떻게 동작할까?

요즘 가장 많이 사용되는 방식은 사실 ES6 모듈 방식이다.

<script type="module" src="main.js"></script>

type="module"을 사용하면 JavaScript 파일을 ES6 모듈로 취급한다. 그리고 정말 좋은 점은 이 모듈 스크립트는 기본적으로 defer처럼 동작한다는 점이다.

type="module"의 특징

모듈 스크립트는 여러 가지 좋은 특징이 있다.

첫째, 자동으로 defer처럼 동작한다. HTML 파싱과 JavaScript 로드가 병렬로 진행되고, HTML 파싱이 완료된 후에 실행된다. 따로 defer를 붙일 필요 없음!

둘째, 자동으로 strict mode가 적용된다. 'use strict'를 명시하지 않아도 엄격 모드로 동작한다.

셋째, 모듈 스코프를 가진다. 일반 스크립트는 전역 스코프를 공유하지만, 모듈은 각자의 스코프를 가진다. 그래서 변수 충돌 걱정 없음!

넷째, import와 export를 사용할 수 있다. 모듈 간에 명시적으로 필요한 것만 주고받을 수 있어서 코드 구조가 훨씬 깔끔해진다.

다섯째, 같은 모듈은 한 번만 실행된다. 여러 곳에서 같은 모듈을 import해도 실제로는 한 번만 로드되고 실행된다.

모듈 스크립트에서도 async를 사용할 수 있어요

모듈 스크립트는 기본적으로 defer처럼 동작하지만 필요하면 async를 붙일 수도 있다.

<script type="module" async src="analytics.js"></script>

이렇게 하면 모듈이 async처럼 동작해서, 로드가 완료되는 즉시 실행된다. 하지만 역시 일반적인 애플리케이션 코드에는 사용하지 않고, 독립적인 모듈에만 제한적으로 사용한다.


전체 흐름 다시 한번

브라우저 렌더링의 큰 그림

우리가 URL을 입력하면 DNS를 통해 IP 주소로 변환하고, 서버에 요청을 보내 응답을 받는다.

그러면 HTML 파싱을 시작해서 DOM을 만든다. 중간에 CSS를 만나면 CSSOM을 만들고, JavaScript를 만나면 실행한다.

DOM과 CSSOM을 결합해서 렌더 트리를 만들고, 레이아웃을 계산한 다음 페인트해서 화면에 표시한다.

JavaScript가 DOM이나 CSSOM을 변경하면 리플로우와 리페인트가 다시 일어난다.

핵심 포인트

이 과정은 한 번만 일어나는 게 아니다. JavaScript로 DOM을 수정할 때마다, 사용자가 윈도우 크기를 조절할 때마다 반복적으로 일어날 수 있다. 그래서 렌더링은 반복적(iterative)인 과정이라고 한다.

JavaScript는 렌더링을 블로킹할 수 있는 리소스다. 그래서 script 태그의 위치나 async, defer, type="module" 어트리뷰트를 적절히 사용하는 게 정말 중요하다. 이게 웹 성능 최적화의 핵심이기 때문이다.

0개의 댓글