[프론트엔드] [번역] 깃허브는 어떻게 빠르고 성능 좋은 홈페이지를 만들었는가

김학재·2021년 2월 10일
2

프론트엔드

목록 보기
1/12
post-thumbnail

JavaScript Weekly를 통해서 매 주 자바스크립트 관련 뉴스들을 받아보고 있는데 최근에 흥미로운 자료를 찾아 공부 겸 번역해보기로 했다.

안 그래도 사이드 프로젝트에 이미지나 애니메이션을 삽입 시에 로딩이 느려지는 것을 경험하던 터였는데 어찌 알고 딱 보내줬는지...


Making GitHub’s new homepage fast and performant
(원문은 위 링크에서 볼 수 있습니다.)

서론

 제품 사진, 애니메이션, 영상 등으로 가득 차 있으면서도 동시에 빠른 속도 및 수행 능력을 선보이는 페이지를 만드는 것은 어려운 일입니다. 깃허브의 새로운 홈페이지를 만드는 과정에 있어, 우리는 Core Web Vitals를 지표로서 사용했습니다. 이를 활용하는 방안에는 여러 가지가 있으며, 이미 WebGL globe를 어떻게 최적화 했는지 서술한 바 있습니다.

 우리는 전체적으로 제일 우수한 수행 능력을 선보인 2가지 전략(우수한 애니메이션 수행 능력, 완벽한 이미지 제공)에 대해 알아보고자 합니다.

고성능 애니메이션 및 상호 작용

 깃허브 홈페이지에서 스크롤 할 경우, 특정 요소들이 애니메이션화되어 당신의 이목을 끕니다.
깃허브 홈페이지 스크롤 예시

 기존에 이런 스크롤 이벤트에 의존하는 애니메이션을 만드는 경우, 스크롤이 위치하고 있는(tracking하고 있는) 모든 요소의 가시성을 계산하고, 뷰포트 내에서 요소의 위치에 의존해 애니메이션을 동작시킵니다.

// Old-school scroll event listening (avoid)
window.addEventListener('scroll', () => checkForVisibility)
window.addEventListener('resize', () => checkForVisibility)

function checkForVisibility() {
  animatedElements.map(element => {
    const distTop = element.getBoundingClientRect().top
    const distBottom = element.getBoundingClientRect().bottom
    const distPercentTop = Math.round((distTop / window.innerHeight) * 100)
    const distPercentBottom = Math.round((distBottom / window.innerHeight) * 100)
    // Based on this position, animate element accordingly
  }
}

이러한 접근 방식에는 하나의 큰 문제가 존재합니다. getBoundingClientRect()의 호출은 reflow를 발생시키며, 수행 능력에 있어 병목 현상을 발생시킬 수 있습니다.

reflow
github - What forces layout / reflow
reflow는 페이지의 레이아웃을 계산하는 것을 말한다.
이 경우, 자식, 조상 요소를 비롯한 다른 요소들의 reflow를 발생시키며, 모든 reflow가 완료된 뒤 DOM에 나타나게 된다.

 다행히도 IntersectionObservers는 모든 현대적 브라우저에서 지원하며, 스크롤 이벤트나 getBoundingClientRect()의 호출 없이 뷰포트 내에서 요소의 위치를 파악할 수 있습니다.

 IntersectionObservers는 단 몇 줄의 코드로 뷰포트 내에서 요소가 나타나는지 알 수 있으며, 각 entry의 isIntersecting 메소드를 통해 요소 별 상태에 따라 애니메이션을 동작하게 할 수 있습니다.

// Create an intersection observer with default options, that 
// triggers a class on/off depending on an element’s visibility 
// in the viewport
const animationObserver = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    entry.target.classList.toggle('build-in-animate', entry.isIntersecting)
  }
});

// Use that IntersectionObserver to observe the visibility
// of some elements
for (const element of querySelectorAll('.js-build-in')) {
  animationObserver.observe(element);
}

애니메이션의 오염 방지

 애니메이션 요소들에 대해 IntersectionObservers를 적용함과 동시에, 모든 애니메이션을 살펴보았고 애니메이션을 최적화하는 핵심 원칙들 중 하나를 절반으로 줄였습니다. transformopacity 속성만을 애니메이션화 하는 것이며, 이 속성들은 브라우저가 나타내기 쉽습니다.

 우리는 이 규칙 하에 잘 진행했다고 생각했으나, 특정 상황에서 의도치 않은 속성이 transition에 들어와 상태를 변화시키며 애니메이션의 결점을 일으켰습니다.

 혹자는 "transform과 opacity만을 활용"하는 이상적인 방법은 다음과 같이 CSS를 정의하는 것이라고 생각할 것입니다.

.animated {
  opacity: 0;
  transform: translateY(10px);
  transition: * 0.6s ease;
}

.animated:hover {
  opacity: 1;
  transform: translateY(0);
}

 우리는 오직 opacity와 transform 속성만 바꾸고 있지만, 바뀐 모든 속성들에 대해 transition을 정의하고 있습니다. 이런 transition은 다른 속성의 변화가 transition을 오염시킬 수 있기 때문에 성능 저하를 일으킬 수 있으며, 이는 곧 불필요한 스타일 및 레이아웃의 계산을 야기할 수 있습니다. 이런 애니메이션의 오염을 피하기 위해, 우리는 항상, 오직 opacity와 transform만을 정의하고 이를 애니메이션으로 변환했습니다.

.animated {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.animated:hover {
  opacity: 0;
  transform: translateY(0);
}

 IntersectionObservers의 활용과 opacity 와 transform을 애니메이션으로 변환하는 작업을 모든 애니메이션에 적용한 결과, CPU 사용과 style 재계산에 있어 급격한 완화를 볼 수 있었습니다.
CPU 사용 및 스타일 재계산 비교

IntersectionObservers를 활용한 비디오의 Lazy-loading

 만약 비디오 요소를 통해 애니메이션을 사용하고자 한다면 다음 2가지 동작을 구현하고자 할 수 있습니다 : 비디오가 뷰포트 내에서 보일 경우 재생, 비디오가 필요한 경우에만 lazy-load

 슬프게도, lazy load 속성은 비디오에서 동작하지 않지만, IntersectionObservers를 활용해 비디오가 뷰포트 내에서 나타날 경우 재생하고자 한다면, 두 가지 동작을 한번에 구현할 수 있습니다.

<!-- HTML: A video that plays inline, muted, w/o autoplay & preload -->
<video loop muted playsinline preload="none" class="js-viewport-aware-video" poster="video-first-frame.jpg">
  <source type="video/mp4" src="video.h264.mp4">
</video>
// JS: Play videos while they are visible in the viewport
const videoObserver = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) entry.isIntersecting ? video.play() : video.pause();
});

for (const element of querySelectorAll('.js-viewport-aware-video')) {
  videoObserver.observe(element);
}

비디오의 preload 속성을 none으로 정의하는 것과 함께, 이 간단한 방식은 각 페이지의 로드에 있어 일정량의 메가바이트를 절약할 수 있습니다.

완벽한 이미지의 제공

 우리는 무궁무진한 디바이스, 스크린, 브라우저를 통해 웹 페이지를 방문하며, 이들을 모두 커버하기 위해서는 이미지를 나타내는 방식도 점점 복잡해지고 있습니다. 우리의 일러스트레이션 스타일 또한 JPG, PNG, SVG 형식들 사이에서 난항을 겪는 일이 발생합니다. 다음 그림은 본문에서 footer로 전환 시 사용하는 이미지입니다.

github from main to footer image

 이 그림을 렌더링하려면 PNG의 투명도가 필요하지만, PNG의 경우 몇 메가바이트가 더 들기 때문에 JPG의 압축과 결합합니다. 다행히도, WebP는 iOS14와 사파리 등 90% 이상의 브라우저에서 동작하며 투명도와 동시에 손실 압축된 이미지를 제공합니다. 그렇다면 구 브라우저에선 어떨까요? macOS Catalina 운영체제 하에서 최신 버전의 Mac에서 최신 버전의 Safari를 구동해도 WebP 이미지를 렌더링할 수 없습니다. 따라서 우리는 무언가 조치를 취해야 했습니다.

 이 난관은 우리로 하여금 모호한 해결책을 내놓게 했습니다. 두 개의 JPG가 SVG로 합쳐졌으며(이미지 데이터용 하나, mask용 하나), 하나의 HTTP 요청으로 base64 인코딩 방식을 통해 투명한 JPG를 만들어 냅니다. 이 이미지는 투명도를 갖춘 JPG 파일이며, base64 형식으로 인코딩 되어 있으며 SVG 형식으로 둘러싸여 있습니다.

 SVG 명세 중 일부는 mask element입니다. 이 기능을 사용하면, SVG의 일부를 마스크할 수 있습니다. 만약 SVG를 문서 내에 삽입한다면, 마스크 요소를 이미지 요소와 함께 사용해 투명도를 가진 이미지를 렌더링할 수 있습니다.

<svg viewBox="0 0 300 300">
  <defs>
    <mask id="mask">
      <image width="300" height="300" href="mask.jpg"></image>
    </mask>
  </defs>
  <image mask="url(#mask)" width="300" height="300" href="image.jpg"></image>
</svg>

 이는 좋지만, WebP를 위한 fallback으로서 동작하지는 못합니다. 이미지 경로가 동적이기 때문에, SVG는 문서 내에 삽입되어야 합니다. SVG를 파일로서 img의 src형태로 선언하면 이미지는 로딩되지 않아 볼 수 없습니다.

 우리는 이미지 데이터를 base64 방식으로 SVG 내에 삽입하는 방식을 시도할 수 있습니다. 이미지를 base64로 변환하는 서비스는 온라인 상에 있습니다. MAC의 경우는 터미널에서 다음과 같은 작업을 수행할 수 있습니다.

base64 -i <in-file> -o <outfile>

 in-file은 변환하고자 하는 이미지, outfile은 base64 데이터를 저장할 문서 파일입니다. 이를 통해 이미지를 SVG내에 삽입할 수 있고, SVG를 img의 src로서 활용할 수 있습니다.

 footer의 그림을 나타내기 위해 사용한 2개의 이미지는 다음과 같습니다 : 이미지 데이터용 하나, 마스크용 하나(검정 : 투명, 하양 : 불투명)

github footer image and mask
우리는 마스크와 이미지를 터미널 명령어를 사용해 base64 형식으로 변환했으며 이 데이터를 SVG내에 붙여넣었습니다.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2900 1494">
  <defs>
    <mask id="mask">
      <image width="300" height="300" href="data:image/png;base64,/* your image in base64 */”></image>
    </mask>
  </defs>
  <image mask="url(#mask)" width="300" height="300" href="data:image/jpeg;base64,/* your image in base64 */”></image>
</svg>

 SVG를 저장해서 일반 이미지처럼 사용할 수 있습니다. 이제 WebP와 lazy loading을 활용해 모든 브라우저 내에서 안정적으로 동작할 수 있습니다.

<picture>
  <source srcset="compressed-transparent-image.webp" type="image/webp">
  <img src="compressed-transparent-image.svg" loading="lazy">
</picture>

 이 SVG 기술은 각 페이지의 로드에 있어 수백 킬로바이트를 절약할 수 있게 해주며, 이를 지원하는 모든 브라우저 및 OS 상에서 최신의 기술들을 사용할 수 있게 해줍니다.


생각보다 내용이 길어 번역과 이해에 있어 시간이 조금 걸렸다. 하지만 깃허브가 어떻게 전 세계 개발자들에게 사랑받는 도구가 되었는지, 깃허브가 서비스의 개선을 위해 어떤 노력을 하고 있는지 알 수 있는 좋은 글이었다. 특히, 구 OS를 비롯한 모든 사용자에게 최적의 서비스를 제공하고자 한다는 점이 놀라웠다.

웹 페이지 구현에 있어 이미지나 애니메이션을 사용할 일이 생기면 이 방법들을 생각해봐야겠다.

profile
YOU ARE BREATHTAKING

0개의 댓글