프론트엔드 공부를 하다보면 분명 브라우저의 세부적인 동작원리를 이해하고 있을 필요가 많이 있다고 느꼈다. 필요할 때마다 공부를 했던 것 같은데 막상 질문으로 들어왔을 때 그에 대한 답변으로 완벽히 대답할 수 없다는 걸 최근에 느꼈다 ..
그래서 많이들 말하는 URL 을 주소창에 쳤을 때!부터 시작하여 렌더링이 최종적으로 어떻게 이루어지는 지 과정들을 살펴보고자 한다.
탐색은 웹페이지를 로딩하는 첫 단계이다.
URL을 입력하거나, 폼을 제출하는 등의 동작 시에 발생하게 된다.
처음 특정 URL이 주소창에 입력되면, 브라우저는 해당 도메인이 실제로 어느 서버에 있는지 알아내야 한다. 하지만 실제 HTML 페이지는 도메인이 아닌 IP 주소에 해당하는 서버에 위치해 있다.
이렇듯 도메인 이름을 컴퓨터가 이해할 수 있는 IP 주소로 변환하는 과정을 DNS 조회라고 한다.
이 때, 한 번 조회된 IP 주소는 일정 기간동안 캐시된다. 이로 인해 동일한 사이트에 재방문할 때에는 네임 서버에 매번 물어보지 않고 캐시에서 가져와 접속 속도를 높여준다.
일반적으로는 DNS 조회를 페이지 로드할 때 호스트네임 당 한 번만 수행하지만, 복잡한 웹 페이지에서는 여러 번이 요구되어 성능 저하에 영향을 미치기도 한다.

또한, DNS 조회는 모바일 환경에서 더 큰 영향을 미친다.
모바일 기기에서는 휴대폰 → 기지국 → 권한 있는 DNS 서버의 과정을 거쳐야한다. 따라서 물리적인 거리가 멀고, 네트워크 단계가 복잡할 수 록 지연 시간이 늘어나 사용자의 체감 속도는 느려질 것이다.
위와 같이 IP 주소를 알아낸 후에는 실제 데이터를 주고받기 위해 TCP 핸드셰이크와 보안 강화를 위한 TLS 협상이 필요하다.
IP 주소를 확인한 브라우저는 서버와의 연결을 위해 TCP 3-way 핸드쉐이크를 수행한다. 이 과정은 본격적인 데이터 전송(HTTPS 등)에 앞서 양측이 통신 매개변수를 협상하는 단계다.
TCP 핸드셰이크는 "SYN-SYN-ACK"로 불리기도 한다.
두 컴퓨터 간에 TCP 세션을 협상하고, 시작하기 위해 총 3개의 메시지를 주고 받기 때문이다.
간단하게 과정을 보면

위 TCP 협상 과정은 앞으로의 통신을 하기 위해 상태를 확인하고 규칙을 정하는 등의 과정이다.
만약 보안을 위해 HTTPS를 사용한다면 TLS 협상이 추가로 필요하다.
HTTPS를 이용해 보안성있는 연결을 하기 위해 TLS 협상을 한다.
통신 암호화에 쓰일 암호를 결정하고, 서버를 확인하며, 실제 데이터 전송 전 안전한 연결이 이루어질 수 있도록 한다.

보안을 위해 협상을 추가로 주고받는 것은 페이지 로딩을 더디게 할 수는 있다. 하지만 그러한 지연을 감수할 가치가 충분히 있다.
이렇게 총 8번의 왕복이 끝난 후 브라우저는 데이터 요청/전송을 할 수 있게 된다.
웹서버로 연결이 한 번 성립된 후
해당 응답은 한 번에 모든 것이 오는 게 아닌, 조각(chunk) 단위로 오게된다. 또한, linked resources도 바로 요청되지 않는다.
HTML을 만났을 때 브라우저는
HTML 한 줄씩 파싱 → <link>, <script>, <img>를 만난 순간에 추가 요청을 보냄
따라서, 처음부터 모든 데이터를 받지 않았으며, CSS/JS/이미지 등이 없고, HTML의 일부만 받은 상태이다.
이때 첫 번째 바이트가 오는 순간이 중요하다.
첫 요청을 보낸 시점부터 첫 번째 바이트를 받는 데까지 걸린 시간이다.
TTFB에는 아래가 전부 포함된다
여기서 받은 첫 청크는 일반적으로 14KB 이하의 데이터이다.
아래에서 나올 slow start 때문에 브라우저가 첫 청크를 작게 받아, 특정 서버가 응답을 잘 하는지 빠르게 확인하기 위함이다.
결국 TTFB는 전체 HTML을 다 받았다는 것이 아닌, 응답이 시작됐다는 신호일 뿐이다
서버
네트워크
TCP 패킷은 데이터 전송 시 세그먼트로 분할되어 전송된다. TCP는 순서를 보장하기 때문에 세그먼트 전송 후, 클라이언트에게서 ACK 패킷 형태로 지금 잘 전송되고 있는지, 현재 속도가 감당 가능한지 등의 승인을 받아야한다.
이때 서버가 각 세그먼트마다 ACK를 받아야한다면 너무 자주 ACK를 받아야하기에, 전송 시간이 과하게 증가할 수 있다.
하지만 한 번에 너무 많은 세그먼트를 보낸다면 사용량이 많은 네트워크 상태인 경우에 문제가 될 수 있다.
만약 서버가
1, 2, 3, 4, 5, 6, 7
를 계속 전송하지만, 네트워크 사용량이 많아 아래와 같이 클라이언트에 전송한다면,
1, 2, 3, 4❌(유실), 5, 6
클라이언트는 계속 ACK = 3을 전송하게 된다(3까지만 받았다고 알려주는!)
결국 클라이언트는 계속 ACK만 응답하게 되고, 서버는 계속 세그먼트를 재전송하게 되어
클라이언트는 중복 ACK 문제가 발생하고, 서버에서는 현재 네트워크가 문제가 있구나. 라고 판단하여CWND를 감소시키며 속도를 줄이고 계속 재전송을 하게 된다.
이런 문제를 방지하며 전송되는 세그먼트 수의 균형을 맞추기 위해 TCP 슬로우 스타트 알고리즘을 사용한다.
TCP 슬로우 스타트란
네트워크가 감당할 수 있는 최대 전송 속도를 알 수 없기 때문에, 초기에는 적은 양의 데이터부터 전송하고, ACK(응답)를 통해 네트워크 상태를 확인하며 점진적으로 전송량을 늘려가는 혼잡 제어 알고리즘이다.
네트워크 혼잡이 감지될 경우에는 전송 속도를 줄여 패킷 손실과 재전송을 최소화한다.
이러한 네트워크 혼잡 감지는 혼잡 윈도우(CWND) 값으로 판단하게 되며, CWND는 MSS의 개수 또는 바이트 단위이다.
네트워크 혼잡을 고려하여, 송신 측이 현재 시점에서 한 번에 전송할 수 있는 데이터 양의 상한선
TCP가 한 번에 전송할 수 있는 순수 데이터(payload)의 최대 크기
슬로우 스타트에서 TCP 연결이 시작되면, CWND는 매우 작은 값으로 초기화된다.
→ RTT 마다 CWND 를 2배로 증가
패킷 손실, 중복 ACK, 타임아웃
→ CWND = CWND/2 로 전송량을 줄이며, 네트워크에 부담 주지 않도록 속도를 낮춘다.
→ ACK 한 번 수신되지 않는다고 바로 줄이지 X, 지속적으로 신호가 감지될 때 혼잡으로 판단.
이렇게 TCP는 ACK를 통해 네트워크의 감당 여부를 지속적으로 확인하며,
CWND를 조절해 적절한 속도를 유지한다.
브라우저가 첫 번째 데이터 청크를 받으면 구문 분석을 시작한다.
브라우저는 HTML/CSS/JS 라는 텍스트 파일들을 그대로 화면에 그릴 수 없기에 간략하게 아래와 같은 과정을 거친다
HTML (문자열)→ DOM (구조화된 객체 트리)CSS (문자열)→ CSSOM (스타일 규칙 트리)DOM + CSSOM→ Render Tree→ Layout→ Paint
여기서 구문 분석은 브라우저가 받은 데이터를 DOM 이나 CSSOM 으로 바꾸는 단계이다.
브라우저는 마크업을 단순 문자열이라고 생각하기에
따라서 브라우저는 다루기 쉬운 구조로 바꾸는 것이다.
이러한 구문 분석은 브라우저가 HTML을 다 받기도 전에, 데이터를 조금이라도 받으면 바로 파싱을 시작한다.
가장 먼저 HTML을 처리하여 DOM 트리를 만든다. HTML 파싱 시에는, 크게 토큰화와 트리 생성 과정을 거친다.
DOM 트리는 HTML 문서의 내용을 설명하는 역할이다.
HTML 요소는 첫번째 요소이자, DOM 트리의 루트 노드이다. 아래 트리는 태그들 간의 관계 와 계층 구조를 반영한다. 다른 태그로 감싸져있는 자식 노드이다.
당연하게도 DOM 노드의 개수가 많아지면 DOM 트리를 구축하는데 더 오랜 시간이 걸리게 된다.

이 과정에서 구문 분석기(파서)가 이미지와 같은 논블로킹 자원을 만나면, 브라우저는 해당 리소스를 요청하되, 파싱을 계속해서 진행한다. CSS 파일을 만나더라도 파서는 계속 진행하게 된다.
하지만, <script> 요소 등은 특별히 async 나 defer 속성을 추가하지 않는다면 렌더링을 블로킹하게 되어 HTML 파싱도 멈추게 된다.
브라우저 입장에서는 아래처럼 생각하게 된다.
이 JS가 DOM을 바꿀 수도 있는데 DOM을 계속 만들어도 괜찮을까?
그래서 이처럼 렌더링 블로킹을 하고 script 를 읽게 된다.
이러한 문제를 줄이기 위해 프리로드 스캐너가 존재한다. 브라우저의 프리로드 스캐너가 작업을 가속화하지만, 과도한 스크립트는 여전히 병목구간이 될 가능성이 있다.
브라우저가 DOM 트리를 만드는 프로세스는 메인 쓰레드가 실행한다.
그렇기에 프리로드 스캐너는 사용 가능한 컨텐츠를 분석하고 CSS, JS, 폰트 등과 같이 우선순위가 높은 자원을 요청한다.
프리로드 스캐너가 미리 자원을 요청하기에 파서가 외부 자원에 대한 참조를 만나서 요청하는 순간까지 기다리지 않아도 된다. 그래서 파서가 해당 요소를 읽을 때쯤이면 이미 해당 자원들을 전송받고 있거나, 받은 후일 것이다. 이렇게 프리로드 스캐너는 블로킹을 줄여주는 역할을 한다.
만약 JS의 분석과 실행 순서가 중요하지 않고, 스크립트가 메인 쓰레드에서 프로세스를 막지 않게 하길 원한다면 async 나 defer 속성을 추가하면 된다.
JS는 종종 특정 요소들에 영향을 미칠 수 있는 CSS 속성을 조작할 수 있기에 파싱 과정을 막게 되지만 CSS 다운은 영향을 미치지 않기에 HTML 분석이나 다운로드를 막지 않는다.
렌더링 경로에서 두번째 단계는 CSSOM 트리를 만드는 것이다.
CSSOM 은 DOM과 꽤 비슷하다
브라우저는 CSS 규칙을 읽고, 트리 노드를 만든다. CSS 셀렉터에 기반하여 각 부모,자식, 형제 관계 등의 노드를 만들어낸다.
위에서 HTML 을 통해 DOM 을 생성한 것과 마찬가지로 CSS로 한 번 더 동일한 과정을 거친다.
우선 브라우저는 CSS를 아무것도 안 준 상태에서도 기본 스타일을 가지고 있다.
아래와 같은 예시들은 우리가 작성한 것이 아닌 User Agent Stylesheet (브라우저 기본 CSS) 에 들어 있는 것이다.
// 예시body { margin:8px; }h1 { font-size:2em; font-weight:bold; }
그래서 CSSOM은 다음을 모두 포함해서 만들어진다.
다음으로 브라우저는 CSS를 위에서 아래로 한 번에 적용하지 않는다.
가장 일반적인 규칙부터, 점점 더 구체적인 규칙으로 덮어쓰며 최종 계산된 스타일(computed style) 을 만든다. (재귀적으로 스타일을 좁혀가는 느낌)
예를 들어,
/* 브라우저 기본 스타일 */p { display:block; }/* 사용자 CSS */p { color:black; }.articlep { color:blue; }#title
<p id="title"class="article">Hello</p>
브라우저의 처리 순서
→ display: block
CSSOM 생성과 스타일 계산은 빠르게 수행되며, 일반적으로 한 번의 DNS 조회보다도 짧은 시간이 소요된다.
따라서 웹 성능 최적화 관점에서 CSSOM 자체보다는 렌더링을 차단하는 CSS, 과도한 JavaScript 실행, 네트워크 요청에 더 집중하는 것이 효과적이다.
렌더링 과정에는 스타일, 레이아웃, 페인트 그리고 합성이 있다.
위에서 다룬 CSSOM 과 DOM 트리는 구문 분석 시에 생성된 후 렌더 트리로 합서된다.
브라우저는 먼저 생성된 DOM(구조)과 CSSOM(스타일)을 결합하여 렌더 트리(Render Tree)를 만든다. 이 트리는 화면에 실제로 '보이는' 노드들로만 구성된다.
렌더 트리가 생성된 후에는 레이아웃 작업이 시작된다.
이때에는 렌더 트리를 기반으로 각 노드들의 너비, 높이, 위치 등 도형 값을 계산한다.
렌더 트리는 보이지 않더라도 계산된 스타일과 함께 어떤 노드가 화면에 표시될지 식별한다. 하지만 각 노드의 위치, 좌표는 알지 못하는 상태이다. 따라서 각 객체의 정확한 크기 및 위치를 결정하기 위해 브라우저는 렌더 트리의 루트부터 시작하여 순회한다.
웹 페이지의 대부분의 요소는 박스 형태이다. 브라우저는 뷰포트를 기준으로 요소들의 크기를 결정한다.
보통 body 에서 시작하여 자식의 크기들을 각 요소의 박스 모델 속성을 통해 계산한다.
이미지와 같이 크기를 모르는 요소를 위해서는 위치 표시 공간을 별도로 남겨둔다. 이렇게 되면 크기가 미리 지정되지 않아 이미지 로드 후 브라우저가 크기를 다시 계산해야한다.
이때 브라우저가 처음으로 위치와 크기를 잡는 것은 레이아웃, 이후에 일부라도 변경되어 다시 계산하게 되는 것들을 리플로우(Reflow)라고 한다.
이미지가 로드된 후 브라우저가 재계산하는 것 또한 리플로우이며, 이는 성능에 안 좋은 영향을 준다.
렌더링에서 가장 마지막 단계는 각 노드를 화면에 페인팅하는 단계이다.
이 단계에서는 이미 계산된 레이아웃 정보(박스, 좌표)를 바탕으로
각 노드의 색상, 테두리, 텍스트, 그림자, 이미지 등을 픽셀 단위로 그린다.
즉, 아래처럼 이해하면 된다.
Paint 단계에서는 다음과 같은 작업들이 수행된다.
이 작업의 결과는 비트맵(bitmap) 이며, 브라우저는 이를 메모리 상에 그려둔다.
이 시점에서는 아직 스크린에 바로 표시되는 것은 아니다.
예를 들어 iPad(2048 X 1536)화면에서는 페인트해야 할 3,145,000 픽셀이나 된다. 이 모든 픽셀을 16.67ms(60fps 기준) 안에 처리해야 부드러운 스크롤/애니메이션이 가능하게 되는 것이다.
그래서 페인트 작업이 자주 일어나면
스크롤이 끊겨보이거나, 애니메이션 프레임이 드롭되거나, 체감 성능이 저하될 수 있다.
페인트 단계가 다시 실행되는 것이 repaint 이며, 아래와 같은 상황에서 repaint 가 발생한다.
→ 레이아웃(위치, 크기)에 영향을 주지 않는 변경은 Reflow 없이 Repaint만 발생한다.
이러한 리페인트를 더 빠르게 마무리하기 위해서, 화면에 그리는 작업을 여러 개의 레이어로 구분해서 하게 된다.
Paint 성능을 높이기 위해 브라우저는 화면을 여러 개의 레이어(Layer) 로 나누어 관리한다.
한 레이어가 변경되더라도 전체 화면을 다시 그리지 않고, 해당 레이어만 다시 Paint 할 수 있다
→ 이로 인해 첫 페인트 이후의 재페인트 비용이 줄어든다.
이러한 별도의 레이어로 나누어 관리하면서, 일부 요소는 CPU 메인 스레드에서 GPU 레이어로 승격된다.
GPU 레이어가 되면 Paint 작업은 최소화되고, GPU가 합성(Composite)을 담당하여 애니메이션, 스크롤 성능이 등이 향상된다
아래가 대표적인 GPU 레이어로의 승격을 트리거하는 요소/속성이다.
will-change는 브라우저에게 사전 힌트를 주는 속성이다.
.card {will-change:transform;}
그럼 Layer가 항상 만능일까?
레이어가 만능은 당연히 아니다. GPU로 미루게 되면서 각 레이어는 메모리를 차지하게 된다.
하나의 레이어가 생성된다는 것은
브라우저가 그 요소의 크기만큼 픽셀 정보(비트맵)을 담은 메모리 공간을 할당한다는 의미이기 때문이다.
그에 따라 너무 많은 레이어는 오히려 성능을 저하시킬 수 있다.
따라서 가장 중요한 것은 필요한 때에는 선택적으로 사용하는 것!!
지금까지 왔을 땐 각 섹션들이 그럼 다른 레이어로 나누어져 비트맵으로 그려진 상태이다.
각 레이어는 독립된 상태이기에 섹션을 올바른 순서로 화면에 그리고, 정확한 렌더링을 보장하기 위해서는 합성(Compositing)과정을 거쳐야한다.
페이지가 처음 로드된 이후에도 새로운 리소스가 있다면 화면은 다시 그려져야 한다. 이때 가장 주의해야할 게 리플로우이다. 리플로우는 리페인트와 재합성을 일으키기 때문이다.
이미지에 크기가 지정되어 있지 않았다면 아래의 과정을 거치게 된다.
만약 우리가 이미지의 크기를 미리 코드에 적어두었다면 ..!
참고
이렇게 선언하면 브라우저는 이 요소는 곧 변할 것이다라고 생각하고
미리 레이어를 생성하여, 애니메이션 시 Repaint 비용을 감소시킨다.
** 하지만 과도하게 사용하면 메모리 사용량이 급증할 수 있다.