문서 읽는 데 50분 · E3

E-3: 모듈 시스템 심화 & Vite — 여러 파일을 하나로 묶고, 빌드 도구로 배포까지

목차 31
전체 34강 중 27강 · HTML·CSS·JS
난이도 · 입문

안녕하세요, 홍순구 튜터입니다. 지난 시간(E-2)에 우리는 실패를 구조화했어요. ApiError를 만들어 상태 코드·주소·사용자 메시지를 담아 던지고, 받는 쪽은 try-catch로 안전하게 받았죠. 그때 제가 마무리에서 두 가지를 살짝 흘렸어요.

하나는 "코드를 네트워크로 받아오다 실패할 수도 있는데, 그때도 오늘 배운 try-catch로 똑같이 감싼다"였고, 다른 하나는 "배포할 때 코드를 잘게 압축하면 stack이 알아볼 수 없게 되는데, 다음 시간 빌드 도구가 그걸 풀어준다"였어요. 오늘 그 두 약속을 모두 회수해요.

우리는 C-5부터 import / export로 코드를 파일별로 나눠 써왔어요. 그 모듈 시스템을 오늘 한 단계 더 깊이 들여다보고, Vite라는 빌드 도구로 우리 인스타그램 프로젝트를 옮겨요.

텍스트
   지금까지 (Live Server)               오늘 (Vite 빌드 도구)
   ┌─────────────────────────────┐        ┌───────────────────────────────┐
   │ js 파일 여러 개를 그대로    │        │ 여러 파일을 하나로 묶고       │
   │ 브라우저가 직접 읽음        │  ──   │ 압축하고, 안 쓰는 코드는 빼고 │
   │ 새로고침해서 확인           │        │ 소스맵으로 원본을 추적        │
   └─────────────────────────────┘        └───────────────────────────────┘

💡 오늘 수업의 핵심 — "여러 모듈을 하나로 묶어주는 번들러의 필요성을 이해하고, Vite로 개발 서버(dev)와 배포 묶음(build)을 만든다. 동적 import()로 필요할 때만 코드를 불러오고, 소스맵으로 압축된 코드를 추적하고, Tree Shaking으로 안 쓰는 코드를 덜어낸다." 🎯

🎯 학습 목표

  • 우리가 C-5부터 써온 import/exportESM(ECMAScript Modules) 이라는 표준임을 알고, 모듈이 없던 시절과 비교해 무엇이 좋아졌는지 이해합니다.
  • ESMCommonJS 두 모듈 방식의 차이를 알고, 왜 ESM의 "정적인 구조"가 빌드 도구에 유리한지 이해합니다.
  • 동적 import()필요할 때만 모듈을 불러오고, 실패는 try-catch로 처리합니다.
  • 번들러(bundler) 가 왜 필요한지, 작은 파일 여러 개를 그대로 배포할 때의 문제를 이해합니다.
  • Vite를 우리 프로젝트에 얹어 개발 서버(npm run dev)와 배포 묶음(npm run build)을 만듭니다.
  • 소스맵(source map) 으로 압축된 코드에서도 원본 파일·줄 번호를 추적합니다.
  • Tree Shaking 으로 import하지 않은 코드가 배포 묶음에서 빠지는 것을 확인합니다.

Step 1: "우리가 이미 모듈을 쓰고 있었다고요?"

새로운 걸 배우기 전에, 우리가 이미 하고 있던 걸 다시 봐요. C-5를 떠올려보세요. 그때 우리는 거대한 main.js 한 덩어리를 data.js(데이터) · format.js(꾸미기) · main.js(조립)로 쪼갰어요. 그리고 import / export로 서로 연결했죠. 그게 바로 모듈(module) 이에요. 코드를 기능별 파일로 나누고, 필요한 것만 가져다 쓰는 방식이요.

모듈이 없던 시절은 어땠을까

지금은 당연해 보이지만, 예전 브라우저에는 import가 없었어요. 그 시절 자바스크립트는 파일을 <script> 태그로 하나씩 줄줄이 불러왔어요. 그런데 이 방식엔 두 가지 큰 골칫거리가 있었어요.

텍스트
  모듈 이전 (전역 변수 시대)              ESM (지금 우리)
  ┌───────────────────────────┐         ┌──────────────────────────┐
  │ <script src="a.js">       │         │ import { x }             │
  │ <script src="b.js">       │         │   from "./a.js";         │
  │ <script src="c.js">       │  ──    │                          │
  │ • 순서 틀리면 깨짐        │         │ • 필요한 것만 콕 집어    │
  │ • 전역 변수 충돌 위험     │         │ • 누가 누굴 쓰는지 명확  │
  └───────────────────────────┘         └──────────────────────────┘

첫째, 순서에 목숨을 걸어야 했어요. b.jsa.js의 함수를 쓰는데 <script> 순서가 뒤바뀌면, 아직 없는 함수를 부르다 깨졌어요. 둘째, 모든 변수가 전역(global) 으로 섞였어요. a.jsuserb.jsuser가 같은 이름이면 서로 덮어써 버렸죠. 파일이 수십 개가 되면 누가 누구를 망가뜨리는지 추적이 거의 불가능했어요.

ESM은 이걸 깔끔하게 풀었어요. 파일마다 export로 "이건 밖에서 써도 돼"라고 내놓고, 쓰는 쪽은 import로 "나는 이 파일에서 이것만 가져올게"라고 콕 집어요. 순서는 import가 알아서 맞추고, 변수는 파일 안에 숨어 충돌하지 않아요.

지금 우리 feed.js가 모듈을 어떻게 엮고 있나

우리 피드 화면의 출발점은 feed.js예요. 맨 위를 보면 일곱 줄의 import가 있어요. 이게 곧 "피드 화면을 그리려면 이 파일들이 필요해"라는 지도예요.

JavaScript
// instagram-clone-frontend/js/feed.js
import { fetchPosts, createComment } from "./api.js";
import { ApiError } from "./api-error.js";
import { toggleLike } from "./like.js";
import { addComment, removeComment } from "./comment.js";
import { setupInfiniteScroll } from "./infinite-scroll.js";
import { PostCard, AdPostCard } from "./post-card.js";
import { ThemeToggle } from "./theme-toggle.js";

그리고 api.js는 또 자기 나름대로 auth.jsapi-error.jsimport해요. 이렇게 import가 import를 부르면서 파일들이 나무처럼 이어져요. 이걸 모듈 그래프(module graph) 라고 불러요.

텍스트
  feed.html
     └─ <script type="module" src="js/feed.js">
           feed.js ─┬─ api.js ─┬─ auth.js
                    │          └─ api-error.js
                    ├─ like.js
                    ├─ comment.js
                    ├─ infinite-scroll.js
                    ├─ post-card.js
                    └─ theme-toggle.js

지금은 이 그래프를 브라우저가 직접 따라가요. C-5에서 배웠듯이 <script type="module">을 만나면, 브라우저가 feed.js를 읽고, 거기서 만난 import를 보고 api.js를 또 받아오고… 이렇게 한 파일씩 네트워크로 받아가며 그래프를 펼쳐요. 오늘 이 "한 파일씩 받아가는" 방식이 배포할 때 왜 문제가 되는지, 그리고 Vite가 그걸 어떻게 푸는지를 차근차근 볼 거예요.

Step 2: "import에 두 종류가 있다고요? — ESM vs CommonJS"

우리가 쓰는 import / export에는 정식 이름이 있어요. ESM, 풀어 쓰면 ECMAScript Modules예요. ECMAScript는 자바스크립트의 공식 표준 이름이고, 그 표준이 정한 모듈 방식이라 ESM이에요. 브라우저가 알아듣는 모듈은 이거예요.

그런데 자바스크립트 세계엔 모듈 방식이 하나 더 있어요. CommonJS예요. 이름이 둘이라니, 왜 이렇게 됐을까요?

왜 두 가지가 생겼나 — 짧은 역사

순서가 이래요. 브라우저 밖에서 자바스크립트를 돌리는 도구(우리가 json-server를 켤 때 쓰는 그 런타임)가 먼저 세상에 나왔는데, 그때는 아직 자바스크립트 표준에 모듈 기능이 없었어요. 그래서 그 도구는 자기만의 모듈 방식을 만들어 썼고, 그게 CommonJS예요. require()로 가져오고 module.exports로 내보내요.

한참 뒤에야 자바스크립트 표준 자체가 모듈을 정식으로 품었어요. 그게 ESM이에요. 그래서 지금은 브라우저·표준은 ESM, 오래된 도구 생태계엔 CommonJS가 함께 남아 있어요.

텍스트
                 ESM (우리가 쓰는 표준)        CommonJS (오래된 방식)
  가져오기        import { x } from "./a.js"    const x = require("./a.js")
  내보내기        export function x() {}        module.exports = { x }
  주로 쓰는 곳     브라우저 · 최신 표준           오래된 서버 도구

우리 교재의 코드는 전부 ESM이에요. CommonJS의 require는 "이런 게 있구나" 정도로만 알아두면 충분해요. 우리가 직접 쓸 일은 없어요.

진짜 중요한 차이 — "미리 알 수 있느냐"

겉모습 말고, 오늘 수업에서 진짜 의미 있는 차이가 하나 있어요. ESM의 import파일 맨 위에 고정으로 적어요. 함수 안에서 조건에 따라 가져오거나 하지 않아요. 그래서 코드를 실행하기 전에 "이 파일은 무엇을 가져오고 무엇을 내보내는지"를 그냥 읽기만 해도 전부 알 수 있어요.

반면 CommonJS의 require()는 함수처럼 코드 중간 아무 데서나 부를 수 있어요. if (조건) { require(...) }처럼요. 그러면 실제로 돌려보기 전엔 무엇을 가져올지 확실히 알기 어려워요.

이 "미리 다 알 수 있다"는 ESM의 성질이 왜 중요하냐면, 빌드 도구가 코드를 실행하지 않고도 분석할 수 있게 해주거든요. 누가 무엇을 쓰는지 미리 다 보이니까, "이건 아무도 안 쓰네? 그럼 빼자"가 가능해져요. 이게 Step 8에서 만날 Tree Shaking의 바탕이에요. 지금은 "ESM은 구조가 고정이라 미리 분석된다" 한 문장만 기억하고 넘어가요.

Step 3: "필요할 때만 불러오기 — 동적 import()"

지금까지 우리 import는 전부 파일 맨 위에 있었어요. 화면이 켜지는 순간 일곱 개 모듈이 무조건 다 따라 들어왔죠. 그런데 생각해보면, 어떤 기능은 사용자가 영영 안 쓸 수도 있어요. 그런 코드까지 첫 화면에서 다 받아오면 그만큼 느려져요.

그래서 자바스크립트엔 필요한 순간에 불러오는 방법이 따로 있어요. 함수처럼 생긴 import()예요. 맨 위의 정적 import와 구분해서 동적 import() 라고 불러요.

텍스트
  정적 import (맨 위, 항상)            동적 import() (필요할 때, Promise)
  import { x } from "./a.js";        const m = await import("./a.js");
  • 화면 켜질 때 무조건 로드          • 그 줄을 만나는 순간에 로드
  • 파일 맨 위에만                    • 함수 안 아무 데서나

핵심은 동적 import()Promise를 돌려준다는 거예요. 모듈을 네트워크로 받아오는 데 시간이 걸리니까, C-6에서 배운 그 Promise 그대로예요. 그래서 await로 기다렸다가 꺼내 써요.

댓글창에 이모지 고르개를 달아보자

우리 댓글 폼에 이모지 버튼(😊)을 하나 달았어요. 이 버튼을 누르면 이모지를 고르는 작은 패널이 떠요. 그런데 이 이모지 고르개는 누군가 버튼을 누르기 전엔 필요 없어요. 그래서 첫 화면엔 빼두고, 버튼을 누르는 그 순간에야 받아오게 만들었어요.

먼저 댓글 폼에 버튼을 더했어요. PostCard가 그리는 댓글 폼 안이에요.

HTML
<!-- instagram-clone-frontend/js/post-card.js — 댓글 폼 템플릿 -->
<form class="comment-form">
  <textarea class="comment-input" rows="1" placeholder="댓글 달기..." aria-label="댓글 입력"></textarea>
  <button type="button" class="comment-emoji" aria-label="이모지 넣기">😊</button>
  <button type="submit">게시</button>
</form>

그리고 feed.js의 위임 클릭 핸들러에 이 버튼을 처리하는 가지를 더했어요. 바로 여기가 오늘의 주인공이에요.

JavaScript
// instagram-clone-frontend/js/feed.js — 댓글 위임 클릭 핸들러 안
const emojiBtn = event.target.closest(".comment-emoji");
if (emojiBtn) {
  const input = emojiBtn.closest(".comment-form").querySelector(".comment-input");
  try {
    // import()는 Promise 를 돌려줘요 — 모듈이 도착할 때까지 기다렸다(await) 꺼내 써요.
    const { openEmojiPicker } = await import("./emoji-picker.js");
    openEmojiPicker(emojiBtn, input);
  } catch (error) {
    // 네트워크로 받아오다 실패할 수도 있어요 — 비동기가 있는 곳엔 에러 처리가 따라다녀요.
    showToast("이모지 고르개를 불러오지 못했어요.");
    console.error("emoji-picker 로딩 실패:", error);
  }
}

await import("./emoji-picker.js")가 모듈을 받아온 뒤, 거기서 openEmojiPicker 함수를 꺼내 바로 써요. 이 줄을 만나기 전까지 emoji-picker.js는 받아오지 않아요. 사용자가 😊를 처음 누르는 순간에야 도착해요.

여기서 지난 시간의 약속을 회수해요. 모듈을 네트워크로 받아오니까, 신호가 끊기면 실패할 수 있어요. 그래서 동적 import()try-catch로 감쌌어요. E-2에서 익힌 그 감각 그대로예요. "비동기가 있는 곳엔 에러 처리가 따라다닌다"는 말, 여기서도 똑같이 통해요.

⚠️ 핸들러 맨 윗줄을 보면 feed.addEventListener("click", async (event) => {처럼 async가 붙어 있어요. 안에서 await를 쓰려면 그 함수가 async여야 하기 때문이에요. 이모지 가지를 더하면서 기존 클릭 핸들러를 async로 바꿨어요.

🙋 학생 질문 — "튜터님, 그냥 맨 위에서 정적 import 하면 안 되나요? 뭐가 그렇게 이득이죠?"

좋은 질문이에요. 지금 이모지 고르개는 작아서 차이가 잘 안 느껴질 수 있어요. 하지만 이게 만약 큰 기능이라면 이야기가 달라져요. 예를 들어 사진 편집기, 동영상 플레이어, 지도처럼 코드가 무거운 기능이요.

정적 import로 맨 위에 적으면, 그 무거운 코드가 첫 화면이 뜨기도 전에 전부 따라 들어와요. 사용자가 사진 편집을 쓸지 안 쓸지도 모르는데 말이죠. 동적 import()로 미루면, 첫 화면은 가벼운 코드만으로 빠르게 뜨고, 무거운 기능은 사용자가 실제로 누를 때 받아와요.

지금은 "필요할 때만 불러오는 방법이 있다"는 감각만 잡아두면 충분해요. 이게 Step 4에서 만날 번들러와 만나면 코드 스플리팅(code splitting) 이라는 멋진 효과가 되는데, 거기서 다시 이야기할게요.

Step 4: "작은 파일 여러 개의 함정 — 번들러는 왜 필요한가"

이제 오늘의 큰 그림으로 가요. 우리 피드 화면은 모듈 그래프(Step 1)를 따라 파일이 여덟 개쯤 이어져 있어요. 지금까지는 브라우저가 이걸 한 파일씩 받아왔어요. 개발할 땐 별 문제가 없어요. 내 컴퓨터 안이라 빠르니까요.

문제는 배포예요. 진짜 사용자가 멀리 떨어진 서버에서 우리 사이트를 열면, 브라우저는 파일 하나를 받고 → 그 안의 import를 읽고 → 다음 파일을 또 요청하고 → 그걸 받아 또 읽고… 이렇게 줄줄이 이어가요. 파일이 많을수록 이 왕복이 쌓여 느려져요.

텍스트
  번들 전: 브라우저가 파일마다 따로 왕복
  feed.js ─ (받고 분석) ─ api.js ─ (받고) ─ auth.js ─ ...
            요청            요청           요청       ... 요청 

  번들 후: 미리 하나로 합쳐 한 번에
  feed-abc123.js  ── 요청  (끝)

여기서 번들러(bundler) 가 등장해요. 번들(bundle)은 "묶음"이라는 뜻이에요. 번들러는 흩어진 모듈들을 미리 따라가며 하나(또는 몇 개)의 파일로 합쳐 줘요. 그러면 브라우저는 왕복을 여러 번 할 필요 없이 합쳐진 파일을 한 번에 받아요.

번들러가 하는 일은 합치기만이 아니에요. 합치는 김에 이런 것도 해줘요.

  • 압축(minify): 공백·줄바꿈·긴 변수 이름을 줄여 파일 크기를 작게 만들어요.
  • 안 쓰는 코드 제거(Tree Shaking): export했지만 아무도 import 안 한 코드는 묶음에서 빼요. (Step 8)
  • 필요할 때만 쪼개기(code splitting): Step 3의 동적 import()로 부른 모듈은 따로 떼어 둬요. 첫 묶음엔 안 넣고, 진짜 필요할 때 그 조각만 받아오게요.

마지막 항목이 바로 Step 3과 이어져요. 우리가 이모지 고르개를 동적 import()로 부른 덕분에, 번들러는 그걸 첫 화면 묶음에서 떼어내 별도 조각으로 만들어요. 그래서 첫 화면은 그만큼 가벼워져요.

자, 그러면 이 똑똑한 번들러를 우리가 직접 손으로 만들어야 할까요? 다행히 아니에요. 잘 만들어진 도구가 있어요. 바로 Vite예요. 다음 Step에서 만나요.

Step 5: "Vite 만나기 — 우리 프로젝트에 빌드 도구 얹기"

Vite(비트라고 읽어요)는 요즘 가장 널리 쓰이는 프론트엔드 빌드 도구예요. 방금 이야기한 번들러를 품고 있으면서, 개발할 때 쓰는 빠른 서버까지 함께 줘요. 우리 인스타그램 프로젝트를 Vite로 옮겨볼게요.

npm — 도구를 받아오는 창구

지금까지 우리는 D-3에서 json-server를 쓸 때 말고는 외부 도구를 거의 안 썼어요. Vite는 우리가 직접 짤 수 없는 도구라, 만들어진 걸 받아와야 해요. 그 창구가 npm이에요. (Node 생태계의 패키지 관리자예요. json-server를 켤 때 쓰던 그 도구 묶음이에요.)

먼저 우리 프로젝트가 어떤 도구를 쓰는지 적어두는 명세서, package.json을 만들어요.

JSON
// instagram-clone-frontend/package.json
{
  "name": "instagram-clone-frontend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "mock": "json-server mock/db.json --port 3001"
  },
  "devDependencies": {
    "vite": "^8.0.16"
  }
}

scripts는 "이 이름으로 부르면 이 명령을 실행해줘"라는 단축키 모음이에요. npm run dev라고 치면 vite가 돌아가는 식이에요. "type": "module"은 "이 프로젝트는 ESM을 쓴다"는 선언이고요(우리가 줄곧 import/export를 써왔으니 당연하죠).

그다음 Vite를 실제로 받아와요. 터미널에서 한 줄이면 돼요.

Bash
npm install -D vite

이러면 node_modules라는 폴더에 Vite와 그게 필요로 하는 부품들이 내려받아져요. -D는 "개발할 때만 쓰는 도구"라는 표시예요(배포 결과물엔 Vite 자체는 안 들어가요).

vite.config.js — Vite에게 우리 프로젝트를 소개하기

Vite는 기본적으로 index.html 하나를 출발점으로 봐요. 그런데 우리 프로젝트는 페이지가 여러 개예요(로그인 index.html, 피드 feed.html, 프로필 profile.html). 그래서 "이 HTML들이 각각 출발점이야"라고 알려주는 설정 파일을 둬요.

JavaScript
// instagram-clone-frontend/vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // 압축된 코드에서도 원본 파일·줄 번호를 추적하도록 소스맵을 켜요.
    sourcemap: true,
    rollupOptions: {
      input: {
        main: 'index.html',
        feed: 'feed.html',
        profile: 'profile.html',
      },
    },
  },
});

input에 세 페이지를 적어, "이 셋을 각각 묶어줘"라고 부탁했어요. sourcemap: true는 잠시 뒤 Step 7에서 진가를 발휘할 설정이에요(압축된 코드를 원본으로 되짚는 지도를 함께 만들어줘요).

여기서 반가운 사실 하나. 우리 프로젝트는 C-5부터 줄곧 ESM(import/export)으로 짜왔고, HTML도 이미 <script type="module">로 모듈을 불러오고 있었어요. 그래서 Vite로 옮기는 데 고칠 게 거의 없었어요. 설정 파일 하나만 두면 끝이에요. 평소에 모듈을 제대로 써온 보람이 여기서 나와요.

Step 6: "npm run dev — 개발 서버와 즉시 반영(HMR)"

설정을 마쳤으니 개발 서버를 켜요. 터미널에서 한 줄이에요.

Bash
npm run dev

그러면 Vite가 주소를 하나 알려줘요. http://localhost:5173예요. 이 주소를 브라우저로 열면 우리 사이트가 떠요. 지금까지 쓰던 Live Server를 Vite의 개발 서버가 대신하는 거예요.

⚠️ 우리는 D-3부터 데이터를 json-server(포트 3001)에서 받아왔죠. 그건 그대로예요. 터미널 하나에선 npm run dev(우리 사이트, 5173), 다른 하나에선 npm run mock(가짜 API, 3001)을 함께 켜두면 돼요.

Live Server와 뭐가 다를까 — HMR

겉보기엔 비슷한데, 개발 경험에서 큰 차이가 하나 있어요. HMR, 풀어 쓰면 Hot Module Replacement(뜨거운 모듈 교체)예요.

Live Server는 파일을 저장하면 페이지 전체를 새로고침했어요. 그래서 입력하던 내용이나 열어둔 상태가 날아갔죠. Vite의 HMR은 바뀐 그 모듈만 콕 집어 화면에 갈아끼워요. 전체 새로고침 없이요. CSS 한 줄을 고치면, 화면은 그대로인 채 색만 스르륵 바뀌는 식이에요. 작업하다 보면 이 차이가 생각보다 크게 느껴져요.

텍스트
  Live Server            Vite (HMR)
  파일 저장               파일 저장
                           
  페이지 전체 새로고침      바뀐 모듈만 교체
  (상태 날아감)            (상태 유지, 깜빡임 없음)

이 개발 서버는 우리가 쓴 코드를 묶지 않고 그대로 보여줘요. 그래서 브라우저 개발자 도구로 열어보면 js/feed.js가 우리가 쓴 모습 그대로 보여요. 개발 중엔 원본 그대로가 디버깅하기 편하니까요. 진짜로 합치고 압축하는 건 배포할 때예요. 그게 다음 Step이에요.

Step 7: "npm run build — 배포용 묶음과 소스맵"

개발이 끝나고 진짜 사용자에게 내보낼 차례가 되면, 배포용 묶음을 만들어요.

Bash
npm run build

그러면 Vite가 모듈 그래프를 전부 따라가 합치고, 압축하고, dist라는 폴더에 결과물을 만들어요. dist는 distribution(배포)의 줄임말이에요. 우리 프로젝트에서 만들어진 결과를 그려보면 이래요.

텍스트
  src (우리가 쓴 코드)              dist (배포용 묶음)
  js/feed.js  ┐                   assets/feed-CeR4U_ri.js      8개 모듈을 1개로 (압축)
  js/api.js   │                   assets/emoji-picker-*.js     동적 import 조각은 따로
  js/auth.js  ├─ vite build ─    assets/responsive-*.css      CSS 6개를 1개로
  css/*.css(6)┘                   feed.html  (script·link 자동 교체)
                                   assets/*.js.map              원본 추적용 지도

눈여겨볼 게 많아요. 첫째, feed.js가 import하던 여덟 개 모듈이 하나의 파일로 합쳐졌어요. 여섯 개였던 CSS도 하나로 묶였고요. 둘째, 파일 이름에 feed-CeR4U_ri.js처럼 알 수 없는 글자가 붙었어요. 이건 내용이 바뀔 때마다 달라지는 표식이에요(캐시 때문인데, 자세한 건 F 카테고리 성능 편에서 다뤄요). 셋째, feed.html 안의 <script>·<link> 주소도 이 새 파일 이름으로 자동으로 바뀌어 있어요. 우리가 손댈 필요 없이요.

그리고 Step 3에서 동적 import()로 불렀던 이모지 고르개는 emoji-picker-*.js라는 별도 조각으로 떨어져 나왔어요. 첫 화면 묶음(feed-*.js)엔 안 들어가 있어요. 이게 바로 Step 4에서 예고한 코드 스플리팅이에요. 사용자가 😊를 누를 때에야 이 조각이 따로 도착해요.

압축된 코드와 소스맵 — 지난 시간의 약속 회수

이제 지난 시간(E-2)에 흘린 두 번째 약속을 회수해요. 배포용으로 압축된 feed-*.js를 열어보면, 우리가 정성껏 지은 이름들이 이렇게 뭉개져 있어요.

JavaScript
// 압축된 배포 묶음의 일부 — 변수 이름이 한 글자로 줄어 있어요
var n=class extends Error{constructor(e,t,n){super(`API ${e} 응답 — ${t}`),this.name=`ApiError`,...

ApiError의 그 깔끔한 코드가 e, t, n 같은 한 글자로 변했죠. 파일을 작게 만들려고 일부러 이렇게 줄인 거예요. 그런데 E-2에서 걱정했듯이, 이 상태에서 에러가 나면 stack(에러가 난 위치 추적표)이 이 뭉개진 코드를 가리켜요. "n 파일 1번째 줄에서 났어요"라고 하면 어디가 어딘지 알 수가 없죠.

그래서 우리가 vite.config.js에 켜둔 sourcemap: true가 일을 해요. 빌드할 때 feed-*.js.map이라는 지도 파일을 함께 만들어요. 이 지도가 "압축된 코드의 이 위치는 사실 원본 js/api-error.js의 몇 번째 줄이야"를 전부 기록해둬요. 그래서 개발자 도구는 압축된 코드를 보면서도 원본 파일·줄 번호로 에러 위치를 되짚어 보여줄 수 있어요. E-2에서 "압축하면 stack을 못 알아본다"던 걱정이 소스맵으로 풀린 거예요.

npm run preview — 배포 묶음을 미리 확인하기

dist 폴더가 진짜 잘 도는지 배포 전에 확인하고 싶을 때가 있어요. 그때 쓰는 명령이에요.

Bash
npm run preview

이건 개발 서버(dev)와 달리, 방금 빌드한 dist 묶음을 그대로 띄워줘요. 사용자가 받을 모습 그대로 미리 보는 거예요. 여기서 화면이 잘 돌고 이모지 조각도 잘 도착하면, 배포 준비가 끝난 거예요.

Step 8: "안 쓰는 코드는 떨어진다 — Tree Shaking"

마지막으로, Step 2에서 심어둔 씨앗을 거둬요. ESM은 구조가 고정이라 "누가 무엇을 쓰는지" 미리 다 분석된다고 했죠. 그 덕분에 번들러는 아무도 안 쓰는 코드를 찾아낼 수 있어요. 그리고 배포 묶음에서 그걸 빼버려요. 이걸 Tree Shaking(나무 흔들기)이라고 불러요. 나무를 흔들면 죽은 잎이 떨어지듯, 안 쓰는 코드가 떨어진다는 비유예요.

우리 util.js로 직접 확인하기

D-2에서 만든 util.js를 떠올려보세요. 디바운스와 스로틀, 두 함수를 export해뒀어요.

JavaScript
// instagram-clone-frontend/js/util.js
export function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

export function throttle(fn, delay) {
  let waiting = false;
  return (...args) => {
    if (waiting) return;
    fn(...args);
    waiting = true;
    setTimeout(() => { waiting = false; }, delay);
  };
}

그런데 우리 이모지 고르개(emoji-picker.js)는 이 중 debounce 가져다 써요. 검색창에 글자를 칠 때마다 거르지 않고, 다 치고 잠깐 멈춘 뒤에 한 번만 거르려고요.

JavaScript
// instagram-clone-frontend/js/emoji-picker.js (발췌)
import { debounce } from "./util.js";

export function openEmojiPicker(anchorButton, targetInput) {
  // ... 패널을 만들고 이모지 격자를 그려요 ...

  // 검색창 입력은 디바운스 — 다 치고 손을 뗀 뒤에야 한 번 걸러요.
  search.oninput = debounce(() => renderGrid(grid, targetInput, search.value), 200);

  // ... anchor 버튼 근처에 띄워요 ...
}

debounceimport하고 throttle은 어디서도 안 써요. 그러면 Vite는 빌드할 때 "throttle은 아무도 안 쓰네?" 하고 배포 묶음에서 빼버려요. 빌드해서 결과물을 열어보면, debounce의 코드는 들어 있는데 throttle의 코드는 흔적도 없어요. import 하나 차이로 안 쓰는 함수가 깔끔하게 떨어진 거예요.

이게 가능한 건 우리가 ESM을 썼고, util.js가 그냥 함수만 내보내는 깔끔한 모듈이기 때문이에요. 만약 모듈이 불러오는 것만으로 화면을 건드리는 동작(부수 효과)을 한다면, 번들러는 "혹시 필요할지 몰라" 하고 함부로 못 빼요. 그래서 "안 쓰는 건 빠진다"는 효과를 제대로 누리려면, 모듈을 부수 효과 없이 깔끔하게 짜는 습관이 중요해요.


마무리

오늘 우리는 모듈 시스템을 한 단계 깊이 들여다보고, 빌드 도구 Vite로 우리 프로젝트를 옮겼어요.

  • ESM: 우리가 C-5부터 써온 import/export의 정식 이름. 모듈 이전의 전역 변수·순서 문제를 풀어줬어요.
  • ESM vs CommonJS: 브라우저·표준은 ESM, 오래된 도구는 CommonJS. ESM은 구조가 고정이라 미리 분석돼요.
  • 동적 import(): 필요할 때만 불러오기. Promise라서 await로 받고, 실패는 try-catch로(E-2 회수).
  • 번들러: 흩어진 모듈을 합치고 압축하고 안 쓰는 코드를 덜어내요.
  • Vite: npm run dev(개발 서버 + HMR) · npm run build(배포 묶음) · npm run preview(미리 보기).
  • 소스맵: 압축된 코드를 원본 파일·줄로 되짚어요(E-2 회수).
  • Tree Shaking: import 안 한 코드는 배포 묶음에서 떨어져요.

지난 시간 두 약속 — 동적 import() 실패도 try-catch로, 압축된 코드도 소스맵으로 — 둘 다 오늘 지켰어요. 에러를 다루던 감각과 모듈을 다루는 감각이 Vite 위에서 한데 만났어요.

다음 시간 예고

다음 시간(F-1)부터는 웹 성능으로 넘어가요. 오늘 우리가 만든 배포 묶음이 얼마나 빠른지를 숫자로 측정하기 시작해요. 화면이 얼마 만에 뜨는지(LCP), 클릭에 얼마나 빨리 반응하는지(INP) 같은 지표를 Lighthouse라는 도구로 재요. 오늘 번들 크기를 줄인 게 그 점수와 직접 이어져요. 그리고 그다음(F-2)엔 오늘 맛본 코드 스플리팅을 본격적으로 써서, 무거운 조각을 잘게 쪼개 더 빠르게 만들어요.

그리고 한 가지만 귀띔할게요. 오늘 만난 Vite는 다음 과목에서 React라는 도구와 함께 다시 만나요. 모듈과 빌드 도구를 제대로 익혀둔 오늘이 그때의 든든한 바탕이 돼요.


과제

오늘은 Vite를 실제로 켜고 굴려보는 게 핵심이에요. 터미널 둘을 준비하세요. 하나는 npm run dev(우리 사이트), 다른 하나는 npm run mock(가짜 API, 포트 3001)이에요.

[구현] 동적 import로 모듈을 하나 더 미뤄보기

오늘 이모지 고르개를 동적 import()로 불렀죠. 같은 방식으로, 사용자가 자주 안 쓰는 기능을 하나 골라 똑같이 미뤄보세요.

  • 예를 들어 게시물의 "공유" 버튼을 눌렀을 때에야 공유 관련 코드를 받아오게 만들어보세요. 새 모듈(예: js/share-card.js)에 함수 하나를 export하고, feed.js에서 버튼을 누를 때 await import("./share-card.js")로 불러오면 돼요.
  • 반드시 try-catch로 감싸세요. 모듈을 받아오다 실패할 수 있다는 걸 잊지 마세요.
  • npm run build로 빌드한 뒤 dist/assets 폴더를 열어보세요. 여러분이 미룬 그 모듈이 별도 조각 파일로 떨어져 나왔나요?

[구현] 소스맵으로 원본 위치 추적하기

vite.config.jssourcemap: true를 켜둔 효과를 직접 확인해보세요.

  • npm run build로 빌드한 뒤 npm run preview로 그 결과를 띄우세요.
  • 개발자 도구 Sources 탭을 열어보세요. 압축된 feed-*.js만 있는 게 아니라, 원본 js/feed.js·js/api.js 모습도 함께 보이나요? 소스맵이 만들어준 거예요.
  • 일부러 코드 한 곳에서 에러를 내보세요(예: 없는 함수 호출). 콘솔의 에러가 압축된 파일이 아니라 원본 파일의 줄 번호를 가리키는지 확인해보세요.

[탐구] Tree Shaking을 눈으로 확인하기

Step 8에서 throttle이 빠진다고 했죠. 진짜 그런지 직접 실험해보세요.

  • npm run build로 빌드한 결과물(dist/assets의 js 파일)을 텍스트 에디터로 열어보세요. clearTimeout(디바운스가 쓰는 함수)은 보이는데, 스로틀만의 코드는 안 보이나요?
  • 이번엔 emoji-picker.js에서 throttle도 함께 import하고 어딘가에서 한 번 써보세요. 다시 빌드하면 결과물 크기가 어떻게 달라지나요?
  • 안 쓰던 코드를 쓰기 시작하니 묶음에 다시 들어오는 걸 보면, "import가 곧 묶음의 명단"이라는 감각이 잡힐 거예요.

생각해볼 주제

정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어보는 질문들이에요. 스스로 답을 만들어본 뒤, 예시답안과 비교해보세요.

1. 모든 걸 동적 import로 미루면 더 빠를까?

동적 import()로 미루면 첫 화면이 가벼워진다고 배웠어요. 그러면 모든 모듈을 다 동적 import()로 미루면 가장 빠를까요?

그런데 미룬 모듈은 나중에 별도로 받아와야 해요. 사용자가 그 기능을 누르는 순간, 잠깐 기다림이 생기죠. 너무 잘게 쪼개 미루면 오히려 누를 때마다 끊기는 느낌이 날 수도 있어요. 어떤 코드는 첫 화면에 함께 넣는 게 낫고, 어떤 코드는 미루는 게 나을까요? 그 경계를 우리 인스타그램 화면을 예로 생각해보세요.

2. 개발 서버는 안 묶는데, 배포는 왜 묶을까?

Vite는 개발할 땐(dev) 모듈을 안 묶고 그대로 보여주고, 배포할 땐(build) 하나로 묶어요. 같은 도구가 왜 두 상황에서 정반대로 행동할까요?

개발할 때 중요한 것과 배포할 때 중요한 것이 다르기 때문이에요. 개발 중엔 무엇이 더 중요하고(고치고 바로 확인하기?), 사용자에게 내보낼 땐 무엇이 더 중요할까요(빠르게 받기?)? 두 상황의 우선순위가 어떻게 다른지, 그게 왜 "안 묶기 vs 묶기"라는 정반대 선택으로 이어지는지 정리해보세요.

3. ESM의 고정된 구조가 왜 도구에 유리할까?

오늘 "ESM은 import가 맨 위에 고정이라 미리 분석된다, 그래서 Tree Shaking이 된다"고 했어요. CommonJS의 require()는 코드 중간 아무 데서나 부를 수 있어서 미리 알기 어렵다고 했고요.

여기서 한 걸음 더 들어가 보세요. 만약 우리가 importif문 안에 조건부로 넣을 수 있었다면, 번들러는 "이 코드는 쓰일까 안 쓰일까"를 어떻게 판단해야 할까요? "제약이 오히려 도구에 자유를 준다"는 말이 모듈 시스템에서 어떻게 들어맞는지 곱씹어보세요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니에요. 모듈 이름이나 버튼 종류는 자유롭게 바꿔도 좋아요. 중요한 건 동적 import()로 코드를 미뤘는가, 빌드해서 별도 조각으로 떨어지는 걸 직접 봤는가, 소스맵과 Tree Shaking을 눈으로 확인했는가 예요. 터미널 둘(npm run dev · npm run mock)을 켜둔 채로 진행하세요.


과제 예시답안

🎯 [과제 1 예시답안] 동적 import로 모듈을 하나 더 미뤄보기

핵심 접근

오늘 이모지 고르개를 동적 import()로 불렀던 그 모양을 그대로 복사하는 과제예요. 새 모듈을 하나 만들어 함수를 export하고, 버튼을 누를 때 await import(...)로 받아오면 끝이에요. 핵심은 세 가지예요 — ①버튼 클릭을 받는 핸들러가 async일 것, ②await import()로 받아온 뒤 함수를 꺼내 쓸 것, ③try-catch로 감쌀 것.

⚠️ 기존 "공유" 버튼은 이미 다이얼로그를 여는 동작이 달려 있어요. 그러니 댓글 폼에 새 버튼을 하나 더 달거나, 다른 기능으로 골라도 좋아요. 여기서는 "공유 카드" 모듈을 새 버튼으로 띄우는 예시로 보여줄게요.

예시 구현

먼저 미뤄서 불러올 새 모듈을 만들어요. 작아도 괜찮아요 — 중요한 건 "필요할 때만 받아온다"는 흐름이니까요.

JavaScript
// instagram-clone-frontend/js/share-card.js (과제용 예시 — 직접 만들어보세요)
export function openShareCard() {
  const box = document.createElement("div");
  box.className = "share-card";
  box.textContent = "이 게시물을 공유했어요!";
  document.body.append(box);
  setTimeout(() => box.remove(), 2000);
}

그리고 feed.js의 위임 클릭 핸들러에 가지를 하나 더해요. 이모지 가지와 똑같아요.

JavaScript
// instagram-clone-frontend/js/feed.js — 위임 클릭 핸들러 안 (과제용 예시)
const shareBtn = event.target.closest(".share-trigger");
if (shareBtn) {
  try {
    // 이 버튼을 누르는 순간에야 share-card.js 를 받아와요
    const { openShareCard } = await import("./share-card.js");
    openShareCard();
  } catch (error) {
    showToast("공유 기능을 불러오지 못했어요.");
    console.error("share-card 로딩 실패:", error);
  }
  return;
}

이제 npm run build로 빌드한 뒤 dist/assets 폴더를 열어보세요. share-card-XXXX.js 처럼 여러분이 미룬 모듈이 별도 조각 파일로 떨어져 나와 있을 거예요. 첫 화면 묶음(feed-*.js)엔 안 들어가 있고요. 그게 코드 스플리팅이 일어난 증거예요.

채점 포인트

포인트 설명 배점 가중
동적 import 사용 정적 import가 아니라 await import("./...")로 미뤘는가
async 핸들러 await를 쓰는 핸들러가 async로 선언됐는가
try-catch 받아오다 실패할 수 있음을 알고 try-catch로 감쌌는가
별도 조각 확인 빌드 후 dist/assets에서 별도 js 조각이 생긴 걸 직접 봤는가
트리거 정리 기존 공유 버튼과 충돌 없이 새 트리거를 깔끔히 달았는가

흔한 실수

  • 핸들러에 async를 안 붙임await만 쓰고 함수는 일반 화살표 함수 그대로면 문법 오류가 나요. await를 쓰는 함수는 반드시 async여야 해요.
  • import()의 결과를 그냥 함수처럼 부름import()는 모듈 객체를 담은 Promise를 줘요. await로 받은 뒤 { openShareCard }처럼 꺼내 써야 해요. await import(...)()처럼 바로 부르면 안 돼요.
  • try-catch를 빼먹음 — 네트워크가 끊기면 모듈 로딩이 실패해요. E-2에서 배운 감각대로 감싸세요.
  • 정적 import로 적어버림 — 맨 위에 import { openShareCard } from "./share-card.js"로 적으면 미루는 효과가 사라져요. 그러면 빌드해도 별도 조각으로 안 떨어지고 첫 묶음에 합쳐져요.

실무 개선 포인트 (심화)

  • 실제 서비스에선 동적 import()에 "불러오는 중" 표시를 함께 둬요. 무거운 모듈은 도착에 시간이 걸리니까, 버튼을 누른 직후 작은 스피너를 보여줬다가 도착하면 치우는 식이죠. 우리 showLoading이 하던 일과 똑같아요.
  • 한 번 받아온 모듈은 브라우저가 기억해둬서, 두 번째 클릭부터는 네트워크 없이 즉시 떠요. 그래서 "처음 한 번만 살짝 느리고 그다음은 즉시"가 동적 import의 자연스러운 모습이에요. 이걸 알면 첫 클릭의 짧은 기다림을 설계로 가릴 수 있어요.

🎯 [과제 2 예시답안] 소스맵으로 원본 위치 추적하기

핵심 접근

이건 코드를 새로 짜기보다 눈으로 확인하는 과제예요. vite.config.jssourcemap: true가 켜져 있으니, 빌드한 결과를 npm run preview로 띄우고 개발자 도구에서 원본이 보이는지를 확인하면 돼요. 핵심은 "압축된 코드만 있는 게 아니라 원본 파일도 함께 보인다"와 "에러가 원본 줄을 가리킨다" 두 가지를 직접 보는 거예요.

관찰 절차

  1. 터미널에서 npm run build로 배포 묶음을 만들어요.
  2. npm run preview로 그 묶음을 띄워요(개발 서버 dev가 아니라 방금 만든 dist를 보여줘요).
  3. 브라우저에서 열고, 개발자 도구 → Sources 탭을 열어요.
  4. 파일 목록을 보면, 압축된 assets/feed-XXXX.js만 있는 게 아니라 원본 js/feed.js·js/api.js 모습도 함께 보여요. 소스맵이 만들어준 거예요.
  5. 이제 일부러 에러를 내봐요. 예를 들어 feed.js에서 함수 이름을 살짝 틀리게 적어요(없는 함수 호출).
JavaScript
// instagram-clone-frontend/js/feed.js — 일부러 오타 (확인용, 끝나면 되돌리세요)
loadPage();
loadPagee(); // 없는 함수 — 일부러 에러를 내봐요
  1. 다시 npm run buildnpm run preview 후 콘솔을 봐요. 에러가 압축된 feed-XXXX.js가 아니라 원본 feed.js의 그 줄 번호를 가리키나요? 소스맵 덕분이에요.
  2. 확인이 끝나면 오타를 꼭 되돌리세요.

채점 포인트

포인트 설명 배점 가중
preview 사용 dev가 아니라 build 결과를 preview로 띄워 확인했는가
원본 노출 확인 Sources 탭에서 원본 js/*.js가 함께 보이는 걸 확인했는가
원본 줄 추적 일부러 낸 에러가 원본 파일·줄 번호를 가리키는 걸 봤는가
설정 이해 이 추적이 vite.config.jssourcemap: true 덕분임을 설명할 수 있는가
되돌리기 확인용 오타를 원래대로 복구했는가

흔한 실수

  • dev로 확인하고 끝냄 — 개발 서버는 원래 코드를 안 묶고 그대로 보여줘서, 소스맵의 효과를 못 느껴요. 소스맵의 진가는 묶고 압축한 build 결과에서 나와요. 반드시 preview로 봐요.
  • 소스맵이 원본 코드 자체라고 오해 — 소스맵은 코드가 아니라 "압축된 위치 ↔ 원본 위치"를 잇는 지도예요. 배포 묶음(js)과 짝꿍 지도(.map)가 함께 있어야 추적이 돼요.
  • 에러를 안 내보고 넘어감 — 원본이 목록에 보이는 것까지만 보고 끝내기 쉬워요. 직접 에러를 내서 "줄 번호가 원본을 가리킨다"까지 봐야 진짜로 확인한 거예요.

실무 개선 포인트 (심화)

  • 진짜 배포에선 소스맵을 사용자에게 그대로 노출할지 말지를 고민해요. 소스맵이 있으면 누구나 우리 원본 코드를 되짚어 볼 수 있거든요. 그래서 소스맵은 만들되 사용자에겐 안 내려주고, 에러 추적 서버에만 따로 올려서 개발자만 원본으로 추적하게 하는 방식을 많이 써요.
  • 에러 추적 서비스(예: 운영 중 에러를 모아 보여주는 도구)에 이 소스맵을 올려두면, 사용자 화면에서 난 에러가 자동으로 원본 파일·줄로 풀려서 쌓여요. 압축된 코드에서 "어디가 터졌는지"를 손으로 풀 필요가 없어지죠.

🎯 [과제 3 예시답안] Tree Shaking을 눈으로 확인하기

핵심 접근

Step 8에서 "throttle은 안 써서 빠진다"고 했죠. 진짜 빠지는지, 그리고 쓰기 시작하면 다시 들어오는지를 빌드 결과로 직접 보는 과제예요. 핵심은 "import가 곧 묶음의 명단"이라는 감각을 손에 쥐는 거예요.

관찰 절차

  1. 지금 상태로 npm run build를 돌려요.
  2. dist/assets의 js 조각을 텍스트 에디터로 열어봐요. 디바운스가 쓰는 clearTimeout이라는 글자는 보이는데, 스로틀만의 동작은 안 보여요(throttle은 아무도 안 써서 빠졌어요).
  3. 이번엔 emoji-picker.js에서 throttle도 함께 가져와 한 번 써봐요.
JavaScript
// instagram-clone-frontend/js/emoji-picker.js — throttle 도 써보기 (확인용)
import { debounce, throttle } from "./util.js";

// (어딘가에서 한 번 호출) — 예: 패널을 띄울 때 로그를 스로틀로 한 번만
const logOnce = throttle(() => console.log("picker opened"), 1000);
logOnce();
  1. 다시 npm run build를 돌리고, 빌드 요약에 찍히는 조각의 크기(kB)를 비교해봐요. throttle을 쓰기 시작하니 그만큼 묶음이 커졌나요?
  2. 확인이 끝나면 추가한 throttle 코드를 되돌려, 다시 빠지는 것까지 보면 완벽해요.

채점 포인트

포인트 설명 배점 가중
부재 확인 안 쓰는 throttle이 빌드 결과에 없다는 걸 직접 확인했는가
복귀 확인 throttle을 쓰기 시작하니 묶음에 다시 들어오는 걸 봤는가
크기 비교 빌드 요약의 조각 크기 변화를 비교했는가
원리 설명 ESM의 고정된 구조 덕분에 가능함을 설명할 수 있는가

흔한 실수

  • import만 하고 안 쓰면 들어온다고 오해import { throttle }만 적고 한 번도 안 쓰면, Vite는 그래도 안 쓰는 걸로 보고 빼버려요. "쓰는지"가 기준이에요. 그래서 과제에선 throttle을 실제로 한 번 호출해야 묶음에 들어와요.
  • dev에서 확인하려 함 — 개발 서버는 안 묶고 그대로 보여줘서 Tree Shaking이 안 일어나요. 반드시 build 결과로 봐요.
  • 압축된 코드에서 함수 이름을 찾으려 함 — 압축되면 우리 변수 이름은 한 글자로 뭉개져요. 그래서 throttle이라는 이름으로 검색하면 안 나와요. clearTimeout 같은 줄지 않는 함수 이름으로 비교하거나, 조각의 크기 변화로 보는 게 더 정확해요.

실무 개선 포인트 (심화)

  • Tree Shaking이 잘 되려면 모듈이 "부수 효과 없이 깔끔해야" 해요. 불러오는 것만으로 화면을 건드리거나 전역을 바꾸는 모듈은, 번들러가 "혹시 필요할지 몰라" 하고 함부로 못 빼요. 그래서 util.js처럼 순수한 함수만 내보내는 습관이 묶음을 가볍게 만들어요.
  • 큰 도구 묶음을 통째로 가져오는 대신, 필요한 함수만 콕 집어 가져오면 Tree Shaking이 훨씬 잘 들어요. "이 묶음에서 한 함수만 쓰는데 통째로 들어왔네?" 싶을 땐, 가져오는 방식을 점검하는 신호예요.

생각해볼 주제 예시답안

1. 모든 걸 동적 import로 미루면 더 빠를까?

[문제 상황 요약]

동적 import()로 미루면 첫 화면이 가벼워진다고 배웠어요. 그러면 모든 모듈을 다 미루면 가장 빠를까요? 그런데 미룬 모듈은 나중에 별도로 받아와야 해서, 사용자가 누르는 순간 잠깐 기다림이 생겨요. 어떤 코드는 첫 화면에 같이 넣고, 어떤 코드는 미루는 게 나을까요?

[튜터의 가이드 및 해설]

기준은 "첫 화면에 곧바로 필요한가" 예요. 두 갈래로 나눠볼게요.

  • 첫 화면에 바로 필요한 코드 — 피드를 그리는 feed.js, 데이터를 받아오는 api.js처럼 화면이 뜨자마자 도는 코드예요. 이건 미루면 안 돼요. 미뤄봐야 어차피 즉시 또 받아와야 하고, 오히려 왕복만 한 번 더 생겨 느려져요.
  • 나중에, 가끔 필요한 코드 — 이모지 고르개처럼 사용자가 특정 버튼을 눌러야 쓰는 코드예요. 안 누르는 사람도 많고, 누르더라도 첫 화면 이후예요. 이런 건 미루는 게 이득이에요.

너무 잘게 미루면 역효과가 나요. 버튼 하나하나를 다 별도 조각으로 떼면, 누를 때마다 짧은 기다림이 쌓여 오히려 끊기는 느낌이 들어요. 또 조각이 너무 많아지면 그것대로 관리가 번거로워요. 그래서 보통은 "화면 단위"나 "무거운 기능 단위"로 끊어요 — 사진 편집기, 동영상 플레이어, 지도처럼 코드가 크고 가끔 쓰는 덩어리를 미루는 거죠.

현업에서는 보통 이렇게 정리해요. 첫 화면에 보이는 것과 그걸 그리는 데 필요한 것은 같이 묶고(미루지 않고), 무겁고 가끔 쓰는 기능은 미뤄요. "작은 함수 하나하나"가 아니라 "덩어리 단위"로요. 그래서 미루기는 많을수록 좋은 게 아니라, 첫 화면에 필요한가 / 무거운가 / 가끔 쓰는가를 보고 고르는 결정이에요.

🎯 면접관을 홀리는 핵심 멘트

"동적 import는 많이 쓸수록 빠른 게 아니라, '첫 화면에 필요한가'로 갈라요. 화면이 뜨자마자 도는 코드는 미루면 왕복만 늘어 손해고, 무겁고 가끔 쓰는 기능 덩어리는 미뤄서 첫 화면을 가볍게 만들어요. 너무 잘게 쪼개면 누를 때마다 기다림이 쌓이니, 함수 단위가 아니라 화면·기능 덩어리 단위로 끊는 게 핵심입니다."


2. 개발 서버는 안 묶는데, 배포는 왜 묶을까?

[문제 상황 요약]

Vite는 개발할 땐(dev) 모듈을 안 묶고 그대로 보여주고, 배포할 땐(build) 하나로 묶어요. 같은 도구가 왜 두 상황에서 정반대로 행동할까요?

[튜터의 가이드 및 해설]

개발할 때 중요한 것과 배포할 때 중요한 것이 다르기 때문이에요.

  • 개발 중에 중요한 것 — 빠른 확인 — 코드 한 줄을 고치면 화면에 즉시 보이는 게 가장 중요해요. 만약 매번 전체를 묶고 압축한다면, 한 글자 고칠 때마다 그 묶는 시간을 기다려야 해요. 그래서 개발 서버는 안 묶고 바뀐 모듈만 콕 집어 갈아끼워요(HMR). 디버깅할 때도 우리가 쓴 원본 모습 그대로가 편하고요.
  • 배포할 때 중요한 것 — 빠른 전달 — 진짜 사용자는 멀리서 우리 사이트를 받아요. 그땐 파일을 한 파일씩 왕복하면 느려요. 그래서 미리 합치고, 압축하고, 안 쓰는 코드를 빼서 최대한 작고 빠르게 만들어요. 이건 한 번만 하면 되니까 오래 걸려도 괜찮아요.

정리하면, 개발은 "고치고 바로 확인"이 1순위라 안 묶고, 배포는 "사용자에게 빠르게"가 1순위라 묶어요. 묶는 건 시간이 걸리는 작업이라, 자주 반복하는 개발 중엔 부담이고 한 번만 하는 배포 땐 가치가 있어요. 같은 도구가 두 상황의 우선순위에 맞춰 정반대로 행동하는 거예요.

현업에서는 이 둘을 아예 다른 명령(dev / build)으로 분리해 둬요. 개발자는 종일 dev로 일하다가, 내보낼 때만 build를 한 번 돌려요. 한 도구가 상황을 알아서 구분해주니, 우리는 "지금 개발 중인가 배포 중인가"만 고르면 돼요.

🎯 면접관을 홀리는 핵심 멘트

"개발과 배포는 우선순위가 정반대라 도구도 정반대로 동작해요. 개발 중엔 '고치고 바로 확인'이 1순위라 묶지 않고 바뀐 모듈만 교체(HMR)하고, 배포 땐 '사용자에게 빠르게'가 1순위라 합치고 압축하고 안 쓰는 코드를 빼요. 묶기는 시간이 드는 작업이라 자주 반복하는 개발엔 부담이고 한 번 하는 배포엔 가치가 있어서, devbuild를 분리해 둔 겁니다."


3. ESM의 고정된 구조가 왜 도구에 유리할까?

[문제 상황 요약]

오늘 "ESM은 import가 맨 위에 고정이라 미리 분석된다, 그래서 Tree Shaking이 된다"고 했어요. CommonJS의 require()는 코드 중간 아무 데서나 부를 수 있어서 미리 알기 어렵다고 했고요. 만약 우리가 importif문 안에 조건부로 넣을 수 있었다면, 번들러는 무엇이 쓰일지 어떻게 판단해야 할까요?

[튜터의 가이드 및 해설]

핵심은 "실행하지 않고도 읽어서 알 수 있느냐" 예요.

ESM의 import는 규칙이 빡빡해요 — 반드시 파일 맨 위에, 조건 없이 적어야 해요. 이 빡빡함이 도구에는 선물이에요. 코드를 한 줄도 실행하지 않고 그냥 읽기만 해도, "이 파일은 무엇을 가져오고 무엇을 내보내는지"가 100% 확정되거든요. 그래서 번들러는 자신 있게 "이건 아무도 안 쓰네? 빼자"라고 판단할 수 있어요.

반대로 require()if문 안에 넣을 수 있다고 해볼게요.

JavaScript
// 만약 이게 가능하다면 — 번들러는 무엇을 빼야 할지 알 수 없어요
if (어떤_조건) {
  const heavy = require("./heavy.js");
}

어떤_조건이 참이 될지는 실제로 돌려봐야 알아요. 사용자 입력일 수도, 날짜일 수도 있죠. 번들러는 미래를 모르니까 "혹시 쓰일지 몰라" 하고 heavy.js를 못 빼요. 안전하게 다 넣는 수밖에 없어요. 자유롭게 아무 데서나 부를 수 있는 대신, 도구가 미리 정리할 여지를 잃은 거예요.

여기서 재밌는 역설이 나와요. 제약이 오히려 자유를 준다는 거예요. ESM이 "맨 위에 고정"이라는 제약을 받아들인 덕분에, 도구는 미리 분석하고 안 쓰는 걸 떨어내고 조각을 나누는 자유를 얻었어요. 우리가 짜는 코드도 마찬가지예요 — 규칙을 지켜 짜면 도구가 더 똑똑하게 도와줘요. 동적 import()가 따로 있는 것도 이 맥락이에요. "정말 동적으로 미뤄야 하는 것"은 함수형 import()로 명확히 표시하고, 나머지는 전부 고정된 정적 import로 둬서 도구가 분석할 수 있게 남겨두는 거죠.

🎯 면접관을 홀리는 핵심 멘트

"ESM의 import는 맨 위에 조건 없이 고정이라, 코드를 실행하지 않고 읽기만 해도 의존 관계가 100% 확정돼요. 그래서 번들러가 안 쓰는 코드를 자신 있게 떨어낼 수 있죠(Tree Shaking). 만약 import를 if문 안에 둘 수 있었다면 조건은 실행해야 알 수 있으니 도구는 다 넣을 수밖에 없어요. '제약이 오히려 도구에 자유를 준다'가 핵심이고, 그래서 정말 미뤄야 하는 것만 동적 import()로 따로 표시하는 겁니다."

전체 목록 HTML·CSS·JS