[JavaScript] 클로져와 스코프

JavaScript에서 클로져와 스코프는 전반적인 언어에 대한 이해도를 높일 때, 꼭 등장하는 개념입니다.

코드가 처리 될 때 Closure와 Scope는 프로그램이 동작할 때 이해하기 위한 필수 개념입니다. 먼저 JavaScript의 Scope를 이해하고, 그 다음 Closure를 이해하면 좀 더 깊은 JavaScript 세상을 맞이 할 수 있습니다.

여기서 Scope는 언어마다 다르기 때문에 JavaScript Scope가 다른 언어의 Scope와 같다는 잘못된 말입니다.

JavaScript에서 유효범위(Scope)

유효범위라는 뜻의 Scope는 프로그래밍 언어에서 코드의 범위를 의미합니다. 흔히 코드에서 보이는 블록 단위의 코드나 각 함수가 가지고 있는 중괄호({}) 범위를 유효범위라고 이야기들 합니다. 여기에 자바스크립트는 고유 유효범위를 가지고 있습니다.

기본적으로 스코프는 두 가지 범위가 있습니다.

  • 지역 스코프 (Global Scope) : 정의된 함수 내에서만 참조 할 수 있는 Scope를 의미합니다. 밖에서 참조하기 위해서는 스코프 밖으로 반환하는 함수를 작성해야 가능하며, 기본적으로는 함수 내에서 사용되고 소멸됩니다.
  • 전역 스코프 (Local Scope) : 전체 프로젝트에서 참조 할 수 있는 Scope를 의미합니다. 어떤 파일에서든 접근이 가능합니다.

유효범위의 특징은 아래와 같습니다.

  • Lexical Scoping (Static Scoping)
  • Implied Globals (암묵적 선언)
  • 변수명 중복 허용
  • Function-Level-Scope (함수 단위의 유효범위

하나씩 살펴보죠.

Lexical scoping (Static scoping)

자바스크립트가 가지고 있는 특이한 Scope 중 하나 입니다. 바로 함수가 선언된 시점에서 유효범위를 갖는 것이죠.

렉시컬 스코프는 함수를 어디에 선언했는지에 따라 상위 스코프가 결정되는 것을 의미합니다. 이를 보통 정적 스코프(Static Scope)라고도 합니다.

var x = 1;

function foo() {
	var x = 10;
	bar();
}

function bar() {
	console.log(x);
}

foo(); // 1
bar(); // 1

위 코드에서 x는 현재 가장 상단에 전역에 선언되었습니다. 함수 안에 선언되거나 블록 안에 선언 된 것이 아니기 때문에 x의 스코프는 전역 스코프가 됩니다. 그렇기 때문에 foo()와 bar()를 호출하면 처음에 선언된 1이 출력됩니다.

이렇듯 렉시컬 스코프는 어디에 선언 되었는가가 매우 중요합니다. 이와 반대되는 개념으로 동적 스코프가 있습니다.

  • 동적 스코프 : 렉시컬 스코프와 다르게 함수를 어디서 호출했는지에 따라 상위 스코프가 결정되는 스코프 입니다. 다른말로 동적 스코프라고도 합니다.

암묵적 선언 (implied globals)

암묵적 선언은 간단합니다. 말 그대로 변수 앞에 명시적으로 var와 같은 문법을 사용하지 않으면 암묵적 선언이 됩니다.

function foo() {
  x = 1;   // Throws a ReferenceError in "use strict" mode
  var y = 2;
}

foo();

console.log(x); // 1
console.log(y); // ReferenceError: y is not defined

위 코드를 보면 x를 선언할 때 ReferenceError가 발생한다고 합니다. 또한 아래에 y를 호출하면 ReferenceError가 발생합니다.

이유는 스코프와 관련되어 있습니다. x 변수는 foo() 메서드 안에 선언되지 않았기 때문에 x 변수에 대한 값 설정은 오류가 발생합니다.

y 변수는 foo() 함수 안에 선언되어 있기 때문에 범위 밖은 전역에서는 사용이 불가능합니다.

변수명 중복 허용

글로벌 영역에서 변수를 선언하면 이 변수는 Global Scope를 갖는 전역 변수가 됩니다. 이 전역 변수는 window의 프로퍼티의 변수로 들어가는데 window는 전역 객체(Global Object)입니다.

var global = 'global';

function foo() {
  var local = 'local';
  console.log(global);
  console.log(local);
}
foo();

console.log(global);
console.log(local); // Uncaught ReferenceError: local is not defined

위 코드에서 global 변수는 global 영역에 선언되었습니다. local은 foo() 함수 안에서 선언되었습니다. 그렇기 때문에 global 변수는 어디서 호출해도 문제 없이 가져올 수 있습니다.

함수 단위의 유효범위 (function-level scope)

먼저 함수 단위의 유효범위 입니다. 우리가 흔히 알고 있는 함수 코드 블럭 내에서 선언된 변수들이 가지는 유효범위입니다. 함수 외부에서는 유효하지 않으며, 함수 외부에서 호출하기 위해서는 함수 자체에서 해당 변수를 반환해야 참조가 가능합니다.

var x = 0;
{
  var x = 1;
  console.log(x); // 1
}

console.log(x); // 1

let y = 0;
{
  let y = 1;
  console.log(y); // 1
}

console.log(y); // 0

위 코드는 함수 단위 유효범위에 대한 예시 코드입니다. 신기한 점은 let으로 선언한 변수와 var로 선언한 변수의 결과가 다르다는 점입니다. let 같은 경우 ES6 이후에 나온 문법으로 block-level scope를 사용할 수 있습니다.

function-level과 차이점은 function-level은 선언 이후 다른 블록에서 변수 초기화가 발생하더라도 기존 변수를 갱신시키지만 block-level은 특정 block 내에서 선언한 변수는 그 블록을 벗어나면 다시 원래의 값으로 돌아 간다는 특징이 있습니다.

사용할 때 이러한 특징을 알고 있어야 추후 다른 개념을 이해할 때 도움이 됩니다.

스코프 체인 (Scope Chain)

스코프 체인은 일종의 리스트로서 전역 객체와 중첩된 함수의 스코프 레퍼런스를 차례로 저장하고, 의미 그대로 각각의 스코프가 어떻게 연결되고 있는지 보여주는 것을 말합니다. 스코프 체인을 이해하기 위해서는 실행 컨택스트(Execution context) 개념을 알아야 합니다.

실행 컨텍스트(Execution Context)

실행 컨테긋트는 우리가 작성한 코드가 실행되는 환경을 의미합니다. 이 환경에서는 스코프, 호이스팅 등 다양한 동작원리를 담고 있는 자바스크립트의 핵심 원리입니다. 이 실행 컨텍스트 안에도 두 개의 종류가 존재합니다.

글로벌 실행 컨텍스트(Global Execution Context)

함수 내에 없는 코드는 모두 전역 실행 컨텍스트 안에 존재합니다. 그렇기 때문에 일부 자바스크립트 코드를 실행할 때 글로벌 실행 컨텍스트를 작성합니다. 글로벌 실행 컨텍스트의 특징은 무조건 하나의 전역 실행 컨텍스트 만이 존재한다는 특징을 가지고 있습니다. 또한 애플리케이션이 종료 될 때까지 유지합니다.

함수 실행 컨텍스트(Functional Execution Context)

전역 실행 컨텍스트가 생성된 후, 함수가 실행 될 때마다 새로운 실행 컨텍스트가 작성됩니다.

실행 컨텍스트에서 스코프 체인

앞서 실행 컨텍스트를 이야기했습니다. 이 실행 컨텍스트는 LIFO(Last In, First Out) 구조의 스택으로 모든 실행 컨텍스트를 저장하는 데 사용합니다. 그리고 실행 컨텍스트가 실행될 때, 자바스크립트 엔진은 스코프 체인을 통해 렉시컬 스코프를 파악합니다.

그리고 함수가 중첩으로 되어 있을 때 하위 함수 내에서 상위 함수의 스코프와 전역 스코프까지 참조할 수 있습니다. 이러한 참조를 탐색할 때 사용하는 것이 스코프 체인입니다.

var global = 'global';

function foo() {
  var foo = 'foo';
  function() soo() {
    var soo = 'soo';

    console.log(foo);
    console.log(soo);
    console.log(global);
  }
  soo();
}
foo();

위 코드는 스코프 체인을 잘 보여주는 코드입니다. 먼저 soo 환경이 있으며, 그 위에 foo 환경, global 순으로 스코프가 연결되어 있습니다. 그래서 스택에는 아래 이미지처럼 실행 컨텍스트에 함수들이 쌓이고, 호출됩니다.

클로져와 스코프 체인
https://medium.com/@pvivek4/scope-and-execution-context-in-javascript-3b71e76cd193

이렇듯 실행 컨텍스트에 담긴 함수들을 연결해서 탐색하는 것을 스코프 체인이라 합니다.

클로져 (Closure)

클로저는 함수가 선언된 환경의 스코프를 기억합니다. 여기서 스코프는 렉시컬 스코프를 보통 이야기합니다. 함수가 밖에서 실행 되더라도 이 스코프에 접근할 수 있는 기술을 클로져라고 합니다. MDN에 있는 의미를 보면 클로져는 함수와 함수가 선언된 어휘적 환경의 조합이다 라고 합니다.

아래 코드를 보면서 이야기하죠.

const outer = () => {
  const outerVariable = 'outer!'; // 1. outer 함수 안에 지역변수 outerVariable 선언

  const inner = () => {
    console.log(outerVariable); // 2. 바깥의 outerVariable을 참조해 console.log 출력
  };

  return inner; // 3. 바깥의 outerVariable을 참조해 console.log를 출력하는 함수를 반환
};

const fano = outer(); // 4. outer함수 호출 -> 변수 fano에 inner함수의 주소값이 저장됨

fano(); // 5. 'outer!'

코드를 보면 outer 함수에서 반환한 inner 함수는 console log를 통해 outerVariable를 출력하는 코드입니다. 이를 통해 알 수 있는 것은 클로져는 어떤 함수 내부에 선언된 함수가 바깥 함수의 지역변수를 참조하는 것이 함수가 종료된 이후에도 계속 유지되는 현상을 의미합니다.

외부함수가 소멸 되더라도 내부함수가 외부 함수 변수에 접근할 수 있는 메커니즘인데, 이걸 우리는 클로져라고 합니다.

앞서 먼저 설명한 스코프체인을 보면 어떤 스코프가 가지고 있지 않는 변수 및 함수는 그 바깥 스코프를 참조한다는 것을 알 수 있습니다.

여기에 하나의 개념이 더 필요한데 바로 1급 시민이라는 개념이죠.

1급 시민

1급 시민은 4개의 요구조건이 있습니다. 자바스크립트 객체는 전부 1급 시민인데 그에 대한 특징은 아래와 같습니다.

  1. 모든 요소는 함수의 실제 매개변수가 될 수 있다.
  2. 모든 요소는 함수의 반환 값이 될 수 있다.
  3. 모든 요소는 할당 명령문의 대상이 될 수 있다.
  4. 모든 요소는 동일 비교의 대상이 될 수 있다.

1급 시민은 이러한 특징을 가지고 있습니다. 그렇다면 클로져는 이러한 개념으로 어떻게 설명할 수 있을까요?

다시 보면 fano() 함수는 inner() 함수를 호출하는 것과 같아 보이고, outerVariable은 소멸되어야 한다고 생각됩니다.

하지만 클로져는 inner() 함수가 가지고 있던 outrerVariable의 참조를 스코프체인으로 가지고 있으며, outer() 함수가 종료 되더라도 outerVariable를 inner() 함수가 사용하기 때문에 outer() 스코프는 여전히 남아있게 됩니다.

클로져를 활용하는 영역

이러한 클로져는 어디서 활용될까요? 이렇게 복잡한 개념은 추후 변수를 관리하거나 함수를 관리할 때, 충돌이 날 수 있지만 자바스크립트는 언어의 특징으로 잘 활용하고 있습니다. 그렇다면 활용 방법을 알아보죠.

함수를 여러 번 호출하면 상태가 연속적으로 유지되어야 할 때

const counterCreator = () => {
  let value = 0;

  return {
    increase() {
      console.log(++value);
    },
    decrease() {
      console.log(--value);
    },
  };
};

const counter = counterCreator();

counter.increase(); // 1
counter.increase(); // 2
counter.decrease(); // 1

위 코드는 함수를 호출 할 때 이전 함수 호출 상태를 기억되어야 원하는 결과가 나오는 함수입니다. 이처럼 계속해서 함수 내부의 값이 기억되길 바랄 때 사용할 수 있습니다. 가장 많이 사용 되는 사용처는 React의 hook API 입니다.

React에서 사용되는 hook은 함수를 여러번 호출하면서 상태값을 저장하는 메커니즘을 가지고 있습니다. 이를 통해 특정 상태를 계속해서 가져올 수 있으며, Count 앱을 만들 때 가장 많이 활용됩니다. 사용자 상태를 지속적으로 기록할 때도 유용하게 사용됩니다.

const Counter = () => {
  const [value, setValue] = useState(0); // 이 hook함수가 클로져를 통해 구현되었습니다.

  return (
    <div>
      <p>{value}</p>
      <button onClick={() => setValue(value + 1)}>+</button>
      <button onClick={() => setValue(value - 1)}>-</button>
    </div>
  );
};

위 코드는 React 코드입니다. 실행하면 카운트를 늘리고 줄일 수 있는 전형적인 클로져 코드입니다.

Counter라는 함수 안에 value 값을 기억하고, 계속해서 호출하면 그에 따라 변하는 기능입니다. useState가 hook 함수이며 클로져를 통해 구현되었기 때문에 가능한 코드입니다.

변수를 숨겨야 할 때

클로져로 함수를 만들면 외부에서 변수에 접근시 레퍼런스 에러를 발생시킬 수 있습니다. 함부로 외부에서 변경할 수 없도록 하기 위해 클로져를 사용합니다. 외부에서 변경할 수 없게 되면 변수의 안정성이 증가하고, 캡슐화를 통해 코드의 품질이 올라가게 됩니다.

const counterCreator = () => {
  let value = 0;

  return {
      increase() {
        console.log(++value);
      },
      decrease() {
        console.log(--value);
      },
    };
  };

const counter = counterCreator();

function unknown() {
  value = -100000;
}

counter.increase(); // 1
unknown(); // error: Uncaught ReferenceError: value is not defined
counter.decrease(); // 정상적으로 진행된다면 0

함수가 독립적으로 동작해야 할 때

const counterCreator = () => {
  let value = 0;

  return {
    increase() {
      console.log(++value);
    },
    decrease() {
      console.log(--value);
    },
  };
};

const myCounter = counterCreator();
const yourCounter = counterCreator();

myCounter.increase(); // 1
myCounter.increase(); // 2
yourCounter.increase(); // 1
myCounter.decrease(); // 1

위 코드는 함수 Counter가 두 개 선언되어 있는 코드입니다. 각 함수는 독립적으로 선언 되었으며, value도 서로 다르게 기억하고 있습니다. 이렇듯 클로져를 활요하면 외부함수를 호출할 때마다 새로운 컨텍스트를 생성하기 때문에 독립적인 카운터를 만들 수 있습니다.

클로져는 자세히 보면, 다른 언어의 클래스 맥락과 비슷합니다. 클래스 내부에 프로퍼티가 있고, 메서드(함수)들이 있습니다. 각 메서드를 생성할 때 클래스 내부의 속성 값을 따로 가지고 있으며, 여러 함수를 통해 기능 개발이 가능합니다. 또한, 객체를 새로 만들 때 마다 새로운 객체로 독립적으로 사용할 수 있죠.

물론, 클래스와는 차이점이 있습니다. 클로져는 익명으로 생성이 가능하며, 값으로 변수가 자료구조에 저장도 가능합니다. 클래스는 상속, 다형성 등 객체 지향 프로그래밍의 개념을 전부 사용할 수 있다는 특징이 있습니다. 또한, 이 둘의 차이가 생긴 이유는 어떤 프로그래밍 패러다임을 사용하냐에 따라 다릅니다. 객체 지향 프로그래밍은 클래스, 함수형 프로그래밍은 클로져 및 고차 함수를 사용한다고 보시면 되겠습니다.

참고

https://velog.io/@aeong98/JS-%EC%8A%A4%EC%BD%94%ED%94%84Scope-%EC%99%80-%ED%81%B4%EB%A1%9C%EC%A0%80Closure%EC%9D%98-%EC%9D%B4%ED%95%B4

https://velog.io/@rkio/Javascript-%ED%98%B8%EC%9D%B4%EC%8A%A4%ED%8C%85hoisting%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC

https://medium.com/@pvivek4/scope-and-execution-context-in-javascript-3b71e76cd193

https://tecoble.techcourse.co.kr/post/2021-07-16-closure/

https://yoosioff.oopy.io/43f310c2-ecc3-48b8-abac-d797c4bc4d0d

Leave a Comment