운영 중인 FE_devtalk 스터디 2주차 주제가 릴리즈 노트 분석 훈련 - vue3.5로 선정되었습니다.
제가 선택한 항목은 reactivity: avoid infinite recursion when mutating ref wrapped in reactive 으로 vue의 reactive를 깊게 이해해야 분석 가능하기에 vue-ref를 먼저 정밀 분석하게 되었습니다.

Vue Ref 구현 상세 분석

 

Vue의 반응성 시스템을 좀 더 깊이 이해하고 싶다면, `ref`가 어떻게 만들어지고 동작하는지 알아두면 좋습니다. 이 글에서는 **`ref`의 생성 과정**, **내부 클래스(`RefImpl`)**, 그리고 **의존성 관리(`Dep`)**가 어떻게 연결되어 있는지 정리해 보겠습니다.


1. Ref 생성 과정

Vue 코드에서 ref를 호출하면 아래와 같은 흐름을 거칩니다:

export function ref<T = any>(value: T): Ref<T> {
  return createRef(value, false);
}

function createRef<T>(rawValue: T, shallow: boolean): Ref<T> {
  if (isRef(rawValue)) {
    return rawValue;
  }
  return new RefImpl(rawValue, shallow);
}
  1. ref()
    • 제네릭(T)을 받고, createRef 함수를 호출합니다.
  2. createRef()
    • 이미 ref인 경우(isRef(rawValue)) 그대로 반환(중복 래핑 방지)
    • 아닌 경우 RefImpl 인스턴스 생성 후 반환

핵심 포인트

// 예시
const count = ref(1);       // 새로운 RefImpl 인스턴스 생성
const count2 = ref(count);  // 이미 ref이므로 그대로 반환
  1. 이미 ref인 경우 → 그대로 반환 (중복 래핑 방지)
  2. ref가 아닌 경우RefImpl 인스턴스를 새로 생성

2. RefImpl 클래스

ref()로부터 최종적으로 생성되는 객체는 RefImpl 클래스의 인스턴스입니다.
이 클래스는 Getter/Setter를 통해 의존성 추적값 변경 시 반응성을 처리합니다.

class RefImpl<T = any> {
  _value: T;
  private _rawValue: T;
  dep: Dep = new Dep();
  public readonly [ReactiveFlags.IS_REF] = true;
  public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false;

  constructor(value: T, isShallow: boolean) {
    this._rawValue = isShallow ? value : toRaw(value);
    this._value = isShallow ? value : toReactive(value);
    this[ReactiveFlags.IS_SHALLOW] = isShallow;
  }

  get value() {
    // (1) 의존성 추적
    if (__DEV__) {
      this.dep.track({
        target: this,
        type: TrackOpTypes.GET,
        key: 'value',
      });
    } else {
      this.dep.track();
    }
    return this._value;
  }

  set value(newValue) {
    // (2) 변경 감지 후 업데이트 & 의존성 실행
    const oldValue = this._rawValue;
    const useDirectValue =
      this[ReactiveFlags.IS_SHALLOW] || isShallow(newValue) || isReadonly(newValue);

    newValue = useDirectValue ? newValue : toRaw(newValue);

    if (hasChanged(newValue, oldValue)) {
      this._rawValue = newValue;
      this._value = useDirectValue ? newValue : toReactive(newValue);

      if (__DEV__) {
        this.dep.trigger({
          target: this,
          type: TriggerOpTypes.SET,
          key: 'value',
          newValue,
          oldValue,
        });
      } else {
        this.dep.trigger();
      }
    }
  }
}
  • 생성자(constructor)
    • isShallow 여부에 따라 toRaw/toReactive로 내부 값(_rawValue, _value)을 설정
    • dep는 의존성 추적/실행을 담당하는 Dep 인스턴스
  • Getter (get value())
    1. dep.track(...) 호출로 의존성 등록
    2. 현재 _value를 반환
  • Setter (set value(...))
    1. 새 값(newValue)과 이전 값(_rawValue)을 비교
    2. 값이 바뀌었다면 _rawValue/_value를 갱신하고, dep.trigger(...)의존성 실행

2.1 Getter 동작

const count = ref(1);
effect(() => {
  console.log(count.value); // getter 호출
});
  • count.value를 읽을 때마다 dep.track()이 실행되어,
    현재 실행 중인 effectcount 값에 의존하고 있음을 등록합니다.

2.2 Setter 동작

count.value = 2; // setter 호출
  • 새 값기존 _rawValue를 비교(hasChanged)하여 달라졌다면,
    _value_rawValue를 갱신하고, dep.trigger()로 의존성(effect)을 재실행시킵니다.

RefImplvalue 프로퍼티를 통해 getter/setter 단에서 의존성 관리 로직을 삽입하는 구조입니다.


3. Dep 클래스 (의존성 관리)

RefImpl 내부에서 의존성을 관리하는 핵심 객체는 Dep입니다.
여기서 dep.track(...), dep.trigger(...)의존성을 등록/실행합니다.

export class Dep {
  version = 0;
  activeLink?: Link = undefined;
  subs?: Link = undefined;
  subsHead?: Link;
  map?: KeyToDepMap = undefined;
  key?: unknown = undefined;
  sc: number = 0;

  constructor(public computed?: ComputedRefImpl | undefined) {
    if (__DEV__) {
      this.subsHead = undefined;
    }
  }

  track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
    // 의존성 등록 로직
    // ...
  }

  trigger(debugInfo?: DebuggerEventExtraInfo): void {
    // 의존성 실행 로직
    // ...
  }

  notify(debugInfo?: DebuggerEventExtraInfo): void {
    // 등록된 effect를 순회하며 실행
    // ...
  }
}

3.1 의존성 등록 (track)

track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
  if (!activeSub || !shouldTrack || activeSub === this.computed) {
    return;
  }

  let link = this.activeLink;
  if (link === undefined || link.sub !== activeSub) {
    link = this.activeLink = new Link(activeSub, this);

    // 이중 연결 리스트를 통해 effect와 dep를 연결
    if (!activeSub.deps) {
      activeSub.deps = activeSub.depsTail = link;
    } else {
      link.prevDep = activeSub.depsTail;
      activeSub.depsTail!.nextDep = link;
      activeSub.depsTail = link;
    }

    // Dep 내부에도 등록
    addSub(link);
  }

  return link;
}
  • activeSub: 현재 실행 중인 effect(반응형 함수)
  • Link: effect와 dep를 연결하는 이중 연결 리스트 노드
  • addSub(link)를 통해 dep.subs 리스트에도 등록

결과적으로, count.value 같은 반응형 데이터가 어떤 effect에 의해 소비되는지 추적하게 됩니다.

3.2 의존성 실행 (trigger → notify)

trigger(debugInfo?: DebuggerEventExtraInfo): void {
  this.version++;
  globalVersion++;
  this.notify(debugInfo);
}

notify(debugInfo?: DebuggerEventExtraInfo): void {
  startBatch();
  try {
    // (1) 개발 환경에서 onTrigger 훅 실행
    if (__DEV__) {
      // ...
    }
    // (2) 실제 의존성 실행 (역순)
    for (let link = this.subs; link; link = link.prevSub) {
      if (link.sub.notify()) {
        (link.sub as ComputedRefImpl).dep.notify();
      }
    }
  } finally {
    endBatch();
  }
}
  1. trigger()
    • 버전(this.version)을 갱신하고, notify()를 호출
  2. notify()
    • startBatch() / endBatch()로 감싸 배치 처리
    • this.subs 연결 리스트를 역순으로 돌며, 각 effect를 실행
// 예시
const count = ref(1);
effect(() => {
  console.log(count.value);
});

count.value = 2; 
// -> trigger 호출 -> notify 실행 -> effect 재실행

위 흐름에서 link.sub.notify()가 내부적으로 effect(또는 computed)를 다시 실행하기 때문에
결과적으로 렌더 로직이나 콘솔 출력이 자동 갱신됩니다.


4. Effect와 배치 시스템

Vue의 반응성 로직에는 배치(batch) 개념이 있습니다.
이를 통해 짧은 시간 안에 여러 개의 변경(setter 호출 등)이 일어나도,
한 번에 처리하여 불필요한 계산이나 렌더링을 최소화합니다.

let batchDepth = 0;

export function startBatch(): void {
  batchDepth++;
}

export function endBatch(): void {
  // 배치 스코프를 벗어났을 때만 실제 작업 수행
  if (--batchDepth > 0) return;

  // 컴퓨티드 먼저 갱신
  // 일반 effect 갱신
  // ...
}
  • startBatch()
    • 배치 범위를 시작할 때 카운터(batchDepth)를 1 증가
  • endBatch()
    • 배치 범위가 끝나면 1 감소
    • 카운터가 0이 되었을 때, 컴퓨티드 → 일반 effect 순으로 실제 실행

4.1 배치 시스템이 필요한 이유

  1. 중첩된 업데이트 방지
    • 한 effect 안에서 count.value++ 여러 번 호출 시,
      매번 재실행하지 않고 마지막에 한 번 모아서 실행
  2. 정확한 실행 순서 보장
    • 컴퓨티드를 먼저 업데이트하고, 이어서 일반 effect 실행
    • 의존성 엉킴을 방지
  3. 코드 구조상 이점
    • 여러 effect/computed/watch를 한 번에 처리하기 쉬움
    • 최종 시점에 렌더링 실행

예시 상황

effect(() => {
  console.log(count.value);
});

startBatch();
count.value++;
count.value++;
endBatch(); 
// -> 실제 effect는 마지막에 한 번만 실행

즉, 짧은 시간에 많은 setter가 발생해도, 배치 시스템불필요한 재계산을 막아주고 순서를 관리합니다.


결론

Vue의 ref단순한 래퍼가 아니라, 정교한 반응성 로직이 작동합니다.

  1. ref(...) 호출 → RefImpl 생성 (이미 ref라면 그대로 반환)
  2. Getter/Setter (get value(), set value(...))로 의존성 등록/실행
  3. Dep 객체가 어떤 effect(또는 컴퓨티드)가 의존하는지 추적 & 실행
  4. 배치 시스템으로 여러 업데이트를 한 번에 처리 → 성능 최적화 & 순서 보장

결국, 사용자는 count.value처럼 간단한 API만 알면 되지만, Vue 내부에서는 트리거-이펙트, 배치 처리, 이중 연결 리스트 관리 등이 맞물려 반응형을 달성하고 있습니다.

 

이제 릴리즈 노트에서 언급된 “ref가 reactive로 감싸진 경우 발생하는 무한 재귀 이슈”를 톺아보러 가보겠습니다!

운영중인 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 등)를 사용하시는 것을 추천드립니다!

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

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

링크

코타키나발루 투아란 선셋

1. 개선 방향

bundle size 분석을 위하여 webpack-bundle-analyzer를 사용하였습니다. [부록 4.1]

성능 개선을 위하여 번들파일 분석을 진행하였고, 우선적으로 가장 사이즈가 큰 vender 파일을 중점으로 개선방향을 모색하였습니다.

현재 벤더파일의 문제점으로 용량이 큰 라이브러리(lodash, moment)들이 트리쉐이킹 되지지 않은 채 통째로 번들링되어 벤더파일로 적재되어 있는것을 발견하였고 이를 경량화 하는것을 이번 태스크의 목표로 잡았습니다.S


2. 개선 과정

단순 타겟 라이브러리의 개선 결과가 아닌 과정을 기록한 이유는 향후 다른 라이브러리를 도입하게 될 때 이번 트리쉐이킹에서 배제된 기술들이 더 용이한 경우도 있어 참고용으로 기록하였습니다.

2.1 lodash

현재 프로젝트에서 lodash를 사용하는 구문은 아래와 같습니다.

import { orderby } from 'lodash'; //71.1k

현재 개발환경에서 named import 를 사용하는건 탁월한 선택입니다. webpack에서 자동으로 tree shaking을 진행하여 사용되지 않은 메소드들은 번들파일에 포함되지 않기 때문입니다. 하지만 예외 사항으로 위의 lodash 처럼 commonJS 모듈을 사용하는 경우는 webpack은 트리쉐이킹을 진행하지 않습니다.

이를 해결하기 위하여 lodash를 트리쉐이킹하는 보편적인 3가지 방법을 소개하겠습니다

2.1.2 default import

import orderBy from "lodash/orderBy"; //20.8k

const arr = [1, 2, 3, 4, 5];

orderBy(arr, (num) => {
  console.log(num, "hello");
});

장점: lodash는 default import가 가능하며, 지정한 메서드만 사용하기 때문에 이미 트리쉐이킹 한 효과를 줄 수 있습니다.

단점: lodash의 메서드를 사용하기 때문에 lodash-es보다 사이즈가 크며 lodash의 여러가지 기능을 사용 시 부수적인 작업이 필요합니다.

ex) 여러개의 메소드드 가져올 시 webpack 세팅 필요

// 개별 import
import orderBy from "lodash/orderBy"; 
import debounce from "lodash/debounce"; 

//여러개 import 시 webpack 세팅 필요
import {orderBy, debounce} from "???"

2.1.3 lodash-es

import { orderBy } from "lodash-es"; //16.4k
const arr = [1, 2, 3, 4, 5];

orderBy(arr, (num) => {
  console.log(num, "hello");
});

장점: es6 구문으로만 쓰여저 가장 크기가 작으며, commonJS 모듈을 사용하지 않아 웹팩에서 tree shaking을 지원해줍니다. 즉 import 시 조금만 신경을 쓸 시 webpack 세팅이나 babel-plugin-lodash 등을 사용하지 않아도 자유롭게 사용 가능합니다.

단점: lodash uninstall 후 lodash-es 설치, import 변경 ‘lodash’ ⇒ ‘lodash-es’

2.1.4 개선 결과

2.1.3 lodash-es를 채택하여 현 프로젝트에 적용 시켜 보았습니다.

부수적인 코드 작성 없이 성능개선을 이루어낼 수 있었습니다


2.2 moment

현 프로젝트에서 moment의 사용은 아래와 같이 이루어져 있습니다.

import moment from 'moment';
formatDate(date) {
    return date ? moment(date.slice(0, 8)).format('MM월 DD일') : '';
}

현재 moment의 일부 기능만 사용하지만 모든 moment 기능이 트리쉐이킹 되지 않고 남아있는것을 볼 수 있습니다. 이를 개선하기위한 3가지 방법을 소개하겠습니다.

2.2.1 IgnorePlugin

const webpack = require('webpack')
module.exports = {
  plugins: [
    new webpack.IgnorePlugin(/^\\.\\/locale$/, /moment$/),
  ],
}

웹팩 내장 메서드를 사용하여 moment의 locale을 전부 무시할 수 있습니다. locale을 사용하지 않고 순수 date조작 기능만 사용하는 경우 유용한 방법입니다.

2.2.2 ContextReplacementPlugin 사용

const webpack = require('webpack')
module.exports = {
  plugins: [
    new webpack.ContextReplacementPlugin(/moment[/\\\\]locale$/, /ko/),
  ],
}

웹팩 내장 메서드를 사용하여 moment의 특정 locale만 추가하여 가져올 수 있는 방법입니다. date조작 및 특정 언어의 지원이 필요할 시 유용한 방법입니다.

2.2.3 moment → dayjs

moment를 뜯어내고 dayjs를 도입하는 방법입니다.

moment 단점

  • 내부적으로 정규표현식을 사용하여 타 date 라이브러리보다 느림
  • 용량이 무겁고 locale을 트리쉐이킹 하더라도 타 data 라이브러리 보다 무거움
  • Moment 라이브러리의 개발 중단(업데이트 x 트리쉐이킹 개선 x)

dayjs 장점

  • Immutable
  • 2KB의 경량 사이즈
  • moment의 대부분의 기능 대체 가능 및 사용법이 비슷해 러닝커브가 짧음
  • 가장많이 쓰는 date 라이브러리

2.2.4 개선 결과

2.2.2의 contextReplacementPlugin을 사용하여 해결하였습니다.

webpack에 간단한 플러그인을 추가하여 성능 개선을 이루어 낼 수 있었습니다.

2.2.1 미채택 - 현 프로젝트에서 moment.local(’ko’) 의 사용

2.2.3 미채택 - 팀원들의 협의가 필요


3. 개선 결과

번들사이즈 전/후

빌드 속도 전/후

빌드 속도 측정을 위하여 [부록 4.2]의 speed-measure-webpack-plugin 를 사용하였습니다.

청크 사이즈 축소 435kb → 350 kb

번들 사이즈 측면에서 10%이상의 개선효과를 볼 수 있었으며, 빌드 속도 측면에서는 40%이상의 개선 효과가 있었습니다.

4. 부록

4.1 webpack-bundle-analyzer

https://github.com/webpack-contrib/webpack-bundle-analyzer

4.1.1 소개

웹팩 번들 분석 플러그인 입니다. 웹팩을 이용하여 번들링되는 모듈들을 분석해서 시각적으로 나타냅니다.

자세한 사용법은 사이트 참조

4.1.2 사용법

# install
npm install --save-dev webpack-bundle-analyzer
//usage (as a plugin)
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
    ...
  plugins: [
    new BundleAnalyzerPlugin() //webpack 플러그인에 추가
  ]
    ...
}

실행 방법

  1. npm 설치
  2. 플러그인 추가
  3. 컴파일 시 자동실행

4.2 speed-measure-webpack-plugin

https://github.com/stephencookdev/speed-measure-webpack-plugin

4.1.1 소개

웹팩 번들 분석 플러그인 입니다. 웹팩을 이용하여 번들링되는 모듈들을 분석해서 시각적으로 나타냅니다.

자세한 사용법은 사이트 참조

4.1.2 사용법

# install
npm install --save-dev speed-measure-webpack-plugin
//usage (as a plugin)
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
})

//smp.wrap으로 webpack 설정 감싸주기
//vue cli 기준 configureWebpack: smp:wrap({}) 자세한 이유는 vue cli 참조

실행 방법

  1. npm 설치
  2. 플러그인 추가
  3. 컴파일 시 자동 실행

코타키나발루 사피섬

해당 포스트는 하루에 하나씩 FE 개발자 면접 문제 오픈 채팅방에서 매일 출제되는 퀴즈를 토대로 학습 및 토론내용을 정리한 게시글입니다.

1. Quiz 및 답변

퀴즈

안녕하세요~ 좋은 하루입니다! 오늘의 FE의 질문은!!

가상 DOM의 동작 원리를 설명하고, 실제 DOM과의 차이점을 설명해 주세요. 또한, Virtual DOM을 사용하는 React의 렌더링 성능 최적화 방식에 대해 설명하고, shouldComponentUpdate 또는 React.memo와 같은 기법이 어떤 역할을 하는지 설명하세요.

답변

 가상 DOM은 DOM의 업데이트 비용을 줄이기 위해 설계되었으며, 실제 DOM의 구조를 메모리에 경량화시킨 객체로 표현한 것입니다.
리액트는 상태변경이 일어나면 가상 DOM을 생성해 이전 가상 DOM과 비교하여 실제DOM에 필요한 최소한의 업데이트만 수행하는 방식으로 렌더링 비용을 최소화 합니다.

 React.memo는 리액트에서 props가 동일함에도 리렌더링 계산이 일어나는 비용을 방지하기 위해 고안되었으며, 컴포넌트의 props를 메모해 이전값과 같다면 리렌더링 계산을 일으키지 않도록 설계하였습니다.

2. 토론

일치하는 답변은 가상 DOM은 DOM 조작 비용을 최소화 시켜준다! 와 React.memo는 불필요한 리렌더링을 방지하는 최적화 기법이다! 의 내용이였습니다.

관련하여 다양한 이야기가 오고갔고, 대표적인 몇가지 사례만 공유하겠습니다.

 

Q.1 '🤔  대체 불필요한 리렌더링이랑 무엇일까..'

대체 불필요한 리렌더링이랑 무엇일까.. 애니메이션 아니면 딱히 보이지도 않지 않나 라는 의문이 생겨요!
리액트 성능최적화는 "불필요한 리렌더링을 피하라"가 아니라 초당 16ms 안에 모든 계산을 끝내라가 핵심인거 같아요. 결국 리액트 씀으로써 버벅거리는건 초당 60프레임을 유지 못하기 때문이라서 그렇다고 생각하는데.. 이 부분에 대해서 어떻게 생각하시나요 여러분?

 

 

FE 토론방의 장점같은 질문이 나왔습니다.!

 사담을 펼치자면 저는 요즘들어 깊게 고민하지 않고 타인이 알려주는 해설지를 가지고 개발하는 경우가 많아졌어요.. 교보재나 강의, AI등의 조언을 듣고 가장 이상적인 방법을 선택하는거죠! 이전보다 혁신적으로 개발이 편해졌지만 이전처럼 한 가지 문제를 가지고 더 나은 방향성은 무엇일까? 고민하는 시간들이 줄어든것 같아요! 그 점에서 위와같은 질문은 오랜만에 느껴보는 고민이였어요!

 

[사고의 맥락]
  불필요한 렌더링을 어떤 관점에서 보고 계실까.. 맥락상 1프레임안에 다발적으로 일어나는 렌더링까지 최소화할 필요가 있나? 라는 관점에서 이야기를 하신것 같습니다. 저도 무의식적으로 모든 최적화를 자연스럽게 진행하지만 이것또한 비용이기 때문에 위와같은 고민은 공감이 갔습니다.

'왜 나는 무의식적으로 모든 최적화를 진행하였을까? 그렇다면 최적화를 후순위로 미루어볼까?''


 위와 같은 생각을 하자마자 든 결론은 미리 하는게 낫다! 라는 결론이 였습니다. 아래와 같은 고민들을 하게 되었습니다.

 

1프레임 안에 리렌더링 되는 컴포넌트 기준을 무엇으로 할까?
확장성이 올라가서 여러 컴포넌트끼리 묶였을때는 최적화를 진행해야겠다!


슬슬 머리가 복잡해 지는 시점에 다시금 내린 결론은 아래와 같습니다.

 

'최적화를 하면서 개발하는 비용보다, 할지 말지를 고려하는 비용이 더 큰 것 같다!'

 

 

[당시 답변]

 

 저는 '안해도 될 것을 왜 해야하는가?' 에 초점을 맞추어 대답을 진행하였습니다. 당연히 하는게 좋지만 안해도 문제가 없으면 굳이 안해도 되는 부분을 수용하였고, 하지만 그때 고려해야 할 비용이 하는것 보다 크다면 하는게 나을 것 같다라는 의견을 제시하였습니다.


 

3. 후기

 

 이전에 리액트를 공부할때 'React.memo 는 리렌더링을 방지한다' 라는 관점에서 가상DOM이 있으면 실제DOM에 리렌더링되지 않을텐데 왜 React.memo가 없으면 리렌더링을 일으킨다 하는거지? 와 같이 실제DOM부분에서 리렌더링이 없는데 그러면 'React.memo 는 리렌더링을 방지한다' 라는 표현은 틀린게 아닐까? 와 같은 생각을 했었습니다.

 

 결과적으로 React.memo는 실제 DOM이 아닌 가상 DOM 수준에서의 렌더링을 방지하는것이였습니다! React.memo는 컴포넌트의 props가 변경되지 않았을 때 가상 DOM에서의 렌더링 계산을 건너뛰게 하여, 불필요한 가상 DOM 업데이트를 방지하였습니다.

 

리액트의 렌더링 최적화가 가상 DOM에서 이루어지고, 실제 DOM 업데이트는 diffing 과정에서 최소화된다는 개념을 이해하고 있었다면 크게 고민하지 않아도 될 내용이였습니다.

 

이전 학습내용이 떠오르는 재미있는 토론 주제였습니다!

 

읽어주셔서 감사합니다.

 

 

 

 

머리말

자바스크립트는 싱글 스레드에서 작동한다. 즉 한 번에 하나의 작업만 처리할 수 있는데, 현대의 모던 웹 애플리케이션에서는 네트워크 요청이 발생하는 순간에도 다른 작업을 처리할 수 있다. 이는 어떻게 된 일일까?

이를 이해하려면 비동기 작업이 어떻게 처리 되는지 이해하고 비동기 처리를 도와주는 이벤트 루프를 비롯한 다양한 개념을 알고 있어야한다. 이번 포스트에서는 자바스크립트가 어떻게 여러가지 요청을 동시에 처리하고 있는지, 요청받은 테스크에 대한 우선순위는 무엇인지 파악해보자

KeyPoint

스택, 큐, 이벤트루프, 태스크 큐, 마이크로 태스크큐

런타임 환경과 함께 내용을 알아보자


Stack

함수의 호출들은 ‘스택’을 형성합니다 아래 예시코드를 들어보겠습니다

function foo(b) {
  const a = 10;
  return a + b + 11;
}

function bar(x) {
  const y = 3;
  return foo(x * y);
}

const baz = bar(7);
  1. bar(7)을 호출할 때 인수와 지역변수를 포함하는 첫 번째 프레임이 생성됩니다.
  2. bar가 foo를 호출할 때 위와같은 방식으로 두 번째 프레임이 생성됩니다.
  3. 이제 foo가 반환되면, 맨 위의 프레임 요소를 스택 밖으로 꺼냅니다.
  4. 마지막으로 남아있던 bar가 반환되며, 스택이 비어있게 됩니다.

이때 콜스택이 가득차면, 흔히보던 Uncaught RangeError: Maxium call stack size exceeded 오류를 만나볼 수 있습니다.


큐(Queue)

자바스크립트의 런타임은 메세지 큐, 즉 처리할 메세지의 대기열을 사용하는데. 각각의 메세지에는 메세지를 처리하기 위한 함수가 연결되어 있습니다.

이벤트 루프가 시작되는 시점에, 런타임은 대기열에서 가장 오래된 메세지부터 큐에서 꺼내 처리하기 시작합니다. 이를 위해 런타임은 꺼낸 메세지를 매개 변수로, 메세지에 연결된 함수를 호출합니다. 이 때 함수를 호출하면 해당 함수가 사용할 새로운 스택 프레임이 생성됩니다.

(setTimeout이 시작되는 시점에 해당 함수에 관된 내용이 스택에 쌓임)

여기서 이벤트 루프가 시작되는 시점이 대체 언제냐? 이벤트 루프를 통해 알아봅시다.


이벤트 루프

이벤트루프는 이벤트 루프만의 단일스레드 내에서 두 가지 역할을 합니다.

  1. 호출 스택이 비어있는지 확인
  2. 호출 스택 내부에 수행해야 할 작업이 있는지 확인하고, 수행해야 할 코드가 있으면 자바스크립트 엔진을 이용해 실행

코드를 통해 알아봅시다.

function foo(){
  setTimeout(() => console.log('bar'));
  console.log('foo');
}
foo()
  1. foo가 호출스택에 들어감
  2. setTimeout이 호출스택에 들어감
  3. setTimeout이 타이머 이벤트에 의해 큐로 들어가고 스택에서 제거됨.
  4. console.log(‘foo’)가 스택에 들어간 후 실행되고 스택에서 빠짐.
  5. 이벤트 루프가 호출 스택이 비워져있다는 것을 확인
  6. 이벤트 루프가 큐를 확인하여 수행해야 할 작업이 있나 확인 후 있다면 스택에 넣음
  7. 스택 실행 console.log(‘bar’) 실행

 

여기서 궁금할 수 있는 점으로는 setTimeout과 같이 n초를 기다리거나 fetch를 기반으로 실행되는 네트워크 요청은 누가 보내고 응답 받는것일까? 입니다.

 이러한 작업들은 전부 자바스크립트 메인 스레드가 아닌 태스크 큐가 할당되는 별도의 스레드에서 실행됩니다. 바로 브라우저나 Node.js에서 말입니다!(Web API등 외부에서 실행되고 큐로 들어감)

자 이제 우리는 이벤트루프를 통해 스택과 큐가 어떻게 작동 하는지 알게 되었습니다. 이제 마지막 챕터인 마이크로 태스크 큐로 넘어가 봅시다.


큐는 사실 FIFO가 아닌 set형태를 띄고 있다. 선택된 중 ‘실행 가능한’ 가장 오래된 태스크를 가져와야하기 때문이다.

 이때까지 큐라고 불렀던 공간은 흔히 태스크 큐(task queue) 또는 매크로 태스크 큐(macrotask que)라고들 합니다. 이번에는 이 태스크 큐보다 우선순위가 높은 마이크로 태스크 큐에대해 알아봅시다.

 

마이크로 태스크 큐는 대표적으로 Promise가 있다. 즉 setTImeout, 클릭 이벤트 등은 Promise보다 늦게 실행이 됩니다.

명세에 따르면 마이크로 태스크 큐가 빌 때까지는 기존 태스크 큐의 실행은 뒤로 미루어집니다.

  • 태스크 큐: 이벤트 핸들러, setTImeout, setInterval, promise를 사용하지않은 AJAX 요청
  • 마이크로 태스크 큐: Promise, queueMicrotask 함수, MutationObserver

이때 중요한 점으로 브라우저 렌더링 작업은 주로 태스크 큐에 속하고 있으며, Dom 변경에 대한 콜백이 Promise의 then() 또는 MutationObserver와 같은 API를 통해 등록된 경우가 아닌 이상 마이크로 태스크 큐태스크 큐 사이에 렌더링이 일어납니다.

 


 끝까지 읽어주셔서 감사합니다.! 이번 글을 통해 조금이라도 도움이 되셨다면 좋겠습니다. 마무리삼아 퀴즈를 하나 내드리겠습니다.

아래 코드는 어떤 순서대로 실행이 될까요??

const quiz = () => {
  console.log('1');

  Promise.resolve().then(() => {
    console.log(2);
  });

  setTimeout(() => console.log(3));

  window.requestAnimationFrame(() => console.log(4));
};

quiz();

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

Vue Ref 톺아보기  (0) 2025.03.26
빌드시스템은 왜 필요할까?  (0) 2025.03.26
번들사이즈 최적화  (0) 2024.09.30

호이안

해당 포스트는 하루에 하나씩 FE 개발자 면접 문제 오픈 채팅방에서 매일 출제되는 퀴즈를 토대로 학습 및 토론내용을 정리한 게시글입니다.

 

1. Quiz 및 답변

퀴즈

안녕하세요~ 좋은 하루입니다! 오늘의 FE의 질문은!!

Q1. 자바스크립트의 event loop와 call stack의 동작 방식을 설명하고, microtask와 macrotask 큐의 차이점을 설명해 주세요. 이를 통해 비동기 코드가 실행되는 순서에 대해 설명해 주세요.

답변

 싱글스레드인 자바스크립트의 실행 방식을 보완하기 위하여 브라우전 엔진 및 노드등은 이벤트 루프와 같은 설계 방식을 선택하였습니다!
이벤트 루프는 자바스크립트의 콜스택이 비게되면 태스크큐에 있는 작업을 순차적으로 실행시켜 자바스크립트에서 비동기 로직을 가능하게 합니다. 이때 큐 하나로는 비동기 작업의 우선순위 및 효율성을 지키기 어려워 **매크로 태스크 큐****마이크로 태스크 큐** 두 가지 큐를 통하여 비동기 처리의 일관성을 유지합니다.
마이크로 태스크큐는 주로 프로미스나 뮤테이션 옵져버가 들어가며, 매크로 태스크 큐는 그 이외의 대부분 비동기 작업이 들어갑니다.

2. 토론

 대부분의 답변들이 콜스택과 태스크 큐를 강조하며, 태스크 큐와 마이크로 태스크 큐의 차이점, 그리고 각 큐에는 어떤 작업들이 들어가게 되는지를 설명해 주었습니다. 이 중 이슈가 되는 답변과 그에 대한 저의 생각들을 나열해보겠습니다.

 

Q.1 '🤔 자바스크립트가 동기적으로 처리한다는 말이 약간 애매하지않을까요 ?'

 

 저는 질문의 의도를 아래와 같이 생각하였습니다.

 

[사고의 맥락]
'자바스크립트는 비동기도 되니깐 동기라고 하기에는 조금 애매한거 같다' 라고 생각을 하시고 있으신가?. 라고 생각하였으며, 그에 대해 저는 '자바스크립트 자체는 싱글 스레드로 동기가 맞으며, 비동기를 가능하게 한 것 은 런타임 에서 이벤트루프와 같은 설계를 도입하여 싱글스레드를 보완 한 것이다' 와 같이 생각하였습니다.

 

 

[당시 답변]

이와 관련하여 '동기적인데 이벤트루프, 큐로 인해서 비동기처럼 보이는거 뿐일거로 알고 있어요' 와 같은 답변들도 있었으며, 해당 질문을 통하여 토론방에서 '자바스크립트는 어떻게 비동기를 실행할까' 에 대한 이상적인 토론이 오고갔습니다.


Q.2 console.log 랑 value값이 어떻게 찍힐지 예상해보세요~!

감사하게도 이와 관련된 재미있는 문제를 올려주신분이 있습니다.

 let value = 100;

const delay = () => {
  return new Promise((resolve, reject) => {
    console.log(0, value);
    setTimeout(() => {
      console.log(1, value);

      value = 200;
      console.log(2, value);
      resolve(value);
    });
  });
};

(async () => {
  const output = await delay();
  console.log("3", output);
})();

value = 300;

 

[사고의 맥락]

1️⃣ console.log(0, value); → 0 100

  • delay() 함수가 실행되면, 동기적으로 console.log(0, value);가 실행됩니다.
  • 이 시점에서 value는 100입니다.

2️⃣ value = 300;

  • 비동기 함수인 **await delay()**는 Promise가 처리될 때까지 일시 중단됩니다.
  • value는 300으로 변경됩니다.

3️⃣ setTimeout 콜백 실행 → 1 300

  • 이벤트 루프에 의해 **setTimeout()**이 실행되면, 변경된 **value = 300**이 출력됩니다.

4️⃣ value = 200; → 2 200

  • setTimeout 내부에서 **value**가 200으로 업데이트되고, **2 200**이 출력됩니다.

5️⃣ resolve(value);

  • Promise가 fulfilled 상태가 되어, await 다음 줄이 실행될 준비가 됩니다.
  • 이때 value는 200입니다.

6️⃣ console.log("3", output); → 3 200

  • **output**에는 **resolve(value)**로 전달된 200이 저장되어 출력됩니다.

[당시 답변]

0 100

1 300

2 200

3 200

 

 

[정답]

 

 

[공유]

Node.js와 V8의 이벤트 루프 차이

V8 엔진 (브라우저 환경) Node.js의 이벤트 루프
V8 엔진에서는 이벤트 루프가 매크로태스크(예: setTimeout)보다 항상 마이크로태스크(예: Promise, process.nextTick)를 우선 처리합니다. 이는 이벤트 루프의 각 반복에서 콜 스택이 비워질 때마다 마이크로태스크 큐를 우선적으로 비우는 구조입니다. Node.js는 V8 엔진 위에 구축된 환경이지만, 이벤트 루프의 동작 방식은 libuv 라이브러리에 의해 제어됩니다. Node.js 이벤트 루프는 여러 단계로 나뉘며, 각 단계가 끝날 때마다 마이크로태스크 큐를 비우지 않습니다. 대신, 특정 단계에서만 마이크로태스크(예: Promise의 후속 작업)가 처리됩니다.

3. 후기

이전에 블로그 포스팅을 했던 내용이라 꽤 관심있게 토론을 진행하였습니다. 다시금 자바스크립트 동작원리에 대해 공부할 수 있어 좋았고 다음에 더 흥미로운 주제로 찾아 뵙겠습니다!!

+ Recent posts