프론트엔드 성능최적화를 위한 코드 스플릿 알아보기

타락한스벨트전도사·2025년 6월 10일
11

처음 개발할 때는 괜찮았는데 조금만 지나도 점점 무거워지는 느낌 안 드나요?

어느 프로젝트든 처음 개발할 때는 빠릿빠릿합니다. 그러다가 서비스가 커지고 기능이 추가될수록 웹은 무거워지죠. 개발 환경에서는 여전히 빠른데, 실제 배포된 서비스를 모바일로 접속해보면 답답할 정도로 느립니다. 특히 3-4년 된 안드로이드 폰에서는 첫 화면이 뜨는 데만 5-6초가 걸리기도 하죠.

그래서 이런 성능 최적화하는 역량도 프론트엔드로서 중요합니다. 주니어 시절에는 기획에 맞게 기능 구현하는 것을 최우선으로 두었다면, 연차가 쌓여갈수록 사용자들에게 일관된 경험을 주기 위해 번들 사이즈를 줄이고 성능 병목을 찾아내는 역량이 필요하죠.

그런데 왜 성능 최적화가 중요할까요?

단순히 기능 구현을 넘어서 번들 사이즈, 로딩 시간, 인터랙션 지연까지 챙기는 것이 비즈니스적으로도, 사용자 경험으로도 중요하기 때문입니다.

실제 데이터를 보면 그 영향력이 상당합니다. 홈페이지 로딩 속도가 100ms 빨라질 때마다 전환율이 1% 증가하고, 페이지 로드 시간을 절반으로 줄이면 매출이 12% 향상됩니다. BBC는 사이트 로딩이 1초 늦어질 때마다 사용자의 10%를 잃는다고 발표했죠.

모든 사용자의 접근성을 고려해야 합니다. SEO에서도 속도는 중요한 랭킹 요소가 되어 서버사이드 렌더링을 도입하지만, HTML을 먼저 보여주는 것만으로는 부족합니다.

Next.js를 쓴다면 이미 코드 스플릿을 하고 있습니다

혹시 모르셨을 수도 있지만, Next.js는 자연스럽게 코드 스플릿을 지원합니다. 라우트별로 모든 스크립트 파일을 자동으로 분리해주거든요. /about 페이지에 접근할 때만 해당 페이지의 JavaScript가 로드되는 식으로요.

하지만 이것만으로는 충분하지 않습니다. 하나의 페이지 안에서도 수많은 컴포넌트와 라이브러리들이 한꺼번에 로드되면서 여전히 성능 병목이 발생할 수 있습니다. 더 세밀한 최적화가 필요한 이유죠.

이 글에서는 기본적인 SPA, SSR, 하이드레이션 개념부터 시작해서, Next.js에서 제공하는 실전 코드 스플릿 기법들을 알아보겠습니다. 그리고 마지막에는 기존 접근법의 한계를 넘어선 Qwik의 혁신적인 아이디어까지 살펴보며, 웹 프론트엔드 성능 최적화의 현재와 미래를 함께 탐구해보겠습니다.

웹 개발의 진화 - SPA에서 SSR, 그리고 하이드레이션까지

SPA의 등장, 그리고 새로운 문제들

React가 등장하면서 웹 개발 패러다임이 크게 바뀌었습니다. 전통적인 멀티 페이지 애플리케이션(MPA)에서는 페이지를 이동할 때마다 전체 HTML을 다시 받아와야 했죠. 하지만 SPA(Single Page Application)는 거의 빈 HTML을 받습니다.

┌─────────────────────┐     ┌─────────────────────┐
│        MPA          │     │        SPA          │
├─────────────────────┤     ├─────────────────────┤
│  완성된 HTML 전송   │     │  <div id="root">    │
│  <h1>Title</h1>     │     │                     │
│  <p>Content</p>     │     │  JS로 동적 렌더링   │
│  매번 전체 로드     │     │  한 번만 로드       │
└─────────────────────┘     └─────────────────────┘
<div id="root"></div>

이것만 받아오고, JavaScript로 화면을 동적으로 다시 만드는 거죠.

사용자 경험은 확실히 좋아졌습니다. 페이지 전환이 매끄럽고, 마치 네이티브 앱을 사용하는 것 같은 느낌이죠. 하지만 새로운 문제들이 생겼습니다.

첫 번째는 초기 로딩 시간입니다. 모든 JavaScript 코드를 다운로드하고 실행해야 첫 화면을 볼 수 있으니까요. 두 번째는 SEO 문제입니다. 검색 엔진 봇이 JavaScript를 실행하기 전의 빈 HTML만 보게 되어 콘텐츠를 제대로 인덱싱하지 못했습니다.

SSR의 등장과 해결책

이 문제들을 해결하기 위해 서버사이드 렌더링(SSR)이 등장했습니다. React에서는 renderToString이나 Next.js 같은 프레임워크를 통해 서버에서 미리 HTML을 생성해서 보내주는 방식이죠.

SSR을 사용하면 사용자는 훨씬 빠르게 첫 화면을 볼 수 있습니다. 검색 엔진도 완성된 HTML을 받아서 SEO 문제가 해결되고요. 하지만 여기서 또 다른 문제가 생깁니다.

하이드레이션, 그리고 새로운 성능 병목

서버에서 받은 HTML은 정적입니다. 클릭해도 반응하지 않고, 상태도 변하지 않죠. 이를 인터랙티브하게 만들어주는 과정이 바로 하이드레이션(Hydration)입니다.

서버 렌더링                    클라이언트 하이드레이션
    │                               │
    ▼                               ▼
┌────────────┐                ┌────────────┐
│ 정적 HTML  │  ─────────────▶ │ 정적 HTML  │
│            │                │     +      │
│ 이벤트 ❌   │                │ 이벤트 ✅   │
│ 상태 ❌     │                │ 상태 ✅     │
└────────────┘                └────────────┘

서버사이드에서는 컴포넌트 트리를 다 렌더링한 후 toString()으로 HTML 문자열을 만들어서 보냅니다. 클라이언트에서는 React가 같은 컴포넌트 트리를 다시 객체로 만들고, 서버에서 받은 DOM에 이벤트 리스너를 부착하는 과정을 거칩니다.

// 서버에서는 정적 HTML 생성 (toString())
<button>클릭하세요</button>

// 클라이언트에서 하이드레이션으로 이벤트 리스너 부착
<button onClick={handleClick}>클릭하세요</button>

하이드레이션의 성능 문제

여기서 문제가 생깁니다. 하이드레이션 과정에서 JavaScript가 메인 스레드를 점유하면서 UI가 블로킹되기도 하고, 이벤트가 아직 안 붙어있으니까 모든 이벤트가 다 붙고 나서야 클릭이 되는 거죠. 사용자는 화면은 보이지만 클릭할 때 반응이 없다가 시간이 지나야 버튼들이 동작하는 경험을 하게 됩니다.

하이드레이션 시간
    ▲
    │
80ms├─────────────────────────╱╱
    │                    ╱╱╱
60ms├──────────────╱╱╱╱╱
    │         ╱╱╱╱╱
40ms├────╱╱╱╱╱
    │╱╱╱╱
20ms├
    │
    └────┬────┬────┬────┬────▶
         100  500  1K   5K   요소 개수

저도 렌더링 라이브러리를 만들면서 이 문제를 직접 겪었습니다. SVG를 서버사이드 렌더링으로 조작함과 동시에 SVG에 대한 가상 DOM을 만드는 하이드레이션을 수행한 결과, 1프레임인 16ms에 5배나 넘는 시간이 마운트 동작에 소요되었습니다. 이것저것 최적화를 해봤지만 근본적인 해결은 어려웠죠.

하이드레이션 비용은 첫 화면의 요소 개수에 비례합니다. 아무리 코드를 최적화해도, 첫 화면에 요소가 많을수록 하이드레이션 비용이 커지는 것은 막을 수 없습니다. 뒤에 나오는 방법을 제외하고는 말이죠.

그렇다면 이 문제를 어떻게 해결할 수 있을까요? 여기서 코드 스플릿이 중요한 역할을 하게 됩니다.

Next.js로 배우는 코드 스플릿 실전 가이드

코드 스플릿이란?

하이드레이션 문제를 해결하는 핵심 기법이 바로 코드 스플릿입니다. 모든 JavaScript를 한 번에 다운로드하고 실행하는 대신, 필요한 코드만 필요한 시점에 로드하는 방식이죠.

간단히 말하면 "지금 당장 필요하지 않은 코드는 나중에 불러오자"는 아이디어입니다. 사용자가 실제로 해당 기능을 사용할 때까지 기다리는 거죠.

Next.js의 자동 라우트 코드 스플릿

Next.js를 쓴다면 이미 코드 스플릿을 하고 있습니다. Next.js는 기본적으로 라우트별로 코드를 자동 분리해주거든요.

            main.js (전체 번들)
                  │
            코드 스플릿 적용
                  │
     ┌────────────┼────────────┐
     ▼            ▼            ▼
index.js      about.js    contact.js
 (30KB)       (25KB)       (20KB)
pages/
  index.js        → /_next/static/chunks/pages/index.js
  about.js        → /_next/static/chunks/pages/about.js
  contact.js      → /_next/static/chunks/pages/contact.js

/about 페이지에 접근할 때만 해당 페이지의 JavaScript가 로드됩니다. /contact 페이지를 방문하지 않는 사용자는 그 페이지의 코드를 다운로드할 필요가 없죠.

하지만 이것만으로는 충분하지 않습니다. 하나의 페이지 안에서도 수많은 컴포넌트와 라이브러리들이 한꺼번에 로드되면서 여전히 성능 병목이 발생할 수 있습니다.

dynamic()으로 컴포넌트 레벨 최적화

Next.js의 dynamic() 함수는 React의 lazy()Suspense를 합친 것이라고 보면 됩니다. 특정 컴포넌트를 필요할 때만 로드할 수 있게 해주죠.

import dynamic from 'next/dynamic'

// 차트 라이브러리는 용량이 크니까 나중에 로드
const Chart = dynamic(() => import('../components/Chart'))

// 모달은 사용자가 버튼을 클릭할 때만 필요
const Modal = dynamic(() => import('../components/Modal'))

function Dashboard() {
  const [showModal, setShowModal] = useState(false)
  
  return (
    <div>
      <h1>대시보드</h1>
      <Chart data={chartData} />
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </div>
  )
}

여기서 중요한 점은 하이드레이션 순서입니다. 처음에는 dynamic으로 명시하지 않은 컴포넌트들만 하이드레이션하고, dynamic으로 감싼 컴포넌트들은 나중에 필요할 때 하이드레이션합니다. 하이드레이션을 지연하면, 다른 것들은 미리미리 인터랙션이 가능하게 되죠.

SSR 제어로 더 세밀한 최적화

때로는 특정 컴포넌트를 아예 클라이언트에서만 렌더링하고 싶을 때가 있습니다. 브라우저 API를 사용하는 컴포넌트나, 서버에서 렌더링할 필요가 없는 경우죠.

const ClientOnlyComponent = dynamic(
  () => import('../components/InteractiveWidget'),
  {
    ssr: false,
    loading: () => <div>위젯을 불러오는 중...</div>
  }
)

// 차트 라이브러리는 캔버스니까 SSR에서 렌더링할 필요 없음
const ChartComponent = dynamic(
  () => import('../components/Chart'),
  { ssr: false }
)

ssr: false로 설정하면 서버사이드 렌더링에서는 아예 HTML 태그마저 생략해서 클라이언트에서 다시 그려옵니다. 그러면 네트워크 병목도 줄고, 한 번에 더 적은 코드를 실행시키니까 더 빠르게 동작까지 이어질 수 있죠.

서버 컴포넌트의 등장

Next.js 13부터는 서버 컴포넌트라는 새로운 개념이 등장했습니다. 모든 컴포넌트는 기본적으로 서버에서만 실행되고, JavaScript 번들에 포함되지 않습니다. 더욱더 하이드레이션 과정을 줄여주는 거죠.

서버 컴포넌트는 서버에서 HTML 스니펫으로 렌더링되어 클라이언트에 전달되며, 하이드레이션 대상에서 제외됩니다. React 렌더 트리는 이 위치를 "구멍(hole)"으로 남겨두었다가 해당 HTML 스니펫을 삽입하는 형태로 페이지를 완성합니다.

[Server: RSC Render] --> [HTML snippet] 
                              | 
                              v 
        [React Render Tree]: "구멍" 위치 마련
                              | 
                              v 
            합친 결과 HTML --> 클라이언트 전송 
            (서버컴포넌트는 하이드레이션 대상에서 제외)
// app/page.tsx - 서버 컴포넌트 (기본값)
async function HomePage() {
  const data = await fetch('https://api.example.com/data')
  
  return (
    <div>
      <h1>홈페이지</h1>
      <UserProfile data={data} />
    </div>
  )
}

서버 컴포넌트는 대신 이벤트 핸들러나 useState를 쓸 수 없습니다. JS 파일에 포함 안 되도록 명시하는 거기 때문에, Next.js에서는 서버 컴포넌트에서 useState를 쓰면 하이드레이션이 필요한 컴포넌트라고 판단해서 use client를 붙이라고 하죠. 안 그러면 빌드 실패합니다.

여전히 남는 근본적 한계

지금까지 살펴본 최적화 기법들은 분명 효과적입니다. 하지만 근본적인 문제가 여전히 남아있습니다.

JavaScript 다운로드는 비동기이지만, 하이드레이션 과정에서 서버사이드에서 했던 렌더링을 그대로 반복하면 메인 스레드를 잡아먹습니다. 특히 모바일에서는 더더욱 느리죠.

하이드레이션을 우선하는 것과 우선하지 않는 것들을 개발자가 정하는 것도 귀찮고, 파일 단위라서 만들기도 힘들고, 하나의 컴포넌트 안에서는 모든 JavaScript가 함께 번들링됩니다. 하이드레이션을 나눠도 요소가 많아지고 복잡해질수록 여전히 부담이 됩니다.

그렇다면 이 한계를 어떻게 극복할 수 있을까요? 여기서 완전히 다른 접근법이 등장합니다.

Zero Hydration, 재개가능성(Resumability) - Qwik의 혁신적 접근

기존 접근법의 답답한 현실

Next.js에서 useState 하나 썼다고 전체 컴포넌트가 하이드레이션 대상이 된다거나, dynamic으로 import하려면 해당 컴포넌트를 따로 파일로 빼야 하는 불편함이 있습니다.

서버 컴포넌트로 잘게 쪼개는 것도 어렵죠. 만들다 보면 useState나 이벤트 핸들러 하나씩은 들어간 컴포넌트가 나오거든요. 그렇다고 모든 컴포넌트를 use client로 명시해버리면... 최적화의 의미가 없어집니다.

결국 개발자가 수동으로 "이건 서버에서, 이건 클라이언트에서" 하나하나 결정해야 하는 상황입니다.

대부분의 컴포넌트는 인터랙션이 있지만, 동시에 필요 없다

흥미로운 점이 있습니다. 대부분의 컴포넌트는 인터랙션이 들어가지만, 동시에 대부분의 컴포넌트는 인터랙션이 필요 없습니다.

모든 페이지들의 요소요소들은 다 상호작용할 부분들이 많으면서도, 동시에 유저들은 모든 요소들을 한 페이지에서 눌러보지 않기 때문이죠. 사용자는 보통 페이지의 일부분만 실제로 상호작용합니다.

그렇다면 필요할 때만 그 부분의 JavaScript를 로드하면 되지 않을까요?

Qwik의 혁신: No Hydration과 자동 코드 스플릿

Qwik은 완전히 다른 접근을 합니다. 하이드레이션을 아예 하지 않습니다.

기존 프레임워크들은 서버에서 했던 렌더링을 클라이언트에서 한 번 더 반복하면서 하이드레이션을 수행합니다. 하지만 Qwik은 서버에서 실행을 일시정지하고, 클라이언트에서 실행을 재개합니다.

핵심은 HTML에 모든 정보가 이미 시리얼라이즈되어 있다는 점입니다.

<button on:click="./chunk-c.js#Counter_onClick[0,1]">클릭하세요</button>

이 버튼을 보세요. on:click 속성에 어떤 파일의 어떤 함수를 실행해야 하는지, 심지어 어떤 변수들을 복원해야 하는지([0,1]) 모든 정보가 들어있습니다.

Qwikloader: 1KB의 마법

Qwik이 클라이언트에 보내는 JavaScript는 Qwikloader라는 1KB짜리 스크립트 하나뿐입니다.

<html>
  <body q:base="/build/">
    <button on:click="./myHandler.js#clickHandler">push me</button>
    <script>
      /* Qwikloader */
    </script>
  </body>
</html>

Qwikloader의 역할은 단순합니다:

  1. 전역 이벤트 리스너 하나만 등록
  2. 사용자가 클릭하면 해당 요소에서 on:click 속성 찾기
  3. 속성값을 파싱해서 필요한 청크 파일 다운로드
  4. 해당 함수 실행

실제 동작 원리: 개발자 코드에서 최적화까지

개발자가 이렇게 코드를 작성한다면:

export const Counter = component$((props: { step: number }) => {
  const count = useSignal(0);
 
  return <button onClick$={() => (count.value += props.step || 1)}>{count.value}</button>;
});

Qwik의 Optimizer가 이를 자동으로 여러 청크로 분리합니다:

개발자 코드 (하나의 컴포넌트)
         │
         ▼
   Qwik Optimizer
         │
    ┌────┼────┐
    ▼    ▼    ▼
chunk-a chunk-b chunk-c
(mount) (render) (click)
// chunk-a.js - 컴포넌트 마운트
export const Counter_onMount = (props) => {
  const count = useSignal(0);
  return qrl('./chunk-b.js', 'Counter_onRender', [count, props]);
};

// chunk-b.js - 렌더링
const Counter_onRender = () => {
  const [count, props] = useLexicalScope();
  return (
    <button onClick$={qrl('./chunk-c.js', 'Counter_onClick', [count, props])}>{count.value}</button>
  );
};

// chunk-c.js - 클릭 핸들러
const Counter_onClick = () => {
  const [count, props] = useLexicalScope();
  return (count.value += props.step || 1);
};

결과적으로 생성되는 HTML:

<button q:obj="456, 123" on:click="./chunk-c.js#Counter_onClick[0,1]">0</button>

사용자가 버튼을 클릭하는 순간:

   사용자 클릭
       │
       ▼
 Qwikloader (1KB)
       │
       ▼
on:click 속성 파싱
"./chunk-c.js#Counter_onClick[0,1]"
       │
       ▼
chunk-c.js 다운로드
       │
       ▼
함수 실행 + 상태 복원
  count, props
  1. Qwikloader가 클릭 이벤트를 감지
  2. on:click="./chunk-c.js#Counter_onClick[0,1]" 파싱
  3. chunk-c.js 파일을 동적으로 로드
  4. Counter_onClick 함수 실행
  5. [0,1]로 필요한 변수들(count, props) 복원

혁신의 핵심: 컴포넌트 내에서도 핸들러별 청킹

기존 접근법의 한계였던 "하나의 컴포넌트 안에서는 모든 JavaScript가 함께 번들링"되는 문제를 Qwik은 근본적으로 해결합니다.

컴포넌트 내에서 이벤트 핸들러마저도 알아서 Optimizer가 다른 번들로 분리합니다. 개발자는 신경 쓸 필요 없이 평범하게 코드를 작성하면, 프레임워크가 알아서 최적의 코드 스플릿을 만들어줍니다.

사용자가 실제로 상호작용하는 그 순간에만 해당 JavaScript가 로드되니, 메인 스레드 블로킹도 없고 인터랙션 지연도 없습니다.

이것이 바로 Zero Hydration이자 재개가능성(Resumability)의 힘입니다. 서버에서 일시정지된 실행이 클라이언트에서 필요한 순간에 정확히 재개되는 것이죠.

앞으로 개발자들은 더욱 게을러져도 됩니다

Qwik을 보면서 정말 감탄했습니다. 하이드레이션이라는 근본적인 문제를 아예 다른 관점에서 해결해버린 접근법이 인상적이었어요.

┌─────────────────┐    ┌─────────────────┐
│  기존 방식      │    │  Qwik 방식     │
├─────────────────┤    ├─────────────────┤
│ 전체 JS: 500KB  │    │ Qwikloader: 1KB │
│ 하이드레이션 ✓  │    │ 하이드레이션 ✗  │
│ 초기 로딩 느림  │    │ 초기 로딩 빠름  │
└─────────────────┘    └─────────────────┘

기존 프레임워크들이 "어떻게 하면 하이드레이션을 더 효율적으로 할까?"를 고민했다면, Qwik은 "하이드레이션을 왜 해야 하지?"라는 질문부터 시작했습니다. 이런 근본적인 사고의 전환이 혁신을 만들어내는 것 같습니다.

프레임워크가 담당하는 최적화

Svelte가 "리렌더링을 왜 개발자가 신경 써야 해?"라고 하면서 등장했듯이, Qwik은 "코드 스플릿을 왜 개발자가 생각해야 해?"라고 묻고 있습니다.

어떻게 하면 서버 컴포넌트로 잘게 더 쪼개볼 수 있을지, "use client"를 어떻게 하면 덜 쓸 수 있을까, 어떻게 파일을 분리해야 할지, 어떤 컴포넌트를 dynamic으로 감쌀지 같은 복잡한 결정들을 개발자가 매번 고민해야 하는 것은 피곤한 일이죠.

프로젝트가 아무리 커져도 O(1)의 속도를 유지할 수 있도록 프레임워크가 담당하는 거죠. 개발자가 컴포넌트를 100개 만들든 1000개 만들든, 사용자가 실제로 상호작용하는 부분만 로드되니까 초기 성능은 일정하게 유지됩니다.

성능 최적화를 프레임워크가 담당하니까 개발자의 인지 부하와 초기 지식 습득 난이도가 줄어듭니다. 복잡한 최적화 지식들을 프레임워크에게 위임하면서 좀 더 제품에 집중할 수 있는 거겠죠.

저도 배우고 있습니다

사실 커뮤니티에서 어느 한 분이 소개해줘서 Qwik이라는 것을 알게 되었습니다. 이런 글을 쓰면서도 저 역시 계속 배우고 있는 중이에요.

Qwik도 다른 프레임워크와 마찬가지로 리렌더링을 신경 쓰지 않도록 설계되었더라고요. 성능 최적화라는 게 결국 개발자의 인지 부하를 줄이면서도 사용자 경험을 개선하는 방향으로 발전하고 있다는 걸 느낍니다.

SPA에서 시작해서 SSR, 하이드레이션, 코드 스플릿, 서버 컴포넌트, 그리고 이제는 재개가능성(resumability)까지. 각 단계마다 "개발자가 신경 써야 할 것들"을 하나씩 프레임워크가 가져가는 과정이었다고 생각합니다.

프레임워크들이 점점 더 똑똑해져서, 우리는 비즈니스 로직과 사용자 가치 창출에만 집중할 수 있게 되는 것 같아요. 앞으로도 이런 흐름은 계속될 것 같고, 저도 그런 변화를 따라가면서 더 나은 사용자 경험을 만드는 개발자가 되려고 노력하고 있습니다.

이력서멘토링 신청받습니다

신청란: https://fe-resume.coach?utm_source=velog&utm_medium=blog&utm_campaign=codesplit-qwik-over-nextjs

profile
기부하면 코드 드려요

0개의 댓글