운영중인 FE_devtalk스터디 1주차 주제가 빌드시스템로 선정되었습니다. 이번 글은 왜 빌드 시스템이 필요한지에 집중하여 진행해보겠습니다!

 

이 글에서는 왜 빌드 시스템이 필요한지, 그리고 빌드 시스템이 어떤 과정을 거쳐 동작하는지를 알아봅니다. 마지막에는 간단한 예시 코드를 통해 직접 구현해보는 과정도 살펴보겠습니다.


1. 빌드 시스템이 왜 필요할까?

1️⃣ 모듈 시스템의 필요성

예전에는 JavaScript에서 공식적인 모듈 시스템이 없었습니다. 그래서 프로젝트를 여러 파일로 나누면 전역 스코프가 오염되어, 의도하지 않은 변수 충돌이 빈번하게 일어났습니다.

  • requireJS, AMD, CommonJS 등의 모듈 시스템이 등장하면서 파일 간 의존성을 정리하고 전역 스코프를 지키는 일이 중요해졌습니다.
  • ES6부터 공식적으로 import/export 문법이 도입됐지만, 브라우저 호환성 문제가 여전히 남아 있습니다(오래된 브라우저는 미지원).

결국, 빌드 과정을 통해 여러 모듈(파일)을 하나로 합치면서도, 전역 스코프 오염 없이 안전하게 코드를 동작하도록 만드는 과정이 필요해졌습니다.

2️⃣ 성능 최적화가 필요함

웹 애플리케이션 규모가 커지면서, 페이지 로드 속도와 같은 성능 이슈도 빼놓을 수 없게 됐습니다.

  • 작은 JS 파일이 수백 개 있으면, 이를 번들링해 하나 혹은 소수의 파일로 합쳐서 HTTP 요청 수를 줄여야 합니다.
  • 트리 쉐이킹(Tree Shaking)을 통해 사용하지 않는 코드를 제거하면, 최종 번들의 크기가 줄어듭니다.
  • 압축(Uglify, Minify)으로 JS/CSS 코드 크기를 최소화하면 페이지 로딩이 빨라집니다.

3️⃣ 다양한 언어의 전처리 과정이 필요함

현대 프런트엔드 개발 환경에서는 TypeScript, SCSS, JSX 같은 언어들이 많이 사용됩니다.

  • 브라우저는 .ts, .scss, .jsx 파일을 직접 실행할 수 없습니다.
  • 빌드 도구(예: Webpack, Vite, Babel 등)를 통해 TS → JS, SCSS → CSS, JSX → JS 변환 과정을 거쳐야만 브라우저가 이해할 수 있는 최종 산출물이 만들어집니다.

4️⃣ 브라우저 간 차이를 해결해야 함

최신 브라우저는 ES6+ 기능을 대부분 지원하지만, 오래된 브라우저는 아직도 구버전(ES5 이하) 문법만 이해하기도 합니다.

  • Babel 같은 트랜스파일러를 이용하면, 최신 코드를 ES5 수준으로 변환해줍니다.
  • Polyfill(core-js 등)을 추가하여 지원하지 않는 기능을 보완할 수 있습니다.
  • @babel/preset-env를 사용하면, 브라우저 목록(예: > 2%, not dead 등)에 맞춰 필요한 부분만 자동으로 트랜스파일링합니다.

2. 빌드 시스템의 논리적 구조

일반적으로 빌드 시스템은 다음과 같은 3단계를 거칩니다.

1. 입력 (Input)

  • 개발자가 작성한 소스 코드 (TS, JSX, SCSS 등)
  • 외부 라이브러리(node_modules 등)

2. 변환 과정

  1. 트랜스파일링 (Transpiling)
    • TS → JS, SCSS → CSS, JSX → JS 등
  2. 번들링 (Bundling)
    • 여러 개의 파일을 하나 혹은 소수로 합침
  3. 트리 쉐이킹 (Tree Shaking)
    • 사용되지 않는 코드 제거
  4. 압축 & 최적화
    • 코드 크기를 최소화 (Uglify, Minify)

3. 출력 (Output)

  • 브라우저에서 실행할 수 있는 HTML, CSS, JS 파일

3. 구현해보기

빌드 시스템의 핵심 이슈를 하나씩 짚어보고, 간단하게나마 구현 과정을 살펴보겠습니다.

1️⃣ 모듈 시스템의 필요성

전역 스코프 오염 문제

파일 여러 개를 사용하면 전역 스코프를 공유하게 되어, 의도치 않게 변수 충돌이 일어날 수 있습니다.
이를 해결하기 위해 CommonJS(CJS) 모듈 시스템에서는 각 모듈을 함수로 감싸서 스코프를 분리하는 방식을 썼습니다.

// (커스텀 번들 코드 예시)
function customBundle(entry) {
  const content = fs.readFileSync(entry, 'utf-8');
  const wrapped = `(function(require, module, exports) { ${content} })`;

  return wrapped;
}

// before
const message = 'Hello, Bundle!';
console.log(message);

// after
(function (require, module, exports) {
  const message = 'Hello, Bundle!';
  console.log(message);
});

위와 같이 함수를 통해 모듈을 감싸면, 각 모듈 간 전역 변수 충돌을 막고 독립성을 유지할 수 있습니다.

브라우저 환경에서의 실행

문제는 이렇게 감싼 코드가 브라우저 환경에서 바로 동작하지는 않는다는 점입니다.
require, module, exports는 Node.js 환경을 가정한 것이기 때문에, 웹팩(Webpack) 같은 번들러는 이것을 브라우저에서 동작 가능한 형태로 변환(__webpack_require__)해 줍니다.

// webpack 내부 구조 예시
var __webpack_modules__ = {
  './message.js': module => {
    module.exports = 'Hello, Bundle!';
  },
};

function __webpack_require__(moduleId) {
  var module = { exports: {} };
  __webpack_modules__[moduleId](module, module.exports);
  return module.exports;
}

var message = __webpack_require__('./message.js');
console.log(message); // "Hello, Bundle!"

2️⃣ 성능 최적화가 필요함

아래 세 가지가 성능 최적화의 핵심입니다.

  1. 번들링
    • 여러 JS 파일을 하나로 합쳐서 HTTP 요청 수 감소
  2. 트리 쉐이킹
    • 사용되지 않는 코드를 제거해 파일 크기 최소화
  3. 압축(Uglify/Minify)
    • 코드 난독화, 압축으로 전송해야 할 파일의 크기를 크게 줄임

이 과정을 간단하게 직접 구현해볼 수 있습니다.

1) 모듈 의존성 분석 및 실행 순서 정리

function findImports(code) {
  const importRegex = /import\s+{?\s*(\w+)\s*}?\s+from\s+['"](.+)['"]/g;
  const requireRegex = /const\s+(\w+)\s*=\s*require\(['"](.+)['"]\)/g;

  let imports = [];
  let match;

  // import 구문 분석
  while ((match = importRegex.exec(code)) !== null) {
    imports.push({ name: match[1], path: match[2] });
  }
  // require 구문 분석
  while ((match = requireRegex.exec(code)) !== null) {
    imports.push({ name: match[1], path: match[2] });
  }

  return imports;
}
  • 위 코드는 importrequire 구문을 찾아내 모듈 의존성을 수집합니다.
  • 이후, 위상 정렬과 같은 알고리즘을 이용해 모듈 간 로드 순서를 파악할 수 있습니다.

2) 코드 AST로 변환(파싱)

다음 단계에서는 AST(추상 구문 트리)를 활용해 트리 쉐이킹 등을 수행할 수 있습니다.

const esprima = require("esprima"); // 코드 → AST 변환
const estraverse = require("estraverse"); // AST 탐색

// 분석할 JavaScript 코드
const code = `
function add(a, b) {
  return a + b;
}
console.log(add(10, 20));
`;

// 코드 → AST 변환
const ast = esprima.parseScript(code);
console.log(JSON.stringify(ast, null, 2));

위와 같이 AST를 얻어내면, 각 노드를 탐색하여 사용되지 않는 코드를 찾고 제거할 수 있습니다.

3) 트리 쉐이킹(사용되지 않는 코드 제거)

사용되지 않는 함수를 찾아 제거하는 예시입니다.

const definedFunctions = new Set(); // 선언된 함수 목록
const usedFunctions = new Set(); // 실제 사용된 함수 목록

estraverse.traverse(ast, {
  enter(node) {
    // 함수 정의 확인
    if (node.type === 'FunctionDeclaration') {
      definedFunctions.add(node.id.name);
    }

    // 함수 호출 확인
    if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
      usedFunctions.add(node.callee.name);
    }
  },
});

const unusedFunctions = [...definedFunctions].filter(fn => !usedFunctions.has(fn));
console.log('사용되지 않은 함수:', unusedFunctions);

// AST에서 제거하기
const escodegen = require('escodegen'); // AST → 코드 변환

const optimizedAST = estraverse.replace(ast, {
  enter(node) {
    if (node.type === 'FunctionDeclaration' && unusedFunctions.includes(node.id.name)) {
      return null; // 노드 삭제
    }
  },
});

const optimizedCode = escodegen.generate(optimizedAST);
console.log('최적화된 코드:\n', optimizedCode);

4) 코드 번들링

이제 모듈 간 의존성 관리를 마친 뒤, 필요한 JS 코드를 하나로 합칠 수 있습니다.
간단한 예시로, 문자열을 이어붙이는 방식입니다.

// 모듈 파일들이 있다고 가정
const files = {
  'utils.js': 'export function add(a, b) { return a + b; }',
  'main.js': "import { add } from './utils.js'; console.log(add(10, 20));",
};

const bundle = `
(function() {
  var modules = {
    'utils.js': function(exports) { ${files['utils.js']} },
    'main.js': function(exports, require) { ${files['main.js']} }
  };

  var require = function(module) {
    var exports = {};
    modules[module](exports, require);
    return exports;
  };

  require('main.js');
})();
`;

console.log(bundle);

5) 압축(망글링, Minify)

마지막으로, Uglify나 Terser 등을 이용해 변수명을 짧게 바꾸는 망글링(mangling), 공백 제거 등의 Minify 과정을 거치면 최종 결과물이 더욱 작아집니다.

// before
function add(a, b) {
  return a + b;
}
console.log(add(10, 20));

// after (단순 예시)
console.log((a,b)=>a+b)(10,20);

3️⃣ 다양한 언어의 전처리 과정이 필요함

TypeScript, SCSS, JSX는 브라우저가 직접 실행할 수 없습니다.
간단히 TypeScript 예시를 들어보면,

// before.ts
const greet = (name: string): string => {
  return `Hello, ${name}!`;
};

console.log(greet('Alice'));

이를 tsc 또는 Babel(Typescript 플러그인) 등을 이용하면 아래처럼 트랜스파일링됩니다.

"use strict";
const greet = name => {
    return `Hello, ${name}!`;
};
console.log(greet('Alice'));
  • 타입 정보가 제거되고, ES5/ES6 호환 JavaScript 코드로 변환됩니다.
  • React의 JSX 문법과 SCSS도 원리는 같습니다. 모두 빌드 단계에서 표준 JS/CSS 형태로 변환해주어야 합니다.

4️⃣ 브라우저 간 차이를 해결해야 함

현대 브라우저는 최신 문법을 잘 지원하지만, 오래된 브라우저는 그렇지 않습니다.
이를 해결하기 위한 방법은 다음과 같습니다.

  • Polyfill: 지원되지 않는 메서드나 함수를 추가로 정의(core-js 등)
  • Transpiling: Babel, SWC 등을 이용해 ES6+ 코드를 ES5 등 하위 버전으로 변환
  • 자동 브라우저 대응: @babel/preset-env로 지원 범위를 설정하면, 필요한 폴리필과 트랜스파일 과정을 자동화

예를 들어 Promise가 지원되지 않는 IE 환경에서 Promise를 사용하려면, 폴리필과 트랜스파일링 과정이 필수입니다.


정리

  1. 모듈 시스템: 전역 스코프 오염을 막기 위해 ES6 이전부터 CommonJS, AMD 등의 모듈화 방식이 생겼고, 지금은 ES6 import/export를 표준으로 많이 사용합니다.
  2. 성능 최적화: HTTP 요청 수를 줄이고, 코드 용량을 최소화하기 위해 번들링, 트리 쉐이킹, 압축 등이 필수입니다.
  3. 여러 언어 전처리: TypeScript, SCSS, JSX 등은 브라우저가 이해할 수 있도록 빌드 과정에서 JS/CSS로 변환해주어야 합니다.
  4. 브라우저 호환성: 구형 브라우저에서도 문제없이 동작하도록 Babel, 폴리필 등을 활용합니다.

이렇듯, 빌드 시스템은 단순히 코드 압축만을 하는 도구가 아니라, 프로젝트를 안전하고 효율적으로 유지할 수 있게 해주는 필수 요소입니다.
규모가 커질수록 빌드 시스템은 더욱 중요해지고, 다양한 빌드 도구(예: Webpack, Vite, Rollup, Parcel 등)를 활용하여 최적의 개발 환경을 구성해 보시기 바랍니다.

Tip: 실제 프로젝트에서 0부터 빌드를 구현하기보다는, 이미 검증된 도구(Webpack, Vite, Babel, TypeScript CLI 등)를 사용하시는 것을 추천드립니다!

'개발' 카테고리의 다른 글

[nextjs 톺아보기] <head>  (0) 2025.04.08
Vue Ref 톺아보기  (0) 2025.03.26
번들사이즈 최적화  (0) 2024.09.30
자바스크립트 이벤트 루프와 비동기 통신의 실행순서  (2) 2024.09.06

+ Recent posts