목차
- 블로그 서비스 최적화
- 올림픽 통계 서비스 최적화
- 홈페이지 최적화
- 이미지 갤러리 최적화
머릿말
이번에 처음으로 데보션 오픈랩 스터디에 참여하면서, 이 교재를 가지고 스터디를 진행하였고, 알게된 점과 전반적인 내용을 정리해보려고합니다.
1️⃣ 블로그 서비스 최적화
첫번째 장은 아티클 리스트와 아티클 상세페이지로 이루어져있는 작은 블로그를 통해 실습을 진행합니다.
라이트하우스 ( lighthouse )
구글에서 개발한, 웹 페이지의 품질을 개선할 수 있는 오픈 소스 형태의 자동화 도구
현업에서도 제일 많이 쓰이고 프론트엔드 개발자라면 자주 접하는 라이트하우스를 통해 부족한 최적화를 진행합니다.
고의적으로 최적화가 필요하도록 설계된 블로그이다 보니 아래와 같은 문제들이 있었습니다.
Properly size images
실제로 가져오는 이미지와 리스트에서 사용되는 이미지 사이즈가 많이 상이하기 때문에 발생합니다. 이 프로젝트에서는 12001200px 사이즈의 사진들을 전부 240240px로 고정이 되어있는 이미지로 사용되고 있습니다.
제일 먼저 떠오르는건 이미지 사이즈 자체를 맞게 수정해서 스토어해놓는 것이겠지만,
대부분의 이미지는 CDN을 통해 제공되기 때문에 그건 힘들 수 있습니다.
여기 실습에서는 unsplash
라는 이미지 CDN 무료로 제공해주는
서비스를 통해 서빙되고 있습니다. 그리고 대부분의 이미지 CDN이 그렇듯 사이즈가 조절이 가능한 파라미터를 지원하기 때문에
이 예시에서는 아래와 같이 이미지를 가져옴으로써 해결했습니다.
https://images.unsplash.com/photo-345?q=80&w=240&h=240
TMI: 저희 회사에서는 imageKit이라는 서비스를 사용해서 최적화를 진행하고 있습니다.
이미지 CDN이란 ?
전 세계에 분산된 서버를 통해 사용자가 가장 가까운 서버에서 이미지를 받아 빠르고 효율적으로 로딩되도록 돕는 네트워크
병목 코드
Performance tab을 켜서 확인해보면 단순히 렌더링임에도 불구하고 1.4초 이상 걸리는 구간을 확인할 수 있습니다. 좀 더 자세히보면 해당 렌더링때 발생하는 함수이름을 알 수 있는데, 그거를 단서로 해당 함수의 로직을 보면 의도적으로 특수문자를 제거하는 함수가 비효율적으로 작성되어 있습니다.
자세히 읽어보면 removeSpecialCharacter
함수가 계속해서 반복된는데. 이는 일부러 굉장히 잘못된 로직으로 구성된 함수입니다.
코드를 리팩토링 함으로써 성능을 비약적으로 개선하는 그 과정을 체험할 수 있었습니다.
최적화 전 | 최적화 후 |
---|---|
코드 분리 && 지연 로딩
처음 렌더링할때, 처음에 FCP 걸리는 걸 확인 할 수 있습니다. 로컬 서버에 단순한 서비스임에도 불구하구요. 라이트하우스에서 위와 같은 경고가 있는데 트레이싱해서 그 청크파일을 확인해보면 한 청크파일이 4MB가 넘는걸 알 수 있습니다.
이때 webpack-bundle-analyzer
패키지를 이용해서 번들파일의 구성을 쉽게 확인 가능합니다.
실습코드는 lazy loading이나 코드 스플릿 같은게 하나도 적용이 안되어 있기때문에 위처럼 하나의 청크로 다 묶여있는 것을 볼 수 있습니다. 그렇기 때문에 분리할 수 있는 코드는 하나의 다른 모듈로 분류하여 컴포넌트화를 진행하고, 모달 혹은 라우트에 사용되는 컴포넌트 같은 경우는 동적으로 가져올 수 있으므로, react에서 지원하는 lazy
, Suspense
를 사용해서 청크를 여러개로 나누는 작업을 했습니다.
간단하게 코드를 추상화하면 아래와 같습니다.
// 전
import React from "react";
import LargeComponent from "./LargeComponent";
function App() {
return (
<div>
<h1>My App</h1>
<LargeComponent />
</div>
);
}
export default App;
// 후
import React, { Suspense } from "react";
const LazyLargeComponent = React.lazy(() => import("./LargeComponent"));
function App() {
return (
<div>
<h1>My App</h1>
<Suspense fallback={<div>Loading...</div>}>
<LazyLargeComponent />
</Suspense>
</div>
);
}
export default App;
enable text compression
HTML, CSS, Javascript와 같은 리소스는 전부 텍스트 압축이 가능합니다.
네트워크 응답 헤더를 확인해보면 Content-Encoding
에 gzip
이 들어가 있는데 메인 번들 파일의 응답헤더를 보면 Content-Encoding
이 비어져 있는 것을 알 수 있습니다.
이또한 이 프로젝트에서는 disabled 처리해 놓았던걸 풀어서 해결 할 수 있었습니다.
텍스트 압축이란 ?
HTML, CSS, JavaScript 등 텍스트 기반 파일의 크기를 줄여 웹 페이지 로딩 속도를 높이는 최적화 기법입니다.
2️⃣ 올림픽 통계 서비스 최적화
하이라이트 모달 | 설문 조사 |
---|---|
두번째 장은 리우올림픽과 런던올림픽의 사진을 비교하고 하단에는 그 설문조사 결과를 보여줍니다.
애니메이션 최적화
설문조사 부분을 부분을 클릭하게되면 Jank 현상이 발생합니다. 코드를 보면 아래와 같습니다.
Jank 현상이란 ?
사이트나 앱이 주사율에 맞추지 못하고 더듬거리며 요동치거나 잠시 멈춘다는 것을 사용자가 보는 것
const BarGraph = styled.div`
position: absolute;
left: 0;
top: 0;
width: ${({ width }) => width}%;
transition: width 1.5s ease;
height: 100%;
background: ${({ isSelected }) =>
isSelected ? "rgba(126, 198, 81, 0.7)" : "rgb(198, 198, 198)"};
z-index: 1;
`;
width 속성을 transition 속성을 통해서 percent prop에 따라 가로 길이를 조정하면서 올라가는 것을 구현되어 있습니다. 하지만 이는 별로 좋은 접근이 아니라는 것을 책에서 알려주는데요.
먼저 witdh를 변경하게 되면 브라우저는
- 해당 요소의 가로, 세로를 다시 계산하여 화면을 새로 그릴 것입니다.
- CSSSOM을 새로 만듭니다.
- 다시 렌더 트리를 만들게 됩니다.
- 커밋된 화면 구성에 알맞게 색을 칠하고 분할된 레이어를 하나로
합성 (컴포지트)
합니다.
이 과정이 리플로우
입니다. 즉 width를 바꾸게 되면 위에서 보여주는 렌더링 사이클을 전부 돌게됩니다.
리플로우와 리페인트를 일으키는 요인들은 정말 알면알수록 많은데 잘 잘 정리된 문서가 있어서 첨부합니다.
const BarGraph = styled.div`
position: absolute;
left: 0;
top: 0;
width: 100%;
transform: scaleX(${({ width }) => width / 100});
transform-origin: center left;
transition: transform 1.5s ease;
height: 100%;
background: ${({ isSelected }) =>
isSelected ? "rgba(126, 198, 81, 0.7)" : "rgb(198, 198, 198)"};
z-index: 1;
`;
교재에서는 애니메이션 부분을 width
-> transform
에서 scale
을 통해 GPU에 위임하여 효율적으로 처리하도록 가이드했습니다.
Performance를 통해 확인하면 Layout과 paint가 사라진것을 확인이 가능했습니다.
Before |
---|
After |
컴포넌트 지연로딩과 사전로딩
프로젝트 코드를 살펴보면 이미지 모달 코드가 하나의 파일에 작성된 것을 알 수 있습니다.
사실 이 부분은 Nexjts
/dynamic
이나 React
/lazy
import를 통해서 간단하게 코드 분리가 가능합니다.
코드 분리 전 | 코드 분리 후 |
---|---|
코드분리를 적용하면 번들이 작아지면서, 초기 로딩이나 자바스크립트 실행 타이밍이 빨라져서 화면이 더 빨리 표시되는 그런 장점이 있습니다. 하지만 이렇게 분리를 하게 되면 모달 컴포넌트를 로드하는데 약간의 지연이 있는 건 당연한 수순입니다.
교재에서 제시한 사전로딩은 두가지가 있습니다.
onMouseEnter
을 통한 사전로딩- mount가 끝났을때
useEffect
를 통한 사전로딩
const LazyImageModal = lazy(() => import("./components/ImageModal"));
function App() {
const [showModal, setShowModal] = useState(false);
// 1. onMouseEnter를 통한 사전로딩
const handleMouseEnter = () => {
const component = import("./components/ImageModal");
};
// 2. Mount가 끝났을때 사전로딩
useEffect(() => {
const component = import("./components/ImageModal");
}, []);
return (
<div className="App">
<ButtonModal
onClick={() => {
setShowModal(true);
}}
onMouseEnter={handleMouseEnter}
>
올림픽 사진 보기
</ButtonModal>
</div>
);
}
이미지 사전로딩
실습에서 사용되는 이미지 모달을 띄울때, 이미지를 가져오는 동안 레이아웃이 깨지게 됩니다.
이미지모달 오픈 직전 | 이미지모달 이미지 로드 후 |
---|---|
컴포넌트들 가져올때 이미지를 사전 로딩하면 이런 현상을 없앨수가 있겠죠. 이미지를 보면 모달 컴포넌트와 이미지를 사전 로드를 하는 것을 볼 수 있습니다.
const handleMouseEnter = () => {
const component = import("./components/ImageModal");
const img = new Image();
img.src = `<image url>`;
};
이는 자바스크립트로 이미지 사전로딩하는 방법중에 하나고, 아래와 같은 방법들이 있습니다.
3️⃣ 홈페이지 최적화
세번째 장은 롱보드를 소개하는 홈페이지입니다.
이미지 지연 로딩
프로젝트를 시작하고 네트워크 탭을 확인해보면 뷰포트에 없는 이미지가 전부 다운되는 것을 확인할 수 있습니다.
교재에서는 이를 Intersection Observer API
를 통해서 개선했습니다. 이미지 관련해서는 항상 언급되는 API죠.
그리고 webp
로 원본 이미지를 최적화하면서, 기존 이미지는 fallback으로 webp를 우선적으로 로드하게 개선하였다.
entry.isIntersecting
을 통해 이미지가 화면에 보일 때 data-src 속성의 실제 URL을 src로 설정
합니다.
그리고 img
태그에 loading="lazy"
라는 어트리 뷰트를 사용하는 것도 한 방법일것이다.
function MainPage(props) {
const imgRef = useRef(null);
useEffect(() => {
const options = {};
const callback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const sourceEl = entry.target.previousSibling;
sourceEl.srcset = sourceEl.dataset.srcset;
entry.target.src = entry.target.dataset.src;
observer.unobserve(entry.target);
}
});
};
const observer = new IntersectionObserver(callback, options);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return (
<div className="MainPage -mt-16">
<TwoColumns
bgColor={"#fafafa"}
columns={[
<picture>
<source data-srcset={"image_webp_url"} type="image/webp" />
<img data-src={"image_original_url"} ref={imgRef} alt="image" />
</picture>,
]}
mobileReverse={true}
/>
</div>
);
}
비디오 최적화
교재에서는 비디오에 대해서 webm
으로 파일자체를 최적화하는 방법을 제시해줍니다. 그리고 위 이미지에서 진행됬던 것처럼
webm
을 우선적으로 로드하고, mp4
를 fallback으로 로드하게끔 수정했습니다.
<video autoplay loop muted>
<source src={video_webm} type="video/webm" />
<source src={video_original} type="video/mp4" />
</video>
하지만 영상은 최적화하게 되면 저화질이 되기때문에 교재에서는 팁으로 패턴과 필터를 적용해서 사용자가 그를 인지하게 못하게하는 tip
을 언급합니다.
<video autoplay loop muted style={{ filter: blue(10px) }}>
<source src={video_webm} type="video/webm" />
<source src={video_original} type="video/mp4" />
</video>
비디오 요소에 blur
를 주면서 저화질의 단점을 조금 완화시킬 수 있겠네요.
반드시 고화질 영상이 사용되야할 상황이 있습니다. 예를 들면 랜딩페이지같이 고화질 영상을 고수해야할때, HLS ( HTTP Live Streaming ) 방식을 통해 개선했던 경험이 있습니다.
영상을 mp4 -> hls (m3u8, ts)으로 변환하는 방법은 많지만 저는 PC에 ffmpeg라는 오픈소스 소프트웨어로 CLI를 통해 변환했습니다.
ffmpeg란 ?
멀티미디어 데이터를 처리하기 위한 오픈 소스 소프트웨어로, 다양한 비디오 및 오디오 형식의 변환, 스트리밍, 편집, 인코딩, 디코딩을 가능하게 합니다.
-
https://ffmpeg.org/download.html 에서 ffmpeg를 PC에 설치를 해줍니다.
-
영상 경로로 들어가 아래 커맨드를 실행시켜줍니다.
ffmpeg -i INPUT.mp4 -profile:v baseline -level 3.0 -s 1920x1080 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls OUTPUT.m3u8
옵션 | 설명 |
---|---|
-i INPUT.mp4 | 입력 파일을 지정합니다. |
-profile:v baseline -level 3.0 | 호환성을 위해 H.264 프로파일을 baseline으로 설정하고, 레벨을 3.0으로 설정합니다. |
-s 1920x1080 | 출력 해상도를 1920x1080으로 설정합니다. |
-start_number 0 | TS 파일의 시작 번호를 0으로 지정합니다. |
-hls_time 10 | 각 TS 파일이 10초의 비디오를 포함하도록 설정합니다. |
-hls_list_size 0 | 모든 세그먼트가 재생목록에 포함되도록 하며, 오래된 세그먼트를 제거하지 않습니다. |
-f hls | 포맷을 HLS로 지정합니다. |
- 위 명령어를 실행하면 m3u8 파일 하나, 다수의 ts 확장자파일이 변환되어 나오게 됩니다.
- 파일들을 원하는 경로에 넣고, 클라이언트에서 가져와줍니다.
<ReactPlayer
url={`${IMAGE_ROOT}/banners/video/InfoCloud.m3u8`}
playing
muted
width={getCalculateSize.width}
height={getCalculateSize.height}
controls
loop
/>
결과물 |
---|
이렇게하면 동영상을 작은 세그먼트로 분할하여 HTTP를 통해 전송합니다. ( 마치 유튜브처럼 )
폰트 최적화
폰트 적용전 | 폰트 적용후 |
---|---|
이 프로젝트 뿐만아니라 간혹 폰트가 기본 폰트에서 딜레이가 어느정도 지난후에 폰트가 적용되는 상황을 보셨을 수 있습니다.
웹 폰트가 동작하는 방식 |
---|
글꼴의 지연 로드에는 중요한 함축이 숨겨져 있어 텍스트 렌더링이 지연될 수 있습니다. 브라우저는 텍스트를 렌더링하는 데 필요한 글꼴 리소스를 인식하기 전에 DOM 및 CSSOM 트리에 종속된 렌더링 트리를 생성해야 합니다. 따라서
글꼴 요청은 다른 중요한 리소스 후 훨씬 지연
되고 리소스를 가져올 때까지 브라우저가 텍스트를 렌더링하지 못할 수 있습니다. by web.dev
이때 브라우저마다 폰트가 준비되지 않았을때 처리하는 방식이 다릅니다.
구분 | FOUT (Flash of Unstyled Text) | FOIT (Flash of Invisible Text) |
---|---|---|
브라우저 | Edge | Chrome, Safari, Firefox 등 |
폰트 다운로드 전 | 텍스트가 표시됨 | 텍스트가 보이지 않음 |
폰트 다운로드 후 | 폰트가 적용된 텍스트가 표시됨 | 폰트가 적용된 텍스트가 표시됨 |
사용 권장 상황 | 중요한 내용을 전달하는 텍스트 | 꼭 전달할 필요가 없는 보조적인 텍스트 |
교재에서는 다음과 같이 진행했습니다.
font-display
속성에서 원하는 폰트 제어 방식 적용- 폰트 다운로드 완료 후, 애니메이션을 통해 자연스럽게 렌더링
- 폰트 파일 크기 최적화 및 포맷 변경
font-display 적용
/* 웹에서 사용할 커스텀 폰트를 정의합니다. */
@font-face {
/* 폰트의 이름을 정의합니다. 이 이름은 이후 font-family에서 사용할 수 있습니다. */
font-family: BMYEONGSUNG;
/* 폰트 파일의 경로와 포맷을 지정합니다. */
src: url('./assets/fonts/BMYEONGS.woff2') format('woff2');
/* font-display 속성을 통해 폰트 로딩 방식을 설정합니다. */
font-display: block;
/*
font-display: block은 폰트가 로드될 때까지 텍스트를 숨깁니다.
폰트가 로드되면 숨겨진 텍스트가 폰트와 함께 표시됩니다.
이렇게 하면 플래시 효과 없이 폰트가 로드된 후 전체 텍스트가 한 번에 나타납니다.
*/
}
font-display 란? font-face가 표시되는 방법을 결정하는데, 적절히 활용하면 폰트 로딩 문제로 인한 사용자 경험 악화를 방지할 수 있습니다
font-display
에 몇가지 속성들이 있는데 다음과 같습니다.
-
auto
- 동작: 브라우저 기본 설정을 따름 (예측 불가, 브라우저마다 동작 다름).
- 사용 예시: 특별한 제어가 필요 없을 때, 기본 동작을 그대로 따르고 싶을 때.
-
block
- 동작: 최대 3초 동안 텍스트 숨김 → 로드 후 폰트 교체 (FOIT 가능성).
- 사용 예시: 브랜드 아이덴티티가 중요하고, 로드 전까지 텍스트가 보이지 않아야 할 때.
-
swap
- 동작: 기본 폰트를 즉시 렌더링 → 로드 후 웹폰트 교체 (FOUT 발생 가능).
- 사용 예시: 사용자 경험을 중시하고, 텍스트가 빠르게 렌더링되어야 할 때.
-
fallback
- 동작: 최대 100ms 숨김 → 기본 폰트로 렌더링 → 로드 후 교체(또는 대체 폰트 유지).
- 사용 예시: 빠른 초기 렌더링이 중요하지만, 폰트 교체 지연을 최소화하고 싶을 때.
-
optional
- 동작: 최대 100ms 숨김 → 기본 폰트 렌더링 → 네트워크 상황에 따라 교체 여부 결정.
- 사용 예시: 네트워크 성능이 중요한 상황에서 폰트 로드 실패 시 기본 폰트로 유지해도 되는 경우.
폰트 다운로드 완료 후, 애니메이션을 통해 자연스럽게 렌더링
교재에서는 애니메이션이 기본 폰트에서 다운로드 완료된 폰트로 넘어가는 swap
동작이 UI 적으로 좋지 않고 아무래도 메인화면 중앙에 큰 글자로 표시되기 때문에,
block
으로 변경해서 폰트 다운로드가 완료되면 애니메이션을 통해 자연스럽게 렌더링되도록 하였습니다.
폰트가 다운로드 되는 시점을 알기위해서는 일반적으로는 Broswer Native API를 통해서 구현할 수 있겠지만 fontfaceobserver라는 유명한 패키지를 통해서 다음과 같이 구현이 된것을 확인할 수 있었습니다.
import FontFaceObserver from "fontfaceobserver";
function Background() {
// 폰트 로드 상태를 관리하기 위한 상태값 선언 (초기값: false)
const [isFontLoaded, setIsFontLoaded] = useState(false);
useEffect(() => {
// 'FONTNAME'이라는 폰트를 감시하기 위한 FontFaceObserver 객체 생성
const font = new FontFaceObserver("FONTNAME");
// 폰트 로드 시도를 시작, 최대 20초(20000ms)까지 기다림
font
.load(null, 20000)
.then(() => {
// 폰트가 성공적으로 로드되었을 때 실행
console.log("FONTNAME has loaded");
setIsFontLoaded(true); // 폰트 로드 상태를 true로 업데이트
})
.catch(() => {
// 폰트 로드에 실패했을 때 실행
console.error("FONTNAME failed to load");
});
}, []);
return (
<div className="container">
{/* 폰트가 로드되었는지 여부에 따라 스타일 변경 */}
<div
className="font-wrapper"
style={{
opacity: isFontLoaded ? 1 : 0, // 폰트가 로드되었으면 불투명(1), 로드되지 않았으면 투명(0)
transition: "opacity 0.3s ease", // 투명도 변화 시 0.3초 동안 부드럽게 전환
}}
>
{/* 텍스트 콘텐츠 */}
<span className="text content">Your content here</span>
</div>
</div>
);
}
export default Background;
폰트 파일 크기 최적화 및 포맷 변경
이미지에서도 Webp
, avif
확장자가 후발주자로 웹에서의 이미지 최적화가 되어있듯이 font도 후발주자로 나온 WOFF
, WOFF2
확장자가 있습니다.
[transfonter](https://transfonter.org/)
가 대표적인 폰트 변환 사이트입니다.
여기서 기존의 ttf
-> woff2
로 변환을 해줍니다. 그렇게 거의 절반으로 크기가 줄어든 것을 확인할 수 있습니다.
더욱 극단적으로 간다면 사용하는 언어, 더 나아가서 사용되는 문자만 가져오도록 서브넷 폰트로 만들 수 있습니다.
용량과 다운로드 시간을 보시면 많이 줄어드신 것을 확인 할 수 있습니다.