기본 콘텐츠로 건너뛰기

웹 애플리케이션 성능 최적화, 로딩 경로별로 정리하는 핵심 방법

웹 애플리케이션 성능 최적화, 로딩 경로별로 정리하는 핵심 방법

빠른 답

  • 첫 화면에 필요 없는 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는 맞지 않을 수 있습니다.

렌더링: 렌더 트리, 레이아웃, 페인트, 컴포지팅의 비용

렌더 트리가 만들어지면 브라우저는 요소의 위치와 크기를 계산하는 레이아웃을 수행합니다. 그다음 실제 픽셀을 칠하는 페인트가 일어나고, 여러 레이어를 합쳐 화면에 올리는 컴포지팅 단계가 이어집니다. 사용자 눈에는 하나의 화면 그리기로 보이지만, 내부적으로는 비용 성격이 꽤 다릅니다.

레이아웃 비용은 문서 구조와 요소 크기 계산에 영향을 받습니다. 폭이 넓은 영역 전체가 다시 계산되면 작은 변경 하나도 큰 레이아웃 재계산으로 이어질 수 있습니다. 페인트 비용은 배경, 그림자, 블러, 복잡한 벡터, 큰 이미지 등 픽셀을 다시 그려야 하는 요소에서 커집니다. 컴포지팅은 레이어를 합성하는 단계라 상대적으로 저렴할 수 있지만, 무분별하게 레이어를 늘리면 메모리와 관리 비용이 올라갑니다.

애니메이션에서 transformopacity가 자주 언급되는 이유도 여기에 있습니다. 위치를 바꾸기 위해 top, left, width, height를 지속적으로 수정하면 레이아웃과 페인트를 다시 유발할 가능성이 큽니다. 반면 transformopacity는 경우에 따라 컴포지팅 단계에서 끝날 수 있어 더 부드럽게 보이기 쉽습니다.

또 하나 자주 보이는 병목은 강제 동기 레이아웃입니다. 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

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...