• 브라우저의 동작 (3) - 웹 페이지 최적화

    2020. 6. 12.

    by. 나나 (nykim)

     

     

     

    Critical Rendering Path

    지난 글에서 웹 브라우저가 어떻게 일하는지 간략하게 알아보았습니다.

    구글에서는 이를 Critical Rendering Path(CRP, 주요 렌더링 경로)라고 부르며, 이를 최적화하는 방법에 대한 강의문서도 있습니다.

    사이트 최적화를 하는 방법 중 하나는 이 CRP를 간결하게 하는 것입니다.

     

     

     

    HTML 다운로드

    <html>
    <body>
      <p>Hello, world</p>
      <img src="awesome-photo.jpg"/>
    </body>
    </html>

     

    우리가 웹 사이트에 접속하면 브라우저는 우선 서버에 페이지를 요청합니다. 

    이후 html에 대한 응답이 오면, 쉬고 있던(idle 상태의) 브라우저는 DOM을 만들기 시작합니다.

    그리고 DOM이 다 그려지면 렌더 트리를 구현하고 페이지를 렌더링합니다.

     

     

    출처: Google Developers

     

     

    위 이미지를 보면 html을 다운로드받는 데 205ms가 걸린 걸 볼 수 있습니다.

    DOM 빌딩에는 216ms가 소요됐으며, 이 시점에 DOMContentLoaded이벤트가 발생합니다.

     

    이미지는 페이지의 초기 렌더링을 차단하지 않습니다.

    다만 이미지 등의 리소스를 모두 다운로드 받은 뒤에 onload 이벤트가 발생합니다.

     

     

     

    출처: Google Developers

     

     

    각 타임라인 사이의 시간은 네트워크 및 서버 처리 시간을 나타냅니다.

    TCP 전송 프로토콜 방식에 따라 다르지만, 최상의 경우 한 번의 네트워크 왕복만으로 전체 문서를 가져올 수 있습니다.

    따라서, 위의 경우 5KB짜리 html을 받기 위해 최소 1번 왕복하는 CRP 경로를 갖습니다.

     

     

     

    CSS, JS 다운로드

    <html>
      <head>
        <link href="style.css" rel="stylesheet">
      </head>
      <body onload="measureCRP()">
        <p>Hello!</p>
        <img src="awesome-photo.jpg">
        <script src="app.js"></script>
      </body>
    </html>

     

    대부분의 웹사이트는 html 파일 외에도 css와 js 파일을 갖고 있고, 이를 가져오기 위한 요청-응답 과정이 더 필요합니다.

     

     

    출처: Google Developers

     

    이 경우에는 3개의 주요 리소스를 받아오기 위해, 최소 2번 이상 왕복하는 경로를 갖습니다.

    (브라우저는 HTML을 받아온 뒤, CSS와 JS 리소스에 대한 두 요청을 실행해 동시에 받아올 수 있습니다.)

     

    이렇게 DOM트리와 CSSOM트리까지 다 만들어진 후에 DOMContentLoaded이벤트가 발생합니다.

     

     


     

    리소스 최적화

     

    결국 렌더링 경로를 최적화하려면, 최대한 적게 요청하고 최대한 빠르게 받아오는 게 중요합니다.

     

     

    CSS는 최상단에

    CSSOM이 만들어지지 않으면 렌더링을 진행할 수 없습니다. 따라서 브라우저가 빠르게 CSSOM을 빌드할 수 있도록, CSS는 <head> 바로 아래에 작성합니다.

    <head>
      <link href="style.css" rel="stylesheet" />
    </head>

     

     

    자바스크립트 최적화

     

     

    자바스크립트는 DOM을 조작할 수 있기에 HTML 파싱을 막아버립니다. 즉, HTML을 읽다가 <script>를 만나면 그 즉시 파싱은 중단되며, 이전까지 생성된 DOM만 다룰 수 있습니다. 

     

    하지만 asyncdefer 속성을 명시하면 파싱을 멈추지 않고 그대로 진행시킬 수 있습니다.

     

     

     

    async는 HTML 파싱 중에 파일을 다운로드하고 다운로드가 완료되면 HTML 파서를 일시 중지하여 실행합니다.

    다만 여러 외부 스크립트에 사용했을 때 순서를 보장하지 않아 DOM 제어에 어려움을 겪을 수 있습니다. 그래서 주로 구글 애널리틱스 등, 다른 스크립트에 의존하지 않는 경우에 사용합니다.

     

     

     

    defer는 HTML 파싱 중에 파일을 다운로드하고 파싱이 완료된 후, DOMContentLoaded 이벤트 이전에 실행됩니다. 스크립트는 문서에 표시된 순서대로 실행되도록 보장됩니다.

     

    하지만 둘 다 IE9를 지원하지 않는 등, 브라우저별로 한계가 있으니 주의해서 사용해야 합니다. (참고: Can I use... async / defer )

     

     

     

    애니메이션 최적화

    가능한 자바스크립트보다 CSS로 처리하는 것이좋습니다. 

    프레임 속도가 60fps되어야 사용자는 자연스럽게 받아들이므로, 프레임 하나의 처리는 16ms 미만으로 이루어져야 합니다.
    만약 16ms 내에 하나의 프레임을 만들지 못하면 애니메이션이 뚝뚝 끊기는, 이른바 프레임 드랍이 발생합니다.

     

    requestAnimationFrame()를 사용하면 setInterval()보다 훨씬 부드러운 애니메이션을 만들 수 있습니다.

    이 API는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 합니다

    이 API는 IE10부터 지원됩니다.

     

     

     

     

    꾹꾹 압축하기

    불필요한 공백과 주석을 없애 부피를 줄이는 것도 좋은 최적화 방법입니다.

     

     

     

     

    이미지 적게 & 작게 쓰기

    이미지가 많을수록 HTTP 요청도 많아집니다. 따라서 스프라이트 기법을 쓰면 더 적게 요청할 수 있습니다.

    다만 스프라이트 기법은 불필요한 이미지도 모두 받아오는데다 유지보수가 다소 까다롭다는 문제가 있어, 저는 수정사항이 거의 없을 부분에만 사용합니다.

    대신, 간단한 아이콘은 Data URI를 사용하고 있습니다. 임베드 된 데이터에는 HTTP 요청 및 헤더 트래픽이 필요하지 않습니다. (참고: stackoverflow) 얘도 캐싱은 안 된다는 문제가 있긴 하지만요...

     

     

     

    출처: Google Developers

     

     

    때에 따라 어떤 이미지를 적절하게 제공할지 택하는 것도 퍼블리셔의 일입니다. 

    필요한 경우 WebP나 Lazy Loading 등을 쓸 수도 있을 것 같습니다. 저의 경우 포트폴리오 사이트에서 이미지 용량을 줄이기 위해 WebP를 쓰고 있으며 용량을 꽤 압축할 수 있었습니다. 하지만 이건 사용자가 명확해서 쓸 수 있었던 거라, 상용 사이트에서 사용하기엔 아직 이르지 않나 하는 생각도 듭니다.

     

    이미지 최적화에 대한 내용은 Google Developers에서 자세히 설명하고 있습니다.

     

     

     

     

    파일 하나로 몰기

    <!-- 최적화 전 -->
    <html>
      <head>
        <link href="foo.css" rel="stylesheet" />
        <link href="bar.css" rel="stylesheet" />
      </head>
      <body>
        <script async src="foo.js" type="text/javascript"></script>
        <script async src="bar.js" type="text/javascript"></script>
        <script async src="baz.js" type="text/javascript"></script>
      </body>
    </html>
    
    
    <!-- 최적화 후 -->
    <html>
      <head>
        <link href="bundle.css" rel="stylesheet" />
      </head>
      <body>
        <script async src="bundle.js" type="text/javascript"></script>
      </body>
    </html>

     

    아무리 가벼운 짐이라도 여러 번 들고오면 피곤하기 마련입니다.

    최대한 서버와의 네트워크 비용을 줄이면 좋습니다.

     

     

     

     

    마크업은 간결히

    태그 중첩을 최소화하고, 꾸며주는 요소는 가상 선택자 등을 이용해 최대한 CSS로 몰아넣어 줍니다.

     

     

     

     


     

     

    렌더링 최적화

    기껏 렌더링 해놨는데, '다시 그리는' 작업을 시키면 브라우저에게 부담이 갑니다. 버벅이거나 시간이 오래 걸리겠죠.

    따라서 렌더링 성능을 최적화하면 끊김 없이 부드러운 UI를 제공할 수 있습니다.

     

    지난 번에 배운 바로는, 렌더링 엔진은 렌더 트리가 형성되면 Layout(Reflow) - Paint 작업을 거쳐 화면에 표시한다고 했습니다.

    그런데 사실 화면에 표시하기 전, 브라우저는 사용할 Layer를 계산해서 생성하고 합성하는 과정을 거칩니다.

    이 부분을 좀 더 자세히 들여다보죠!

     

    (*이하의 내용은 모두 크롬을 기준으로 작성했습니다.)

     

     

     

     

    RenderObject

    웹 페이지의 내용은 DOM 트리라고 하는 Node객체의 트리로 저장된다고 했습니다.

    DOM 트리의 노드들 중, 시각적으로 화면에 표시될 노드는 각각 'RenderObject'라는 걸 갖고 있습니다. RenderObject는 이 노드의 컨텐츠를 어떻게 그려내야 하는지 알고 있습니다.

    렌더 트리는 화면에 실제로 보여지는 애들이 저장되는 트리라고 했는데, 이 RenderObject가 저장되어 있다고 생각하면 됩니다.

     

     

    RenderLayer

    RenderObject 중 속성에 따라 필요한 경우 RenderLayer라는 것과 연결됩니다.

    RenderLayer는 겹쳐있는 박스를 표시하거나, 반투명한 걸 그려내기 위해 올바른 순서로 합성되도록 하는 역할을 합니다. 우리가 z-index 속성을 줬을 때 착착착 잘 쌓이도록 돕는 게 이 RenderLayer인 셈이죠.

     

    RenderObject와 RenderLayer가 반드시 1:1로 매핑되지 않으며, 동일한 좌표 공간(coordinate space)을 공유하는 RenderObject는 동일한 RenderLayer에 속합니다.

     

     

    GraphicsLayer

    RenderLayer 중 GPU 처리가 필요한 애들은 GraphicsLayer를 갖게 됩니다. 하드웨어 가속 처리를 위한 물리적인 레이어로, 일반적으로 레이어라 함은 이걸 뜻합니다. GPU에 텍스처로 업로드된 뒤 합성되기 때문에 변형을 빠르게 반영할 수 있습니다.

     

    RenderLayer가 다음의 조건 중 하나라도 만족한다면 GraphicsLayer를 가질 수 있습니다.

    - 3D transform 또는 perspective transform 속성을 가진 경우

    - <video>, <canvas>

    - 불투명도를 위해 CSS 애니메이션를 사용하는 경우

    - CSS 필터를 사용하는 경우

    - 자식 요소가 레이어인 경우

    - z-index 값이 낮은 형제 레이어를 갖는 경우 (즉, 레이어의 상단에 렌더링되는 경우)

     

     

    이렇게 생성된 레이어는 Composite(합성)되어 화면에 표시됩니다.

    이 레이어 개념을 언급하는 이유는, 레이아웃-페인트 단계를 거치지 않고 바로 합성하는 경우 더 나은 성능을 끌어낼 수 있기 때문입니다.

     

     

     

    Re-layout와 Re-paint

     

     

    width, left, font, float, display 등은 객체의 모양과 위치를 나타내므로 Layout 과정에서 계산되는 속성입니다.

    따라서 이 속성들이 바뀌면 Layout 단계부터 다시 렌더링합니다.

     

     

     

    color, background, visibility 등은 객체의 픽셀을 채우므로 Paint 과정에서 계산되는 속성입니다.

    이 속성들이 바뀌면 Layout을 다시 계산하지는 않지만 다시 칠하는 작업을 해야 합니다.

     

    따라서 top: 30px같은 애니메이션이 주어진다면 Layout과 Painting을 다시 수행해야 합니다.

    E

     

     

    하지만 transform, opacity 속성은 CPU에서 레이아웃을 다시 계산하지 않아도 GPU단에서 처리할 수 있습니다.

    그 소리는 Layout과 Paintitng 할 필요가 없는 transform: translateY(30px)이 훨씬 빠르다는 거죠!

     

     

     

    will-change

    will-change 속성을 쓰면 요소를 레이어로 '승격'시킬 수 있습니다.

     

    .moving-element {
      will-change: transform;
    }

     

    또는 이렇게 처리할 수도 있죠.

     

    .moving-element {
      transform: translateZ(0); //또는 transform: translate3d(0,0,0);
    }

     

    3차원 속성도 will-change와 똑같이 합성레이어를 생성해 GPU에 업로드 하기 때문입니다.

    그래서 브라우저에게 '이거 바꿀게!'라고 미리 알려줄 수 있고, 브라우저는 실제 요소가 변화되기 전에 적절하게 최적화를 할 수 있습니다.

     

    하지만 모든 요소에 will-change를 넣는 건 추천하지 않습니다.

    각 레이어는 메모리와 관리가 필요하고, 제한된 메모리를 가진 기기에서는 생각만큼의 이점이 없을 수도 있습니다.

    너무 많은 레이어를 만들지 않도록 주의하세요. 요소를 새 레이어로 승격시켰다면, 개발자 도구로 성능 상의 이점이 있는지 확인하는 게 좋습니다.

     

     

     

     


     

     

     

     

     

    브라우저는 정말 방대하고도 심오한 세계였습니다. 아주 쬐끔 맛본 건데 엄청 강렬한 맛이네요...

    하지만 '그냥 이게 좋대'서 쓰는 게 아니라, '이렇기 때문에 쓰는구나'라는 걸 알 수 있었던 좋은 시간이었어요.

    사용하는 것도 중요하지만 사용하는 이유를 알아야 다른 사람들을 설득할 수 있겠구나란 생각도 들고요.

     

    내용이 정말 깊고 많아서 이해 못한 것도 많지만, 더 열심히 부딪히다보면 무릎 칠 날이 오겠죠 🖐🦵

     

     

     

     

     

     

    참고 글

    브라우저 렌더링

    성능 최적화 :: TOAST UI

    웹 성능 최적화에 필요한 브라우저의 모든 것

    Google Developers

    프론트엔드 개발자를 위한 Layer Model

    GPU Accelerated Compositing in Chrome

    하드웨어 가속에 대한 이해와 적용

     

    시리즈의 첫 번째 글: 브라우저의 동작 (1) - HTML : 내 마크업을 이해해 줘!
    시리즈의 이전 글: 브라우저의 동작 (2) - CSS : 내 말대로 꾸며줘!

     

    댓글 3

    • 프로필사진
      땅콩버터 2020.07.02 17:31

      안녕하세요 나나님. 저는 지나가던 풀스택이고 싶은 백엔드 개발자입니다.

      나나님의 포트폴리오를 보고 감명 받아 이렇게 글을 남기게 되었습니다.

      https://agopwns.github.io/ 제 이력서 사이트인데 어떤 프로젝트를 했는지 대략 아실 수 있습니다 : )

      사실 프로젝트는 풀스택으로 진행할 때가 많았지만 주로 프론트, 백엔드 개발을 위주로 해서 웹 퍼블리싱 능력이 많이 약합니다.

      최근 이직한 회사에서 이 능력이 많이 요구될 것 같은데 혹시 실례가 되지 않는다면 웹 퍼블리싱 기초 공부는 어떻게 하셨는지 알 수 있을까요?

      • 프로필사진
        나나 (nykim) 2020.07.06 20:30 신고

        안녕하세요, 댓글 남겨주셔서 감사합니다!
        남겨주신 포트폴리오 사이트와 Github를 둘러보았는데 멋진 결과물로 가득해서 깜짝 놀랐습니다.

        꾸준히 나아가는 게 참 어려우셨을 텐데, 정말정말 대단하세요 :)
        이 정도 능력이면 UI 쪽도 금방 익히실 것 같아요. 제가 뭐라 말씀드리기가 민망할 정도네요ㅎㅎ

        사실 배울 수 있는 경로는 참 많더라구요. 생활코딩이나 유튜브만 봐도 좋은 강의가 정말 많죠. 그러니 어떤 방법을 택하든 결국 목표를 향해 꾸준히 나아가는 게 가장 중요하지 않나란 생각이 듭니다.

        저도 아직 좌충우돌 배워나가는 단계라 뭔가 구체적인 방법을 제시해 드릴 수가 없는 게 너무 아쉽지만... 꾸준히 나아가는 데 함께 할 파티원(?!)이 필요하시면 언제든 말 걸어 주세요!
        이미 잘하고 계시니 이대로만 하셔도 충분하실 것 같아요! 저도 본받겠습니다+_+

        제가 근래 정신이 없어서 블로그 확인이 늦었습니다ㅠㅠ 나중에라도 메일(nykim@nykim.net) 주시면 회신 드릴게요~
        좋은 밤 되세요 ;-)

    • 프로필사진
      땅콩버터 2020.07.08 09:14

      답변 감사드려요~ 메일 보냈습니다 : )