Search

웹 애플리케이션 성능 최적화, 어디서부터 시작할까?

subtitle
Tags
자바스크립트
front-end
react
Created
2025/09/27
2 more properties
프론트엔드 개발자가 매일 고민하는 주제 중 하나는 바로 웹 애플리케이션 성능 최적화이다. 성능이 느리면 사용자는 곧바로 이탈하고, 빠른 성능은 곧 서비스의 신뢰와 직결된다. 이 글에서는 실무에서 바로 적용할 수 있는 대표적인 최적화 방법들을 정리한다.

코드 스플리팅

코드 스플리팅은 초기 번들의 크기를 줄여 페이지 로딩 속도를 개선하는 가장 대표적인 방법 중 하나다. 모든 자바스크립트 파일을 한 번에 불러오는 방식은 사용자가 필요로 하지 않는 코드까지 미리 다운로드하기 때문에 불필요한 비용이 발생한다. 반면 코드 스플리팅을 적용하면 사용자가 실제로 필요한 시점에만 해당 코드를 불러오기 때문에 초기 화면 렌더링이 훨씬 빨라진다.
예를 들어 React 애플리케이션에서 React.lazySuspense를 함께 사용하면 특정 페이지나 컴포넌트를 동적으로 분리할 수 있다.
import React, { Suspense } from "react"; // 코드 스플리팅된 컴포넌트 const Chart = React.lazy(() => import("./Chart")); function Dashboard() { return ( <div> <h1>대시보드</h1> {/* Chart 컴포넌트는 실제로 필요할 때 로드된다 */} <Suspense fallback={<div>로딩 중...</div>}> <Chart /> </Suspense> </div> ); } export default Dashboard;
TypeScript
복사
위 코드에서 Chart 컴포넌트는 초기에 번들에 포함되지 않고, 사용자가 Dashboard를 열어 해당 컴포넌트가 실제로 렌더링될 때 비로소 네트워크를 통해 로드된다. 이렇게 하면 사용자가 첫 화면을 보는 시간은 크게 단축되며, 특히 대규모 애플리케이션에서 무거운 그래프, 에디터, 지도와 같은 리소스를 뒤로 미뤄 로딩하는 데 큰 효과를 얻을 수 있다.
실무에서는 라우트 단위로 코드 스플리팅을 적용하는 경우가 많다. React Router와 같은 라우팅 라이브러리에서 각 페이지를 React.lazy로 감싸면, 사용자가 특정 경로에 진입할 때만 해당 페이지의 코드가 로드된다. 이를 통해 사용자는 첫 방문 시 필요한 최소한의 코드만 다운로드하며, 나머지는 자연스럽게 뒤로 밀려난다.
코드 스플리팅은 단순히 성능 최적화 기법을 넘어 사용자 경험을 개선하는 도구라고 할 수 있다. 초기 화면은 빠르게 뜨고, 사용자가 실제로 요청한 기능은 지연 없이 제공되며, 필요하지 않은 기능은 숨겨져 있다가 적절한 타이밍에 등장한다. 이런 방식은 사용자에게 "서비스가 가볍다"는 인상을 주어 만족도를 높이고, 장기적으로 이탈률을 줄이는 데도 긍정적인 영향을 준다.

레이지 로딩(Lazy Loading)

페이지의 모든 리소스를 처음부터 불러올 필요는 없다. 사용자가 실제로 해당 리소스를 필요로 하는 시점에 불러오는 방식을 레이지 로딩이라고 한다. 이 방식은 특히 이미지나 동영상, 그리고 무거운 컴포넌트에서 큰 효과를 발휘한다. 초기 화면에 보이지 않는 리소스까지 미리 다운로드하면 네트워크와 브라우저 렌더링 리소스를 낭비하게 되지만, 레이지 로딩을 적용하면 사용자가 스크롤을 내려 해당 영역에 도달하는 순간 필요한 리소스가 불려와 자연스럽게 나타난다.
가장 단순한 예시는 이미지 태그에 loading="lazy" 속성을 붙이는 것이다. 브라우저는 이 속성을 확인하고 이미지가 뷰포트 근처에 도달했을 때 네트워크 요청을 보낸다.
<img src="/images/large-photo.jpg" alt="풍경 사진" loading="lazy" />
HTML
복사
위와 같이 설정하면, 화면에 처음 등장하는 이미지는 즉시 로드되지만 화면 아래쪽에 있는 큰 이미지들은 사용자가 스크롤할 때까지 로드되지 않는다. 이로써 초기 렌더링 속도가 빨라지고, 불필요한 데이터 전송을 줄일 수 있다.
리액트 환경에서는 react-intersection-observer 같은 라이브러리를 활용해 레이지 로딩을 구현할 수 있다. 뷰포트와 요소의 교차 여부를 감지해 필요한 시점에 네트워크 요청을 트리거하는 방식이다.
import { useInView } from "react-intersection-observer"; function LazyImage({ src, alt }: { src: string; alt: string }) { const { ref, inView } = useInView({ triggerOnce: true, // 한 번만 로드 rootMargin: "100px", // 뷰포트 근처에서 미리 로드 }); return ( <div ref={ref}> {inView ? <img src={src} alt={alt} /> : <div>이미지 로딩 중...</div>} </div> ); }
TypeScript
복사
이 방법은 브라우저 기본 기능인 loading="lazy"보다 더 세밀한 제어가 가능하다. 예를 들어 이미지가 화면에 등장하기 100px 전에 미리 로드하도록 설정해 스크롤 시 사용자에게 지연 없이 보이게 할 수 있다.
레이지 로딩은 단순한 기술적 최적화가 아니라 사용자 경험을 향상시키는 중요한 전략이다. 사용자는 불필요한 로딩 지연을 느끼지 않고, 필요한 순간에만 리소스가 자연스럽게 나타나는 쾌적한 흐름을 경험한다. 특히 이미지나 동영상이 많은 페이지에서 이 기법을 적용하면 체감 성능이 크게 향상된다.

이미지 최적화

이미지는 웹 애플리케이션 성능 최적화에서 가장 큰 비중을 차지한다. 실제로 많은 웹사이트에서 전체 페이지 용량의 절반 이상이 이미지가 차지하는 경우가 흔하다. 따라서 이미지를 어떻게 다루느냐에 따라 페이지 로딩 속도와 사용자 경험이 크게 달라진다.
첫 번째로 고려해야 할 것은 파일 크기다. 필요 이상으로 큰 해상도의 이미지를 그대로 사용하는 경우가 많다. 예를 들어, 모바일 기기에서는 최대 1080px 정도면 충분한데 4000px 해상도의 이미지를 내려받게 하면 불필요하게 데이터가 낭비된다. 이미지를 용도에 맞게 리사이징하고 압축률을 조정하면 시각적 품질을 크게 해치지 않으면서도 용량을 대폭 줄일 수 있다.
두 번째로는 이미지 포맷 최적화다. JPEG나 PNG 같은 전통적인 포맷 대신 WebP, AVIF 같은 차세대 포맷을 사용하면 같은 품질에서도 훨씬 더 작은 용량을 얻을 수 있다. WebP는 대부분의 브라우저에서 지원되고 있으며, AVIF는 더 나은 압축 효율을 제공한다. 예를 들어 500KB의 JPEG 이미지를 WebP로 변환하면 150KB 수준까지 줄어드는 경우가 있다.
세 번째로는 CDN(Content Delivery Network) 활용이다. CDN은 사용자의 지리적 위치와 가까운 서버에서 이미지를 전송하기 때문에 네트워크 지연을 줄여준다. 또한 많은 CDN 서비스는 자동 리사이징 기능을 제공한다. 브라우저의 srcset 속성과 조합해 기기 해상도에 맞는 이미지를 내려줄 수 있어, 고해상도 기기와 저해상도 기기 모두에서 최적화된 이미지를 전달할 수 있다.
<img src="/images/photo-800.webp" srcset="/images/photo-400.webp 400w, /images/photo-800.webp 800w, /images/photo-1600.webp 1600w" sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px" alt="풍경" />
HTML
복사
위 코드는 브라우저가 뷰포트 크기와 해상도를 고려해 적절한 이미지를 자동으로 선택하도록 한다. 모바일에서는 작은 용량의 이미지를, 데스크톱에서는 큰 이미지를 선택하므로 불필요한 다운로드를 피할 수 있다.

캐싱(Caching) 전략

캐시는 웹 애플리케이션 성능 최적화에서 빼놓을 수 없는 핵심 요소다. 기본 아이디어는 단순하다. 한 번 불러온 리소스를 다시 불러오지 않도록 브라우저나 서버가 기억해 두는 것이다. 하지만 실제 구현 단계에서는 다양한 전략과 기술이 개입되며, 이를 어떻게 설계하느냐에 따라 사용자 경험이 크게 달라진다.
가장 기본적인 방법은 HTTP 캐시 헤더를 활용하는 것이다. Cache-Control 헤더를 사용하면 브라우저가 리소스를 얼마 동안 저장해둘지 명시할 수 있다. 예를 들어, 정적 자산(js, css, 이미지)은 변경 주기가 길기 때문에 Cache-Control: max-age=31536000, immutable처럼 설정하면 브라우저가 1년 동안 다시 다운로드하지 않고 캐시된 버전을 재사용한다. 반대로 자주 변경되는 API 응답은 no-cachemax-age=0을 설정해 서버의 유효성을 확인한 후 사용하는 편이 적절하다. 또한 ETag 헤더를 사용하면 파일이 변경되지 않은 경우 브라우저가 서버로부터 전체 리소스를 다시 내려받지 않고, 변경 여부만 확인해 캐시를 재활용할 수 있다.
한 단계 더 나아가면 서비스 워커 기반의 캐싱을 고려할 수 있다. 서비스 워커는 브라우저와 서버 사이에서 프록시처럼 동작하는 스크립트로, PWA(Progressive Web App) 환경에서 특히 유용하다. 네트워크 상태가 불안정하거나 오프라인일 때도 캐시된 데이터를 즉시 반환해 사용자에게 빠른 응답을 제공한다. 예를 들어, 블로그 앱에서는 이미 읽은 게시물 페이지를 서비스 워커가 캐싱해두었다가 네트워크 연결이 끊겨도 사용자에게 그대로 보여줄 수 있다.
// 간단한 서비스 워커 캐시 예시 self.addEventListener("install", (event) => { event.waitUntil( caches.open("app-cache").then((cache) => { return cache.addAll(["/", "/index.html", "/styles.css"]); }) ); }); self.addEventListener("fetch", (event) => { event.respondWith( caches.match(event.request).then((cachedResponse) => { return cachedResponse || fetch(event.request); }) ); });
JavaScript
복사
이 코드는 기본적인 캐시 프리패칭과 네트워크 요청 가로채기를 구현한 예시다. 사용자는 오프라인 상태에서도 최소한의 리소스를 보장받을 수 있으며, 네트워크가 복구되면 새로운 데이터를 가져와 최신 상태를 유지한다.

자바스크립트 로딩 최적화

자바스크립트는 브라우저가 HTML을 해석하고 DOM을 구축하는 과정에서 실행되기 때문에, 잘못 로딩하면 페이지 로딩 전체가 지연될 수 있다. 특히 <script> 태그가 기본 방식으로 삽입되면 브라우저는 DOM 파싱을 멈추고 스크립트를 다운로드하고 실행하는 데 집중한다. 이 때문에 화면이 늦게 나타나거나, 사용자 입장에서 페이지가 “멈춘 것처럼” 보이는 현상이 발생한다. 따라서 자바스크립트를 어떤 방식으로 로딩하느냐가 성능 최적화에서 핵심적인 요소가 된다.
이 문제를 해결하기 위해 가장 많이 사용하는 방법이 asyncdefer 속성이다. async는 스크립트 파일을 비동기적으로 다운로드하고, 다운로드가 끝나는 즉시 실행한다. 덕분에 DOM 파싱이 차단되지 않지만, 실행 시점이 DOM 파싱 중간일 수도 있어 의존 관계가 있는 스크립트에는 적합하지 않다. 반면 defer는 스크립트를 병렬로 다운로드하되, 실행은 DOM 파싱이 완료된 이후에 진행된다. 덕분에 DOM을 건드리는 스크립트에도 안전하며, 보통 페이지 하단에 삽입하는 것보다 defer를 쓰는 것이 더 일관된 동작을 보장한다.
<!-- async: 다운로드 후 바로 실행 --> <script src="analytics.js" async></script> <!-- defer: DOM 파싱이 끝난 뒤 실행 --> <script src="main.js" defer></script>
HTML
복사
실무에서는 사용자 추적이나 광고 SDK처럼 DOM과 독립적인 스크립트는 async로, DOM을 조작하는 메인 번들은 defer로 설정하는 것이 일반적이다. 이를 통해 페이지가 중간에 끊기지 않고 매끄럽게 로드된다.
또한 단순히 로딩 방식을 최적화하는 것만으로는 한계가 있다. 자바스크립트 자체의 크기를 줄이는 작업이 병행되어야 한다. 모던 빌드 툴은 Tree Shaking이나 Dead Code Elimination 기능을 통해 사용되지 않는 코드를 제거할 수 있다. 예를 들어, 대형 라이브러리에서 극히 일부 기능만 사용하는데도 전체 라이브러리를 불러온다면 번들이 불필요하게 커진다. 이런 경우 필요한 함수만 임포트하는 방식으로 바꿔야 한다.
// 잘못된 예시: lodash 전체를 번들에 포함 import _ from "lodash"; const result = _.uniq([1, 2, 2, 3]); // 올바른 예시: 필요한 기능만 임포트 import uniq from "lodash/uniq"; const result = uniq([1, 2, 2, 3]);
JavaScript
복사
이렇게 하면 최종 번들 크기를 수십 KB에서 몇 KB 단위로 줄일 수 있다. 번들 크기를 줄이는 것은 네트워크 비용을 절감할 뿐 아니라, 파싱과 실행 속도까지 개선한다.
결국 자바스크립트 로딩 최적화는 두 가지 축에서 접근해야 한다. 하나는 asyncdefer를 활용해 실행 시점을 조절하는 방식이고, 다른 하나는 번들 크기를 줄여 다운로드와 실행 자체를 가볍게 만드는 방식이다. 이 두 가지가 함께 적용될 때 사용자에게 빠르고 쾌적한 페이지 경험을 제공할 수 있다.