대체 클로저가 뭐에요

클로저는 Javascript 개발자에게 단골 면접 질문이다.

뭔가 한마디로 정의하기가 어려웠던 개념이다. 

https://ko.javascript.info/closure

 

변수의 유효범위와 클로저

 

ko.javascript.info

나는 이 글을 읽으면서, 내 나름대로 클로저에 대해 정리해보고자 한다. 면접에서 질문을 받았을 때 완벽히 설명할 수 있을만큼 말이다!

시작한다.

 

중첩 함수

함수 내부에서 선언한 함수를 중첩 함수( Nested function ) 이라고 한다.

function greet(firstName, lastName) {
  function getFullName() {
    return `${firstName} ${lastName}`;
  }
  
  alert("Hello, " + getFullName());
  alert("Bye, " + getFullName());
}

중복되는 코드를 줄이기에 좋은 방식으로, 자바스크립트에 익숙하다면 자연스럽게 쓰고 있을 것이다.

이제 클로저를 설명할 때, 혹은 질문을 받을 때 가장 단골로 나오는 makeCounter 예제를 보자.

 

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2

의문이 들 것이다.

대체 왜 0, 1, 2 가 찍히지?

나는 0,0,0 이 찍히는 게 맞지 않나? 생각했다.

함수가 실행될 때 마다 count 가 0이 되고, count++ 이 되지만 리턴 후에 적용되기 때문에 0,0,0 이 찍히는 게 맞지 않나??????

 

이런 의문을 해결할 수 있게 되면 자바스크립트 숙련도가 올라간다고 한다. 가보자!

 

렉시컬 환경

자바스크립트에선

1. 실행 중인 함수,

2. 코드 블록( {...} ) ,

3. 스크립트 전체

 

는 Lexical Environment 라 불리는 내부 숨김 연관 객체(Internal hidden associated object) 를 갖는다.

(일단 넘어가자)

 

렉시컨 환경 객체는 두 부분으로 구성된다.

1. 환경 레코드(Environment Record) 

모든 지역 변수를 프로퍼티로 저장하고 있는 객체. this 값과 같은 정보도 여기에 저장된다.

 

2. 외부 렉시컬 환경에 대한 참조 - 외부 코드와 연관됨

 

즉,

 

변수는 특수 내부 객체인 환경 레코드

의 프로퍼티일 뿐이다.

변수를 가져오거나 변경하는 것은, 환경 레코드의 프로퍼티를 가져오거나 변경함을 의미한다.

 

let phrase = "Hello";

alert(phrase);

위의 두 줄짜리 코드엔 하나의 렉시컬 환경이 존재한다.

이런 식이 될 것이다.

이렇게 스크립트 전체와 관련된 렉시컬 환경을 전역 렉시컬 환경이라고 한다.

전역 렉시컬 환경은 당연히, 외부 렉시컬이 없으므로 null 을 가리킨다. ( global 보다 더 큰 scope 는 없으니까! )

 

좀 더 긴 걸 보자.

오른쪽의 네모 상자들은 코드가 한 줄 한 줄 실행될 때마다 전역 렉시컬 환경이 어떻게 변화하는지 보여준다,

  1. 스크립트가 시작되면, 스크립트 내에서 선언한 변수 전체가 렉시컬 환경의 프로퍼티로 올라간다.
    1. 이 때 변수의 상태는 특수 내부 상태(special internal state) 인 `uninitialized` 가 된다.
    2. 자바스크립트 엔진은 uninitialized 상태의 변수를 인지하긴 하지만, let 을 만나기 전까진 이 변수를 참조할 수 없다.
  2. let 이 나타났다! 값을 아직 할당하지 않았기 때문에 phrase 프로퍼티의 값은 undefined 이다. pharse 는 이 시점 이후부터 참조될 수 있다.
  3. phrase 에 "Hello"가 할당되었다.
  4. phrase 에 "Bye"가 할당되었다.

아직까지 어려운 건 없다. 지금까지 배운 것을 요약해보면,

  • 렉시컬 환경에는 환경 레코드와 외부 환경에 대한 참조가 있다.
  • 변수는 환경 레코드의 프로퍼티이다.
  • 환경 레코드는 현재 실행 중인 함수와 코드 블록, 스크립트와 연관되어 있다.
  • 변수를 변경하면 환경 레코드의 프로퍼티가 변경된다.

 

함수 선언문

함수도 변수와 마찬가지로, 환경 레코드의 값이다. 하지만 변수와의 큰 차이가 있는데,

함수 선언문(function declaration) 으로 선언한 함수는 일반 변수와는 달리 바로 초기화된다.

 

함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있다.

변수는 let을 만나 선언이 될 때까지 사용할 수 없는데 말이다. (unintialized 상태로 가는 데 말이다)

 

이런 동작 방식은 함수 선언문으로 정의한 함수에만 적용된다.

let say = function (name) {...} 같이 함수를 변수에 할당한 함수 표현식은 해당하지 않는다.

 

내부와 외부 렉시컬 환경

함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어진다.

이 렉시컬 환경엔 함수 호출 시 넘겨받은 매개변수와 함수의 지역 변수가 저장된다.

함수가 호출 중인 동안에,

호출 중인 함수를 위한 내부 렉시컬 환경 (name : "John" 이 매개변수로써 저장된 say 함수 내부 ) 과

내부 렉시컬 환경이 가리키는 외부 렉시컬 환경을 갖게 된다. ( say : function 과 phrase : "Hello" 가 저장된 전역 렉시컬 환경 )

 

그리고 내부 렉시컬 환경은, 렉시컬 환경의 두 가지 요소 중 두 번째 (첫 번째는 환경 레코드인거 아시죠 ? ) 인 "외부 렉시컬 환경에 대한 참조"를 갖는다. 즉 전역 렉시컬 환경을 가리키는 것이다.

 

변수의 값을 찾아가는 과정

  1. 내부 렉시컬 환경을 먼저 찾는다
  2. 내부에 없다면, 내부에서 참조하고 있는 외부 렉시컬 환경에서 찾는다.
  3. 전역 렉시컬 환경에 도달할 때 까지, 2번을 반복한다.

즉,

내부 -> 외부 -> 외부의 외부 -> 외부의 외부의 외부 -> 외부의 외부의 .... -> 전역 렉시컬

까지 찾는 것이다. 너무 직관적이고 쉽지 않은가?

 

전역 렉시컬에서도 못 찾으면??

  • 엄격 모드 : 에러가 발생한다.
  • 비엄격 모드 : 새로운 전역 변수를 만들어낸다.

이 예시를 보면서 변수 검색 과정을 보자.

내부 렉시컬은 name : "John" 이 들어있는, say 의 렉시컬 환경이고, say 가 참조하는 외부 렉시컬(전역 렉시컬) 에는

phrase : "Hello" 와 say : function 이 담겨 있다.

  1. 함수 say 내부의 alert 에서, 변수 name 에 접근할 때 먼저 내부 렉시컬 환경을 살펴본다. 내부 렉시컬 환경에서 name 을 찾았다.
  2. phrase 를 찾아야 하는데, 내부 렉시컬 환경에 없다. 외부 렉시컬 환경으로 확장한다. 찾았다!

 

함수를 반환하는 함수

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

makeCounter 예시로 돌아오자.

makeCounter() 를 호출할 때마다, 새로운 렉시컬 환경 객체가 만들어지고, 여기에 makeCounter() 를 실행하는데 필요한 변수들이 저장된다.

 

makeCounter의 내부 렉시컬 환경에는 count : 0이 저장되고, 내부에서 참조하는 외부 렉시컬(전역) 은 makeCounter : function 과 counter : undefined 를 가지고 있다.

 

근데 위의 say("John") 예제와의 차이가 있는데, 바로 makeCounter() 는 한줄 짜리인 중첩 함수가 만들어진다는 것이다. (return count++) 현재는 중첩 함수가 생기기만 하고 실행은 되지 않은 상태이다.

 

여기서 클로저에 대해 검색할 때 가장 많이 나오는 말이 나온다.

모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다.

함수는 [[Environment]] 라 불리는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장된다.

여기서 counter.[[Environment]] 에는 { count : 0 } 이 있는 렉시컬 환경 ( 즉, makeCounter 함수의 내부 렉시컬) 에 대한 참조가 저장된다. 호출 장소와 상관없이 함수가 자신이 태어난 곳을 기억할 수 있는 건, 이 [[Environment]] 프로퍼티 덕분이다. 

 

[[ Environment ]] 는 함수가 생성될 때 딱 한 번 값이 세팅되고, 영원히 변하지 않는다.

 

counter() 를 호출하면, 각 호출마다 새로운 렉시컬 환경이 생성된다. 그리고 이 렉시컬 환경은 counter.[[Environment]] 에 저장된 렉시컬 환경을 외부 렉시컬로 참조한다.

 

function() { return count++; } 의 내부 렉시컬에는 아무 프로퍼티도 없다. ( 당연하다. 매개변수도 지역변수도 없으니 ) 그리고 [[ Environment]] 에는 makeCounter() 의 내부 렉시컬에 대한 참조 값이 저장된다.

 

맨 마지막 줄의 alert() 으로 넘어오면 count 변수가 필요하다. 내부 렉시컬 환경에서 count 를 찾아서 count++ 을 해주려고 하는데, 없다! 그러면 어떻게 하는지 기억날 것이다. 찾을 때 까지 외부로 가는 것이다. 즉 makeCounter() 의 내부 렉시컬에서 count 를 찾아내는 것이다.

 

그럼 이제 값이 갱신되어야 한다. 이 때, 변수값 갱신은 변수가 저장된 렉시컬 환경에서 이루어진다. ( 까먹었다면, 맨 위 렉시컬 환경 파트의 네 번째 줄을 보자. )

 

counter() 를 여러 번 호출하면 count 변수가 2,3 으로 증가하는 이유가 바로 여기에 있다.

 

클로저

클로저는 외부 변수를 기억하고, 이 외부 변수에 접근할 수 있는 함수를 의미한다. 자바스크립트에서는 모든 함수가 자연스럽게 클로저가 된다! 예외가 하나 있긴 하다고 하는데, 자세한 건 new Function 문법 에서 찾아보자. (나중에 정리하긴 할 것이다!)

 

요점을 정리하면, 함수는 숨김 프로퍼티인 [[ Environment ]] 를 이용해 자신이 어디서 만들어졌는지를 기억한다.

함수 본문에서는 [[ Environment ]] 를 사용해 외부 변수에 접근한다.

 

 

가비지 컬렉션

함수 호출이 끝나면 함수에 댁응하는 렉시컬 환경이 메모리에서 제거된다. 함수와 관련된 변수들은 이때 모두 사라져버린다. 함수 호출이 끝나면 관련 변수를 참조할 수 없는 것이 바로 이거 때문이다.모든 객체는 도달 가능한 상태일 때만 메모리에서 유지되기 때문이다.

 

그런데 호출이 끝난 후에도, 여전히 도달 가능한 중첩 함수가 있을 수 있다. 이 때는 이 중첩 함수의 [[ Environment ]] 프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장된다. 도달 가능한 상태가 되는 것이다.

 

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g.[[Environment]]에 f() 호출 시 만들어지는
// 렉시컬 환경 정보가 저장됩니다.

그래서 중첩함수를 사용할 때는 주의점이 있다. f() 를 호출 하고 그 결과를 어딘가에 저장하는 경우, 호출 시 만들어지는 각 렉시컬 환경 모두가 메모리에 유지된다는 점이다. 

 

function f() {
  let value = Math.random();

  return function() { alert(value); };
}

// 배열 안의 세 함수는 각각 f()를 호출할 때 생성된
// 렉시컬 환경과 연관 관계를 맺습니다.
let arr = [f(), f(), f()];

렉시컬 환경 객체는 다른 객체와 마찬가지로 도달할 수 없을 때 메모리에서 삭제된다. 해당 렉시컬 환경 객체를 참조하는 중첩 함수( 여기에서는 alert(value) 가 있는 function 함수 ) 가 하나라도 있다면, 영원히 사라지지 않는다. ( 여기서는 value )

 

아래처럼 중첩 함수가 메모리에서 삭제되고 난 후에야 그 중첩 함수를 감싸는 렉시컬 환경 ( value ) 도 메모리에서 제거된다.

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g가 살아있는 동안엔 연관 렉시컬 환경도 메모리에 살아있습니다.

g = null; // 도달할 수 없는 상태가 되었으므로 메모리에서 삭제됩니다.

최적화

앞에서 본 것 처럼, 이론상으론 함수가 살아있다면 모든 외부 변수가 메모리에 유지된다.

하지만 실제로는 자바스크립트 엔진이 이를 지속해서 최적화한다. 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 메모리에서 제거한다. 이것이 부작용을 일으킬 수도 있는데,

 

function f() {
  let value = Math.random();

  function g() {
    debugger; // Uncaught ReferenceError: value is not defined가 출력됩니다.
  }

  return g;
}

let g = f();
g();

value 가 정의되지 않았다고 한다. 이론상으로는 value 에 접근할 수 있어야 하는데, 최적화의 대상이 되어서 이런 에러가 발생한 것이다. 그래서 이런 최적화 때문에 때로는 이상한 문제가 발생하기도 한다.

 

let value = "이름이 같은 다른 변수";

function f() {
  let value = "가장 가까운 변수";

  function g() {
    debugger; // 콘솔에 alert(value);를 입력하면 '이름이 같은 다른 변수'가 출력됩니다.
  }

  return g;
}

let g = f();
g();

g() 가 생성된 환경 ( f() 의 내부 렉시컬 ) 의 value 인 "가장 가까운 변수" 를 출력해야 한다고 생각하지만, 가비지 컬렉션에 의해 "이름이 같은 다른 변수" 를 출력하는 것이다. 알아두면 좋을 것 같다. 알고 이 문제를 직면하는 것과 모르는 것은 천지차이니까 말이다.

 

문제를 풀어봅시다!

1. 함수는 가장 최근의 변화를 감지할까요?

let name = "John";

function sayHi() {
  alert("Hi, " + name);
}

name = "Pete";

sayHi(); // what will it show: "John" or "Pete"?

답은 Pete 입니다. 가장 최근의 값을 사용하기 때문입니다.

 

2. 어떤 변수가 사용 가능할까요?

function makeWorker() {
  let name = "Pete";

  return function() {
    alert(name);
  };
}

let name = "John";

// create a function
let work = makeWorker();

// call it
work(); // what will it show?

makeWorker 는 내부 함수를 만들고 리턴하고 있습니다. 내부 함수의 alert에서 사용하는 name 은 어떤 것일까요?

당연히 "Pete" 입니다.

가장 가까운 외부 렉시컬 환경에서의 변수 값을 가져오기 때문입니다.

 

3. counter 는 독립적일까요?

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

makeCounter를 사용해 두 개의 카운터를 만들었습니다.

출력 값은 0,1,2,3 일까요?

 

답은 0,1,0,1 입니다.

각각 다른 makeCounter 호출에 의해 만들어졌기 때문입니다.

makeCounter one 과 two 가 생성된 환경은 각각 다르니까요!

 

4. 생성자 함수를 이용해 counter 객체를 만들었다.

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };
  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // ?
alert( counter.up() ); // ?
alert( counter.down() ); // ?

이 코드의 실행 결과는 어떨까요?

의도한 대로 잘 동작합니다. 1,2,1 이 될 겁니다.

this.up 에 저장된 중첩 함수와 this.down 에 저장된 중첩 함수 모두 동일한 외부 렉시컬 환경에서 만들어졌기 때문에, 같은 count 변수를 공유합니다. ( 헷갈리지 마세요. 앞서 언급한 클로저가 되지 않는 함수 선언 방식인 new Function 과는 다른, 생성자 함수입니다!! )

 

5. if 문 안의 함수

let phrase = "Hello";

if (true) {
  let user = "John";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();

에러가 발생합니다.

if 문 안에서 정의되었기 때문에 sayHi 는 if 블록의 렉시컬 환경에 있습니다. ( 실행 중인 함수, 코드 블럭, 스크립트 전체는 렉시컬 환경을 갖는 거 기억하시죠? )

 

즉 sayHi 를 전역 렉시컬 환경에서 호출하면, 내부까지 들어가는 것이 아니므로 에러가 발생합니다.

 

6. 클로저의 활용

sum(1)(2) = 3
sum(5)(-1) = 4

이런 연산을 해주는 함수 sum 을 만들어보세요.

function sum(a) {
	return function (b) {
      return a + b;
    }
}

alert(sum(1)(2))

난 이렇게 짰다!

 

7. 함수를 이용해 원하는 값만 걸러내기

배열에 사용할 수 있는 내장 메서드 arr.filter(func) 는 함수 func 의 반환 값을 true 로 만드는 모든 요소를 배열로 반환해줍니다.

filter 에 넘겨서 사용할 수 있는 함수 두 가지를 만들어봅시다.

  • inBetween(a, b) - a 이상 b 이하
  • inArray([...]) - 배열 안에 있는 값인가

위 함수를 활용하면 다음과 같은 결과가 나와야 합니다.

  • arr.filter(inBetween(3,6)) – 3과 6 사이에 있는 값만 반환함
  • arr.filter(inArray([1,2,3]))  [1,2,3] 안에 있는 값과 일치하는 값만 반환함
    /* ... 여기에 두 함수 inBetween과 inArray을 만들어주세요 ...*/
    let arr = [1, 2, 3, 4, 5, 6, 7];
    
    alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6
    
    alert( arr.filter(inArray([1, 2, 10])) ); // 1,2​
function inBetween(a, b) {
  return function(x) {
    return x >= a && x <= b;
  }
}

function inArray(arr) {
  return function(x) {
    return arr.includes(x);
  }
}

8. 필드를 기준으로 정렬하기

객체가 담긴 배열을 정렬해야 합니다.

let users = [
  { name: "John", age: 20, surname: "Johnson" },
  { name: "Pete", age: 18, surname: "Peterson" },
  { name: "Ann", age: 19, surname: "Hathaway" }
];

아래와 같은 방법을 사용해 정렬할 수 있습니다.

// 이름을 기준으로 정렬(Ann, John, Pete)
users.sort((a, b) => a.name > b.name ? 1 : -1);

// 나이를 기준으로 정렬(Pete, Ann, John)
users.sort((a, b) => a.age > b.age ? 1 : -1);

그런데, 아래와 같이 함수를 하나 만들어서 정렬하면 깔끔할 것 같습니다.

users.sort(byField('name'));
users.sort(byField('age'));

byField 함수를 구현해봅시다.

 

해답

let users = [
  { name: "John", age: 20, surname: "Johnson" },
  { name: "Pete", age: 18, surname: "Peterson" },
  { name: "Ann", age: 19, surname: "Hathaway" }
];

function byField(key) {
  return (a,b) => a[key] > b[key] ? 1 : -1
}

console.log(users.sort(byField("name")))

9. 함수를 사용해 군대 만들기

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let shooter = function() { // shooter 함수
      alert( i ); // 몇 번째 shooter인지 출력해줘야 함
    };
    shooters.push(shooter);
    i++;
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0번째 shooter가 10을 출력함
army[5](); // 5번째 shooter 역시 10을 출력함
// 모든 shooter가 자신의 번호 대신 10을 출력하고 있음

왜 둘 다 10이 뜰까?

코드를 분석해보면, 반복문에서 사용되는 i 가 while 반복문의 스코프의 외부 렉시컬 환경에 있다.

즉, shooters 라는 빈 배열에는 function() {alert(i)} 가 10개 들어가게 될 텐데, 여기서 i 는 외부 렉시컬에서 선언된 i에 의존한다는 것이다.

 

i 가 0일 때는

shooters = [ function () { alert(0) } ] 이 될 것이고, i 가 1일 때는 shooters = [ function () { alert(1) }, function () { alert(1) } ]

이렇게 같은 값을 alert 하는 함수들이 그 개수에 맞춰 증가한다.

 

그럼 해결 방법은? 당연히 i 의 값을 반복문의 내부 렉시컬에 있는 값으로 대체해주는 것이다.

for문으로 바꾸거나, let j = i; 같이 선언한 후 i 대신 j 로 바꿔주자.

 

function makeArmy() {

  let shooters = [];

  for(let i = 0; i < 10; i++) {
    let shooter = function() { // shooter function
      alert( i ); // should show its number
    };
    shooters.push(shooter);
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0
army[5](); // 5

 

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let j = i;
    let shooter = function() { // shooter function
      alert( j ); // should show its number
    };
    shooters.push(shooter);
    i++;
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0
army[5](); // 5

 

마치며

자바스크립트 초심자에게는 상당히 어려운 개념일 것 같지만, 다 용어가 어려워서 그런게 아닐까 생각한다. 나도 이해하는데 좀 힘들었다.. 화이팅!

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기