클로저(Closures) 무엇인가? :: 마이구미
이 글은 클로저(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) 에서 프로그램이 시작된다.
이 곳에서 변수들은 선언되어지는데, 이를 전역 변수라고 부른다.
그렇다면, 프로그램에서 함수를 호출한다면, 무슨 일이 발생하는가?
단계별로 나타내면 다음과 같다.
- 새로운 실행 컨텍스트가 생성되고, 이것은 지역 실행 컨텍스트(Local Execution Context)라고 한다.
- 지역 실행 컨텍스트는 자신의 고유한 변수(지역 변수)들을 가지게 된다.
- 새로운 실행 컨텍스트는 콜 스택(Call Stack)으로 들어간다.
* 콜 스택은 실행 순서 또는 위치를 위한 추적 메커니즘이라고 생각하면 된다.
함수가 호출되면 위와 같은 일들이 발생하게 된다.
그렇다면 return 또는 대괄호 } 를 통한 함수 호출이 끝난다면, 다음과 같은 일들이 발생한다.
- 콜스택에서 지역 실행 컨텍스트는 빠지게 된다.
- 함수의 반환값은 이 함수를 호출했던 실행 컨텍스트에게 반환된다.
이 반환받는 실행 컨텍스트는 전역 또는 다른 지역 실행 컨텍스트가 될 수 있다. - 지역 실행 컨텍스트는 소멸된다.
소멸된다는 의미는 지역 실행 컨텍스트에 존재하는 모든 변수들은 더이상 사용할 수 없다는 것이다.
* 이것은 위와 같은 변수들이 왜 지역 변수라 불리는 이유를 알 수 있다.
아래 그림과 함께 참고하면 이해에 도움이 될 것이다.
자주 등장하는 용어들을 나타내고, 실질적으로 자바스크립트의 엔진을 나타내는 그림이다.
위 과정을 이번에는 자바스크립트 예제 코드를 통해 조금 더 자세히 알아보자.
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);
쉽게 이해할 수 있는 단순한 코드로, 어떠한 흐름과 결과를 예측할 수 있다.
하지만 여기서는 자바스크립트의 엔진이 실제로 어떻게 동작하는지 이해하기 위해 내부를 자세하게 다뤄본다.
- Line 1. 전역 실행 컨텍스트에 새로운 변수 a 를 선언하고, 타입이 number 인 3 을 할당한다.
- Line 2 - 5. 전역 실행 컨텍스트에 새로운 변수 addTwo 를 선언한다.
이 변수는 무엇이 할당 되는가? 함수가 정의된 모습을 볼 수 있다. 이것은 function definition 이라고 부른다.
function definition 은 실행 개념이 아닌 추후에 사용하기 위해 단순히 변수에 저장된다. - Line 6. 전역 실행 컨텍스트에 새로운 변수 b 를 선언하고 addTwo(a) 를 할당한다.
단순해보이지만, 사실 이 과정에서 변수 b 는 우선 undefined 값을 가진다.
대입 연산자(=) 이전까지인 let b= addTwo(a)를 의미한다. - 대입 연산자(=) 를 볼 수 있듯이 변수 b 는 값을 할당받을 준비를 한다.
할당할 값은 addTwo() 이다. 모든 함수는 항상 반환값을 가진다. (object, array, function, undefined, etc...)
그로 인해, 변수 b 는 함수로부터 반환된 값을 할당받을 수 있게 된다. - 우리는 addTwo() 호출이 필요하다.
이전 2번 단계를 보다시피, addTwo 변수는 function definition 형태를 포함하고 있다.
전역 실행 컨텍스트 메모리에서 addTwo 이름을 가지는 변수를 찾고, 전달인자 a 또한 찾는다.
이로써, 함수를 호출할 준비는 마쳤다. - 함수 호출로 인해, 실행 컨텍스트가 생성되어 바뀌게 된다.
이것이 지역 실행 컨텍스트인 "addTwo 실행 컨텍스트" 로 볼 수 있다.
그리고 무슨 일이 발생하는가? 위에서 언급했듯이, 콜 스택에 생성된 실행 컨텍스트를 넣는다는 것을 기억해낼 수 있을 것이다.
그 후 지역 실행 콘텍스트에서 처음으로 하는 일은 무엇인가? - 아마 다음과 같이 말할 수 있을 것이다. => "지역 실행 컨텍스트에 새로운 변수 ret 을 선언한다."
하는 일이지만 첫번째로 하는 일은 아니다. 정확한 대답은 함수의 첫번째 전달인자 a 를 확인한다.
즉, 전달인자 a 에 해당하는 새로운 변수 x 를 선언하고, 전달된 값인 3을 할당하는 것이 첫번째 하는 일이 된다. - Line 3. 그 다음 일로 지역 실행 컨텍스트에서는 새로운 변수 ret 을 선언하고 값은 undefined 을 가진다.
- 변수 ret 에 값을 할당하기 위해 x 변수를 찾아야한다.
우선 지역 생성 컨텍스트를 살펴본다.
값이 3이라는 것을 찾은 후, 2 를 더한 값인 5를 ret 은 할당받게 된다. - Line 4. 변수 ret 을 반환하는 코드로써, 결과적으로 5라는 값을 반환한다.
이 함수는 종료된다. - Line 4 - 5. 함수가 종료되면, 지역 실행 컨텍스트는 소멸된다.
변수 x, ret 은 더이상 존재하지 않는다.
지역 실행 컨텍스트는 콜 스택에서 빠진 후, 반환되는 값을 호출 컨텍스트(자신을 호출한 컨텍스트)에게 반환한다.
여기서는 addTwo 함수를 호출한 전역 실행 컨텍스트에게 반환하는 것을 의미한다. - 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 를 나타낸다.
지금 이해하지 못해도 된다.
위 예제 코드에 대한 설명을 따라간다면, 명확해질 것이다.
만약 스코프를 이해하고 있다면, 넘어가도 좋다.
- Line 1. 전역 실행 컨텍스트에서 새로운 변수 val1 를 선언하고 2 를 할당한다.
- Line 2- 5. 전역 실행 컨텍스트에서 새로운 변수 multiplyThis 를 선언한다.
- Line 6. 전역 실행 컨텍스트에서 새로운 변수 multiplied 를 선언한다.
- 전역 실행 컨텍스트 메모리에서 multiplyThis 변수를 찾아 실행한다.
- 새로운 함수의 호출은 새로운 실행 컨텍스트를 생성한다고 했다. 즉, 지역 생성 컨텍스트가 생성된다.
- 지역 실행 컨텍스트는 변수 n 에 6을 할당하게 된다.
- Line 3. 지역 실행 컨텍스트에서 변수 ret 을 선언한다.
- 변수 ret 의 값 할당을 위해 변수 n 과 val1 이 필요하다.
우선 지역 실행 컨텍스트에서 변수 n 을 찾는다. 6번 단계를 통해 할당된 값인 6을 가져온다.
변수 ret 을 찾는데 존재하지않는다. 그래서 호출 컨텍스트(전역 실행 컨텍스트)에서 찾게 된다
전역 실행 컨텍스트에서는 1번 단계를 통해 변수 ret 이 할당되었기에 찾을 수 있다. - 결과적으로 변수 ret 에는 6 * 2 를 통해 12 가 할당된다.
- Line 4. 변수 ret 이 반환되면 지역 실행 컨텍스트는 소멸한 사라진다.
하지만 변수 val1 은 지역이 아닌 전역 실행 컨텍스트라서 소멸되지 않는다. - 결과적으로 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)
- Line 1. 전역 실행 컨텍스트에 변수 val 선언하고, 7 이라는 값을 할당했다.
- Line 2 - 8. 전역 실행 컨텍스트에 변수 createAdder 를 선언하고 function definition 를 할당했다.
여기서 function definition 는 3 ~ 7 라인을 뜻한다. 앞서 다뤘던 것처럼 function definition 는 단순히 저장된 형태이다. - Line 9. 새로운 변수 adder 를 선언하고, undefined 를 할당한다.
- 변수 adder 값 할당을 위해 전역 컨텍스트 메모리에서 createAdder 를 찾는다.
2번 단계에서 저장했기에, 찾은 후 호출한다. - createAdder 함수를 호출함으로써, 새로운 실행 컨텍스트가 생성된다.
이 컨텍스트는 콜 스택에 넣게 된다. - Line 3 - 6. 새로운 함수 선언을 가진다. 지역 생성 컨텍스트에 변수 addNumbers 를 정의한다.
즉, addNumbers 이름을 가지는 function definition로써, 지역 생성 컨텍스트에 존재하게 된다. - Line 7. 변수 addNumbers 를 반환한다.
반환하기 위해 변수 addNumbers 를 찾으니 function definition 형태이다.
함수는 어느 타입이든 반환할 수 있다고 했으니 Line 4 - 5 에 해당하는 function definition 를 반환한다고 볼 수 있다.
결론적으로 반환한 후, 콜 스택에서 지역 생성 컨텍스트를 제거한다. - 또한 지역 실행 컨텍스트는 소멸될 것이고, 변수 addNumbers 도 더이상 이용할 수 없게 된다.
반환한 function definition 은 변수 adder 에 할당되어 여전히 존재한다.
이것은 3번 단계에서 생성된 변수를 나타낸다. - Line 10. 새로운 변수 sum 이 선언하고, undefined 를 할당한다.
- 변수 sum 의 값 할당을 위해 adder() 함수를 호출해야한다.
호출을 위한 이전 단계에서 만들어진 adder 변수를 전역 실행 컨텍스트에서 찾을 수 있다. - 그 다음 2 개의 전달인자를 찾고 함수를 호출할 수 있다.
첫번째 인자인 변수 val 는 1번 단계에서 만들어졌다. - 실행하는 함수는 Line 3 - 5 에 해당하는 function definition 을 가르킨다.
함수를 실행하면 새로운 지역 생성 컨텍스트가 생성된다.
새로운 변수 a, b 가 생성되고, 전달된 인자를 통해 값이 할당된다. - 새로운 변수 ret 을 선언한다.
- 변수 ret 에 a + b 의 결과인 15 를 할당한다.
- 함수가 종료되는 시점으로써, 변수 ret 은 함수로부터 반환된다.
지역 생성 컨텍스트는 콜 스택에서 제거되고 컨텍스트는 소멸되어 변수 a, b, ret 은 존재하지 않는다. - 변수 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)
- Line 1 - 8. 전역 실행 컨텍스트에 새로운 변수 createCounter 를 선언하고, function definition 를 할당한다.
- Line 9. 새로운 변수인 increment 를 선언한다.
- createCounter 함수를 호출하고 반환된 값을 변수 increment 에 할당하길 원한다.
- 함수를 호출하고, 지역 생성 컨텍스트가 생성된다.
- Line 2. 지역 생성 컨텍스트에 변수 counter 가 선언되고, 0 으로 할당된다.
- Line 3 - 6. 지역 생성 컨텍스트에 새로운 변수 myFunction 은 선언된다.
또 다른 function definition 으로써, Line 4 - 5 를 의미한다. - Line 7. myFunction 변수를 반환한 후, 지역 실행 컨텍스트는 콜 스택에서 제거되고, 컨텍스트는 소멸된다.
그로 인해, 변수 myFunction, counter 는 더이상 존재하지않는다.
결과적으로 제어권은 호출 컨텍스트에게 반환한다. - Line 9. 호출 컨텍스트의 변수 increment 는 createCounter 함수의 반환값이 할당된다.
현재 increment 는 function definition 형태가 된다.
더이상 myFunction 은 존재하지않지만, 이전 단계의 반환을 통해 전역 실행 컨텍스트에서는 같은 function definition(Line 4 - 5) 형태를 가진다. - Line 10. 새로운 변수 c1 이 선언한다.
- 변수 c1 에 값을 할당하기 위해 increment 함수는 호출된다.
- 함수 호출로 인해, 새로운 실행 컨텍스트가 생성된다.
여기까지는 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) 가 만들어진다는 것이다.
그리고 클로저는 함수가 생성되는 시점의 스코프의 모든 변수의 컬렉션이라고 보면 된다.