Javascript

클로저(Closures) 무엇인가? :: 마이구미

mygumi 2018. 5. 29. 21:27
반응형

이 글은 클로저(Closures) 에 대해 다룬다.

자바스크립트 개발 면접이라면, 필수적으로 물을 정도로 중요하다.

참고보다 번역에 가까운 글이 되었다.

참고 링크 - https://medium.com/dailyjs/i-never-understood-javascript-closures-9663703368e8


자바스크립트의 클로저는 입문자뿐만 아니라 중급자에게도 다소 어려운 개념이 된다.

그렇기에, 클로저를 설명하기 위해 서론이 굉장히 길 수도 있다.


  • 실행 문맥(Execution Context)
  • 어휘적 범위(Lexical Scope)
  • 함수를 통한 함수 반환(A Function  that return a function)
  • 클로저(Closures)


심지어 모르고 있었더라도, 이미 클로저를 이용하고 있었을 지도 모르는 신기하게 느껴질 수도 있다.

그래서 참고한 글의 필자가 클로저를 표현한 말이 와닿을 수 있다.


I have used closures in my work, sometimes I even used a closures without realizing I was using a closures.

"나는 클로저를 사용하고 있다. 하지만 가끔은 내가 클로저를 사용하고 있다는 것을 알아차리지 못한다. "




실행 문맥(Execution Context)


클로저를 이해하기에 앞서, 몇 중요한 개념 중 하나인 실행 문맥을 본다.


실행 문맥(Execution Context) 이란 무엇인가?


일반적으로 실행 컨텍스트라고 부른다.

"실행 가능한 코드가 실행되기 위해 필요한 환경" 이라는 추상적인 용어이다.

일반적으로 전역 코드와 함수 코드 단위로 나타낼 수 있다.

그림으로 나타내면 다음과 같다.



실제 프로그램으로 생각하면 다음과 같다.

전역 실행 컨텍스트(Global Execution Context) 에서 프로그램이 시작된다.

이 곳에서 변수들은 선언되어지는데, 이를 전역 변수라고 부른다.

그렇다면, 프로그램에서 함수를 호출한다면, 무슨 일이 발생하는가?

단계별로 나타내면 다음과 같다.


  1. 새로운 실행 컨텍스트가 생성되고, 이것은 지역 실행 컨텍스트(Local Execution Context)라고 한다.
  2. 지역 실행 컨텍스트는 자신의 고유한 변수(지역 변수)들을 가지게 된다.
  3. 새로운 실행 컨텍스트는 콜 스택(Call Stack)으로 들어간다.
    * 콜 스택은 실행 순서 또는 위치를 위한 추적 메커니즘이라고 생각하면 된다.


함수가 호출되면 위와 같은 일들이 발생하게 된다.

그렇다면 return 또는 대괄호 } 를 통한 함수 호출이 끝난다면, 다음과 같은 일들이 발생한다.


  1. 콜스택에서 지역 실행 컨텍스트는 빠지게 된다.
  2. 함수의 반환값은 이 함수를 호출했던 실행 컨텍스트에게 반환된다.
    이 반환받는 실행 컨텍스트는 전역 또는 다른 지역 실행 컨텍스트가 될 수 있다.
  3. 지역 실행 컨텍스트는 소멸된다.
    소멸된다는 의미는 지역 실행 컨텍스트에 존재하는 모든 변수들은 더이상 사용할 수 없다는 것이다.
    * 이것은 위와 같은 변수들이 왜 지역 변수라 불리는 이유를 알 수 있다.


아래 그림과 함께 참고하면 이해에 도움이 될 것이다.

자주 등장하는 용어들을 나타내고, 실질적으로 자바스크립트의 엔진을 나타내는 그림이다.


위 과정을 이번에는 자바스크립트 예제 코드를 통해 조금 더 자세히 알아보자.


1: let a = 3; 2: function addTwo(x) { 3: let ret = x + 2; 4: return ret; 5: } 6: let b = addTwo(a); 7: console.log(b);


쉽게 이해할 수 있는 단순한 코드로, 어떠한 흐름과 결과를 예측할 수 있다.

하지만 여기서는 자바스크립트의 엔진이 실제로 어떻게 동작하는지 이해하기 위해 내부를 자세하게 다뤄본다.


  1. Line 1. 전역 실행 컨텍스트에 새로운 변수 a 를 선언하고, 타입이 number 인 3 을 할당한다.
  2. Line 2 - 5. 전역 실행 컨텍스트에 새로운 변수 addTwo 를 선언한다.
    이 변수는 무엇이 할당 되는가? 함수가 정의된 모습을 볼 수 있다. 이것은 function definition 이라고 부른다.
    function definition 은 실행 개념이 아닌 추후에 사용하기 위해 단순히 변수에 저장된다.
  3. Line 6. 전역 실행 컨텍스트에 새로운 변수 b 를 선언하고 addTwo(a) 를 할당한다.
    단순해보이지만, 사실 이 과정에서 변수 b 는 우선 undefined 값을 가진다.
    대입 연산자(=) 이전까지인 let b = addTwo(a) 를 의미한다.
  4. 대입 연산자(=) 를 볼 수 있듯이 변수 b 는 값을 할당받을 준비를 한다.
    할당할 값은 addTwo() 이다. 모든 함수는 항상 반환값을 가진다. (object, array, function, undefined, etc...)
    그로 인해, 변수 b 는 함수로부터 반환된 값을 할당받을 수 있게 된다.
  5. 우리는 addTwo() 호출이 필요하다.
    이전 2번 단계를 보다시피, addTwo 변수는 function definition 형태를 포함하고 있다.
    전역 실행 컨텍스트 메모리에서 addTwo 이름을 가지는 변수를 찾고, 전달인자 a 또한 찾는다.
    이로써, 함수를 호출할 준비는 마쳤다.
  6. 함수 호출로 인해, 실행 컨텍스트가 생성되어 바뀌게 된다.
    이것이 지역 실행 컨텍스트인 "addTwo 실행 컨텍스트" 로 볼 수 있다.
    그리고 무슨 일이 발생하는가? 위에서 언급했듯이, 콜 스택에 생성된 실행 컨텍스트를 넣는다는 것을 기억해낼 수 있을 것이다.
    그 후 지역 실행 콘텍스트에서 처음으로 하는 일은 무엇인가?
  7. 아마 다음과 같이 말할 수 있을 것이다. => "지역 실행 컨텍스트에 새로운 변수 ret 을 선언한다."
    하는 일이지만 첫번째로 하는 일은 아니다. 정확한 대답은 함수의 첫번째 전달인자 a 를 확인한다.
    즉, 전달인자 a 에 해당하는 새로운 변수 x 를 선언하고, 전달된 값인 3을 할당하는 것이 첫번째 하는 일이 된다.
  8. Line 3. 그 다음 일로 지역 실행 컨텍스트에서는 새로운 변수 ret 을 선언하고 값은 undefined 을 가진다.
  9. 변수 ret 에 값을 할당하기 위해 x 변수를 찾아야한다.
    우선 지역 생성 컨텍스트를 살펴본다.
    값이 3이라는 것을 찾은 후, 2 를 더한 값인 5를 ret 은 할당받게 된다.
  10. Line 4. 변수 ret 을 반환하는 코드로써, 결과적으로 5라는 값을 반환한다.
    이 함수는 종료된다.
  11. Line 4 - 5. 함수가 종료되면, 지역 실행 컨텍스트는 소멸된다.
    변수 x, ret 은 더이상 존재하지 않는다.
    지역 실행 컨텍스트는 콜 스택에서 빠진 후, 반환되는 값을 호출 컨텍스트(자신을 호출한 컨텍스트)에게 반환한다.
    여기서는 addTwo 함수를 호출한 전역 실행 컨텍스트에게 반환하는 것을 의미한다.
  12. 4번 단계를 마친 결과, 변수 b 에 값을 할당되었다.


굉장히 간단한 코드임에도 불구하고, 다소 긴 설명을 했다.

그럼에도 아직 클로저에 대해 언급하지 않았다.

하지만 직접적인 연관이 있기 때문에 일단 계속 보자.




Lexical scope


lexical scope 를 이해하기 위해 다음 코드를 보자.


1: let val1 = 2 2: function multiplyThis(n) { 3: let ret = n * val1 4: return ret 5: } 6: let multiplied = multiplyThis(6) 7: console.log('example of scope:', multiplied)


자바스크립트에 대해 어려움을 느끼는 것 중 하나는 변수를 찾는 방법이다.

지역 실행 컨텍스트에서 변수를 찾지 못한다면, 호출 컨텍스트에서 찾는다.

이렇게 반복하다 전역 실행 컨텍스트까지 없다면 undefined 를 나타낸다.


지금 이해하지 못해도 된다.

위 예제 코드에 대한 설명을 따라간다면, 명확해질 것이다.

만약 스코프를 이해하고 있다면, 넘어가도 좋다.


  1. Line 1. 전역 실행 컨텍스트에서 새로운 변수 val1 를 선언하고 2 를 할당한다.
  2. Line 2- 5. 전역 실행 컨텍스트에서 새로운 변수 multiplyThis 를 선언한다.
  3. Line 6. 전역 실행 컨텍스트에서 새로운 변수 multiplied 를 선언한다.
  4. 전역 실행 컨텍스트 메모리에서 multiplyThis 변수를 찾아 실행한다.
  5. 새로운 함수의 호출은 새로운 실행 컨텍스트를 생성한다고 했다. 즉, 지역 생성 컨텍스트가 생성된다.
  6. 지역 실행 컨텍스트는 변수 n 에 6을 할당하게 된다.
  7. Line 3. 지역 실행 컨텍스트에서 변수 ret 을 선언한다.
  8. 변수 ret 의 값 할당을 위해 변수 n 과 val1 이 필요하다.
    우선 지역 실행 컨텍스트에서 변수 n 을 찾는다. 6번 단계를 통해 할당된 값인 6을 가져온다.
    변수 ret 을 찾는데 존재하지않는다. 그래서 호출 컨텍스트(전역 실행 컨텍스트)에서 찾게 된다
    전역 실행 컨텍스트에서는 1번 단계를 통해 변수 ret 이 할당되었기에 찾을 수 있다.
  9. 결과적으로 변수 ret 에는 6 * 2 를 통해 12 가 할당된다.
  10. Line 4. 변수 ret 이 반환되면 지역 실행 컨텍스트는 소멸한 사라진다.
    하지만 변수 val1 은 지역이 아닌 전역 실행 컨텍스트라서 소멸되지 않는다.
  11. 결과적으로 Line 6 으로 돌아가면, 변수 multiplied 는 12 를 할당받게 된다.


여기서 기억할 점은, 함수는 호출 컨텍스트의 변수에 접근 권한을 가진다는 것이다.

이러한 현상을 공식적인 이름으로 렉시컬 스코프(Lexical scope) 라고 한다.




A function that returns a function


첫번째 예제 코드의 addTwo 함수는 number 를 반환했다.

위에서 언급했듯이, 함수는 타입과 상관없이 어느 것이든 반환할 수 있다.

이번 예제에서는 함수가 함수를 반환하는 예제 코드를 다뤄본다.


1: let val = 7 2: function createAdder() { 3: function addNumbers(a, b) { 4: let ret = a + b 5: return ret 6: } 7: return addNumbers 8: } 9: let adder = createAdder() 10: let sum = adder(val, 8) 11: console.log('example of function returning a function: ', sum)


  1. Line 1. 전역 실행 컨텍스트에 변수 val 선언하고, 7 이라는 값을 할당했다.
  2. Line 2 - 8. 전역 실행 컨텍스트에 변수 createAdder 를 선언하고 function definition 를 할당했다.
    여기서 function definition 는 3 ~ 7 라인을 뜻한다. 앞서 다뤘던 것처럼 function definition 는 단순히 저장된 형태이다.
  3. Line 9. 새로운 변수 adder 를 선언하고, undefined 를 할당한다.
  4. 변수 adder 값 할당을 위해 전역 컨텍스트 메모리에서 createAdder 를 찾는다.
    2번 단계에서 저장했기에, 찾은 후 호출한다.
  5. createAdder 함수를 호출함으로써, 새로운 실행 컨텍스트가 생성된다.
    이 컨텍스트는 콜 스택에 넣게 된다.
  6. Line 3 - 6. 새로운 함수 선언을 가진다. 지역 생성 컨텍스트에 변수 addNumbers 를 정의한다.
    즉, addNumbers 이름을 가지는 function definition로써, 지역 생성 컨텍스트에 존재하게 된다.
  7. Line 7. 변수 addNumbers 를 반환한다.
    반환하기 위해 변수 addNumbers 를 찾으니 function definition 형태이다.
    함수는 어느 타입이든 반환할 수 있다고 했으니 Line 4 - 5 에 해당하는 function definition 를 반환한다고 볼 수 있다.
    결론적으로 반환한 후, 콜 스택에서 지역 생성 컨텍스트를 제거한다.
  8. 또한 지역 실행 컨텍스트는 소멸될 것이고, 변수 addNumbers 도 더이상 이용할 수 없게 된다.
    반환한 function definition 은 변수 adder 에 할당되어 여전히 존재한다.
    이것은 3번 단계에서 생성된 변수를 나타낸다.
  9. Line 10. 새로운 변수 sum 이 선언하고, undefined 를 할당한다.
  10. 변수 sum 의 값 할당을 위해 adder() 함수를 호출해야한다.
    호출을 위한 이전 단계에서 만들어진 adder 변수를 전역 실행 컨텍스트에서 찾을 수 있다.
  11. 그 다음 2 개의 전달인자를 찾고 함수를 호출할 수 있다.
    첫번째 인자인 변수 val 는 1번 단계에서 만들어졌다.
  12. 실행하는 함수는 Line 3 - 5 에 해당하는 function definition 을 가르킨다.
    함수를 실행하면 새로운 지역 생성 컨텍스트가 생성된다.
    새로운 변수 a, b 가 생성되고, 전달된 인자를 통해 값이 할당된다.
  13. 새로운 변수 ret 을 선언한다.
  14. 변수 ret 에 a + b 의 결과인 15 를 할당한다.
  15. 함수가 종료되는 시점으로써, 변수 ret 은 함수로부터 반환된다.
    지역 생성 컨텍스트는 콜 스택에서 제거되고 컨텍스트는 소멸되어 변수 a, b, ret 은 존재하지 않는다.
  16. 변수 sum 에 반환된 값을 할당된다.


위와 같은 흐름을 통해 변수 sum 은 15 를 출력할 수 있게 된다.

이를 통한 몇가지 요점은 다음과 같다.


  • function definition 은 단순히 변수로 저장된다.
  • function definition 은 함수가 호출되기전까지는 보이지 않는다. (예제 코드에서는 addNumbers)
  • 매번 함수가 호출될때마다 지역 생성 컨텍스트가 생성(임시적)된다.
  • 함수가 종료되면 지역 생성 컨텍스트는 소멸된다.




Closures


클로저란 MDN 에서는 "함수와 함수가 선언된 어휘적 환경(렉시컬 환경)의 조합" 이라고 정의했다.

앞서 다룬 2가지의 예제의 조합이라고 볼 수 있다.

클로저를 위한 예제 코드를 추측해보자.


1: function createCounter() { 2: let counter = 0 3: const myFunction = function() { 4: counter = counter + 1 5: return counter 6: } 7: return myFunction 8: } 9: const increment = createCounter() 10: const c1 = increment() 11: const c2 = increment() 12: const c3 = increment() 13: console.log('example increment', c1, c2, c3)


  1. Line 1 - 8. 전역 실행 컨텍스트에 새로운 변수 createCounter 를 선언하고, function definition 를 할당한다.
  2. Line 9. 새로운 변수인 increment 를 선언한다.
  3. createCounter 함수를 호출하고 반환된 값을 변수 increment 에 할당하길 원한다.
  4. 함수를 호출하고, 지역 생성 컨텍스트가 생성된다.
  5. Line 2. 지역 생성 컨텍스트에 변수 counter 가 선언되고, 0 으로 할당된다.
  6. Line 3 - 6. 지역 생성 컨텍스트에 새로운 변수 myFunction 은 선언된다.
    또 다른 function definition 으로써, Line 4 - 5 를 의미한다.
  7. Line 7. myFunction 변수를 반환한 후, 지역 실행 컨텍스트는 콜 스택에서 제거되고, 컨텍스트는 소멸된다.
    그로 인해, 변수 myFunction, counter 는 더이상 존재하지않는다.
    결과적으로 제어권은 호출 컨텍스트에게 반환한다.
  8. Line 9. 호출 컨텍스트의 변수 increment 는 createCounter 함수의 반환값이 할당된다.
    현재 increment 는 function definition 형태가 된다.
    더이상 myFunction 은 존재하지않지만, 이전 단계의 반환을 통해 전역 실행 컨텍스트에서는 같은 function definition(Line 4 - 5) 형태를 가진다.
  9. Line 10. 새로운 변수 c1 이 선언한다.
  10. 변수 c1 에 값을 할당하기 위해 increment 함수는 호출된다.
  11. 함수 호출로 인해, 새로운 실행 컨텍스트가 생성된다.


여기까지는 increment() 함수가 3번 호출되기전까지 상황이다.

만약 함수가 3번 호출되고 난 후, 콘솔창에는 어떤 값들이 출력될 것인지 예상해보자.

렉시컬 스코프로 인해, 호출 컨텍스트의 접근 권한을 가진다고 한 것을 기억하면 쉽게 추측할 수 있다.

결과적으로 출력값은 1, 2, 3 이다.


사실상 이것이 가능한 이유는 결과적으로 클로저라는 또 다른 메커니즘 때문이다.

함수를 선언할때마다 클로저는 형성된다. 즉, function definition 과 closures 가 함께 저장된다.

클로저는 함수를 생성한 시점의 스코프에 있는 모든 변수를 가진다.


이를 바탕으로 흐름을 다시 이어나가보자.


   12. Line 4. counter = counter + 1 를 위해 counter 변수를 찾아야한다.

   13. 로컬 또는 전역 실행 컨텍스트를 보기 전에, 클로저를 확인한다.

        클로저에는 0인 값을 가지는 counter 변수를 포함하고 있기에, 연산을 통해 1 이라는 값을 할당한다.

   14. Line 5. 마찬가지로 클로저를 통해 counter 변수를 가져와서 할당함으로써 2 라는 값으로 변경된다.

   15. Line 6. 마찬가지다.


참고한 글의 필자는 클로저를 배낭이라고 표현했다.

함수 선언에 있어, function definition 과 배낭을 함께 제공한다.

increment 변수는 function definition 과 배낭을 함께 가지고 있는 상태라고 보자.

increment() 함수가 호출되면, 배낭에서 필요한 변수(counter)를 꺼내서 사용하는 것으로 볼 수 있다.

그 결과 같은 변수를 참조한 결과인 출력값을 얻게 된다.


기억해야할 요점은 함수가 선언 될때마다, (function definition, closures) 가 만들어진다는 것이다.

그리고 클로저는 함수가 생성되는 시점의 스코프의 모든 변수의 컬렉션이라고 보면 된다.

반응형