지난번 멘토링에서 최적화 책을 추천받아서 공부하게 됐다. 이전에도 다른 최적화 책을 읽었는데 두꺼운 책이었고, 몇 개월 전에 대출해서 읽었더니 기억이 잘 안 나는 부분들도 있어 새 마음 새 뜻으로 다시 공부해 봤다. 이미 알고 있던 부분은 제외하고 새롭게 알게 된 부분만 정리해 보았다. (이미지 최적화는 별도의 글로 작성할 예정이다.)
Lighthouse 관련
- Lighthouse 검사 후 하단에서 검사 환경을 확인할 수 있다. 항상 결과 값에만 집중해서 이런 게 있는 줄 전혀 몰랐다...
CPU 성능과 네트워크 성능이 얼마나 제한되었는지 확인할 수 있다. 데스크톱에서 실행하면 CPU가 1x로 제한이 되지 않고, 모바일로 설정하면 4x로 제한된다. 네트워크 속도도 10,240Kbps로 제한되었다. 이러한 이유로 Lighthouse로 측정할 때 페이지 로드 속도가 그냥 페이지를 로드하는 속도보다 느려진다!
- Performance 지표의 가중치
Performance 점수는 여섯 가지 지표에 가중치를 적용해 평균 낸 점수이다. 그런데 지금까지 가중치가 각각 어떻게 되는지는 몰랐었다.
- First Contentful Paint(FCP) - 10%
- Speed Index(SI) - 10% (전체 화면이 표시되는 데 같은 시간이 걸려도, 일부 콘텐츠가 먼저 로드되면 전체적으로 더 빨리 로드된 것으로 계산된다.)
- Larget Contentful Paint(LCP) - 25%
- Time to Interactive(TTI) - 10%
- Total Blocking Time(TBT) - 30%
- Cumulative Layout Shift(CLS) - 15%
병목 코드 찾기
브라우저의 performance 탭에서 아주 자세하게 어떤 작업에서 많은 시간이 걸렸는지 파악할 수 있다.
CPU 차트 - 시간에 따라 CPU가 어떤 작업에 리소스를 사용하고 있는지 비율로 보여준다. JS 실행 작업은 노란색, 렌더링/레이아웃 작업은 보라색, 페인팅 작업은 초록색, 기타 작업은 회색으로 표시한다.
Network 차트 - 대략적인 네트워크 상태를 보여준다. 진한 막대는 우선순위가 높은 네트워크 리소스, 아래쪽 옅은 막대는 우선순위가 낮은 네트워크 리소스를 나타낸다.
스크린샷 - 서비스가 로드되는 과정을 보여준다.
Network 타임라인 - Network 패널과 유사하게 서비스 로드 과정에서의 네트워크 요청을 시간 순서에 따라 보여준다.
- 왼쪽 회색 선: 초기 연결 시간
- 막대 옅은 색 영역: 요청 보낸 시간부터 응답 기다리는 시점까지의 시간(TTFB, Time to First Byte)
- 막대 짙은 색 영역: 콘텐츠 다운로드 시간
- 오른쪽 회색 선: 해당 요청에 대한 메인 스레드의 작업 시간
Frames 섹션 - 화면의 변화가 있을 때마다 스크린샷을 찍어 보여 준다.
Timings 섹션 - User Timing API를 통해 기록된 정보를 기록한다.
위 예시에서의 막대들은 React에서 각 컴포넌트의 렌더링 시간을 측정한 것이다.
Main 섹션 - 브라우저의 메인 스레드에서 실행되는 작업을 플레임 차트로 보여준다.
각 작업의 소요 시간을 확인할 수 있어 병목 코드를 찾을 수 있다.
하단 탭 - 전체 또는 선택된 영역에 대한 상세 내용을 확인할 수 있다.
- Summary - 선택 영역에서 발생한 작업 시간의 총합과 각 작업이 차지하는 비중을 나타낸다.
- Bottom-Up - 최하위 작업부터 상위 작업까지 역순으로 보여준다.
- Call Tree - 최상위 작업부터 하위 작업까지 보여준다.
- Event Log - 발생한 이벤트를 보여준다. 이벤트로는 Loading, Experience, Scripting, Rendering, Painting이 있다.
애니메이션 최적화
애니메이션의 원리
다양한 주사율의 모니터가 있지만, 일반적으로 사용되는 디스플레이의 주사율은 60Hz로 1초에 60장의 정지된 화면을 빠르게 보여준다. 브라우저는 이에 맞춰 최대 60FPS(Frames Per Second)로 1초에 60장의 화면을 새로 그린다. CPU 성능 등의 이유로 초당 60장의 화면을 그리지 못하면 애니메이션이 끊기는 느낌을 준다. 이를 쟁크(Jank) 현상이라고 한다.
왜 브라우저가 초당 60프레임을 제대로 그리지 못할까?
요소의 스타일이 변하면, 리플로우 또는 리페인트가 발생한다. CSSOM을 새로 만들어야 하고, 이를 통해 새로운 렌더 트리를 생성해야 한다. (레이아웃-)페인트-컴포지트 과정을 다시 거쳐야 한다. 이러한 과정은 렌더링 경로의 많은 단계를 재실행하여 브라우저 리소스를 많이 사용하고, 이로 인해 화면을 새로 그리는 것이 느린 것이다.
* 리플로우 발생시키는 속성 - position, display, width, height, float, font-family, top, left, font-size, font-weight, line-height, min-height, margin, padding, border 등
* 리페인트 발생시키는 속성 - background, background-image, background-position, border-radius, border-style, box-shadow, color, line-style, outline 등
💡리플로우와 리페인트를 피하자
transform, opacity와 같은 속성을 사용하면 해당 요소를 별도의 레이어로 분리하고 작업을 GPU에 위임하여 처리함으로써 레이아웃 단계와 페인트 단계를 건너뛸 수 있다. 이를 하드웨어 가속(GPU 가속)이라고 한다.
하드웨어 가속(GPU 가속)
CPU가 처리해야 할 작업을 GPU에 위임하여 더욱 효율적으로 처리하는 방법. 그래픽 작업 처리가 목적인 만큼, 화면을 그릴 때 활용하면 매우 빠르다. transform과 opacity 속성을 사용하면 요소를 별도의 레이어로 분리하여 GPU로 보내 하드웨어 가속을 사용할 수 있다. 분리된 레이어는 GPU에 의해 처리되어 레이아웃, 페인트 단계 없이 화면상의 요소의 스타일 변경이 가능하다.
*transform: translate()는 처음부터 레이어를 분리하지 않고, 변화가 일어나는 순간 레이어를 분리한다. 반면 transform: translate3d() 또는 scale3d()와 같은 3d 속성들, will-change 속성은 처음부터 레이어를 분리해 두어 더욱 빠르게 대처할 수 있다. 단, 레이어가 너무 많아지면 그만큼 메모리를 많이 사용하기 때문에 주의해야 한다.
이전에는 단순히 'width, left 이런 속성 대신 transform 3d 속성을 사용하면 GPU로 처리해 애니메이션 성능이 개선된다.' 이 정도로만 알고 있었는데 이번에 공부하면서 애니메이션의 원리와 처리 과정에 대해 조금 더 자세히 알게 되어 좋았다!
컴포넌트 사전 로딩
모달과 같은 컴포넌트에 지연 로딩을 적용하면 최초 페이지 로드 시 당장 필요 없는 컴포넌트와 관련 코드가 번들에 포함되지 않아, 로드할 파일의 크기가 작아지고 초기 로딩 속도나 자바스크립트의 실행 타이밍이 빨라져서 화면이 더 빨리 표시되는 장점이 있다. 하지만 초기 로딩에는 효과적일 수 있으나 해당 모달을 띄울 때 지연이 발생할 수 있다. 이러한 문제를 해결하기 위해 사전 로딩(Preloading) 기법을 이용할 수 있다. 사전 로딩 기법은 나중에 필요한 모듈을 필요해지기 전에 미리 로드하는 기법이다.
모달의 경우, 사용자가 버튼 위에 마우스를 올려놨을 때(mouseenter) 또는 최초에 페이지가 로드되고 모든 컴포넌트의 마운트가 끝났을 때 로드를 시도해 볼 수 있다. 모달 컴포넌트의 크기가 커서 로드하는 데 1초 이상의 시간이 필요하다면, mouseenter 보다는 아래와 같이 컴포넌트 마운트 완료 후, 브라우저에 여유가 생겼을 때 모달을 로드하는 게 더 좋을 것이다.
function App() {
const [showModal, setShowModal] = useState(false);
useEffect(() => {
const component = import('./components/ImageModal');
}, [])
return (
<div className="App">
...
</div>
);
};
서비스나 기능의 특성에 따라 다양한 방법으로 사전 로딩 기법을 적용할 수 있다. 중요한 것은 어느 타이밍에 사전 로드하는 것이 해당 서비스에서 가장 합리적인지 판단하는 일이다!
폰트 최적화
FOUT - 폰트의 다운로드 여부와 상관없이 먼저 텍스트를 보여준 후 폰트가 다운로드되면 그때 폰트를 적용하는 방식 (Edge). 뉴스 제목과 같이 중요한 텍스트를 사용자에게 빠르게 전달해야 하는 경우 적합
FOIT - 폰트가 완전히 다운로드되기 전까지 텍스트 자체를 보여주지 않는 방식 (Chrome, Safari, Firefox). 사용자에게 꼭 전달하지 않아도 되는 텍스트의 경우 적합
CSS의 font-display 속성을 이용하여 폰트가 적용되는 시점을 제어할 수 있다.
- auto - 브라우저 기본 동작 (기본 값)
- block - FOIT (timeout = 3s)
- swap - FOUT
- fallback - FOIT (timeout = 0.1s) / 3초 후에도 불러오지 못한 경우 기본 폰트로 유지, 이후 캐시
- optional - FOIT (timeout = 0.1s) / 이후 네트워크 상태에 따라 기본 폰트로 유지할지 결정, 이후 캐시
서비스하는 콘텐츠의 특성에 맞게 적절한 값을 설정하는 것이 중요하다.
FOIT 방식을 사용할 때, fade in 애니메이션으로 자연스럽게 폰트 등장 시키기
폰트가 다운로드되기 전에는 텍스트를 보여주지 않다가 다운로드가 완료되면 fade in 효과와 함께 폰트가 적용된 텍스트를 보여줘야 한다. 폰트의 다운로드 시점은 fontfaceobserver라는 라이브러리를 통해 알 수 있다.
npm i -D fontfaceobserver
import FontFaceObserver from 'fontfaceobserver'
const font = new FontFaceObserver('BMYEONSUNG');
function BannerVideo() {
const [isFontLoaded, setIsFontLoaded] = useState(false);
useEffect(() => {
// 20초 안에 폰트가 다운로드되지 않으면 에러 발생
font.load(null, 20000).then(function () {
console.log('BMYEONSUNG has loaded');
setIsFontLoaded(true);
});
}, []);
return (
<div>
{...}
<div
className='...'
style={{opacity: isFontLoaded ? 1 : 0,
transition: 'opacity 0.3 ease'}}>
...
</div>
</div>
}
폰트 포맷 변경 및 서브셋 생성 사이트
Data-URI 형태로 CSS 파일에 폰트 포함시키기
Data-URI란 data 스킴이 접두어로 붙은 문자열 형태의 데이터로, 파일을 문자열 형태로 변환하여 문서(HTML, CSS, JS 등)에 인라인으로 삽입하는 것이다. 보통 App.css 파일이 로드된 후 폰트를 적용하기 위해 폰트 파일을 추가로 로드해야 되지만, Data-URI 형태로 만들어서 App.css 파일에 넣어 두면 별도의 네트워크 로드 없이 App.css 파일에서 폰트를 사용할 수 있다.
폰트 파일을 Data-URI 형태로 App.css에 포함하려면 폰트를 문자열 데이터로 변환해야 한다. (마찬가지로 Transfonter 이용)
기본적으로 브라우저에서 Data-URI를 네트워크 트래픽으로 인식해서 기록하지만 실제로는 이미 다른 파일 내부에 임베드되어 있어 별도의 다운로드 시간이 필요하지 않다. 이로 인해 폰트가 로드되는 시간이 매우 짧다. 단, 폰트 내용이 App.css에 포함된 것이므로 App.css의 다운로드 속도 또한 고려해야 한다. Data-URI가 포함된 만큼 App.css 파일의 다운로드는 느려질 것이다. 같은 맥락에서 매우 큰 파일을 Data-URI 형태로 포함한다면 포함된 파일의 크기가 그만큼 커져 또 다른 병목을 발생시킬 수 있다.
캐시 최적화
웹에서 사용하는 캐시 종류
- 메모리 캐시 - 메모리(RAM)에 저장하는 방식
- 디스크 캐시 - 파일 형태로 디스크에 저장하는 방식
어떤 캐시를 사용할지는 직접 제어할 수 없다. 브라우저가 사용 빈도나 파일 크기에 따라 특정 알고리즘에 의해 알아서 처리한다.
* 구글에서 단순 새로고침 후에 확인했다면 메모리 캐시가 많을 것이다. 이미 구글의 리소스가 메모리에 캐시되었기 때문이다. 브라우저를 완전히 종료한 후에 구글에 접속하는 첫 네트워크 리소스를 확인하면 디스크 캐시가 많을 것이다. 브라우저가 완전히 종료되면 메모리에 있는 내용은 제거되고 다음 접속 때는 파일 형태로 남아 있는 캐시를 활용하기 때문이다.
리소스의 응답 헤더에서 Cache-Control 헤더를 통해 브라우저는 해당 리소스를 얼마나 캐시할지 판단한다.
Cache-Control 값
- no-cache: 캐시를 사용하기 전 서버에 검사 후 사용
- no-store: 캐시 사용 안 함
- public: 모든 환경에서 캐시 사용 가능
- private: 브라우저 환경에서만 캐시 사용, 외부 캐시 서버에서는 사용 불가
- max-age: 캐시 유효 시간 (초 단위. 0으로 설정하면 no-cache와 동일한 설정)
public과 private으로 설정하면 max-age에서 설정한 시간만큼은 서버에 사용 가능 여부를 묻지 않고 캐시된 리소스를 바로 사용한다. 유효 시간이 지났다면 서버에 캐시된 리소스를 사용해도 되는지 다시 체크하고 유효 시간만큼 더 사용한다. 서버는 캐시된 리소스의 응답 헤더에 있는 Etag와 최신 리소스의 Etag 값을 비교하여 캐시된 리소스가 최신이지 아닌지를 판단한다. 서버에 있는 리소스가 변했다면 Etag 값이 달라지고, 서버는 새로운 Etag 값과 함께 최신 리소스를 브라우저로 다시 보낸다. 리소스가 변경되지 않았다면 304 상태 코드를 응답으로 보낸다.
웹 리소스는 브라우저뿐만 아니라 웹 서버와 브라우저 사이를 연결하는 중간 캐시 서버에서도 캐시될 수 있다. 중간 서버에서 캐시를 적용하고 싶지 않다면 private 옵션을 사용한다.
적절한 캐시 유효 시간
HTML: no-cache (항상 최신 버전의 웹 서비스를 제공하기 위함. HTML이 캐시되면 캐시된 HTML에서 이전 버전의 JS나 CSS를 로드하게 되므로 캐시 기간 동안 최신 버전의 웹 서비스를 제공하지 못한다. no-cache 옵션으로 항상 최신 버전의 리소스를 제공하면서도 변경 사항이 없을 때만 캐시를 사용하도록 한다.)
JS, CSS, 이미지: 파일명에 해시를 포함하여 코드가 변경되면 해시도 변경되어 다른 파일이 된다. 캐시를 아무리 오래 적용해도 HTML만 최신 상태라면 최신 리소스를 로드한다.
이번 글에서는 새롭게 알게 된 개념만 정리해 보았다. 다음 글에서는 이번에 알게 된 것을 바탕으로 기존 프로젝트를 개선해 보고 이를 기록해 볼 것이다.
'Develop' 카테고리의 다른 글
가비아 도메인 Vercel 프로젝트에 연결하기 (0) | 2025.03.18 |
---|---|
[Webpack] Bundle 사이즈 줄이기 (0) | 2024.05.02 |
[Husky] Git Hooks 설정을 통한 커밋 전 코드 포맷팅 자동 적용하기 (Lint-Staged) (0) | 2024.01.18 |