클로저와 스코프: 클로저 활용 방법

open_h·2일 전
1

JavaScript

목록 보기
4/4
post-thumbnail

클로저라는 개념은 사실 여러 함수형 프로그래밍 언어에서 등장하는 개념이다. 즉, 자바스크립트의 고유 개념이 아니며 ECMAScript 명세서에서도 클로저를 따로 정의하고 있지 않다. 다양한 책에서 클로저에 대해 한 문장으로 정의할 때 서로 다른 단어를 쓰고 있으며, 추상적인 개념이 늘 그러하듯 짧게 줄인 정의만으로는 이해하기가 쉽지 않다.

자바스크립트에서는 클로저를 이용하여 스코프의 특징과 함수에 대한 명세를 구현하고 있다. 자바스크립트에서의 스코프를 먼저 다루고 클로저에 대해 정리하고자 한다. 그 후에 클로저를 실용적으로 활용하는 코드를 살펴보자.

스코프Scope

프로그래밍 언어에서는 "이름:값"의 형태로 변수를 생성하는데 이름(식별자)은 중복이 일어날 가능성이 충분하며 충돌이 일어날 경우 문제가 발생한다. 각 언어마다 스코프라는 규칙을 정하여 이러한 문제를 해결하는데, 스코프란 식별자identifier를 참조할 때, (유효 범위를 고려하여)어떤 대상을 참조할 지 찾아내기 위한 규칙이다. 프로그래밍 언어에서 전역 변수, 지역 변수에 대해 배울 때 자연스럽게 느끼는 개념이다.

아래 간단한 코드에서 스코프의 의의를 알 수 있다.

let a = 'a in global';
function foo() {
  let a = 'a in foo';
  console.log(a);
}
foo(); // 'a in foo'
console.log(a); // 'a in global'

위 코드에서 만약 foo() 안에 변수 a 를 선언하지 않았다면, 함수는 범위를 넓혀 전역 변수 a 에 접근하게 된다(따라서 출력 결과는 모두 'a in global'가 된다). 복잡한 코드의 경우라도 범위를 계속 넓혀가며 결국 전역 스코프까지 찾는 것을 스코프 체인이라고 부르며 자바스크립트의 스코프적 특징 중 하나이다.

함수 레벨 스코프와 블록 레벨 스코프

자바스크립트(ES6)에서는 함수 레벨블록 레벨(ES6)의 렉시컬 스코프lexical scope 규칙을 따른다.

함수 레벨과 블록 레벨에 대해서는 var , let , const 의 차이점에서 다루었다. var 로 선언된 변수는 함수 레벨 스코프를 가졌기에, letconst 를 지원하는 ES6가 되어서야 자바스크립트에서 블록 레벨 스코프를 지원하게 되었다. ES6를 사용하는 요즘은 아래와 같이 혼란을 야기하는 함수 레벨 스코프를 가지는 var 대신 블록 레벨 스코프를 사용하는 letconst 로 모두 대체하고(모두 대체가 가능하다) 있다. 추가적으로 함수 선언식으로 만들어진 함수도 함수 레벨 스코프를 갖는다.

function f(){
	if(true) {
		var a = 10; // function level scope
	}
	console.log(a); // 10
}

function g(){
	if(true){
		let a = 10; // block level scope
	}
  console.log(a) // ReferenceError: a is not defined
}

렉시컬 스코프Lexical Scope

렉시컬 스코프는 보통 동적 스코프dynamic scope와 비교하며 자바스크립트는 렉시컬 스코프 규칙을 사용한다. 현대 프로그래밍에서 언어들은 대부분 렉시컬 스코프 규칙을 따르고 있다. 렉시컬 스코프에 대해 설명하기 전, 먼저 예시 코드를 살펴보자.

let a = 'a in global';
function bar(){
  console.log(a)
}
function foo() {
  let a = 'a in foo';
  bar();
}
bar(); // a in global
foo(); // a in global

만약 foo() 에서 a 를 선언하는 것이 아니라 그저 재할당을 하여 전역 변수를 바꾸었다면 위 코드와 콘솔 결과는 달라졌을 것이다. 하지만 위와 같은 코드라면 두 함수 실행의 결과가 'a in global' 로 나타나는 것으로 예상이 되며 실제로도 그렇다. 사실 이러한 스코핑 규칙을 사실 렉시컬 스코프이라고 부른다.

스코프는 함수를 호출할 때가 아니라 선언할 때 생기는 것이다. 렉시컬 스코프는 함수를 처음 선언하는 순간 함수 내부의 변수는 자기 스코프로부터 가장 가까운 곳(스코프 체인)에 있는 변수를 계속 참조하게 된다. 즉, 위 예제 코드의 경우 bar 이 선언되었을 때 이미 a 는 함수 내에 a 라는 식별자가 없으니 스코프 체인으로 글로벌 변수 a 를 참조하게 된다.

동적 스코프Dynamic Scope

렉시컬 스코프처럼 동작하는 것이 당연한 것이 아닐까? 라고 생각할 수도 있기 때문에, 이해를 위해 동적 스코프의 원리도 간단히 알아보자. 위 렉시컬 스코프 자바스크립트 예시 코드가 렉시컬 스코프가 아니라 동적 스코프dynamic scope 규칙을 따른다고 가정하면 아래와 같은 결과가 나온다.

bar(); // a in foo
foo(); // a in global

렉시컬 스코프와 달리 동적 스코프는 호출 스택call stack에 영향을 받으며 위 코드대로라면 가장 아래에는foo 함수를 호출할 때, Global 스택이 쌓여있는 상태에서, 그 위에 foo 가 쌓이고 그 다음에는 bar 함수가 스택으로 쌓인다. 동적 스코프는 이 스텍의 영향을 받기 때문에 bar 에서는 a 에 접근할 때, 그 아래 스텍인 fooa 에 접근하게 되어 foo() 는 'a in global'이 아닌 'a in function'을 출력하게 된다.

참고로 렉시컬 스코프와 마찬가지로 bar() 를 호출하였을 때는 바로 'a in global'이 출력되는데 이는 Global 스택 위 bar 스텍이 전부이기에 Global 다음 스택의 전역 변수 a 에 접근하기 때문이다. 실제로 동적 스코프 방식은 Perl과 같은 언어에서 나타나는 결과이다.

자바스크립트와 렉시컬 스코프

물론 자바스크립트는 렉시컬 스코프 규칙을 따르기 때문에 호출 스택과 상관 없이 (this 를 제외하고) "이름:값"으로 된 '대응표'를 소스코드를 정의하기 때문에 위 코드는 모두 'a in global'로 나타나는 것이다. 자바스크립트의 스코프는 ECMAScript 언어 명세서에서 렉시컬 환경lexical environment과 환경 레코드environment record라는 개념으로 정의된다. 여기서 "이름:값"이라는 대응표는 환경 레코드라고 볼 수 있으며, 렉시컬 환경은 이 환경 레코드와 상위 렉시컬 환경outer lexical environment에 대한 참조로 이루어진다.

현재 환경 레코드에서 변수를 찾아보고 없다면 바깥 렉시컬 환경을 참조하는식의 스코프 체인 방식이 가능해지며 이름을 찾는데 성공하거나 바깥 렉시컬 환경 참조가 null 이 되었을 때(실패했을 때) 탐색을 멈춘다.

사진 출처: https://meetup.toast.com/posts/86

이와 관련하여 깊게 이해하기 위해서는 실행 컨텍스트execution context에 대해 알아야 한다. 여기서는 클로저 설명을 위해 짧게 렉시컬 스코프에 대해 정리하였다.

클로저Closure

MDN에서는 클로저를

'함수와 그 함수가 선언되었을 때의 렉시컬 환경lexical environment과의 조합이다'

라고 정의하고 있다. 정의상 개념적으로는 모든 함수를 클로저로 칭하는 것 같다. 하지만 실제로 클로저라는 단어를 사용할 때 자바스크립트에서는 모든 함수를 전부 클로저라 부르지 않는다. 위 클로저 정의에서 자바스크립트의 경우 함수는 반환된 내부함수를 의미하고 렉시컬 환경은 내부 함수가 선언되었을 때의 스코프를 뜻한다. 먼저 예제 코드를 살펴 보자.

// 1번 예제 코드
function foo(){
  let a = 10;
  function bar(){
    console.log(a);
  }
  bar();
}
foo();

bar 는 일단 foo 의 안에 있어서 foo 를 상위 환경 참조로 저장할 것이며, 렉시컬 스코프 체인을 통해 한 단계 범위를 넓히면 fooa 를 참조하게 된다. 마치 클로저처럼 보이지만 클로저라고 부르지 않는다. 다음 예제 코드를 보자.

// 2번 예제 코드
let a = 'apple';
function foo() {
    let a = 'grape';
    function bar() {
        console.log(a); // 순서 1
    }
    return bar;
}
let yoo = foo(); // 순서 3
yoo();

2번 예제 코드의 실행순서를 bar 의 스코프 관점에서 차례대로 나열해보자.

  1. bara 를 출력하는 함수로 정의되었다.
  2. bar 는 상위 환경 참조로 foo 의 환경을 저장하였다.
  3. bar 를 전역 변수 yoo 로(함수 선언식으로) 가져왔다.
  4. 전역에서 yoo 를 호출했다.
  5. 이제 bar 는 자신의 스코프에서 a 를 찾지만 없다.
  6. 따라서 외부 환경 참조를 찾아간다.
  7. 외부 환경인 foo 의 스코프에서 a 를 찾을 수 있으며 그 값은 'grape' 이다.
  8. 따라서 'grape'가 출력된다.

2번 예제 코드는(1번 예제코드와 달리) 클로저를 나타내고 있다. 두 예제 코드는 비슷하지만 클로저를 나타내는데 있어 중요한 차이점이 있다.

  1. bar자신이 생성된 렉시컬 스코프에서 벗어나 전역에서 yoo 라는 식별자로 호출되었다.
  2. 스코프 탐색은 현재 실행 스택(yoo 호출)과 관련 없는(동적 스코프와의 차이점이다) foo 를 거쳤다.

자바스크립트의 스코프는 렉시컬 스코프라고 앞서 언급했다. 다시 말하면, 소스코드가 작성된 그 문맥에서 스코프가 결정이 되는 것이다. 이미 bar 의 상위 렉시컬 환경을 결정한 이후에는 bar 과 상관 없는 곳에서 호출한다 하더라도 foo 에서 a 를 탐색하게 된다. 이 때 bar 또는 yoo 와 같은 함수를 클로저라고 부른다.

클로저의 실용적인 활용

그래서 클로저를 왜 알아야 하는데? 에 대한 답변을 하려면 클로저가 어떻게 잘 활용되는지 알아야 할 것이다. 클로저는 자신이 생성될 때의 환경을 기억해야 하므로 MDN에선은 클로저가 필요하지 않는 작업에서 함수 내에서 함수를 불필요하게 작성하는 것은 처리 속도와 메모리 측면에서 현명하지 않다고 말한다. 여기서는 클로저가 실용적으로 잘 사용될 수 있는 예시들을 정리했다.

전역 변수의 사용 억제

전역 변수를 사용하지 않고도 현재 상태(혹은 값)를 기억하고 변경된 최신 상태를 유지할 수 있다. 어떤 상태를 관리할 때, 클로저가 없다면 (불가피하게)전역 변수를 사용하거나 아예 로컬스토리지를 사용할 수도 있다. 하지만 클로저를 활용하면 전역 변수가 아니라 타이트한 변수 스코핑을 통해 현명하게 상태나 값을 관리할 수 있다.

박스의 색을 토글하는 함수를 전역변수를 사용하지 않고 클로저와 즉시실행함수(IIFE)를 활용하여 구현하였다. 아래 코드에서 즉시실행함수가 반환한 (이름없는) 함수는 렉시컬 환경에 속한 변수를 기억하는 클로저가 된다.

<!DOCTYPE html>
<html>
<body>
  <div class="box" style="width: 100px; height: 100px; background: green;"></div>

  <script>
    // Box Color Toggler
    const box = document.querySelector('.box');

    const toggleColor = (function () {
      let isGreen = true;
      // 클로저 반환
      return function () {
        box.style.background = isGreen ? 'red' : 'green';
        // 상태 변경
        isGreen = !isGreen;
      };
    })();
    // 박스 클릭 이벤트
    box.addEventListener('click', toggleColor);
  </script>
</body>
</html>

예시 코드 처럼 클로저를 사용할 경우 전역 변수를 사용하지 않게 된다. 단순히 true , false 토글 이외에도 클로저가 포함된 함수의 변수를 private한 변수 인 것처럼 쓸 수 있을 것이다. 이렇게 불필요한 전역 변수를 사용하지 않음으로서 얻는 이득은 상당하다. 의도되지 않은 변경을 개발자가 걱정할 필요가 없기 때문에 코드가 안정적이게 된다. 필요한 곳에서만 해당 변수에 접근할 수 있게 하고 그 외부에서는 접근하지 못하게 막는 코드는 예상치 못한 부작용을 막을 수 있는 좋은 코드라고 할 수 있다.

프라이빗 메소드private method 흉내내기

이 항목의 제목과 내용은 MDN의 예시를 인용하였다. MDN link

다른 몇몇 언어들은 프라이빗으로 선언할 수 있는 기능을 제공하여 같은 클래스 내부의 다른 메소드에서만 그 메소드들을 호출할 수 있게 만들어준다. 프라이빗 메소드는 name space를 관리할 수 있게 하고 코드에 대한 접근을 제한적으로 하여 타이트한 스코프를 설계할 수 있게 해준다는 이점이 있다. 자바스크립트에 클래스와 매소드는 있지만, 자체적인 프라이빗 메소드는 따로 없다. 그러나 클로저를 이용하여 마치 프라이빗 메소드인 것처럼 구현할 수 있다.

  1. 위에 있었던 toggle 예시와 같은 방식으로는 하나의 함수밖에 클로저로 리턴할 수 없어 구현에 제한이 많다. 하지만 아래 예시 코드와 같이 구현할 경우 다양한 함수를 리턴하여 많은 것을 구현 할 수 있다. 코드는 프라이빗 함수와 변수에 접근하는 퍼블릭 함수를 클로저를 이용해 구현한 코드이며, 이렇게 클로저를 사용하는 것을 모듈 패턴module pattern이라고 한다.
let counter = (function() {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  };   
})();

console.log(counter.value()); // logs 0
counter.increment();
counter.increment();
console.log(counter.value()); // logs 2
counter.decrement();
console.log(counter.value()); // logs 1

counter 라는 객체에 private method increment , decrement , value 를 사용하는 것처럼 구현되었다. counter 내부의 privateCounter 라는 변수에는 개발자가 정해준 (가짜)프라이빗 메소드로만 접근할 수 있는 것이다.

  1. 아래와 같은 생성자 함수로도 위 MDN의 예시 코드와 같은 목적과 기능을 가진 코드를 구현할 수 있다.
function Counter() {
  let counter = 0;
  this.increase = function () {
    return ++counter;
  };
  this.decrease = function () {
    return --counter;
  };
}
const counter = new Counter();
console.log(counter.increase()) // 1
console.log(counter.increase()) // 2
console.log(counter.increase()) // 3
console.log(counter.decrease()) // 2

참고 자료:

https://meetup.toast.com/posts/86
https://www.zerocho.com/category/JavaScript/post/5740531574288ebc5f2ba97e
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Closures
https://poiemaweb.com/js-closure


혹시 잘못된 내용이 있거나 부족한 부분이 있다면 말씀 부탁드립니다. 지적은 진심으로 감사히 받겠습니다!

profile
웹 개발 공부를 하고 있습니다. #wecode

0개의 댓글