지난 글 [브라우저 최적화(1)] CRP의 이해와 브라우저 리소스 식별 방법 CRP에서 렌더링 및 파서 차단에 대해 종류 역할을 이해하고 브라우저의 리소스를 식별하는 방법에 대해서 알아보았다. 브라우저 리소스의 흐름을 파악하게 되면 비로소 브라우저 최적화를 할 수 있는 단계를 밟을 수 있게 된다.
이번 글에는 라이브러리나 프레임워크에서 최적화하는 방법과 이를 의존하지 않는 방법에 대해 정리해본다.
preload
preload는 현재 페이지에서 바로 필요로 하는 리소스를 먼저 가져오는 방법이다. 이 리소스는 CRP 이전에 요청되어 더 빨리 사용할 수 있고, 페이지의 렌더링을 막을 가능성이 낮아져 성능을 향상한다. preload는 폰트나 이미지 등 CSS 내부에서 사용하는 리소스, 크기가 큰 이미지와 비디오 파일처럼 리소스가 늦게 발견되거나 리소소의 크기가 큰 경우에 도움이 된다.
예를 들면 `<img>` 요소에 지정된 CSS와 Javascript 같은 리소스를 브라우저에서 HTML이 차단되는 경우에도 preload 하여 처리가 가능하다.
<link rel="preload" href="style.css" as="style" />
<link rel="preload" href="main.js" as="script" />
<link rel="preload" href="flower.avif" as="image" type="image/avif" />
...
<picture>
<source src="flower.avif" type="image/avif" />
<source src="flower.webp" type="image/webp" />
<img src="flower.jpg" />
</picture>
preload 스캐너가 찾을 수 없는 경우에는 모두 늦게 검색된 리소스이므로 preload의 이점을 누리지 못한다.
- CSS에 `background-image`로 참조한 이미지는 검색 불가
- Javascript 또는 `dynamic import()`를 사용하여 로드된 모듈을 사용하여 DOM에 삽입된 `<script>` 요소
- JavaScript를 사용하여 클라이언트에서 렌더링된 HTML. 이러한 마크업은 JavaScript 리소스의 `문자열`에 포함되어 있으며 preload 스캐너로 검색할 수 없다.
- CSS `@import` 선언된 마크업
prefetch
prefetch는 사용자가 가까운 미래에 사용할 가능성이 있는 리소스를 백그라운드에서서 미리 가져오는 작업이다. preload 리소스 보다 우선순위가 낮으며, 현재 페이지보다는 다음 페이지 이동이나 페이지 로드에 사용될 리소스를 미리 로딩하기 위해 사용된다. 이를 통해 유저가가 페이지를 이동할 시 로드 시간을 대폭 줄일 수 있다.
1. 가까운 시기에 낮은 우선순위로 prefectch
`prefetch`사용 시 브라우저에 가까운 시기에리소스가가 필요한 가능성이 있는리소스를 백그라운드에서서 미리 가져오게 된다. 이때 `가장 낮은 우선순위로 요청`이 가능하다.
아래 코드에선 `date-picker.js`,`date-picker.css`를 prefetch함을 알려준다. 또는 사용자와 상호작용시 동적으로 리소스를 가져와 prefetch도 가능하다.
<head>
<link rel="prefetch" as="script" href="/date-picker.js" />
<link rel="prefetch" as="style" href="/date-picker.css" />
</head>
2. 향후 탐색 속도 증가를 위해 페이지를 prefetch
HTML 문서를 가리킬 때 `as="document"` 속성을 지정하여 `페이지와 해당 페이지 내의 모든 하위 리소스를 미리 가져오는 것`도 가능하다.
<link rel="prefetch" href="/page" as="document" />
3. Next.js - Link
Link 컴포넌트를 통해 특정 리소스를 미리 로드하거나 페이지 전환
Next.js 15로 올라가게되면서 `Link` 컴포넌트의 기본 기능으로 depcrecated 되었다.
import Link from 'next/link';
<Link href="/next-page" prefetch={true}> Next Page </Link>
4. Next.js - useRouter
useRouter 훅의 prefetch 기능을 사용하게 되면, 비동기 프로세스로 사용
const router = useRouter();
fucntion callPrefetch(){
router.prefetch(/page/1);
}
5. Tanstack Query - prefetchQuery
prefetchQuery는 useQuery로 렌더링하거나 쿼리가 필요한 상황 이전에 쿼리를 prefech하여 캐시에 결과를 저장하는 비동기 메서드다. useQuery와 다른 점은 data를 반환하거나, 에러를 던지지 않고 캐시를 저장만한다.
const prefetchTodos = async () => {
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
}
캐싱
브라우저에서는 렌더링 시 필요한 자원을 다운로드를 받기 위해 많은 리소스를 내려받는다. 그때 커다란 규모의 중복된 리소스를 다시 받지 않도록 하여 브라우저 리소스를 최적화하는 방법이 많이 있다.
HTTP 캐싱
HTTP 캐싱은 가장 기본적이며 효율적인 전략이다. 대표적으로 세가지 방법인 `Cache-Control`, `Etag(Entity Tag)`, `Expires`이 있다.
1. Cache-Control 헤더
`Cache-Control` 헤더를 통해 브라우저에 캐싱 정책을 지정한다.
Cache-Control: public, max-age=31536000, immutable
- `public`: 모든 사용자 및 중간 캐시 서버가 자원을 캐싱할 수 있음.
- `max-age=31536000`: 1년 동안 캐싱.
- `immutable`: 리소스가 절대 변경되지 않음을 나타냄.
2. Etag
리소스가 변경될 때만 브라우저가 새 리소스를 요청한다.
ETag: "unique-id"
3. Expire 헤더
`Cache-Control`과 유사하지만 날짜 기반 캐싱 설정
Expires: Fri, 01 Jan 2025 00:00:00 GMT
정적 자원 캐싱
1. 파일 해싱
파일의 콘텐츠가 변경될 때마다 파일 이름에 해시값을 추가한다. 일반적으로는 모듈 번들러를 사용하게 되면 컴파일이나 빌드시 파일의 컨텐츠를 압축하여 해싱 처리가 된다.
main.abc123.js
styles.efg456.css
HTML에서 파일 해싱 요청시 아래와 같은 모습으로 나타난다.
<script src="main.abc123.js"></script>
<link rel="stylesheet" href="styles.efg456.css" />
2. Egde/CDN 서버 활용
- Cloudflare, AWS CloudFront, Azure Front Door 등 CDN 서비스 사용 시. 위 `정적 파일` 자원을 캐싱하도록 구성할 수 있다.
- 사용자의 지리적 위치에 따라 가장 가까운 Edge 서버나 CDN 서버에서 자원을 제공하여 지연(latency)을 줄일 수 있는 큰 장점이 있으나, 캐싱으로 인해 최신 컨텐츠의 반영이 지연되거나 해당 서버에 대한 추가적인 비용 및 관리가 필요하다.
3. Next.js - SSR/SSG 결과물 캐싱(feat, `middleware`, `next.config.js`)
- API 데이터나 SSR 응답을 캐싱하면 브라우저 로드 시간과 서버 부담을 줄이고 다음 리소스 요청을 스킵할 수 있다.
- page router - `getServerSideProps`나 `getStaticProps` 캐싱 헤더 설정
// app/page.jsx
export async function getServerSideProps() {
return {
props: {},
headers: { 'Cache-Control': 's-maxage=10, stale-while-revalidate' }
};
}
- app router - 서버 컴포넌트의 revalidate 설정을 통해 정적 페이지 캐싱 처리가 가능하다.
- middleware - 미들웨어를 통해 캐싱 헤더를 전역 또는 조건부로 설정이 가능하다.
// middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
const response = NextResponse.next();
response.headers.set('Cache-Control', 's-maxage=3600, stale-while-revalidate=60');
return response;
}
- next.config.js - `public` 디렉토리에 있는 정적 자원은 Next.js가 기본적으로 캐싱 헤더를 설정한다. 추가로 원하는 설정을 적용하려면 `next.config.js`에서 설정한다.
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
];
},
};
CSS
앞서 설명한 것처럼 렌더링 차단 요소이며 전체 페이지 로드에 영향을 미치는 리소스이다. 이를 개선하기 위해 4가지 방식을 소개한다.
CSS - Minification(축소)
파일 크기 자체를 축소하면 리소스가 줄어들어 더 빠른 다운이 된다. 주로 콘텐츠를 삭제하는 방식이고 아래와 같이 진행한다.
/* 축소 전 CSS: */
/* Heading 1 */
h1 {
font-size: 2em;
color: #000000;
}
/* Heading 2 */
h2 {
font-size: 1.5em;
color: #000000;
}
/* 축소 후 CSS: */
h1,h2{color:#000}h1{font-size:2em}h2{font-size:1.5em}
이 방식은 FCP, LCP까지 개선할 수 있으며, 번들러에서 자동으로 수행이 가능하다.
사용하지 않는 CSS 삭제
콘텐츠 렌더링 전 브라우저는 모든 스타일 시트를 다운로드하고 파싱(구문을 분석)해야 한다. 완료에 필요한 시간은 사용되지 않는 스타일도 포함되어 모든 CSS를 단일 파일로 결합하는 번들러를 사용 시 더 많은 CSS를 다운할 수 있다.
개발자 도구에서 Coverage Tool도구를 사용하자. 참고로 이 도구는 현재 페이지에서 사용되지 않는 CSS 및 Javascript를 감지하는데 유용하다. 이 도구를 사용하면 렌더링을 지연시키는 요소를 확인하여 해결할 수 있다.
CSS @import 선언 피하기
`@import` 선언은 편리하지만 피해야 한다. `` 요소가 작동하는 방식과 마찬가지로 스타일 시트 내부에서 외부의 CSS를 가져올 수 있게 된다. 차이점은 `` 요소가 HTML 응답에 포함되므로 CSS 보다 `@import`로 선언한 것을 훨씬 빨리 발견하기 때문에 미리 로드하여 렌더링 차단 리소스가 더욱 늦게 발견될 수 있게 된다.
/* 이렇게 쓰지 말것 */
@import url("style.css");
<!-- 이렇게 쓰자 -->
<link rel="stylesheet" href="style.css" />
참고로 `@import`를 사용해야 하는 경우(cascade layers 또는 서드 파티 스타일 시트) 가져온 스타일 시트에 preload 지시문을 사용하여 지연을 완화라 수 있다. 또한 SAAS나 LESS와 같은 CSS 전처리기는 개발자 환경 개선의 일환으로 `@import` 구문을 사용해서 소스 파일을 분리하고 모듈화하는 것이 일반적이다. 하지만 CSS 전처리기에서 `@import`를 만나면 `참조된 파일이 번들로 묶여 단일 스타일 시트로 작성`되어 일반 CSS에서 `@import`로 인한 연속 요청에 대한 불이익을 피할 수 있다.
inline critical CSS
브라우저는 CSS 리소스 다운로드 시간으로 FCP가 증가할 수 있다. `<head>`에 중요한 스타일을 인라인 처리하면 CSS 리소스에 대한 네트워크 요청이 제거되고 올바르게 수행하면 사용자 브라우저 캐시가 준비되지 않은 상태에서 초기 화면(스크롤 없이 볼 수 있는 부분 포함) 노출에 대한 초기 로드 시간을 개선할 수 있다. 나머지 CSS는 비동기적으로 로드하거나 `<body>` 요소 끝에 추가할 수 있다.
<head>
<title>Page Title</title>
<!-- ... -->
<style>
h1,
h2 {
color: #000;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
</style>
</head>
<body>
<!-- Other page markup... -->
<link rel="stylesheet" href="non-critical.css" />
</body>
단점은 많은 양의 CSS를 인라인 처리하게 되면 초기 HTML 응답에 더 많은 바이트가 추가된다. 이는 HTML 리소스를 오래 또는 전혀 캐시가 안되는 경우가 많기 때문에 외부 스타일시트에서 동일한 CSS를 사용할 수 있는 후속 페이지에는 인라인 CSS가 캐시되지 않는다. 상황에 맞게 쓰도록 하자.
Javascript
렌더링 차단 Javascript
`defer`나 `async` 속성 없이 `<script>` 요소를 로드하면 스크립트가 다운로드, 파싱, 실행될 때까지 _파싱 및 렌더링을 차단_ 한다. 마찬가지로 인라인 스크립트는 스크립트가 파싱되고 실행될 때까지 파서를 차단한다.
async와 defer 비교
`defer`나 `async` 사용시 외부 스크립트를 HTML 파서에서 차단하지않고 로드가 되고 유형이 `"module"`인 스크립트(인라인 스크립트 포함)은 자동 지연이 된다.
차이를 보게 되면
- `async`로 로드된 스크립트는 다운로드 즉시 파싱되어 실행한다.
- `defer`로 로드된 스크립트는 HTML 문서 파싱이 완료될 때 실행하며, 브라우저의 `DOMContentLoaded` 이벤트와 동시에 발생한다.
- `async`는 순서 없이 실행될 수 있지만, `defer`는 마크업에 표시된 순서대로 실행된다.
참고로 `type="module"` 속성은 기본적으로 지연되지만 DOM에 `<script>` 마크업을 삽입하여 로드된 스크립트 사용하면 `async` 스크립트처럼 작동한다.
정리
브라우저 최적화를 위해 렌더링 및 파서 차단을 막고, 어떤 이유로 인해 최적화에 영향을 주고 개선할지 공부했다. 아마 라이브러리나 프레임워크를 사용하면서 알게 모르게 이미 최적화를 사용하거나 무분별하게 최적화를 적용했을지 모른다.
브라우저 리소스 최적화에는 정공법도 있지만 정말 다양한 방법으로 최적화를 할 수 있다. 이는 운영 단계에 접어들고 모니터링이나 사용자 피드백이 없다면 확인하기가 어려울 수도 있다. 하지만, 최적화는 사용자 경험과 직결되며, 매출에도 큰 영향을 끼치게 된다. 웹 개발자라면 사소하다고 생각하여 아무도 신경 쓰지 않을 수 있지만, 사소한 문제들이 쌓이게 되면 사용자 경험과 서버 리소스에 큰 문제를 가져오게 된다.
이로써 CRP에 대해 이해하고, 렌더링 차단 종류와 역할을 이해하고 곳곳에 널려있는 요소들을 찾아 최적화하여 더 나은 서비스를 제공해 보도록 노력하자.
참고
'개발 > 테크톡' 카테고리의 다른 글
Typescript 모듈 해석 방식(2) - Module Resolution (0) | 2025.03.09 |
---|---|
Typescript 모듈 해석 방식(1) - tsconfig 설정과 실행 환경에 따른 차이 (0) | 2025.03.02 |
[브라우저 최적화(1)] CRP의 이해와 브라우저 리소스 식별 방법 (1) | 2024.11.24 |
[Radix-UI] React의 Automatic Batching에 의한 문제 해결 사례 (1) | 2024.10.27 |
Suspense의 데이터 페칭 감지 방법(with. tanstack-query) (2) | 2024.10.06 |