ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 브라우저 작동 원리
    Note 2021. 8. 10. 12:20

    브라우저 

    HTML 문서와 그림, 멀티미디어 파일 등 월드 와이드 웹을 기반으로 한
    인터넷의 컨텐츠를 검색 및 열람하기 위한 응용 프로그램의 총칭

     

     

     사용자에게 보이는 부분을 담당하는 프론트엔드 개발자에게 브라우저는 특히 필수로 알아야 하는 부분이다. 프론트엔드 개발자는 브라우저를 통해 개발을 하고, 브라우저를 통해 테스트와 배포를 진행한다. 최종적으로 사용자들은 브라우저를 통해 웹 애플리케이션에 접속할 수 있다. 그만큼 프론트엔드 개발자에게 브라우저는 뗄 수 없는 부분이다.

     

     브라우저의 내부에서 일어나는 모든 일에 대해 이해하는 것은 너무나도 어렵다. 그래서 작성한 HTML, CSS, JavaScript가 의도한 대로 동작되는지만을 확인해도 크게 문제 되지는 않는다. 

     

     하지만 복잡한 웹 애플리케이션을 만들어야 한다면 이야기가 달라진다. 방대한 코드를 관리하고, 다양한 인터렉션을 수용할 수 있어야 하고, 화려한 애니메이션을 넣다 보면 결국 브라우저 단에서의 최적화가 필요해진다. 하지만 브라우저의 동작 원리를 이해하지 않은 상태에서 어플리케이션을 최적화할 수는 없다. 결국 프론트엔드 개발자는 브라우저 위에서 동작하는 애플리케이션을 만드는 사람이기 때문이다.

     

     그래서 브라우저의 동작 원리 중에서 브라우저의 렌더 과정을 중심으로 정리해보려고 한다.  

     

    브라우저의 기본  구조

     

    출처: Naver D2 '브라우저는 어떻게 동작하는가?'

     

    브라우저의 주요 구성 요소는 다음과 같다.

     

    # 사용자 인터페이스

    주소 표시줄, 이전/다음 버튼, 북마크 메뉴 등. 요청한 페이지를 보여주는 창을 제외한 나머지 모든 부분

     

    # 브라우저 엔진

    사용자 인터페이스와 렌더링 엔진 사이의 동작을 제어. 브라우저 자체에 동작하는 소프트웨어 구성에 대한 엔진

     

    # 렌더링 엔진

    서버에 요청해서 가져온 자원, 페이지, 콘텐츠를 화면에 표시하는 작업. 예를 들어 HTML을 요청하면 HTML과 CSS를 파싱하여 화면에 표시한다.

     

    # 통신

    HTTP 요청과 같은 네트워크 호출을 하는 브라우저 내부 계층

     

    # 자바스크립트 해석기

    자바스크립트 코드를 해석하고 실행. 가장 유명한 엔진으로 Chrome에 탑재된 구글의 V8이 있다.

     

    # UI 백엔드

    기본적인 UI 장치를 말한다.

    브라우저가 동작하고 있는 운영체제의 인터페이스를 따르는 UI들을 처리한다. 얼럿(alert)이나 셀렉트 박스(select)가 운영체제 별로 다르게 동작하는 것이 운영체제 별로 인터페이스가 다르기 때문이다. 

     

    # 자료 저장소

    웹 브라우저 내에서 사용되는 자료 저장 계층이다. 우리가 흔히 알고 있는 쿠키나 로컬 스토리지 등이 이에 해당된다.

     

    렌더링 엔진의 동작 과정

     

    네트워크를 통해 얻어온 HTML과 CSS의 해석을 시작하는 것으로 본격적인 렌더링 작업이 시작

     

     어떠한 웹 페이지에 접속하게 되면, 네트워크를 통해 해당 웹 페이지의 HTML 문서를 얻어올 수 있다. 그러면 렌더링 엔진은 위의 다이어그램과 같은 과정을 거쳐 읽어 들인 HTML 문서를 해석한다. 브라우저 엔진마다 해석 방식이 조금씩 다를 순 있지만, 크게 다음과 같은 네 단계로 이루어져 있다고 봐도 무방하다고 한다.

    • 파싱(Parsing)
    • 렌더 트리(Render Tree) 구축
    • 레이아웃(Layout) 또는 리플로우(Reflow)
    • 페인트(Paint)

     

     위에서 이야기한 모든 과정들을 일컫어 우리는 중요 렌더링 경로(Critical Rendering Path)라고 부른다. 각 단계에서 리소스를 로드하는 순서나 작성한 스크립트의 내용에 따라 웹 페이지의 반응 속도가 달라질 수 있다. 이러한 과정의 최적화를 통해 프론트엔드 개발자는 렌더링에 걸리는 시간을 개선시키고, 사용자 경험을 방해하지 않을 수 있게 된다.

     

    파싱

     

     파싱(Parsing)은 토큰화(tokenize)된 코드를 구조화하는 과정. 이러한 파싱 과정을 전문적으로 해주는 부분을 파서(Parser)라고 부른다.

    정확하게는 어휘분석기(Lexical scanner, Lexer)를 통해 토큰화 된 코드가 생성되고, 이를 파서가 해석하는 순서로 이루어진다. 토큰화라는 것은 의미가 있는 최소 단위로 코드를 쪼개는 것을 의미한다. 가령  <div></div>라는 코드를 토큰화하면['<','div','>','</','div','>'] 처럼 나타나게 되는 것이다.

     

     파싱 과정은 입력받은 문자열이 정해진 문법(grammar)들을 모두 따르는지를 확인하는 과정이다. 

     

     브라우저는 HTML, CSS, JavaScript 세 종류의 언어를 해석할 수 있다. 그 중에서 JavaScript는 렌더링 엔진 레이어가 아니라 자바스크립트 해석기라는 별도의 레이어에서 언어를 해석한다. 따라서 렌더링 엔진에서는 HTML과 CSS를 파싱한다. 

     

    HTML 파싱

     

    파싱 흐름도

     브라우저는 위에서 이야기한 토큰화된 HTML의 문자열들을 이용해 파스 트리(Parse Tree)를 생성한다. 파스 트리는 브라우저가 읽어야 할 HTML 코드를 트리 모양으로 구조화하여 나타낸 것이다.

     

     이러한 파스 트리를 이용해서 렌더를 바로 있는 것은 아니다. 브라우저는 파스 트리를 이용해 DOM(Document Object Model) 트리를 새로 만들기 때문이다.

     

     파스 트리는 토큰화된 문자열을 단순하게 구조화한 트리에 불과했지만, DOM 트리는 우리가 실제로 상호작용할 수 있는 HTML 엘리먼트로 이루어진 트리이다. 따라서 실제로 JavaScript로 상호작용할 수 있는 부분은 DOM 트리다.

     

     HTML 파서는 다른 파서와 비교했을 때 조금 독특한 특징을 갖고 있다.

     

    - 오류에 너그러운 속성

    <body>
    <p class=title>Hello
    <div><span>World

    위와 같이 제대로 작성되지 않은 HTML 코드를 파싱하는 도중 에러가 발생한다면, 브라우저는 자체적으로 에러를 복구하려 한다. 이를 통해 HTML 코드를 실제로 브라우저에서 실행시켜보면 다음과 같이 완성된 코드가 나오게 된다.

     

    <body>
      <p class="title">Hello</p>
      <div><span>World</span></div>
    </body>

    이러한 규칙들은 HTML Document Type Definition (DTD)에 의해 정의되고 있다.

     

    - 파싱 과정이 중단될 수 있다는 것

    HTML은 파싱 도중 script, link 같은 외부 태그를 만나게 되면 HTML 파싱을 즉시 중단한다. 그리고 해당 태그의 해석을 실행하게 된다. 만약 해당 태그가 외부 파일을 참조하고 있다면 다운로드를 한 후 해석을 시작한다.

     

    - 재시작

     위에서 말한 것처럼 HTML의 파싱 과정은 어떠한 외부의 요인으로 인해 방해받을 수 있다. 파싱 중간에 외부의 요인으로 인해 DOM이 추가, 변경, 삭제될 수가 있는데 이러한 경우에 HTML은 처음부터 다시 파싱 과정을 거친다. 즉, 바이트를 문자로 변환하고, 토큰을 식별한 후 노드로 변환하고 DOM 트리를 빌드한다. 이 때문에 처리해야 할 HTML이 많을 때에는 파싱 시간이 오래 걸릴 수 있게 된다.

     

    CSS 파싱

     

    CSSOM

     일반적으로 CSS을 링크하는 코드가 HTML 코드 내에 삽입되어 있기 때문에, HTML을 파싱하는 도중에 CSS 파싱이 시작된다. 네트워크를 통해 먼저 받아온 코드부터 해석을 실행할 수 있는 HTML 파서와는 달리, CSS 파서는 전체 파일을 모두 다운로드할 때까지 파싱을 시작할 수 없다.

     

     전체 CSS 파일을 다운로드 한 후 CSS 파싱 과정이 끝나게 되면, 코드에서 명세한 내용과 순서를 바탕으로 DOM과 같은 트리를 구성하는데 이를 CSSOM(CSS Object Model) 트리라 부른다. 이 트리에는 스타일, 규칙, 선택자 등의 정보가 노드에 들어가게 된다.

     

    렌더 트리

     

     위에서 이야기한 DOM 트리가 구성되는 동안 브라우저는 렌더 트리(Render Tree)를 구성하기 시작한다. 동의어로는 프레임 트리(Frame Tree)라고도 한다.

    DOM + CSSOM = 렌더 트리

     

     렌더 트리는 기본적으로 화면에 나타나는 요소들을 결정하는 트리이다. 즉, 어떠한 요소들이 보여야 하는지, 어떤 스타일이 적용되어야 하는지, 그리고 어떤 순서로 나타낼 것인지를 명세하는 트리인 것이다.

     

     렌더 트리는 DOM 트리와 CSSOM 트리를 조합하여 만들어지고, 이때 화면에 그려지지 않는 요소들은 트리에 나타나지 않는다. 가령 head, script 같은 태그나 display: none 스타일이 적용된 엘리먼트 같은 것들이다. 이러한 태그는 시각적으로 나타낼 것이 없기 때문에 렌더 트리에 그려지지 않는다다. 즉 렌더 트리는 DOM 트리와 정확하게 1:1로 매칭이 되지는 않는다.

     

    레이아웃 또는 리플로우

     

     렌더 트리 구성이 끝나면 레이아웃 단계가 이어진다. 모질라에서는 이 과정을 리플로우(reflow)라고 부르기도 한다. 레이아웃 단계에서는 렌더 트리에서 계산되지 않았던 노드들의 크기와 위치, 레이어 간 순서와 같은 정보를 계산하여 좌표에 나타낸다

     

     레이아웃은 계산의 범위에 따라 전역적 레이아웃(Global Layout)과 증분적 레이아웃(Incremental Layout)으로 구분할 수 있다.

     

     전역적 레이아웃은 말 그대로 화면 전체의 레이아웃을 계산하는 것이다. 가령 새로운 폰트를 적용하거나, 폰트 사이즈가 바뀌거나, 뷰포트의 사이즈 변경 같은 경우가 있을 때 전체 레이아웃을 다시 계산한다. offsetHeight 같은 일부 DOM 관련 JavaScript API에 접근을 하는 경우에도 전역적 레이아웃이 다시 계산되기도 한다.

     

     이러한 전역적 레이아웃 단계는 모든 렌더 트리 노드에 대해 기하학적인 계산을 수행하기 때문에, 노드가 많아지게 된다면 그 속도가 느려지게 된다. 따라서 브라우저에서는 자체적인 최적화 로직을 탑재하고 있다.

     

     그 중 하나가 바로 더티 비트 시스템(Dirty bit system)이다. 더티 비트 시스템은 특정 엘리먼트의 레이아웃이 변경이 되었을 때, 렌더 트리를 처음부터 탐색하면서 레이아웃을 계산하지 않고 특정한 부분만 다시 계산하여 리소스의 낭비를 줄이는 최적화 방법이다.

     

     증분적 레이아웃은 이러한 더티 비트 시스템을 활용한다. 레이아웃 과정에서 렌더 트리를 재귀적으로 탐색을 하다가 더티한 엘리먼트들, 즉 레이아웃의 변경이 발생해야 하는 엘리먼트들을 만나게 되면, 그 계산을 즉시 수행하는 것이 아니라 스케쥴러를 통해 비동기로 일괄 작업(batch)을 진행한다. 이를 통해 연산의 횟수와 범위를 줄일 수 있게 된다.

     

     하지만 아주 복잡한 레이아웃의 경우에는 브라우저 단에서의 최적화만으로는 충분하지 않기 때문에, 프론트엔드 개발자 역시 레이아웃 과정의 연산을 최소화하도록 신경을 써야 한다. 때문에 브라우저처럼 행동하는 것이 필요하다. DOM의 레이아웃과 관련된 값을 직접 읽어오거나 변화를 주는 JavaScript 코드를 작성해야 한다면, 그러한 구문들을 최대한 묶어야 한다.

     

    const divWidth = div1.clientWidth;
    div2.style.width = `${diwWidth}px`;
    
    const divHeight = div1.clientHeight;
    div2.style.height = `${divHeight}px`;

     위의 코드는 div1 이라는 태그의 너비와 높이 값을 읽어와 div2 의 인라인 스타일에 적용하는 방식으로 DOM을 직접 수정하는 코드이다. 위에서 언급한 바와 같이, 브라우저는 더티한 레이아웃이 발생할 때마다 증분적 레이아웃을 수행하기 위해 레이아웃 계산을 스케쥴러에 등록한다. 위의 코드는 div2 의 너비를 변경한 후 다시 div1 의 높이를 불러오기 때문에, 두 과정 사이에서 발생했을 수도 있는 레이아웃의 변경 때문에 불필요한 계산이 추가가 된다. 즉, 레이아웃 관련 값을 읽어오는 부분과 레이아웃을 수정하는 코드가 혼용되었기 때문에 최적화 관점에서 문제가 된다.

     

     따라서 위 코드는 아래와 같이 수정함으로써 계산 과정을 줄일 수 있다.

    const divWidth = div1.clientWidth;
    const divHeight = div1.clientHeight;
    
    div2.style.width = `${diwWidth}px`;
    div2.style.height = `${divHeight}px`;

     

    페인트

     

     

     마지막 단계는 페인트 단계이다. 페인트 단계는 말 그대로 레이아웃 단계를 통해 화면에 배치된 엘리먼트들에게 색을 입히고 레이어의 위치를 결정하는 단계이다. 

     

     문서가 클수록 브라우저가 수행해야 하는 작업도 더 많아지며, 스타일이 복잡할수록 페인팅에 걸리는 시간도 늘어난다. 예를 들어, 단색은 페인트하는 데 시간과 작업이 적게 필요한 반면, 그림자 효과는 계산하고 페인트 하는데 시간과 작업이 더 필요하다.

     

     페인팅에는 그 순서가 있는데, 이는 z-index 축을 이용한 쌓임 맥락(Stacking context)과도 일맥상통합니다. 때문에 z-index가 낮은 순서대로 먼저 페인팅이 된다.

     

     한편 블록 단위에서의 페인팅 순서는 CSS 페인팅 명세에 따르면 다음과 같다.

     

    1. background-color
    2. background-image
    3. border
    4. children
    5. outline

     따라서 만약 background-color와 background-image가 함께 세팅되어 있고, background-image로 설정한 외부 리소스의 크기가 크다면 background-color 를 먼저 보게 될 것이고, 나중에 이미지가 완전히 로드된 후 background-image로 교체가 될 것이다.

     

    가상 DOM

     

     오늘 정리한 내용을 바탕으로 React와 Vue를 써보면 반드시 들어봤을 용어 가상 DOM(Virtual DOM)의 출현 배경에 대해서도 정리하려고 한다. 

     

     일반적으로 중요 렌더링 경로는 초당 60회 정도의 주기로 계산을 수행한다고 한다. 이때 가장 비용이 많이 드는 단계가 바로 레이아웃 단계와 페인트 단계이기 때문에 성능 최적화를 위해서는 두 단계에서의 연산을 최소화하는 것이 중요하다.

     

     JavaScript를 이용해 DOM을 직접 조작하면, 변경 사항이 있을 때마다 잠재적인 레이아웃 단계와 페인트 단계를 초래하게 되는 것이다. 만약 10개의 DOM 노드를 for 문으로 일일이 수정하게 되면, 하나의 노드에 수정 사항이 생길 때마다 화면을 다시 그리는 과정을 거쳐야 할 수 있다. 즉 10개를 한 번에 수정하는 것이 아니라, 하나씩 수정된 노드가 10번에 걸쳐서 다시 화면에 그려질 수 있다는 이야기인 것이다. 때문에 일반적으로 DOM을 직접 조작하는 것은 비용이 크다고 이야기한다.

     

     한편 가상 DOM은 실제로 렌더링 되지는 않았지만, 실제 DOM 구조를 반영한 상태로 메모리에 있는 가상의 DOM이다. 메모리 상에 있고, 실제 화면에 그려야 할 필요는 없기 때문에 실제 DOM보다는 연산 비용이 적다. 가상 DOM은 이러한 특징을 바탕으로 위에서 말한 변경 사항들을 한 번에 묶어서 실제 DOM에 반영을 한다. 물론 레이아웃 단계와 페인트 단계에서 한 번에 변경되어야 하는 사항은 많아지지만 단 한 번의 계산만으로도 바뀐 DOM을 적용할 수 있기 때문에 연산의 횟수는 최소한이 된다.

     

     이러한 가상 DOM을 사용하는 대표적인 라이브러리, 프레임워크가 바로 React와 Vue이다.

     


     

    참고 자료

    'Note' 카테고리의 다른 글

    자바스크립트 클린 코드  (0) 2021.08.21
    이벤트 루프  (0) 2021.08.13
    반응형 테이블(Responsive Table)  (0) 2021.08.06
    페이지네이션(Pagination)  (0) 2021.08.02
    무한 스크롤(Infinite Scroll)  (0) 2021.07.26

    댓글