카테고리 없음

스코프는 무엇인가? - You Don't Know JS Yet을 읽고

이민재 2025. 5. 10. 19:50

이번 포스팅은 JS 엔진의 작동 원리에 대한 시리즈 중 두 번째 글입니다.

  1. JS엔진은 우리가 작성한 코드를 어떻게 처리하나? (이전 포스팅)
  2. 스코프는 무엇인가? (현재 포스팅)
  3. 스코프의 작동 방식과 쓰임새 (예정)
  4. 스코프를 다룰 때 주의할 점 (예정)

지난 포스팅에서 자바스크립트 코드가 어떻게 컴파일되고 실행되는지 살펴봤습니다. 이번에는 그 과정에서 핵심적인 역할을 하는 '스코프'에 대해 깊이 있게 알아보겠습니다.

 

왜 스코프를 이해해야 하는가?

스코프는 단순히 알면 좋은 개념이 아닌, 자바스크립트 프로그래밍의 근간이 되는 필수 개념입니다. 스코프를 제대로 이해하지 못하면 다음과 같은 문제들이 발생합니다:

  1. 예상치 못한 버그 발생: 변수가 어디서 접근 가능한지 모르면 의도치 않은 동작이 발생합니다.
  2. 메모리 누수: 불필요하게 긴 변수 생존 기간으로 인해 메모리가 낭비됩니다.
  3. 코드 유지보수 어려움: 변수의 범위를 명확히 알지 못하면 코드 수정 시 부작용이 생깁니다.
  4. 디버깅 복잡성 증가: 어디서 변수가 변경되었는지 추적하기 어려워집니다.

실제로 많은 자바스크립트 초보자들이 겪는 혼란의 상당 부분은 스코프에 대한 이해 부족에서 비롯됩니다. "왜 이 변수가 여기서는 접근 가능하고 저기서는 안 되지?", "왜 이 함수는 외부 변수를 볼 수 있는데 저 함수는 못 보지?" 같은 질문들의 답이 모두 스코프에 있습니다.

 

스코프의 정의와 중요성

스코프(Scope)는 프로그래밍 언어에서 변수와 함수의 접근성과 생존 기간을 결정하는 규칙의 집합입니다. 쉽게 말해 "이 변수는 어디서부터 어디까지 유효한가?"를 정의하는 경계입니다.

자바스크립트에서 스코프의 중요성:

  • 변수 충돌 방지: 동일한 이름의 변수를 서로 다른 스코프에 안전하게 사용할 수 있습니다.
  • 정보 은닉과 캡슐화: 특정 데이터와 기능을 외부로부터 보호할 수 있습니다.
  • 메모리 효율성: 변수가 필요한 범위에서만 살아있도록 하여 메모리를 효율적으로 사용합니다.
  • 코드 가독성: 변수의 사용 범위가 명확해져 코드 이해가 쉬워집니다.

 

자바스크립트의 스코프 종류

자바스크립트에는 크게 세 가지 유형의 스코프가 있습니다:

1. 전역 스코프(Global Scope)

전역 스코프는 코드의 가장 바깥쪽 층으로, 어디서든 접근할 수 있는 변수와 함수가 위치합니다.

 
javascript
// 전역 스코프에 선언된 변수와 함수
var globalVar = "전역 변수입니다";
let globalLet = "전역 let 변수입니다";
const globalConst = "전역 const 상수입니다";

function globalFunction() {
  console.log("전역 함수입니다");
}

// 다른 함수나 블록 내에서도 접근 가능
function testAccess() {
  console.log(globalVar);     // "전역 변수입니다"
  console.log(globalLet);     // "전역 let 변수입니다"
  console.log(globalConst);   // "전역 const 상수입니다"
  globalFunction();           // "전역 함수입니다"
}

testAccess();

전역 스코프에 대한 추가 정보:

  • 브라우저 환경: 전역 객체는 window입니다. 전역 변수는 window의 속성이 됩니다.
  • Node.js 환경: 전역 객체는 global입니다.
  • 모듈 시스템: ES 모듈에서는 최상위 레벨 변수도 모듈 스코프에 속하며 자동으로 전역이 되지 않습니다.
  • 전역 오염: 전역 스코프에 너무 많은 변수를 선언하면 이름 충돌 위험이 높아지고, 메모리 사용량이 증가합니다.
 
javascript
// 브라우저 환경에서 전역 변수와 window 객체의 관계
var x = 10;
console.log(window.x);  // 10

// let과 const로 선언한 전역 변수는 window 객체의 속성이 되지 않음
let y = 20;
console.log(window.y);  // undefined

2. 함수 스코프(Function Scope)

함수 스코프는 함수 내부에 선언된 변수와 함수가 속하는 영역입니다. var 키워드로 선언된 변수는 함수 스코프를 가집니다.

 
javascript
function exampleFunction() {
  var functionScopedVar = "함수 스코프 변수";
  let blockScopedLet = "블록 스코프지만 함수 내부에 있음";
  
  console.log(functionScopedVar);  // "함수 스코프 변수"
  console.log(blockScopedLet);     // "블록 스코프지만 함수 내부에 있음"
  
  function innerFunction() {
    console.log("내부 함수");
    // 외부 함수의 변수에 접근 가능
    console.log(functionScopedVar);  // "함수 스코프 변수"
  }
  
  innerFunction();  // "내부 함수", "함수 스코프 변수"
  
  if (true) {
    var sameVarAgain = "var는 함수 스코프라 if 블록 외부에서도 접근 가능";
  }
  
  console.log(sameVarAgain);  // "var는 함수 스코프라 if 블록 외부에서도 접근 가능"
}

exampleFunction();
// console.log(functionScopedVar);  // ReferenceError
// innerFunction();                 // ReferenceError

함수 스코프의 중요한 특징:

  • 함수 내부에서 선언된 var 변수는 함수 전체에서 접근 가능합니다.
  • 함수 내부의 블록(if, for 등)에서 선언된 var 변수도 함수 전체에서 접근 가능합니다.
  • 함수 스코프는 함수가 호출될 때마다 새로 생성됩니다.

 

3. 블록 스코프(Block Scope)

ES6에서 도입된 let과 const 키워드로 선언된 변수는 블록 스코프를 가집니다. 블록은 중괄호({})로 묶인 코드 영역입니다.

 
javascript
function blockScopeExample() {
  // 함수 레벨의 변수
  var functionVar = "함수 레벨 var";
  let functionLet = "함수 레벨 let";
  
  if (true) {
    // 블록 레벨의 변수
    var blockVar = "var는 함수 스코프";
    let blockLet = "let은 블록 스코프";
    const blockConst = "const도 블록 스코프";
    
    console.log(functionVar);  // "함수 레벨 var"
    console.log(functionLet);  // "함수 레벨 let"
    console.log(blockVar);     // "var는 함수 스코프"
    console.log(blockLet);     // "let은 블록 스코프"
    console.log(blockConst);   // "const도 블록 스코프"
  }
  
  console.log(functionVar);  // "함수 레벨 var"
  console.log(functionLet);  // "함수 레벨 let"
  console.log(blockVar);     // "var는 함수 스코프"
  // console.log(blockLet);  // ReferenceError: blockLet is not defined
  // console.log(blockConst); // ReferenceError: blockConst is not defined
  
  for (let i = 0; i < 3; i++) {
    // 'i'는 이 for 루프 블록에서만 유효
  }
  // console.log(i);  // ReferenceError: i is not defined
  
  for (var j = 0; j < 3; j++) {
    // 'j'는 함수 전체에서 유효
  }
  console.log(j);  // 3
}

blockScopeExample();

블록 스코프의 중요한 특징:

  • let과 const로 선언된 변수는 해당 블록 내에서만 접근 가능합니다.
  • 블록 스코프는 더 정확한 변수 생명주기 제어를 가능하게 합니다.
  • 블록 스코프는 코드 블록이 끝나면 메모리에서 변수가 제거될 수 있어 메모리 효율이 높습니다.

스코프 체인(Scope Chain)

스코프 체인은 중첩된 스코프 간의 연결을 의미합니다. 자바스크립트 엔진은 변수를 찾을 때 현재 스코프에서 시작하여 변수를 찾지 못하면 바깥쪽 스코프로 순차적으로 검색합니다.

 
javascript
var globalVar = "전역 변수";

function outerFunction() {
  var outerVar = "외부 함수 변수";
  
  function innerFunction() {
    var innerVar = "내부 함수 변수";
    
    console.log(innerVar);   // "내부 함수 변수" (현재 스코프)
    console.log(outerVar);   // "외부 함수 변수" (바깥 스코프)
    console.log(globalVar);  // "전역 변수" (전역 스코프)
  }
  
  innerFunction();
  console.log(innerVar);  // ReferenceError (내부 함수의 변수에 접근 불가)
}

outerFunction();

이 코드에서 스코프 체인은 다음과 같이 구성됩니다:

  • innerFunction 스코프 → outerFunction 스코프 → 전역 스코프

스코프 체인의 동작 방식:

  1. 현재 스코프에서 변수를 찾습니다.
  2. 찾지 못하면 바로 바깥쪽 스코프로 이동합니다.
  3. 계속해서 바깥쪽으로 이동하며 변수를 찾습니다.
  4. 전역 스코프까지 검색했는데도 변수를 찾지 못하면 ReferenceError가 발생합니다.

스코프 체인과 관련된 중요 개념

변수 섀도잉(Variable Shadowing)

안쪽 스코프에서 바깥쪽 스코프의 변수와 같은 이름의 변수를 선언하면, 안쪽 변수가 바깥쪽 변수를 '가리는' 현상입니다.

 
javascript
var name = "전역 이름";

function printName() {
  var name = "함수 이름";  // 전역 변수 'name'을 가림
  console.log(name);       // "함수 이름"
  
  if (true) {
    let name = "블록 이름";  // 함수 변수 'name'을 가림
    console.log(name);      // "블록 이름"
  }
  
  console.log(name);  // "함수 이름"
}

printName();
console.log(name);    // "전역 이름"

전역 언섀도잉(Global Unshadowing)

window 객체(브라우저 환경)를 통해 가려진 전역 변수에 직접 접근할 수 있습니다.

 
javascript
var count = 10;

function updateCount() {
  var count = 100;           // 전역 변수 'count'를 가림
  console.log(count);        // 100
  console.log(window.count); // 10 (전역 변수 직접 접근)
  
  // 전역 변수 수정
  window.count = 20;
}

updateCount();
console.log(count);  // 20 (전역 변수가 수정됨)

스코프와 호이스팅(Hoisting)

호이스팅은 자바스크립트 엔진이 코드를 실행하기 전에 변수와 함수 선언을 메모리에 저장하는 동작을 말합니다. 이로 인해 선언 전에도 변수와 함수에 접근할 수 있는 것처럼 보입니다.

변수 호이스팅

 
javascript
console.log(varVariable);  // undefined
var varVariable = "var 변수";

// console.log(letVariable);  // ReferenceError: letVariable is not defined
let letVariable = "let 변수";

// console.log(constVariable);  // ReferenceError: constVariable is not defined
const constVariable = "const 변수";

var로 선언한 변수는 선언 전에 접근해도 undefined가 출력되지만, let과 const로 선언한 변수는 선언 전에 접근하면 에러가 발생합니다. 이는 let과 const가 TDZ(Temporal Dead Zone)에 의해 초기화 전 접근이 제한되기 때문입니다.

함수 호이스팅

 
javascript
// 함수 선언문: 호이스팅됨
sayHello();  // "안녕하세요!"
function sayHello() {
  console.log("안녕하세요!");
}

// 함수 표현식: 변수만 호이스팅됨
// sayHi();  // TypeError: sayHi is not a function
var sayHi = function() {
  console.log("안녕하세요!");
};

// 화살표 함수: 변수만 호이스팅됨
// sayHola();  // ReferenceError: Cannot access 'sayHola' before initialization
let sayHola = () => {
  console.log("Hola!");
};

함수 선언문은 완전히 호이스팅되어 선언 전에도 호출 가능하지만, 함수 표현식과 화살표 함수는 변수 호이스팅 규칙을 따릅니다.

ES 모듈과 스코프

ES6부터 도입된 모듈 시스템은 스코프에 중요한 변화를 가져왔습니다. 모듈의 최상위에 선언된 변수와 함수는 전역 스코프가 아닌 모듈 스코프에 속합니다.

 
javascript
// module.js
var studentName = "카일";

function hello() {
  console.log(`${studentName} 님, 안녕하세요!`);
}

hello();
// 카일 님, 안녕하세요!

export hello;
 
javascript
// main.js
import { hello } from './module.js';

// module.js의 studentName에는 접근할 수 없음
// console.log(studentName);  // ReferenceError

hello();  // "카일 님, 안녕하세요!"

모듈에서 studentName과 hello 함수는 모듈 범위(module-wide) 스코프의 변수가 되며, 전역 변수로 등록되지 않습니다.

예제 1: 루프 내 비동기 함수와 스코프

 
javascript
function createButtons() {
  // 버튼 5개 생성하기
  for (var i = 0; i < 5; i++) {
    var button = document.createElement("button");
    button.innerText = "버튼 " + i;
    
    // 클릭 이벤트 핸들러 추가
    button.addEventListener("click", function() {
      console.log("버튼 " + i + "가 클릭됨");
    });
    
    document.body.appendChild(button);
  }
}

createButtons();

 

예상 결과: 각 버튼을 클릭하면 "버튼 0가 클릭됨", "버튼 1가 클릭됨" 등이 출력될 것 같습니다.

실제 결과: 모든 버튼이 "버튼 5가 클릭됨"을 출력합니다!

원인 분석

이 문제는 스코프와 클로저의 이해 부족에서 발생합니다:

  1. var i는 함수 스코프를 가집니다 (for 블록 내부가 아님).
  2. 이벤트 리스너 함수는 버튼 클릭 시 나중에 실행됩니다.
  3. 그 시점에 for 루프는 이미 종료되었고 i의 값은 5가 되었습니다.
  4. 모든 이벤트 리스너는 같은 i 변수를 참조하고 있습니다.

해결 방법 1: 블록 스코프 변수 사용하기

 
javascript
function createButtons() {
  // let으로 변경하여 블록 스코프 사용
  for (let i = 0; i < 5; i++) {
    const button = document.createElement("button");
    button.innerText = "버튼 " + i;
    
    button.addEventListener("click", function() {
      console.log("버튼 " + i + "가 클릭됨");
    });
    
    document.body.appendChild(button);
  }
}

createButtons();

이제 각 루프 반복마다 새로운 i 변수가 생성되므로, 각 이벤트 리스너는 자신의 고유한 i 값을 참조합니다.

해결 방법 2: 즉시 실행 함수로 스코프 생성하기

 
javascript
function createButtons() {
  for (var i = 0; i < 5; i++) {
    // 즉시 실행 함수를 사용하여 새로운 스코프 생성
    (function(index) {
      var button = document.createElement("button");
      button.innerText = "버튼 " + index;
      
      button.addEventListener("click", function() {
        console.log("버튼 " + index + "가 클릭됨");
      });
      
      document.body.appendChild(button);
    })(i);
  }
}

createButtons();

즉시 실행 함수는 각 반복마다 새로운 스코프를 생성하고, 현재 i 값을 매개변수 index로 복사합니다. 이렇게 하면 각 이벤트 리스너는 자신의 스코프에 있는 index 값을 참조합니다.

이 시뮬레이션은 스코프의 중요성과 특히 비동기 코드에서 발생할 수 있는 문제를 보여줍니다. var와 let의 스코프 차이를 이해하고, 클로저가 어떻게 외부 스코프의 변수를 "기억"하는지 이해하면 이러한 문제를 쉽게 해결할 수 있습니다.

실제 개발에서의 스코프 활용

1. 전역 스코프 오염 방지

전역 스코프에 너무 많은 변수를 선언하면 이름 충돌과 의도치 않은 부작용이 발생할 수 있습니다. 이를 방지하는 방법:

 
javascript
// 즉시 실행 함수 표현식(IIFE)으로 스코프 생성
(function() {
  var privateVar = "비공개 변수";
  
  function privateFunction() {
    console.log("비공개 함수");
  }
  
  // 필요한 경우만 전역으로 노출
  window.myApp = window.myApp || {};
  window.myApp.publicAPI = {
    doSomething: function() {
      privateFunction();
      return "작업 완료!";
    }
  };
})();

// privateVar, privateFunction에 직접 접근 불가
// window.myApp.publicAPI.doSomething()으로만 접근 가능

2. 모듈 패턴

스코프를 활용한 모듈 패턴:

 
javascript
var Counter = (function() {
  // 비공개 변수
  var count = 0;
  
  // 비공개 함수
  function validateCount(newCount) {
    return newCount >= 0;
  }
  
  // 공개 API
  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      if (validateCount(count - 1)) {
        count--;
      }
      return count;
    },
    getValue: function() {
      return count;
    },
    reset: function() {
      count = 0;
      return count;
    }
  };
})();

console.log(Counter.getValue());  // 0
Counter.increment();
Counter.increment();
console.log(Counter.getValue());  // 2
Counter.reset();
console.log(Counter.getValue());  // 0
// console.log(count);  // ReferenceError (비공개 변수에 접근 불가)

3. 블록 스코프 활용하기

 
javascript
// 임시 변수의 생명 주기를 제한하기
{
  let temp = calculateSomething();
  processTempData(temp);
  // temp는 이 블록 외부에서 접근 불가
}

// 루프에서 블록 스코프 활용
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);  // 0, 1, 2, 3, 4
}

// var를 사용하면 다른 결과
for (var j = 0; j < 5; j++) {
  setTimeout(() => console.log(j), 100);  // 5, 5, 5, 5, 5
}

마치며: 스코프의 중요성과 올바른 활용

지금까지 자바스크립트에서 스코프의 개념과 종류, 스코프 체인의 작동 방식에 대해 살펴봤습니다. 스코프는 자바스크립트의 가장 기본적이면서도 중요한 개념 중 하나로, 이를 제대로 이해하면 더 깨끗하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

특히 스코프 체인과 전역 스코프에 대한 이해는 자바스크립트 개발자로서 성장하는 데 필수적입니다. 변수가 어느 스코프에 속하는지, 스코프 체인을 통해 어떻게 변수를 찾는지, 그리고 각 선언 키워드(var, let, const)가 스코프에 어떤 영향을 미치는지 이해하면 많은 일반적인 자바스크립트 문제를 방지할 수 있습니다.

다음 포스팅에서는 "스코프의 작동 방식과 쓰임새"에 대해 더 깊이 알아보겠습니다. 특히 클로저, 렉시컬 환경, 그리고 스코프 체인의 실제 활용 사례를 자세히 다룰 예정입니다.

참고 자료:

  • "You Don't Know JS Yet" by Kyle Simpson
  • ECMAScript 명세서
  • MDN Web Docs - JavaScript 가이드