← 글 목록

notes

14장. 전역 변수의 문제점

모던 JS Deep Dive — 전역 변수의 문제와 모듈·스코프 관리 핵심 정리.

참고: 이웅모 《모던 자바스크립트 Deep Dive》 14장 · [MDN JavaScript modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)

예전에 스크립트를 이어 붙이다 config가 바뀌었다. 범인은 전역이었다. 가시성(누가 보나)과 수명(얼마나 사나)을 나누면 전역·지역·클로저가 갈린다. 이 장은 “로비에 왜 붙이면 아픈지”와 “어떻게 덜 붙이나”다.

flowchart LR
  L[지역] -->|호출 끝| GC[참조 없으면 회수 후보]
  G[전역] --> APP[앱/탭 수명]
  C[클로저] -.->|선택 연장| L
  IIFE --> NS[네임스페이스] --> MP[모듈 패턴] --> ESM[ES Module]

14.1 변수의 생명 주기

변수는 선언으로 생기고, 할당으로 값을 얻고, 언젠가 역할이 끝난다. 생명 주기가 없으면 한 번 만든 이름이 프로그램이 끝날 때까지 메모리를 붙잡는다.

14.1.1 지역 변수

함수 안에서 선언한 지역 변수는 그 함수가 호출될 때 생기고, 함수가 끝나면(그리고 바깥에서 더 이상 참조하지 않으면) 수명이 끝난다. 호출마다 새 환경이 생긴다고 보면 된다.

function foo() {
  var x = 'local';
  console.log(x); // local
  return x;
}
foo();
// console.log(x); // ReferenceError — 바깥에 x 없음

WHY: “함수가 곧 수명의 울타리”라는 감각이 클로저(24)의 출발점이다. 울타리 밖으로 참조가 새면 수명이 길어진다.

14.1.2 전역 변수

전역 코드는 main 같은 진입점 호출 없이 로드되면 바로 평가·실행된다. var로 선언한 전역은 전역 객체의 프로퍼티가 되고, 수명은 전역 객체(앱/탭)와 운명을 같이한다. 길다. 너무 길다.

var globalFlag = true; // 앱이 살아있는 동안 이름·값이 남아 충돌 기회도 김

브라우저에선 window.globalFlag로도 보인다(var 전역). let/const 전역은 성격이 다르다(15장).

14.2 전역 변수의 문제점

암묵적 결합

“어디서든 읽·쓸 수 있다”는 말은 “모든 코드가 서로를 몰라도 같은 상태를 만질 수 있다”는 뜻이다. 유효 범위가 클수록 가독성은 떨어지고, 의도치 않은 변경 확률은 올라간다.

긴 생명 주기

오래 사니까 메모리도 오래 먹고, 값이 바뀌는 시간도 길다. var는 중복 선언까지 허용해서, 긴 수명 + 이름 충돌이 겹치면 “누가 덮었는지” 추적이 지옥이다.

그 외로 체감한 것들

  • 테스트 순서 의존 (“이 파일 먼저 로드해야 통과”)
  • 라이브러리 두 개가 같은 전역 이름 사용
  • 디버거 스코프 창에 잡음이 가득

전역을 절대 쓰지 말라는 뜻이 아니다. 환경이 주는 전역(window, document, process…)은 있다. 줄일 것은 도메인 상태를 로비 게시판에 못 박는 습관이다.

14.3 전역을 억제하는 방법

스코프는 좁을수록 안전하다. 억제 수단은 시대순으로 進化했다.

14.3.1 즉시 실행 함수(IIFE)

정의와 동시에 한 번 호출. 그 안의 var는 지역이 된다. 라이브러리 초기화·레거시 번들에 남아 있다.

(function () {
  var foo = 10; // IIFE 지역
  // ...
})();
// console.log(foo); // ReferenceError

WHY: “파일 전체를 한 칸에 넣기”의 고전. 지금은 ESM이 같은 일을 문법으로 한다.

14.3.2 네임스페이스 객체

전역에 객체 하나만 두고, 나머지는 그 프로퍼티로 모은다. 충돌면은 줄어들지만 전역 하나는 남는다. 계층을 깊게 파면 MYAPP.foo.bar 지옥도 온다.

var MYAPP = {};
MYAPP.name = 'Lee';
MYAPP.person = { name: 'Lee', address: 'Seoul' };

14.3.3 모듈 패턴

관련 상태·행동을 IIFE로 묶고, 공개할 것만 객체로 반환. 닫힌 변수는 클로저로 남는다(캡슐화). 24장을 예고하는 패턴이다.

const Counter = (function () {
  let value = 0; // 외부에서 직접 못 만짐
  return {
    inc() { return ++value; },
    get() { return value; },
  };
})();

console.log(Counter.inc()); // 1
console.log(Counter.get()); // 1

WHY: “전역 억제”와 “비공개 상태”를 한 번에. 다만 패턴 코드가 장황해지면 ESM+클로저/프라이빗 필드로 옮긴다.

14.3.4 ES6 모듈

파일이 곧 모듈 스코프. var로 선언해도 그 파일 밖 전역·window 프로퍼티가 되지 않는다. export한 것만 공개.

<script type="module" src="lib.mjs"></script>
<script type="module" src="app.mjs"></script>
// lib.mjs
export const secret = 42;
export function getSecret() { return secret; }

// app.mjs
import { getSecret } from './lib.mjs';
console.log(getSecret());

WHY: 억제 수단의 종착역에 가깝다. 번들러·"type":"module"이 기본인 이유.

표

예제 — 왜 이렇게 쳐 봤냐

// 나쁜 습관: 설정이 로비에
var config = { api: '/v1' };

// 나은 습관: 모듈 칸
// config.mjs
export const config = { api: '/v1' };
// 레거시 스크립트 격리
(function () {
  'use strict';
  var config = { api: '/v1' };
  window.App = window.App || {};
  App.getConfig = function () { return config; };
})();

WHY: 꼭 전역에 노출해야 하면 표면적(API)만 내보내고 알맹이는 칸 안에.

헷갈리던 장면 / 함정

  • “전역 금지”를 환경 전역까지 금지로 오해
  • 네임스페이스 객체만으로 안전하다고 착각 (여전히 전역 하나 + 뮤터블)
  • 모듈 패턴을 매 파일에 복붙 (ESM으로)
  • 스크립트 태그 여러 개로 전역 공유 → 로드 순서 버그
  • 클로저로 수명 연장해 놓고 GC가 안 된다고 놀람 (참조를 들고 있는 쪽을 보라)

질문 → 자답

Q. 전역이 꼭 필요한 경우는? A. 브라우저 빌트인, 앱 부트스트랩의 아주 얇은 공개면, 임베드 위젯의 window.Xxx 훅 정도. 도메인 상태는 칸 안으로.

Q. IIFE vs 모듈 패턴? A. IIFE는 “칸 만들기”, 모듈 패턴은 “칸 + 공개 API 객체 반환”. 후자가 클로저를 드러낸다.

Q. 지역인데 안 죽으면? A. 누군가(클로저·배열·리스너)가 참조 중. 수명은 스코프 선언이 아니라 도달 가능성이 결정한다.

웹 보강 메모

MDN Modules 가이드와 type="module" defer 기본 동작을 다시 읽었다. 레포에서 window.에 도메인 상태를 붙인 곳을 검색해, export 가능한지 표시했다.

실무로 바로

  • 새 코드: ESM
  • 레거시 진입: IIFE + 최소 공개면
  • 설정·시크릿: 모듈 칸, 필요 시 빌드 타임 env
  • PR 질문: “이 이름이 로비에 있어야 하는 이유?”

가시성 vs 수명 — 한 번 더

“안 보이는데 안 죽는다”가 클로저다. “보이는데 오래 산다”가 전역이다. 둘을 섞어 생각하면 설계가 무너진다.

암묵적 전역과의 접점 (20장 예고)

선언 없이 x = 1을 쓰면 슬롭 모드에선 전역 프로퍼티가 생긴다. 수명은 전역과 같고, 가시성도 전역이다. 14의 문제 + 20의 에러 스위치가 한 줄에서 만난다.

function leak() {
  // 'use strict' 없으면 슬롭에서 전역 오염 가능
  // leaked = 1;
}

실전 리팩터 스케치

// Before: 여러 파일이 window.STATE를 만지며 싸운다
// After: 상태 모듈 하나 + 명시적 액션만 export
// state.mjs
let items = [];
export function addItem(item) {
  items = [...items, item];
}
export function getItems() {
  return items;
}

WHY: 변이 경로를 함수로 모아 두면 로그·테스트·시간 여행 디버깅이 쉬워진다. 전역 억제는 곧 변이 표면 축소다.

브라우저 전역을 대하는 태도

document, fetch, localStorage는 환경이 준 도구다. 감추기보다 어댑터로 감싸 테스트에서 교체 가능하게 만든다. “전역 사용 = 죄”가 아니라 “전역 상태 = 위험”에 가깝다.

여기서 잠깐 💁‍♂️

15장은 블록 칸(let/const), 24장은 수명을 선택 연장하는 클로저. 14는 “왜 로비가 위험한지”의 출발점이다.