JavaScript 스코프 관리: 전역 변수 vs IIFE 모듈 패턴

“왜 내 코드가 다른 스크립트랑 충돌하나요?”

프로젝트에 새 라이브러리를 추가했더니 갑자기 카운터가 작동하지 않았습니다.

// 내 코드 (counter.js)
var count = 0;
function increment() {
    count++;
    console.log('My count:', count);
}

// 새로 추가한 라이브러리 (analytics.js)
var count = 0;  // ⚠️ 같은 이름!
function increment() {
    count += 5;
}

// 버튼 클릭
increment();  // 예상: 1 증가, 실제: 5 증가!

“전역 변수가 뭐가 문제인가요?”

이 글에서는 전역 스코프의 문제점과 IIFE 모듈 패턴으로 코드를 안전하게 캡슐화하는 방법을 알아봅니다.


패턴 1: 전역 변수 사용

기본 예제: 간단한 카운터

전역 변수는 JavaScript에서 가장 기본적인 변수 선언 방식입니다. var, let, const를 최상위 레벨에서 선언하면 전역 스코프에 속합니다.

// 전역 변수
var count = 0;
var userName = 'Alice';

// 전역 함수
function increment() {
    count++;
    updateDisplay();
}

function updateDisplay() {
    console.log(`${userName}: ${count}`);
}

// 어디서든 접근 가능
increment();  // Alice: 1
increment();  // Alice: 2

특징:

  • 파일 어디서든 접근 가능
  • 다른 파일에서도 접근 가능
  • 브라우저 콘솔에서도 접근 가능

전역 변수의 문제점

문제 1: 이름 충돌 (Name Collision)

여러 스크립트가 같은 변수명을 사용하면 덮어씌워집니다.

// ========== script1.js ==========
var data = { user: 'Alice', score: 100 };

function saveData() {
    localStorage.setItem('data', JSON.stringify(data));
}

// ========== script2.js (다른 팀이 작성) ==========
var data = { items: [1, 2, 3] };  // ⚠️ 덮어씌움!

function processData() {
    console.log(data.user);  // undefined (기대했던 값이 아님)
}

// ========== index.html ==========
<script src="script1.js"></script>
<script src="script2.js"></script>  // data가 덮어씌워짐
<script>
    saveData();  // 에러! script1의 data가 사라짐
</script>

실제 발생 시나리오:

  • 여러 개발자가 협업할 때
  • 외부 라이브러리 추가 시
  • 레거시 코드와 새 코드 통합 시

문제 2: 외부에서 수정 가능 (No Encapsulation)

전역 변수는 누구나 수정할 수 있습니다.

// 내 코드
var maxAttempts = 3;
var currentAttempts = 0;

function tryLogin() {
    if (currentAttempts >= maxAttempts) {
        alert('Too many attempts!');
        return;
    }
    currentAttempts++;
    // 로그인 로직...
}

// 악의적인 사용자가 개발자 콘솔에서
maxAttempts = 999999;  // ⚠️ 보안 우회!
currentAttempts = 0;   // ⚠️ 재설정 가능!

// 또는 다른 스크립트가 실수로
currentAttempts = "문자열";  // ⚠️ 타입까지 변경!

보안 위험:

  • 브라우저 확장 프로그램이 접근 가능
  • XSS 공격 시 조작 가능
  • 사용자가 개발자 도구로 수정 가능

문제 3: 네임스페이스 오염 (Namespace Pollution)

변수가 많아질수록 전역 공간이 혼잡해집니다.

// 여러 기능을 추가하다 보면...
var userCount = 0;
var adminCount = 0;
var guestCount = 0;
var totalCount = 0;
var activeCount = 0;
var inactiveCount = 0;

function incrementUserCount() { userCount++; }
function incrementAdminCount() { adminCount++; }
function incrementGuestCount() { guestCount++; }
// ... 수십 개의 함수

// window 객체가 오염됨
console.log(Object.keys(window).length);  // 수백 개...

문제점:

  • 어떤 변수가 어디서 사용되는지 추적 어려움
  • 실수로 기존 변수를 덮어쓸 위험
  • 메모리 관리 어려움 (가비지 컬렉션 불가)

문제 4: 테스트 어려움

전역 상태는 테스트 간 독립성을 해칩니다.

// 전역 변수 사용
var items = [];

function addItem(item) {
    items.push(item);
}

// ========== 테스트 ==========
// 테스트 1
addItem('apple');
console.assert(items.length === 1);  // ✅ 통과

// 테스트 2
addItem('banana');
console.assert(items.length === 1);  // ❌ 실패! (length가 2)
// 테스트 1의 영향을 받음

// 매 테스트마다 초기화 필요
items = [];  // 수동으로 리셋해야 함

패턴 2: IIFE 모듈 패턴

IIFE란?

IIFE (Immediately Invoked Function Expression): 정의되자마자 즉시 실행되는 함수 표현식입니다.

// 기본 구조
(function() {
    // 이 안의 코드는 즉시 실행됨
    console.log('Hello!');
})();

// 화살표 함수 버전
(() => {
    console.log('Hello with arrow function!');
})();

구조 분해:

  1. () - 함수를 표현식으로 만듦
  2. function() { } - 익명 함수 정의
  3. () - 즉시 실행

IIFE로 스코프 격리하기

IIFE를 사용하면 변수와 함수를 독립적인 스코프에 가둘 수 있습니다.

// ========== 전역 스코프 ==========
(() => {
    // ========== 격리된 스코프 ==========
    let count = 0;

    function increment() {
        count++;
        console.log('Count:', count);
    }

    function decrement() {
        count--;
        console.log('Count:', count);
    }

    // 이벤트 연결
    document.getElementById('btn-inc')?.addEventListener('click', increment);
    document.getElementById('btn-dec')?.addEventListener('click', decrement);
})();

// 외부에서 접근 불가
console.log(count);  // ReferenceError: count is not defined
console.log(window.count);  // undefined

핵심:

  • count, increment, decrement가 IIFE 내부에만 존재
  • 외부에서 접근하거나 수정 불가
  • 다른 스크립트와 이름 충돌 없음

실전 예제: 여러 모듈 공존

// ========== 모듈 A: 사용자 카운터 ==========
(() => {
    let count = 0;  // 모듈 A의 count

    function increment() {
        count++;
        console.log('Users:', count);
    }

    document.getElementById('user-btn')?.addEventListener('click', increment);
})();

// ========== 모듈 B: 방문자 카운터 ==========
(() => {
    let count = 0;  // 모듈 B의 count (독립적!)

    function increment() {
        count += 5;
        console.log('Visitors:', count);
    }

    document.getElementById('visitor-btn')?.addEventListener('click', increment);
})();

// 두 모듈의 count는 서로 영향을 주지 않음!
// - 사용자 버튼 클릭 → "Users: 1"
// - 방문자 버튼 클릭 → "Visitors: 5"

외부 API 노출하기

필요한 경우 선택적으로 일부 기능을 외부에 노출할 수 있습니다.

// ========== 패턴 1: 전역 객체에 할당 ==========
const MyCounter = (() => {
    // 비공개 변수
    let count = 0;

    // 비공개 함수
    function validateCount() {
        return count >= 0 && count <= 100;
    }

    // 공개 API
    return {
        increment: () => {
            if (count < 100) {
                count++;
            }
            return count;
        },
        decrement: () => {
            if (count > 0) {
                count--;
            }
            return count;
        },
        getCount: () => count
    };
})();

// 사용
console.log(MyCounter.getCount());  // 0
MyCounter.increment();
console.log(MyCounter.getCount());  // 1

// 비공개 변수는 접근 불가
console.log(MyCounter.count);  // undefined

장점:

  • count는 외부에서 직접 수정 불가 (캡슐화)
  • validateCount는 완전히 비공개
  • 공개 API만 노출하여 의도한 대로만 사용 가능
// ========== 패턴 2: 이벤트 버스 ==========
const EventBus = (() => {
    // 비공개 저장소
    const events = {};

    return {
        // 이벤트 등록
        on: (event, callback) => {
            if (!events[event]) {
                events[event] = [];
            }
            events[event].push(callback);
        },

        // 이벤트 발생
        emit: (event, data) => {
            if (events[event]) {
                events[event].forEach(callback => callback(data));
            }
        }
    };
})();

// 사용
EventBus.on('user-login', (user) => {
    console.log('User logged in:', user);
});

EventBus.emit('user-login', { name: 'Alice' });

// events 객체는 접근 불가 (보호됨)
console.log(EventBus.events);  // undefined

핵심 차이점 비교

1. 스코프와 접근성

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// 전역 변수: 어디서든 접근 가능
var apiKey = 'secret-key-123';
var userData = { name: 'Alice' };

console.log(window.apiKey);  // 'secret-key-123' (노출됨!)
apiKey = 'hacked!';  // 외부에서 수정 가능

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// IIFE: 격리된 스코프
(() => {
    const apiKey = 'secret-key-123';
    const userData = { name: 'Alice' };

    // 내부에서만 사용
    function authenticate() {
        // apiKey 사용
    }
})();

console.log(window.apiKey);  // undefined (보호됨)
apiKey = 'hacked!';  // ReferenceError (접근 불가)

2. 이름 충돌

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// 전역 변수: 충돌 발생
// file1.js
var config = { theme: 'dark' };

// file2.js (다른 개발자)
var config = { lang: 'ko' };  // 덮어씌움!

console.log(config.theme);  // undefined (사라짐)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// IIFE: 충돌 없음
// file1.js
(() => {
    const config = { theme: 'dark' };
    // 사용...
})();

// file2.js
(() => {
    const config = { lang: 'ko' };  // 독립적!
    // 사용...
})();

// 각 모듈의 config는 서로 영향 없음

3. 메모리 관리

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// 전역 변수: 페이지가 닫힐 때까지 메모리 점유
var largeData = new Array(1000000).fill('data');
// 사용이 끝나도 메모리에 계속 남아있음

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// IIFE: 함수 실행 후 가비지 컬렉션 가능
(() => {
    const largeData = new Array(1000000).fill('data');
    processData(largeData);
})();
// IIFE 실행 후 largeData는 메모리에서 해제될 수 있음

4. 보안성

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// 전역 변수: 보안에 취약
var password = 'myPassword123';
var isAdmin = false;

// 사용자가 콘솔에서
isAdmin = true;  // ⚠️ 권한 상승!
password = '';   // ⚠️ 데이터 조작!

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// IIFE: 보호됨
(() => {
    let password = 'myPassword123';
    let isAdmin = false;

    // 검증 로직...
})();

// 외부에서 접근 불가
isAdmin = true;  // ReferenceError

실전 예제: 타이머 애플리케이션

전역 변수 사용 (문제점 있음)

// 전역 공간에 모든 것이 노출됨
var seconds = 0;
var minutes = 0;
var intervalId = null;
var isPaused = false;

function start() {
    if (intervalId) return;

    isPaused = false;
    intervalId = setInterval(function() {
        seconds++;
        if (seconds >= 60) {
            seconds = 0;
            minutes++;
        }
        updateDisplay();
    }, 1000);
}

function pause() {
    if (intervalId) {
        clearInterval(intervalId);
        intervalId = null;
        isPaused = true;
    }
}

function reset() {
    pause();
    seconds = 0;
    minutes = 0;
    updateDisplay();
}

function updateDisplay() {
    var display = pad(minutes) + ':' + pad(seconds);
    document.getElementById('timer').textContent = display;
}

function pad(num) {
    return num < 10 ? '0' + num : num;
}

// 초기화
updateDisplay();
document.getElementById('start').onclick = start;
document.getElementById('pause').onclick = pause;
document.getElementById('reset').onclick = reset;

// 문제점:
// 1. seconds, minutes 등이 전역에 노출
// 2. 다른 타이머를 추가하면 변수명 충돌
// 3. 외부에서 seconds = -999 같은 조작 가능

IIFE 모듈 패턴 사용 (개선됨)

/**
 * 타이머 모듈
 * IIFE로 캡슐화하여 독립적인 타이머 생성
 */
(() => {
    'use strict';

    // ==================== 상수 정의 ====================
    const CONFIG = {
        TICK_INTERVAL: 1000,  // 1초
    };

    const SELECTORS = {
        timer: '#timer',
        btnStart: '#start',
        btnPause: '#pause',
        btnReset: '#reset',
    };

    // ==================== 비공개 상태 ====================
    let seconds = 0;
    let minutes = 0;
    let intervalId = null;
    let isPaused = false;

    // ==================== 비공개 유틸리티 ====================
    const pad = (num) => (num < 10 ? '0' : '') + num;

    const formatTime = () => `${pad(minutes)}:${pad(seconds)}`;

    // ==================== 비공개 함수 ====================
    const updateDisplay = () => {
        const timerEl = document.querySelector(SELECTORS.timer);
        if (timerEl) {
            timerEl.textContent = formatTime();
        }
    };

    const tick = () => {
        seconds++;
        if (seconds >= 60) {
            seconds = 0;
            minutes++;
        }
        updateDisplay();
    };

    const start = () => {
        if (intervalId) return;  // 이미 실행 중

        isPaused = false;
        intervalId = setInterval(tick, CONFIG.TICK_INTERVAL);
        console.log('▶ Timer started');
    };

    const pause = () => {
        if (intervalId) {
            clearInterval(intervalId);
            intervalId = null;
            isPaused = true;
            console.log('⏸ Timer paused');
        }
    };

    const reset = () => {
        pause();
        seconds = 0;
        minutes = 0;
        updateDisplay();
        console.log('🔄 Timer reset');
    };

    // ==================== 초기화 ====================
    const attachEvents = () => {
        document.querySelector(SELECTORS.btnStart)
            ?.addEventListener('click', start);
        document.querySelector(SELECTORS.btnPause)
            ?.addEventListener('click', pause);
        document.querySelector(SELECTORS.btnReset)
            ?.addEventListener('click', reset);
    };

    const init = () => {
        updateDisplay();
        attachEvents();
        console.log('✅ Timer initialized');
    };

    // DOM 로드 완료 후 실행
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

// 장점:
// ✅ 모든 변수가 외부로 노출되지 않음
// ✅ 여러 타이머를 독립적으로 생성 가능
// ✅ 외부에서 상태 조작 불가 (보안)
// ✅ 깔끔한 구조와 명확한 역할 분담

여러 독립적인 타이머 만들기

// 같은 코드를 약간 수정하여 재사용
const createTimer = (selectors) => {
    return (() => {
        let seconds = 0;
        let intervalId = null;

        const start = () => {
            if (intervalId) return;
            intervalId = setInterval(() => {
                seconds++;
                document.querySelector(selectors.display).textContent = seconds;
            }, 1000);
        };

        const reset = () => {
            clearInterval(intervalId);
            intervalId = null;
            seconds = 0;
            document.querySelector(selectors.display).textContent = seconds;
        };

        document.querySelector(selectors.start)?.addEventListener('click', start);
        document.querySelector(selectors.reset)?.addEventListener('click', reset);

        return { start, reset };  // 필요시 API 노출
    })();
};

// 두 개의 독립적인 타이머
const timer1 = createTimer({
    display: '#timer1',
    start: '#start1',
    reset: '#reset1'
});

const timer2 = createTimer({
    display: '#timer2',
    start: '#start2',
    reset: '#reset2'
});

// 두 타이머는 서로 영향을 주지 않음!

성능 비교

메모리 사용

// 전역 변수: 계속 메모리 점유
var tempData = new Array(1000000).fill(0);
processData(tempData);
// tempData는 페이지가 닫힐 때까지 메모리에 남음

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// IIFE: 가비지 컬렉션 가능
(() => {
    const tempData = new Array(1000000).fill(0);
    processData(tempData);
})();
// 실행 후 tempData는 가비지 컬렉션 대상이 됨

실행 속도

// 벤치마크 테스트 결과

// 전역 함수 호출
function add(a, b) {
    return a + b;
}
for (let i = 0; i < 1000000; i++) {
    add(i, i + 1);
}
// 평균: ~10ms

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// IIFE 내부 함수 호출
(() => {
    const add = (a, b) => a + b;
    for (let i = 0; i < 1000000; i++) {
        add(i, i + 1);
    }
})();
// 평균: ~10ms

결론: 실행 속도는 거의 동일
성능보다는 코드 구조와 유지보수성에 집중

언제 어떤 패턴을 사용할까?

전역 변수가 적합한 경우

초간단 스크립트 (5줄 이하)

// 버튼 하나만 다루는 경우
document.getElementById('btn').onclick = () => {
    alert('Clicked!');
};

전역 설정 (실제로 전역이어야 하는 경우)

// 앱 전체에서 사용하는 설정
const APP_CONFIG = {
    API_URL: 'https://api.example.com',
    VERSION: '1.0.0'
};

학습/데모 목적

// 튜토리얼이나 빠른 프로토타입
var count = 0;
function increment() {
    count++;
    console.log(count);
}

IIFE가 필수인 경우

여러 스크립트 통합

// 여러 팀이 작업하는 대규모 프로젝트
// 각 모듈을 IIFE로 격리

라이브러리/플러그인 개발

// jQuery 플러그인처럼 독립적인 모듈
const MyPlugin = (() => {
    // 내부 로직...
    return { init, destroy };
})();

보안이 중요한 코드

// 결제, 인증 등 민감한 로직
(() => {
    const secretKey = '...';
    // 외부 접근 차단
})();

테스트 가능한 코드

// 단위 테스트가 필요한 경우
const createCounter = () => {
    return (() => {
        let count = 0;
        return {
            increment: () => ++count,
            getValue: () => count
        };
    })();
};

마이그레이션 가이드

전역 변수를 IIFE로 단계적으로 마이그레이션하는 방법입니다.

Step 1: IIFE로 감싸기

// Before
var count = 0;
function increment() {
    count++;
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// After
(() => {
    var count = 0;
    function increment() {
        count++;
    }
})();

Step 2: ‘use strict’ 추가

(() => {
    'use strict';  // 엄격 모드 활성화

    var count = 0;
    function increment() {
        count++;
    }
})();

Step 3: 구조화

(() => {
    'use strict';

    // 상수
    const CONFIG = {
        INITIAL_VALUE: 0
    };

    // 상태
    let count = CONFIG.INITIAL_VALUE;

    // 함수
    const increment = () => {
        count++;
    };

    // 초기화
    const init = () => {
        // 설정...
    };

    init();
})();

체크리스트

코드 리뷰 시 확인사항

전역 변수 사용 시:
[ ] 정말 전역이어야 하는가?
[ ] 다른 스크립트와 충돌 가능성은?
[ ] 외부에서 수정되면 문제가 생기는가?
[ ] 10줄 이상의 코드인가?

IIFE 사용 시:
[ ] 코드가 () => { })() 로 감싸져 있는가?
[ ] 'use strict'가 선언되어 있는가?
[ ] 외부 API가 필요하면 명시적으로 노출했는가?
[ ] 여러 모듈이 독립적으로 작동하는가?

흔한 실수와 해결책

실수 1: IIFE 문법 오류

 잘못된 문법:
function() {
    console.log('Hi');
}();
// SyntaxError: Function statements require a name

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 올바른 문법:
(function() {
    console.log('Hi');
})();
// 또는
(() => {
    console.log('Hi');
})();

실수 2: IIFE 내부에서 외부 변수 오염

 실수로 전역 변수 생성:
(() => {
    count = 0;  // var/let/const 없음 → 전역!
})();
console.log(window.count);  // 0 (오염됨)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 'use strict' 방지:
(() => {
    'use strict';
    count = 0;  // ReferenceError!
})();

실수 3: 이벤트 리스너에서 접근 불가

 외부에서 함수에 접근하려고 시도:
(() => {
    function handleClick() {
        console.log('Clicked');
    }
})();

// HTML에서
<button onclick="handleClick()">Click</button>
// ReferenceError: handleClick is not defined

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 IIFE 내부에서 이벤트 연결:
(() => {
    function handleClick() {
        console.log('Clicked');
    }

    document.querySelector('#btn')
        ?.addEventListener('click', handleClick);
})();

// HTML
<button id="btn">Click</button>

마치며

전역 변수 vs IIFE의 핵심:

전역 변수는 간편하지만 위험합니다:

  • 빠르게 작성 가능
  • 이름 충돌, 보안 문제, 유지보수 어려움

IIFE는 안전하고 확장 가능합니다:

  • 초기 설정이 필요
  • 스코프 격리, 캡슐화, 테스트 용이

추천:

  1. 소규모 스크립트: 전역 변수로 시작
  2. 10줄 이상: IIFE로 전환 고려
  3. 팀 프로젝트: 처음부터 IIFE 사용
  4. 라이브러리: 반드시 IIFE 사용

작은 것부터 시작하세요:

  1. 기존 코드를 IIFE로 감싸기
  2. ‘use strict’ 추가
  3. 섹션별로 구조화
  4. 필요시 외부 API 노출

하나씩 적용하다 보면, 어느새 안전하고 유지보수하기 좋은 코드를 작성하게 될 겁니다!


참고 자료

공식 문서

디자인 패턴

스타일 가이드


관련 문서

댓글