웹 애플리케이션 성능 최적화, 로딩 경로별로 정리하는 핵심 방법
빠른 답
- 첫 화면에 필요 없는 JavaScript는 분리하고 늦게 불러와야 초기 로딩이 빨라집니다.
- 이미지와 비디오는 화면에 보일 때 로드하고, 크기와 포맷을 먼저 줄여야 체감 성능이 좋아집니다.
- 캐시는 단순 저장이 아니라 정적 자산과 HTML의 정책을 다르게 가져가야 효과가 큽니다.
- 최적화는 감으로 하지 말고 Network, Performance, Lighthouse 같은 측정 결과로 우선순위를 잡아야 합니다.
목차
왜 웹 성능은 로딩 경로로 봐야 할까
같은 300KB라도 언제 내려오고 무엇을 막느냐에 따라 체감은 크게 달라집니다. 첫 화면에 바로 필요한 CSS 300KB와, 아직 열지 않은 설정 페이지의 JavaScript 300KB는 사용자 경험에 미치는 영향이 다릅니다. 그래서 성능 최적화는 파일 크기를 줄이는 일에만 머물지 않고, 어떤 자원이 어떤 단계의 병목이 되는지 파악하는 작업에 가깝습니다.
브라우저 관점에서 병목은 보통 세 갈래로 나타납니다. 네트워크에서 늦게 내려오는 경우, JavaScript 파싱과 실행 때문에 메인 스레드가 오래 묶이는 경우, 렌더링 단계에서 레이아웃과 페인트가 반복되는 경우입니다. 이 세 지점을 로딩 경로 하나로 묶어 보면 코드 스플리팅, 레이지 로딩, 캐시, CSS 최적화가 각각 어디에 영향을 주는지 자연스럽게 연결됩니다.
흐름으로 보기
이 순서를 먼저 잡아두면 최적화 기법의 위치가 정리됩니다. 코드 스플리팅과 defer는 초기 다운로드와 실행 비용을 줄이고, 이미지 최적화와 레이지 로딩은 다운로드와 렌더링 부담을 뒤로 미루며, 캐시는 재방문에서 요청 자체를 가볍게 만듭니다. DevTools는 이 흐름에서 실제 병목이 어디에 있는지 확인하는 도구입니다.
요청 시작과 리소스 다운로드에서 생기는 병목
요청 시작 단계에서는 DNS 조회, TLS 연결, 서버 응답 대기 시간, HTML 자체의 크기가 누적됩니다. 프런트엔드 코드가 가벼워도 TTFB가 길면 첫 화면은 늦어집니다. CDN 배치, 압축, 서버 응답 시간, HTML 캐시 정책이 이 구간에 먼저 영향을 줍니다.
HTML을 받은 뒤에는 브라우저가 CSS, JavaScript, 폰트, 이미지 같은 하위 리소스를 발견하고 추가 요청을 시작합니다. 이때 중요한 파일보다 덜 중요한 파일이 먼저 네트워크를 차지하면 이후 단계가 함께 밀립니다. 특히 첫 화면과 무관한 큰 번들이 초기에 같이 내려오면, 다운로드가 끝난 뒤 파싱과 실행까지 이어져 전체 렌더링이 늦어지기 쉽습니다.
초기 경로에서 자주 보는 실수는 두 가지입니다. 첫째, 모든 화면의 코드를 하나의 번들로 묶어 첫 방문에 다 내려보내는 경우입니다. 둘째, 분석 스크립트나 실험 도구까지 중요한 리소스와 같은 우선순위로 올려두는 경우입니다. 이런 상태에서는 네트워크 탭의 워터폴만 봐도 첫 화면보다 부가 기능이 먼저 자원을 차지하는 모습이 드러납니다.
파싱과 실행: DOM, CSSOM, JavaScript가 서로 만나는 지점
HTML은 파싱되며 DOM이 되고, CSS는 CSSOM이 됩니다. 브라우저는 DOM만으로는 화면을 그릴 수 없고, CSSOM이 있어야 어떤 요소를 어떤 스타일로 표현할지 결정할 수 있습니다. 그다음 DOM과 CSSOM을 합쳐 렌더 트리를 만듭니다. 이 렌더 트리에는 실제로 화면에 그려질 노드만 포함되므로, display:none 요소처럼 보이지 않는 항목은 빠질 수 있습니다.
JavaScript는 여기에서 메인 스레드를 강하게 점유합니다. 스크립트 파일은 다운로드가 끝나면 곧바로 비용이 사라지는 것이 아니라, 파싱, 컴파일, 실행 시간이 다시 필요합니다. 그래서 번들 크기를 줄이는 일은 네트워크만이 아니라 실행 비용을 줄이는 일과도 연결됩니다.
스크립트 로딩 방식은 이 단계의 병목을 크게 바꿉니다. 일반적인 앱 번들은 순서 보장이 필요하므로 defer가 많이 쓰이고, 독립적으로 동작하는 분석 스크립트나 위젯은 async가 더 맞는 경우가 많습니다. 아래처럼 초기 HTML에서 리소스 우선순위를 구성하면 첫 화면을 가로막는 자원을 줄이기 좋습니다.
<link rel="preload" href="/assets/app.css" as="style">
<link rel="stylesheet" href="/assets/app.css">
<script defer src="/assets/app.js"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
defer는 HTML 파싱을 막지 않고 문서 파싱 이후 순서를 지켜 실행됩니다. 반면 async는 다운로드가 끝나는 즉시 실행되므로 순서가 보장되지 않습니다. 어떤 스크립트가 다른 스크립트보다 먼저 실행되어야 한다면 async는 맞지 않을 수 있습니다.
렌더링: 렌더 트리, 레이아웃, 페인트, 컴포지팅의 비용
렌더 트리가 만들어지면 브라우저는 요소의 위치와 크기를 계산하는 레이아웃을 수행합니다. 그다음 실제 픽셀을 칠하는 페인트가 일어나고, 여러 레이어를 합쳐 화면에 올리는 컴포지팅 단계가 이어집니다. 사용자 눈에는 하나의 화면 그리기로 보이지만, 내부적으로는 비용 성격이 꽤 다릅니다.
레이아웃 비용은 문서 구조와 요소 크기 계산에 영향을 받습니다. 폭이 넓은 영역 전체가 다시 계산되면 작은 변경 하나도 큰 레이아웃 재계산으로 이어질 수 있습니다. 페인트 비용은 배경, 그림자, 블러, 복잡한 벡터, 큰 이미지 등 픽셀을 다시 그려야 하는 요소에서 커집니다. 컴포지팅은 레이어를 합성하는 단계라 상대적으로 저렴할 수 있지만, 무분별하게 레이어를 늘리면 메모리와 관리 비용이 올라갑니다.
애니메이션에서 transform과 opacity가 자주 언급되는 이유도 여기에 있습니다. 위치를 바꾸기 위해 top, left, width, height를 지속적으로 수정하면 레이아웃과 페인트를 다시 유발할 가능성이 큽니다. 반면 transform과 opacity는 경우에 따라 컴포지팅 단계에서 끝날 수 있어 더 부드럽게 보이기 쉽습니다.
또 하나 자주 보이는 병목은 강제 동기 레이아웃입니다. DOM을 수정한 직후 곧바로 offsetHeight, getBoundingClientRect() 같은 레이아웃 정보를 읽으면 브라우저가 계산을 미룰 수 없어 즉시 레이아웃을 수행하게 됩니다. 스크롤 핸들러나 애니메이션 루프에서 이런 읽기와 쓰기가 섞이면 프레임 드롭이 쉽게 생깁니다.
초기 로딩을 줄이는 설정
초기 로딩에서는 "첫 화면에 꼭 필요한 코드만 지금 내려오고 있는가"를 먼저 보는 편이 좋습니다. 라우트별, 기능별 코드 스플리팅은 이 질문에 가장 직접적으로 답하는 방법입니다. 중요한 점은 파일을 무조건 잘게 자르는 것이 아니라, 현재 경로에 필요 없는 코드를 뒤로 미루는 데 있습니다.
아래 예시는 라우트 단위로 페이지 코드를 나누는 단순한 구조입니다.
// router.js
const routes = {
'/': () => import('./pages/home.js'),
'/reports': () => import('./pages/reports.js'),
'/settings': () => import('./pages/settings.js'),
};
export async function renderRoute(path) {
const load = routes[path] ?? routes['/'];
const page = await load();
return page.render();
}
이 구조에서는 보고서 화면이나 설정 화면의 코드를 첫 방문에 모두 내려보내지 않아도 됩니다. 번들 분석 결과에서 관리자 페이지 라이브러리, 에디터, 차트 모듈이 초기 엔트리에 섞여 있다면, 경계가 사용자 경로와 맞지 않게 잡혀 있을 가능성이 큽니다.
리소스 우선순위도 함께 봐야 합니다. 첫 화면을 그리는 CSS, 실제 LCP에 해당하는 이미지, 폰트 중 꼭 필요한 범위만 초기에 올리고, 나머지는 지연시켜야 합니다. 지나친 preload는 오히려 중요한 리소스의 네트워크 경쟁을 늘릴 수 있으므로, 모든 자산을 우선순위 상단에 올리는 방식은 도움이 되지 않는 경우가 많습니다.
무거운 자산을 늦게 가져오는 방법
이미지와 비디오는 네트워크 비용만 큰 것이 아니라, 디코딩과 레이아웃에도 영향을 줍니다. 원본 이미지를 그대로 내려보내면 다운로드 시간이 길어지고, 모바일 기기에서는 디코딩 때문에 메인 스레드가 추가로 바빠질 수 있습니다. 먼저 할 일은 화면에 필요한 크기로 줄이는 것이고, 그다음 WebP나 AVIF 같은 포맷을 검토하는 것입니다. 동일한 이미지라도 화면 폭에 맞게 다른 버전을 제공하면 불필요한 전송량을 줄일 수 있습니다.
레이지 로딩은 그 다음 단계입니다. 다만 모든 이미지를 무조건 늦게 불러오면 첫 화면의 대표 이미지까지 지연되어 LCP가 오히려 나빠질 수 있습니다. 상단 히어로 이미지와 본문 하단 썸네일은 같은 전략으로 다루기 어렵습니다. 화면 상단 이미지는 빠르게, 스크롤 이후 자산은 늦게 가져오는 구성이 보통 더 잘 맞습니다.
브라우저 기본 지연 로딩으로 충분하지 않거나, 배경 이미지처럼 직접 제어가 필요할 때는 IntersectionObserver를 사용할 수 있습니다.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
observer.unobserve(img);
}
}, {
rootMargin: '200px 0px',
});
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
이 방식은 사용자가 이미지에 도달하기 직전 파일을 준비하게 해 스크롤 순간의 빈칸이나 깜빡임을 줄이는 데 도움이 됩니다. 함께 확인할 부분은 레이아웃 안정성입니다. 이미지 박스의 가로세로 크기를 미리 잡아두지 않으면 로딩 중 주변 콘텐츠가 밀리며 CLS가 커질 수 있습니다.
재방문 속도를 높이는 캐시 전략
캐시는 단순히 한 번 받은 파일을 저장하는 기능이 아니라, 어떤 자산을 얼마나 오래 믿고 재사용할지 결정하는 정책입니다. HTML과 해시가 붙은 정적 자산은 성격이 다르기 때문에 같은 규칙을 적용하면 배포 직후 문제가 생기기 쉽습니다.
HTML은 새 배포 때마다 최신 자산 경로를 가리켜야 하므로 자주 재검증할 수 있어야 합니다. 반면 콘텐츠 해시가 포함된 JavaScript, CSS, 폰트, 이미지는 URL이 바뀌면 새 파일로 인식되므로 길게 캐시해도 비교적 안전합니다. 아래는 Nginx에서 두 정책을 나누는 예시입니다.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
add_header Cache-Control "no-cache";
try_files $uri /index.html;
}
이 설정이 실제 응답에 반영되는지는 헤더를 직접 보는 편이 빠릅니다. 아래처럼 HTML과 해시 자산이 다르게 응답하면 의도한 캐시 분리가 이뤄진 상태에 가깝습니다.
$ curl -I https://app.example.com/
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: no-cache
etag: "wzsdm-19b12"
$ curl -I https://app.example.com/assets/app.8f3c1d2a.js
HTTP/2 200
content-type: application/javascript; charset=utf-8
cache-control: public, max-age=31536000, immutable
etag: "8f3c1d2a"
HTML까지 길게 캐시하면 새 배포가 늦게 반영될 수 있고, 반대로 해시 자산을 짧게 캐시하면 재방문 때마다 다시 내려받아 체감 속도가 나빠질 수 있습니다. 특히 청크 파일을 나눠 쓰는 애플리케이션은 오래된 HTML이 새 청크 이름을 모르는 상태가 되면 로딩 오류가 발생할 수 있어 캐시 구분이 더 중요합니다.
DevTools로 병목을 반복 측정하는 방법
최적화는 한 번의 수정으로 끝나기보다, 어디가 느린지 확인하고 다시 좁혀 가는 과정에 가깝습니다. Network 패널에서는 어떤 요청이 오래 걸리는지, CSS와 JavaScript 중 무엇이 렌더링을 막는지, 각 요청의 우선순위와 Initiator가 어떻게 연결되는지 먼저 확인합니다. 여기서 첫 화면에 필요 없는 큰 파일이 초반 워터폴 상단에 올라와 있다면 우선순위와 분할 경계를 다시 볼 수 있습니다.
Performance 패널에서는 긴 JavaScript 작업과 Recalculate Style, Layout, Paint 구간을 확인합니다. 타임라인에서 긴 태스크가 반복된다면 실행 비용이 문제일 가능성이 높고, 스타일 계산과 레이아웃이 비정상적으로 많다면 DOM 구조나 읽기/쓰기 패턴을 의심할 수 있습니다. Rendering 탭의 Paint flashing, Layout Shift Regions 같은 보조 시각화도 문제를 눈으로 확인하는 데 도움이 됩니다.
Lighthouse는 개선 후보를 빠르게 모으는 용도로 유용하지만, 점수만 보고 결론을 내리기보다는 실제 트레이스와 함께 해석하는 편이 낫습니다. 아래처럼 출력이 나왔다면 네트워크와 메인 스레드 비용을 함께 의심해 볼 수 있습니다.
$ npx lighthouse https://app.example.com --only-categories=performance --view
Performance score: 71
First Contentful Paint: 2.7 s
Largest Contentful Paint: 4.3 s
Total Blocking Time: 410 ms
Speed Index: 3.6 s
Diagnostics
- Eliminate render-blocking resources: Potential savings of 620 ms
- Reduce unused JavaScript: Potential savings of 184 KiB
- Properly size images: Potential savings of 312 KiB
이런 결과라면 보통 세 가지 순서로 좁혀 볼 수 있습니다. 첫째, Network에서 큰 CSS와 초기 번들이 첫 화면을 막는지 확인합니다. 둘째, Performance에서 스크립트 평가와 레이아웃 시간이 긴지 봅니다. 셋째, LCP 요소가 이미지인지 텍스트 블록인지 확인해 이미지 최적화가 먼저인지, CSS와 폰트 경로 정리가 먼저인지 판단합니다.
흔한 오해와 함께 보는 정리 포인트
번들 크기만 줄이면 성능이 좋아진다고 생각하기 쉽지만, 실제 체감은 다운로드 이후의 파싱, 실행, 레이아웃 비용까지 함께 결정됩니다. 용량을 줄였는데도 서비스가 느리다면 초기 실행 코드가 여전히 많거나, 첫 렌더 뒤 큰 DOM 변경이 반복될 수 있습니다.
반대로 레이지 로딩이나 프리로드를 많이 쓸수록 좋다고 보기도 어렵습니다. 첫 화면 대표 이미지까지 지연시키면 LCP가 나빠질 수 있고, 중요하지 않은 파일까지 프리로드하면 정작 필요한 CSS와 폰트가 네트워크 경쟁에서 밀릴 수 있습니다. 캐시도 오래 둘수록 좋은 것이 아니라, 자산의 성격에 맞게 달리 두는 쪽이 배포와 재방문 모두에서 안정적입니다.
결국 웹 성능 최적화는 개별 기법의 체크리스트보다, 브라우저가 실제로 거치는 경로를 기준으로 병목을 찾고 조정하는 작업에 가깝습니다. 요청 시작, 다운로드, 파싱과 실행, 렌더링, 상호작용 이후를 한 흐름으로 보면 어떤 개선이 어느 구간에 영향을 주는지 더 분명하게 보입니다.
원문 참고
https://www.maeil-mail.kr/question/40
댓글
댓글 쓰기