어느덧 마지막 과제.
이번 챕터는 저번 과제와 동일하게 성능 최적화지만 9주차에는 인프라 수준의 최적화였다면, 이번에는 코드 수준의 최적화다.
이번 과제는 기본, 심화 과제가 별도의 프로젝트로 되어 있었다.
기본 과제는 바닐라 자바스크립트, 심화는 리액트 코드에서 최적화를 진행한다.
지난 과제에서 S3와 CloudFront를 통해 배포한 것과 동일하게 프로젝트를 배포했다.
아무런 처리를 하지 않았을 때의 Lighthouse 지표는 아래와 같다.
카테고리 | 점수 | 상태 |
---|---|---|
Performance | 72% | 🟠 |
Accessibility | 82% | 🟠 |
Best Practices | 75% | 🟠 |
SEO | 82% | 🟠 |
PWA | 0% | 🔴 |
또한 구글의 Page Speed Insight를 이용하여 점수를 측정했다.
Page Speed Insight로 측정하게 되면 모바일, 데스크톱 환경을 각각 측정해준다.
데스크톱은 이미지 최적화만 해도 성능 부분이 90점이 넘어가기 때문에 모바일을 기준으로 잡았다.
개선 전 성능을 보면 66점으로 저조한 점수를 보인다.
성능 개선은 크게 이미지 최적화, 폰트 최적화, 자바스크립트 파일 병렬 로딩 3가지로 진행했다.
먼저 모든 이미지 파일을 JPEG에서 WebP로 변환했다.
WebP는 구글에서 개발한 최신 이미지 포맷으로 JPEG와 같은 기존 포맷보다 더 효율적인 압축방식을 사용한다.
따라서 JPEG보다 적은 용량으로 비슷한 이미지 품질을 제공한다고 한다.
<img class="desktop" src="images/Hero_Desktop.jpg">
<img class="mobile" src="images/Hero_Mobile.jpg">
<img class="tablet" src="images/Hero_Tablet.jpg">
추가로 기존 img
태그로 보여주던 이미지를 srcset
으로 변경했다.
srcset
은 팀원분들과 CS 스터디를 하다가 알게 된 개념이다.
사용자의 화면 크기에 맞게 적절한 크기의 이미지를 가져오는 속성으로 picture
태그 아래에 source
태그에서 주로 사용된다.
<picture>
<source media="(max-width: 768px)" srcset="images/Hero_Mobile.webp" />
<source media="(max-width: 1024px)" srcset="images/Hero_Tablet.webp" />
<source media="(min-width: 1025px)" srcset="images/Hero_Desktop.webp" />
<img src="images/Hero_Desktop.webp" alt="Hero Image" />
</picture>
팀원분들과 이야기를 하다보면 HTML, CSS를 잘 다뤄야겠다는 생각이 든다.
기존에는 성능 최적화라고 하면 자바스크립트 코드에서 무거운 연산을 효율적으로 바꾼다거나 그런 생각을 했다.
하지만 이미지 파일이나, 사용자의 동작에 따라 화면의 요소를 바꾼다거나, 애니메이션을 넣을 때 자바스크립트로 처리하지 않고 CSS로 할 수 있는게 많고 잘 활용하면 훨씬 성능을 최적화할 수 있다고 한다.
자바스크립트 코드에서의 최적화는 리액트와 같은 라이브러리, 앵귤러, 뷰 같은 프레임워크에서 자동으로 해주니 HTML과 CSS를 잘 다루는 것이 좋지 않을까라는 생각이 부쩍 든다.
아무튼 srcset
은 Nextjs에서 Image
태그에도 쓰인다.
Nextjs의 코드를 살펴보면 generateImgAttrs
함수가 있다.
function generateImgAttrs({
config,
src,
unoptimized,
layout,
width,
quality,
sizes,
loader,
}: GenImgAttrsData): GenImgAttrsResult {
if (unoptimized) {
return { src, srcSet: undefined, sizes: undefined }
}
const { widths, kind } = getWidths(config, width, layout, sizes)
const last = widths.length - 1
return {
sizes: !sizes && kind === 'w' ? '100vw' : sizes,
srcSet: widths
.map(
(w, i) =>
`${loader({ config, src, quality, width: w })} ${
kind === 'w' ? w : i + 1
}${kind}`
)
.join(', '),
// It's intended to keep `src` the last attribute because React updates
// attributes in order. If we keep `src` the first one, Safari will
// immediately start to fetch `src`, before `sizes` and `srcSet` are even
// updated by React. That causes multiple unnecessary requests if `srcSet`
// and `sizes` are defined.
// This bug cannot be reproduced in Chrome or Firefox.
src: loader({ config, src, quality, width: widths[last] }),
}
}
generateImgAttrs
함수는 전달된 속성들을 바탕으로 srcset
과 sizes
를 자동으로 계산하는 역할을 하고 그 결과를 ImageElement
컴포넌트가 img
태그에 속성으로 적용한다.
<picture>
<source
width="576"
height="576"
media="(max-width: 575px)"
srcset="images/Hero_Mobile.webp"
/>
<source
width="960"
height="770"
media="(min-width: 576px) and (max-width: 960px)"
srcset="images/Hero_Tablet.webp"
/>
<img width="1920" height="893" src="images/Hero_Desktop.webp" />
</picture>
또한 이미지 최적화 기법으로 width
와 height
를 정하는 방법이 있다.
이를 통해 이미지 공간을 미리 확보하여 레이아웃 시프트를 방지하여 안정적인 레이아웃을 유지한다.
레이아웃 시프트는 페이지가 로드되는 동안 콘텐츠의 위치가 예기치 않게 이동하는 현상으로 사용자 경험을 해친다.
헷갈렸던 점이 레리아웃 시프트를 방지한다는 걸 리렌더링 방지를 얘기하는 거라고 생각했다.
하지만 리렌더링 방지와는 다르고, 브라우저가 초기 렌더링 시에 해당 영역을 미리 예약해서 레이아웃 변화없이 안정적으로 화면을 구성할 수 있게 한다는 말이 더 적합하다.
구글에서는 이를 측정하는 Core Web Vitals 중 하나인 CLS(Cumulative Layout Shift)로 평가한다.
이미지 최적화를 끝내면 성능이 66점에서 92점으로 확연하게 좋아진다.
폰트 최적화는 간단하다.
폰트 최적화는 기존에 외부에서 불러와서 사용하던 웹폰트를 폰트 파일을 넣어줌으로써 네트워크 요청없이 사용할 수 있도록 했다.
그 결과 92점에서 95점으로 3점 향상됐다.
브라우저가 HTML 파일을 해석할 때 script
태그를 만난다면 해석을 중단한다.
이를 해결하기 위한 방법으로 async
와 defer
을 추가할 수 있다.
두 속성 모두 HTML 파싱과 동시에 파일을 다운로드한다는 특징이 있다.
차이점은 async
는 다운로드가 완료되면 즉시 HTML을 중단하고 스크립트를 실행한다.
또한 순서가 보장되지 않는다.
여러 async
스크립트가 있다면 다운로드를 시작한 순서가 아닌, 다운로드가 완료된 순서로 실행된다.
따라서 외부 라이브러리에 의존하지 않거나 DOM 조작을 하지 않는 경우 사용하면 좋다.
보통 분석 도구나 광고 스크립트 등이 있다.
defer
는 다운로드가 완료되도 HTML 파싱이 완료된 다음 실행된다.
또한 순서가 보장되어 먼저 선언한 스크립트가 먼저 실행된다.
따라서 DOM을 직접 조작하거나 순서가 중요한 스크립트에 사용하면 좋다.
defer
를 추가한 결과 성능 1점이 향상됐다.
카테고리 | 점수 | 상태 | 변화 |
---|---|---|---|
Performance | 90% | 🟢 | +18% ⬆️ |
Accessibility | 82% | 🟠 | 변화 없음 |
Best Practices | 75% | 🟠 | 변화 없음 |
SEO | 82% | 🟠 | 변화 없음 |
PWA | 0% | 🔴 | 변화 없음 |
위 최적화를 마친 결과 Lighthouse의 Performance 부분에서 18% 향상된 효과를 불러왔다.
Page Speed Insight에서 성능 부분 또한 30점으로 개선됐다.
이미지와 같은 무거운 파일을 가볍게 처리하고 어떻게 효율적?으로 불러오는지가 정말 중요한가 보다.
Lazy Loading 같은 기법들도 불필요한 이미지 파일을 불러오지 않으려고 나온 기법이니...
3주차에서 미리 경험한 리액트의 메모이제이션을 다시 활용하는 시간이었다.
다시 간략하게 정리하면
props
로 넘겨줄 경우 함수가 재생성되어 리렌더링되는 경우를 방지하는데, React.memo
와 함께 사용해야 한다.props
가 변화가 없다면 리렌더링하지 않는다.과제를 하다가 기계적으로 props
로 넘기는 함수에 대해서 useCallback
을 사용했다.
{scheduleEntries.map(([tableId, schedules], index) => (
// ...
<Button
colorScheme="green"
mx="1px"
onClick={() => {
setSchedulesMap((prev) => ({
...prev,
[`schedule-${Date.now()}`]: [...prev[tableId]],
}));
}}
>
복제
</Button>
// ...
))}
하지만 함수 내부에서 사용하는 값이 어떤 배열의 map
메서드 안에서 생성되는 경우가 애매했다.
const duplicate = useCallback(
(targetId: string) => {
setSchedulesMap((prev) => ({
...prev,
[`schedule-${Date.now()}`]: [...prev[targetId]],
}));
},
[setSchedulesMap]
);
// ...
{scheduleEntries.map(([tableId, schedules], index) => (
// ...
<Button
colorScheme="green"
mx="1px"
onClick={() => duplicate(tableId)}
>
복제
</Button>
// ...
))}
함수에 tableId
를 넘겨줘야 하는데, onClick{() => duplicate(tableId)}
로 하자니 useCallback
을 감싼 의미가 사라진다.
왜냐하면 렌더링이 될 때마다 화살표 함수는 재생성되기 때문이다.
심화 과제를 제출날에 급하게 시작하느라 useCallback
을 거두고 제출했지만, 제출한 이후 찾아보니 커링 기법으로 해결할 수 있다고 한다.
const duplicate = useCallback(
(targetId: string) => () => {
setSchedulesMap((prev) => ({
...prev,
[`schedule-${Date.now()}`]: [...prev[targetId]],
}));
},
[setSchedulesMap]
);
// ...
{scheduleEntries.map(([tableId, schedules], index) => (
// ...
<Button
colorScheme="green"
mx="1px"
onClick={duplicate(tableId)}
>
복제
</Button>
// ...
))}
이를 고민할 당시에도 '고차 함수를 사용하면 되려나?!'라는 생각을 하긴 했는데, 막상 어떻게 구현할지 고민해보니 생각나질 않았다.
앞선 과제를 하며 함수형 프로그래밍도 공부했지만, 활용하는 건 많이 써봐야 하는 영역인가 보다.
심화 과제에서 가장 주요한 부분은 드래그 드랍 시에 해당 부분만 리렌더링되도록 하는 것이었다.
음... 과제를 말로 설명하자면 여러 시간 표가 있고, 기존에 등록된 시간을 드래그 드랍을 통해 시간과 요일을 바꿀 수 있다.
하지만 드래그 드랍 시에 해당 시간표만 리렌더링되는 것이 아니라 전체 시간표가 모두 리렌더링된다.
이 문제는 혼자 고민해봤는데, 도무지 생각이 나질 않아서 같은 팀원분에게 물어봤다.
결론적으로 말하자면 ContextAPI의 Provider 위치 문제였다.
App.tsx
에 있던 Provider
를 시간표 배열의 반복문 내부에서 시간표 당 하나씩 할당했더니 해결됐다.
하지만 해결책을 안 이후에도 잘 이해되지 않았다.
'ContextAPI에서 사용하는 전역 상태가 변경되면 어차피 다 리렌더링되는 거 아닌가? 그럼 Provider가 App에 있던 반복문 내부에 시간표 당 하나씩 있던 무슨 상관이지?'
ContextAPI는 가장 가까운 Provider 값을 참조한다.
따라서 App.tsx
에 선언을 하면 하나의 시간표 Context 값이 변경되도 전체가 리렌더링 되지만 시간표 당 하나씩 선언하게 되면 Context의 값이 변경되도 해당 시간표만 리렌더링된다.
클린 코드 때 ContextAPI를 사용하며 이해했다고 생각했지만 이것 역시 많이 사용해보고 익숙해져야 하나보다.
'코딩은 운동과 비슷하다.'라는 말을 들은 적있다.
이론적인 학습도 중요하지만 결국 많이 사용해서 근육을 만들어야 한다.
자주 사용하지 않으면 근손실나는 것도 비슷하다.
대충 이런 내용이었다.
사실 코딩 뿐만아니라 그림, 글쓰기 등 다양한 분야에서 비슷한 얘기를 들었다.
이론적인 학습도 중요하지만 자주 사용함으로써 감을 익히는 것이 어떤 분야든 중요한가 보다.
마지막 과제를 마치면서 이론적인 내용은 한 번 훑었으니 앞으로는 많이 써보면서 익숙해져야 할 시간을 가져야 하지 않을까 싶다.
그러다가 다시 생각나지 않을 때는 지난 과제와 자료들을 살펴보며 지식을 쌓고 익숙해지기를 반복해야 하지 않을까?!