Dayong Blog

🚧공사중🚧

web /

브라우저는 어떻게 동작할까?

profile

Dayong Lee

·

2023년 7월 22일

새로운 기술들을 공부할 때마다 기본적인 개념들이 중요하다는 것을 느낀다. 그래서 이번에는 브라우저가 어떻게 동작하는지 알아보려고 한다. 처음부터 브라우저의 코드들을 분석하기엔 부담스러워서 Tail Garsiel의 How Browsers Work: Behind the scenes of modern web browsers를 참고하여 공부했고 나중에 다시 보기 쉽고 정보 공유 목적으로 번역해서 옮기려고 했으나 이미 D2 사이트에 번역이 잘되어 있어 간단하게 목차별 정리만 하려고 한다.

소개

해당 입문서는 Tail Garsiel이 open source browser 들의 소스 코드와 공개된 자료들을 검토하여 만든 글이다. 가장 많이 사용되는 프로그램인 웹 브라우저의 동작 과정을 이해하고 있으면 웹 개발자로서 더 나은 웹사이트를 만들거나 소프트웨어 엔지니어로써 더 나은 프로그램을 만들 수 있다고 개인적으로 기대된다.

우리는 주소창에 주소를 입력하면 화면에 보이기 전까지 어떤 일이 발생하는지 알아본다.

브라우저가 하는 일

브라우저가 하는 일은 우리가 선택한 웹 자료들을 보여주는 것이다. 웹 자료라 하면 보통 HTML 문서를 뜻하지만, 각종 플러그인 및 익스텐션을 통해 PDF, 이미지 등 다양한 자료를 볼 수 있다.

이들 중 HTML을 보여주고 해석하는 방법은 W3C에서 정의한 표준에 따라야 하지만 오랫동안 각 브라우저는 이를 잘 지키지 않고 각자만의 방식으로 동작했다. 이러한 이유로 웹 개발자들은 호환성 문제로 고통받아야 했다.

하지만 신기하게도 표준이 없는 웹 브라우저의 User Interface는 서로 거의 비슷했다. 아마 자주 사용되는 브라우저의 UI를 모방하면 사용자들이 넘어오기 편해지고 이는 사용자 수를 늘리는 데 도움이 되었을 것이다. 하지만 HTML 표준 명세를 따르는 것은 과거에는 사용자 수를 늘리는 데 도움이 되지 않았을 것이고 오픈 소스도 아니었으니 개발 비용 문제가 있었을 것이다.

브라우저 구성요소

  1. 사용자 인터페이스 - 주소창, 뒤로가기/앞으로가기 버튼, 북마크 메뉴 등 요청된 페이지가 보여지는 창을 제외한 모든 부분
  2. 브라우저 엔진 - 사용자 인터페이스와 렌더링 엔진 사이의 동작을 제어
  3. 렌더링 엔진 - 요청된 콘텐츠를 표시, 예를 들어 HTML을 요청하면 HTML과 CSS를 파싱하여 화면에 표시
  4. 네트워킹 - HTTP 요청과 같은 네트워크 호출에 사용 (플랫폼 독립적인 인터페이스로 구성되어 있음)
  5. UI 백엔드 - 콤보 박스와 창 같은 기본적인 장치를 그림 (플랫폼의 일반적인 인터페이스로 구성되어 있음)
  6. JavaScript 해석기 - 자바스크립트 코드를 해석하고 실행
  7. Data Storage - 쿠키, 로컬 스토리지, IndexedDB, WebSQL, FileSystem과 같은 데이터를 저장하는 계층

브라우저 구성요소브라우저 구성요소

보통의 브라우저는 위와 같은 형태로 이루어져 있다. 다만 크롬은 각 탭별로 별도의 렌더링 엔진 인스턴스를 가지고 있어서 각 탭이 독립적으로 동작한다.

렌더링 엔진

렌더링 엔진은 요청한 콘텐츠를 표시하는 역할을 하는 구성요소이다. 플러그인과 익스텐션으로 PDF와 같은 다른 유형의 콘텐츠를 표시할 수 있지만 해당 입문서에서는 HTML과 CSS로 작성된 콘텐츠를 표시하는 방법에 대해서만 다룬다.

각 브라우저 별로 다른 렌더링 엔진을 가지고 있는데 Firefox는 Gecko, Safari는 WebKit, Chrome과 Opera는 Webkit을 포크한 Blink를 사용한다. 웹킷은 오픈 소스 렌더링 엔진으로 리눅스 플랫폼을 위한 엔진으로 시작했다가 애플에 의해 맥과 윈도우 플랫폼을 위한 엔진으로 확장되었다.

렌더링 엔진 흐름

8kB 단위로 네트워크 계층으로부터 요청된 문서의 내용물이 오면 렌더링 엔진은 일을 시작한다.

렌더링 엔진 흐름렌더링 엔진 흐름

렌더링 엔진은 먼저 HTML 문서를 파싱하여 요소들을 DOM node로 이루어진 content tree로 변환한다.

그 후 외부 CSS 파일과 인스타일 요소들을 파싱하고 이들로 또 다른 트리인 render tree를 만든다. 렌더 트리는 색상 그리고 차원들과 같은 시각적 속성을 포함한 사각형들로 이루어져 있다. 이러한 사각형들은 화면에 표시되는 순서대로 정렬되어 있다.

렌더 트리를 만들고 나면 layout 단계가 시작되는데 화면에 표시될 각 노드의 정확한 위치를 계산한다. 다음 단계는 paint 단계인데 렌더 트리의 각 노드를 돌면서 UI 백엔드를 사용하여 노드를 화면에 그린다.

렌더링 엔진은 네트워크로부터 모든 HTML 문서 내용물이 올 때까지 기다리지 않고 최대한 빨리 위와 같은 과정을 진행한다.

WebKit 흐름WebKit 흐름

원문에는 렌더링 엔진 흐름의 예로 Gecko도 다루고 있는데 이는 DOM 요소들을 만드는 "content sink"라는 과정이 추가되고 각 과정의 이름이 다르다는 정도로 크게 다르지 않다. 또한 크롬의 Performance 탭에서 렌더링 과정을 보면 추가적인 과정이 있는 것을 확인할 수 있는데 어쨌든 위 사진에 나와 있는 Webkit 흐름과 크게 다르지 않다.

파싱 과정 - 일반적인 상황

렌더링 엔진은 제일 처음 파싱을 진행하는데 파싱은 보통 node로 이루어진 트리로 변환된다. 파싱은 매우 중요한 과정이라 원문에서 각 과정을 자세히 다루고 있다.

문법

파싱은 문서가 따르고 있는 syntax rules에 따라 진행되는데 모든 형식은 정해진 용어와 구문 규칙들을 가져야 한다. 이를 context free grammar이라고 한다.

파서 - 어휘 분석기 조합

구문 분석은 어휘 분석 그리고 구문 분석으로 이루어지는데 어휘 분석은 문서를 토큰으로 분해하고 구문 분석은 구문 규칙에 따라 문서 구조를 분석함으로써 파싱 트리를 생성한다. 따라서 이러한 일을 하는 구문 분석기는 어휘 분석기와 구문 분석기로 이루어져 있다.

구문 분석기는 의미 없는 빈 곳들을 제거하는 역할을 한다.

토큰은 문법적으로 더 이상 나눌 수 없는 어휘의 최소 단위이다.

문서의 소스에서 parse tree를 만드는 과정문서의 소스에서 parse tree를 만드는 과정

파서는 어휘 분석기에 새로운 토큰을 요청하고 구문 규칙 중 하나와 매칭을 시도한다. 만약에 하나의 규칙과 매칭이 되면 해당 토큰에 해당하는 노드를 구문 트리에 추가할 것이고 다음 토큰을 요청할 것이다. 만약에 매칭이 되지 않으면 내부적으로 토큰을 저장할 것이고 다른 규칙과 매칭을 시도할 것이다. 만약에 모든 규칙과 매칭이 되지 않으면 예외를 발생시킨다. 이 예외는 문서가 유효하지 않고 구문 오류가 있다는 것을 의미한다.

변환

보통 parse tree가 마지막 결과물이 아니고 이를 변환하여 또 다른 형식으로 만드는데 컴파일을 예로 들면 parse tree를 머신 코드로 변환하는 것을 말한다.

파싱 예제

정수, 양수, 음수를 포함한 간단한 수학식을 파싱하는 예제를 보자. Syntax는 다음과 같다.

  1. 표현식, 항, 연산자으로 이루어져 있다.
  2. 표현식의 수는 제한이 없다.
  3. 표현식은 연산자 앞과 뒤에 항이 있어야 한다.
  4. 연산자는 더하기와 빼기만 가능하다.
  5. 항은 정수 또는 연산자로 이루어져 있다.

2 + 3 - 1을 분석하는 과정을 보자.

먼저 5 번째 규칙에 따라 2가 매칭이되고 다음으론 2 + 3은 3, 5 번째 규칙에 따라 매칭이된다. 마지막으로 2 + 3 - 1은 1, 3, 5 번째 규칙에 따라 매칭이 된다.

어휘 그리고 구문의 공식적인 정의

어휘는 정규식으로 표현된다. 위의 예제는 다음과 같이 표현할 수 있다.

1INTEGER: 0|[1-9][0-9]*
2PLUS: +
3MINUS: -

구문은 BNF로 표현된다. 위의 예제는 다음과 같이 표현할 수 있다.

1expression := term operation term
2operation := PLUS | MINUS
3term := INTEGER | expression

앞서 설명한 context free grammer을 직관적으로 정의하면 결국 BNF로 표현될 수 있는 문법인 것이고 따라서 파싱이 가능하려면 context free grammer를 따르는 문서여야 했던 것이다.

파서의 종류

파서는 상향식(top-down) 파서와 하향식(bottom-up) 파서가 존재하는데 하향식은 상위 구조로부터 일치하는 부분을 찾기 시작하는데 상향식은 점진적으로 낮은 수준부터 찾는다.

상향식은 파싱 예제처럼 진행되고 하향식은 2 + 3이 연산자임을 확인하면 2 + 3 - 1을 연산자로 확인하는 식으로 진행한다.

스택입력
Empty2 + 3 - 1
term+ 3 - 1
term operation3 - 1
expression- 1
expression operation1
expressionEmpty

위와 같이 상향식 파서는 스택을 사용하는데 토큰들이 스택에 쌓이고 규칙에 맞는지 확인한다. 이러한 방식을 shift-reduce parsing이라고 한다. 왜냐하면 입력이 오른쪽으로 움직이고 구문 규칙에 따라 점진적으로 제거되기 때문이다.

파서 생성기

파서를 직접 만들려면 파싱에 대한 깊은 이해도가 필요하고 최적화된 파서를 만들기는 쉽지 않기 때문에 파서 생성기를 사용한다. Webkit은 lexer를 만들기 위해 Flex를 사용하고 parser를 만들기 위해 [Bison[(https://www.gnu.org/software/bison/)을 사용한다. Flex는 정규식으로 토큰을 정의한 파일을 입력 받고 Bison은 BNF로 문법을 정의한 파일을 입력 받는다.

HTML 파서

HTML 마크업을 parse tree로 파싱하는 HTML parser는 앞서 설명한 context free grammar로 정의할 수 없다. 또한 엄격한 XML에 반하여 유연한 문법을 가지고 있다. 이러한 이유로 전통적인 파서 생성기를 사용할 수 없다.

파싱 알고리즘

이전 섹션에 말했다시피 일반적인 상향식 또는 하향식 파서로 파싱을 할 수 없다. 아래에 이유를 나열했다.

  1. 언어의 유연한 성질
  2. HTML 오류에 대한 브라우저의 관용
  3. document.write으로 토큰을 추가될 수 있기 때문에 파싱 과정에서 입력이 변경될 수 있다.

이러한 이유로 브라우저들은 맞춤화한 파서들을 만드는데 tokenization 그리고 tree construction과 같은 2 가지 단계로 나누어서 파싱을 한다.

토큰화 알고리즘

토큰화 알고리즘은 HTML 문서를 입력으로 받아서 HTML token을 출력으로 내보낸다. 토큰에는 열린 태그, 닫힌 태그, 속성 이름, 속성값이 있다. 이 알고리즘은 상태 기계로 구현되어 있다. 즉 현재 상태와 입력에 따라 다음 상태를 결정하는 방식으로 동작한다.

예을들어 초기 상태는 "Data state"이고 입력이 <이면 "Tag open state"로 상태가 변경된다. 그 후 a-z 문자를 만나면 "Start tag token"을 만들고 상태는 "Tag name state"로 바뀐다. 이러한 상태를 유지하다가'>'을 만나면 "Data state"로 상태가 변경되고 각 문자는 새로운 토큰 이름으로 추가된다.

토큰화 예제토큰화 예제

트리 구축 알고리즘

트리를 구축하는 동안 문서 최상단에서는 DOM 트리가 수정되고 토큰화에 의해 생긴 노드들이 추가된다. 요소들이 DOM tree에 추가되거나 열린 요소는 스택에 추가된다. 이 스택은 닫히지 않은 태그들과 중첩이 잘못된 것들을 고칠 때 사용한다. 해당 알고리즘 또한 state machine이다.

예제를 살펴보자.

1<html>
2 <body>
3 <p>hello</p>
4 </body>
5</html>

초기엔 상태가 initial mode였다가 <html>을 만나면 "before html"로 상태가 변경된다. 그러면 HTMLHtmlElement 요소가 DOM tree에 추가되고 상태는 "before head"가 되고 <body>을 만나면 <head>가 명시적으론 없지만 HTMLHeadElement 요소가 추가된다. 상태는 "in head", "after head"로 변경된다. 이와 같은 방식으로 DOM tree가 구축된다.

트리 구축 예제트리 구축 예제

이러한 일들이 모두 끝나면 deffered mode의 script들이 실행되고 문서의 상태는 complete로 설정되고 load 이벤트가 발생한다.

브라우저 오류 처리

브라우저는 오류 구문들을 교정해 주는데 이는 HTML 명세는 아니고 관습적으로 오류를 고쳐주고 있다. 하지만 최근 HTML5 명세에서는 이러한 요구 사항 일부를 정의했고 웹킷은 파서 클래스 상단에 주석으로 잘 요약해 두었다고 한다.

파서는 최소한 다음과 같은 오류를 처리한다.

  1. 명시적으로 금지된 태그가 안쪽에 추가 되는 겨우 금지된 태그들을 모두 닫고 외부에 추가한다.
  2. 개발자가 뒤늦게 요소를 추가하거나 생략할 수 있는 경우가 있을 수 있으니 파서가 직접적으로 요소를 추가하면 안 된다.
    1. HTML, HEAD, BODY, TBODY, TR, TD, LI 태그가 이런 경우에 해당
  3. 인라인 요소 안에 블록 요소를 추가하려 하면 블록 요소를 만날 때까지 모든 인라인 태그를 닫는다.
  4. 이래도 안되면 요소를 추가하거나 생략한다.

웹킷의 오류 처리 예는 아래와 같다.

  • -> <br>로 간주
  • 어긋난 테이블
    • 테이블 안에 테이블이 있으면 두 개의 자매 테이블로 만든다.
  • 중첩된 폼
    • 두 번째 폼은 무시된다.
  • 태그 중첩이 너무 깊을 때
    • 최대 20개의 중첩만 허용 나머지는 무시
  • 잘못 닫힌 html 또는 body 태그
    • 문서가 끝나기 전에 닫는 멍청한 페이지가 있어 body tag를 닫지 않고 end() 함수를 호출하여 문서를 닫는다.