JS - Clouser
Clouser
아마 자바스크립트로 코드를 작성하면서 클로저를 고려해서 코드를 작성하는 경우가 과연 많을까 싶다. 나도 프로젝트를 진행하면서 클로저에 대해 들을수 있었다. 그 당시엔 대략 개념만 짚고 넘어갔었는데 이제 제대로 클로저의 정의와 원리, 활용에 대해서 알아보겠다.
Clouser란?
우선 클로저는 함수형 프로그래밍 언어에서 등장하는 보편적인 특성이다. 자바스크립트의 고유 기능은 아니기 때문에 명세서에서 확인할 수는 없지만, 자바스크립트도 함수형 프로그래밍 언어이기 때문에 당연히 발생하는 특성이다. 그래서 이 개념에 대해서 MDN에서 소개한 내용을 보겠다.
원문 - the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment)
번역 - 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합
나름 정의를 하긴 했지만 확 와닿는게 없다. 한번 추론해보겠다.
여기서 말하는 주변 상태란 원문을 보면 렉시컬 환경이라는 것을 알 수가 있다. 앞에서도 다뤘던 내용인 컨택스트에 대해서 떠올려보자. 컨택스트가 생성될때 렉시컬 환경이 구성된다. 이 렉시컬 환경에는 내부 변수에 대한 enviromentRecord와 외부 스코프에 의한 outerEnviromentReference가 생성된다. 이후에 해당 함수 내부에서 다른 함수가 실행되면 다른 컨택스트가 생성되고 동일하게 렉시컬 환경이 구성된다. 이때 내부 함수의 outer는 외부 함수 컨택스트를 가리킨다. 스코프 체인에 의해 내부 함수에서도 외부 함수의 변수에 접근이 가능하다.
이전에 정리했던 컨택스트의 내용을 연관지어 보면, 주변 상태에 대한 참조와 함께 묶인 함수의 조합이라는 의미는 외부 함수 렉시컬 환경에 존재하는 변수를 참조하는 내부 함수에서 발생한다는 것을 추론할 수 있다. 뭔가 알듯말듯하니 코드를 작성해서 알아보겠다.
var outer = function(){ var a = 1; var inner = function(){ console.log(++a); }; inner(); }; outer();
outer함수에서는 a변수를 선언하고 내부 함수인 inner에서는 a의 값을 증가시키는 함수이다. outer를 동작했을때 컨택스트가 어떻게 생성되는지 같이 보겠다.
코드를 실행하면 전역 객체가 생성되고 outer 변수가 선언된다. 그리고 outer에 함수가 할당된다. 이제 outer를 실행하면서 outer 컨택스트가 생성된다. 변수 a와 inner가 선언되고 a에는 1, inner에는 함수가 할당된다. outer함수에서 inner를 실행하면 inner 컨택스트가 생성된다. inner의 enviromentRecord에는 a변수가 없음으로 outer에 참조된 외부 컨택스트에 접근에 a를 발견하고 숫자를 상승시킨다. inner가 종료되면서 컨택스트가 삭제되고 outer까지 종료되면 a와 inner에 대한 참조를 지우고 각 변수들은 참조가 없기때문에 GC 대상이 된다. 이게 일반적인 함수 동작이다.
var outer = function(){ var a = 1; var inner = function(){ return ++a; }; return inner; }; var outer2 = outer(); console.log(outer2()); // 2 console.log(outer2()); // 3
위에서 다뤘던 함수를 약간 수정했다. 기존 함수는 outer함수 내부에서만 inner를 실행했지만 outer의 리턴값으로 inner함수 자체를 리턴해 outer함수의 컨택스트가 종료되도 inner함수를 실행 가능하도록 수정한 것이다. 결과는 a가 정상적으로 증가된다.
여기에서 특이한 점을 발견할 수 있다. inner함수는 outer컨택스트의 변수를 참조하는데 이미 outer2를 실행한 시점에서는 outer 컨택스트가 종료된 상태이다. 그런데 어떻게 outer의 변수에 접근하는 것일까? 이는 GC(가비지 컬렉터)의 동작 방식때문이다. GC는 어떤 값을 참조하는 변수가 하나라도 있다면 수집 대상에 포함시키지 않는다. outer컨택스트는 종료되었지만 inner함수를 outer2에 할당함으로서 inner함수 내부의 참조가 유지되는 것이다.
이제 클로저에 대해 좀더 구체화할수 있다. 위에서 정리한 외부 함수 렉시컬 환경에 존재하는 변수를 참조하는 내부 함수에서 발생하는 현상이란 외부 함수의 렉시컬 환경이 GC되지 않는 현상이라는 것이다.
아주 먼길을 돌아왔다. 모든 내용을 총합해서 이해하기 쉬운 문장으로 바꿔보면
어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우, A의 실행 컨택스트가 종료된 이후에도 변수 a가 사라지지 않는 현상
이렇게 정의할 수 있겠다.
클로저와 메모리
그럼 이런 의문이 든다. GC는 메모리 낭비를 막기위해 알아서 메모리를 비워주는 기능을 하는데 클로저에 의해 GC대상에서 제외된다면 메모리 누수가 되는게 아닐까 싶다. 하지만 누수라는 것은 설계를 잘못해서 발생하는 것이다. 하지만 클로저는 함수형 프로그래밍언어의 특성임으로 잘못된 의도와 설계에 의한 누수가 아니라는 것이다. 그러면 우리는 이 특성을 잘 이해하고 메모리 소모에 대한 관리법을 지켜주면 된다.
방법은 간단하다. GC대상에서 제외됬다면 GC대상으로 만들어주는 것이다. GC대상이 되려면 모든 참조를 끊어버리면 된다.
var outer = function(){ var a = 1; var inner = function(){ return ++a; }; return inner; }; var outer2 = outer(); console.log(outer2()); console.log(outer2()); outer2 = null;
필요에 의해 클로저를 활용하고 용도를 다했다면 기본형 데이터, 보통 null이나 undefined를 할당해서 참조를 끊어준다. 이제 참조가 끊겨 변수 a는 GC대상이 될것이다.
클로저의 활용
이 클로저를 어떻게 활용하면 될까? 외부에서 내부 값에 접근 가능한 특성이 있기 때문에 잘 이해하고 사용한다면 유용한 기능이 될것이다.
콜백 함수 내부에서 외부 값 접근
어떤 배열을 렌더링하고 각 아이템마다 이벤트를 등록하는 코드를 작성해보겠다.
var numbers = [1,2,3,4]; var $ul = document.createElement('ul'); numbers.forEach(function(number){ // (1) var $li = document.createElement('li'); $li.innerText = number; $li.addEventListener('click', function(){ // (2) alert('choice is ' + number); }); $ul.appendChild($li); }); document.body.appendChild($ul);
배열에 forEach를 통해 태그를 생성해주고 각 아이템마다 클릭 이벤트를 추가해줬다. 1번 함수는 외부 값을 사용하는 로직이 없으니 클로저가 없다. 2번 함수는 외부 변수인 number를 사용하고 있기 때문에 클로저가 존재한다. 그러므로 1번 함수가 종료되어도 2번 함수에서는 클로저에 의해 외부 변수에 접근이 가능한 것이다.
하지만 이렇게 코드를 작성하지말고 이벤트 함수를 외부로 분리시켜보자.
... var alertNumber = function(number){ alert('choice is ' + number); }; numbers.forEach(function(number){ // (1) var $li = document.createElement('li'); $li.innerText = number; $li.addEventListener('click', alertNumber); $ul.appendChild($li); }); ...
이렇게 외부에서 함수를 따로 선언해서 사용하면 결과는 다른 결과가 나온다. 각 아이템을 클릭시 숫자가 표시되지 않고
[object MouseEvent]가 출력된다. 왜냐하면 콜백 함수의 제어권을 addEventListener가 가지고 있기때문에 첫번째 인자로 이벤트 객체를 넘겨주기 때문이다.
var alertNumber = function(number){ alert('choice is ' + number); }; numbers.forEach(function(number){ var $li = document.createElement('li'); $li.innerText = number; $li.addEventListener('click', alertNumber.bind(null,number)); $ul.appendChild($li); });
그럼 직접 this를 bind를 통해 바인딩한다면 문제는 없을 것이다. 하지만 만약 함수 내부에서 this를 사용한다면 원하는 this 값은 사용할 수 없는 문제가 발생한다.
var alertNumber = function(number){ return function(){ alert('choice is ' + number); }; }; numbers.forEach(function(number){ var $li = document.createElement('li'); $li.innerText = number; $li.addEventListener('click', alertNumber(number)); $ul.appendChild($li); });
그래서 이런 상황에 클로저를 활용해서 외부 값을 참조하는 것이다. 위의 코드는 고차 함수를 사용해서 클로저를 만들어 주는 것이다. 콜백 함수의 리턴값이 함수이고, 리턴하는 익명 함수에서 외부 값을 참조하기 때문에 클로저가 존재하고, 외부 값 참조가 가능한 것이다.
고차함수란? 함수를 인자로 받거나 리턴하는 함수
접근 권한 제어, 은닉화
접근 권한이란 내부 로직에 대해 접근 가능한 권한을 설정하는 것이다. 그래서 public, private, protected이렇게 세 종류가 있다. 각 단어의 의미 그대로 외부에서 접근 가능 여부와 노출 여부를 가리는 기준이 된다. 자바스크립트에서는 변수 자체에 이런 접근 권한을 직접 부여할 수는 없다. 하지만 클로저를 활용하면 접근 권한을 구분할 수 있게 된다.
var outer = function(){ var a = 1; var inner = function(){ return ++a; }; return inner; }; var outer2 = outer(); console.log(outer2()); console.log(outer2());
위의 예시를 다시 확인해보면 outer의 결과로 inner함수가 리턴됨으로서 외부에서도 outer의 내부 값을 읽고 수정할 수 있게 되었다. 만약 내부값을 참조하지 않는 함수를 리턴했다면 외부에서 내부 변수에 접근할 수 없다. 즉, 함수의 return을 활용해 일부 변수에 접근 권한을 부여할 수 있다.
그래서 외부에 제공하고 싶은 데이터들은 모아서 return하고, 내부에서만 사용되고 노출되면 안되는 데이터는 return하지 않도록 설계함으로서 접근 권한을 설정할 수가 있다.
var dice = { first: Math.floor(Math.random() * 6) + 1, second: Math.floor(Math.random() * 6) + 1, third: Math.floor(Math.random() * 6) + 1, roll: function () { var sum = this.first + this.second + this.third; console.log(`Roll results: ${this.first}, ${this.second}, ${this.third}`); console.log(`Total: ${sum}`); }, };
간단한 주사위 게임을 만들었다. 각 사람들이 dice객체를 생성해서 roll을 실행하면 주사위 3개의 합을 구해서 큰 사람이 이긴다. 당연히 roll만 동작한다면 이 게임에는 아무런 문제가 없다. 하지만 dice객체를 작정하고 수정한다면 막을 방법이 없다.
dice.first = 6; dice.seconde = 6; dice.third = 6; dice.roll(); // 18
이렇게 내부에서만 사용하고, 외부에서는 접근하지 못하도록 하는 변수를 보호하기 위해 클로저를 활용해 필요한 데이터만 리턴한다. 위의 함수에서는 세부 값들은 접근하지 못하고, roll만 제대로 동작하면 된다.
var createDice = function(){ var first = Math.floor(Math.random() * 6) + 1; var second = Math.floor(Math.random() * 6) + 1; var third = Math.floor(Math.random() * 6) + 1; return { roll : function(){ var sum = first + second + third; console.log(`Roll results: ${first}, ${second}, ${third}`); console.log(`Total: ${sum}`); } }; }; var dice = createDice(); dice.roll(); console.log(dice.fisrt); // undefined
그래서 모든 변수를 객체의 프로퍼티로 만들지 않고, 필요한 함수인 roll만 포함하는 객체를 리턴해주면 된다. 이제 dice를 생성한 이후에 dice의 내부 변수에 접근할 수 없고 roll에만 접근 가능하다. 내부 변수를 보호할수는 있지만 roll함수를 덮어 씌우는 어뷰징은 가능하다. 그래서 return 전에 변경되지 않도록 만들어주면 주사위 게임은 정당한 게임이 될것이다.
... var publicMembers = { // 외부 노출 가능한 데이터 }; Object.freeze(publicMembers); return publicMembers; ...
부분 적용 함수
부분 적용 함수란 n개의 인자를 받는 함수에 미리 m개의 인자만 넘겨 저장해두고 나중에 나머지 인자를 넘기면 원래 실행 결과를 얻을 수 있는 함수이다. 이렇게 정리하니까 이해하기 어려우니 직접 로직을 작성해보자.
var add = function(){ var result = 0; for(var i = 0; i < arguments.length; i++){ result += arguments[i]; } return result; }; var addPartial = add.bind(null, 1, 2, 3, 4, 5); console.log(addPartial(6, 7, 8, 9, 10));
이 함수는 기존 함수에 bind를 사용해서 새로운 함수를 생성함으로서 부분적용함수를 생성한 것이다. 하지만 위에서도 언급했듯이 이 함수에서는 this를 사용하지 않기 때문에 문제가 되지 않지만 this를 사용하는 상황에서는 this를 사용하지 못한다.
var partial = function(){ var originalPartialArgs = arguments; // 미리 저장할 값 var func = originalPartialArgs[0]; // 동작할 함수 if(typeof func !== 'function'){ throw new Error('첫번째 인수가 함수가 아닙니다.'); } return function(){ var partialArgs = Array.prototype.slice.call(originalPartialArgs, 1); // 미리 저장한 값에서 함수(1번 인덱스)제외한 배열 var restArgs = Array.prototype.slice.call(arguments); // 동작할 함수 실행시 받을 추가 인수값 return func.apply(this, partialArgs.concat(restArgs)); // 미리 저장한 값 배열과 추가로 넘겨준 인자 값 배열을 합쳐서 함수 실행 }; }; var add = function(){ var result = 0; for(var i = 0; i < arguments.length; i++){ result += arguments[i]; } return result; }; var addPartial = partial(add, 1, 2, 3, 4, 5); console.log(addPartial(6, 7, 8, 9, 10)); // 55
this까지 사용할수 있도록 부분 적용 함수를 만들어봤다. 동작할 함수를 첫번째 인자로 받아 저장하고, 미리 저장할 값들을 복사해 저장한다. 그리고 함수를 return하는 것이다. return하는 함수에서는 외부에서 저장한 함수와 저장한 값을 참조하기 때문에 클로저가 생성된다. 그래서 return된 함수를 인자와 함께 실행하면 이전에 저장된 값을 활용해 동작한다.
이렇게 만들어진 함수는 this까지 활용이 가능하다.
var person = { name: 'alex', explain: partial(function(first, second){ return first + this.name + second; }, '저는 ') }; person.explain('입니다.'); // 저는 alex입니다.
객체의 메서드로 실행한 함수는 this를 바인딩하고 partial함수 내부에서 this를 바인딩한 함수를 리턴함으로서 메서드를 실행한 객체가 this로 바인딩된 함수가 실행되는 것이다.
부분 적용 함수를 어디에 쓰고 있을까? 우리가 자주 사용하는 기능에서 사용한다. 연속적인 이벤트를 제어하기 위해 사용하는 debounce에 활용 가능하다.
var debounce = function(eventName, func, wait){ var timeoutId = null; return function(evnet){ var self = this; console.log(eventName, 'event 발생'); clearTimeout(timeoutId); timeoutId = setTimeout(func.bind(self, event), wait); }; }
timeoutId를 참조하는 함수를 return해서 클로저를 생성시켜준다. 최초 함수 실행시 setTimeout하면서 timeoutId를 설정해준다. 만약 지정한 시간 이내에 다시 함수를 실행하면 clearTimeout에 의해 setTimeout이 취소된다. 그래서 앞에 실행한 함수는 실행되지 않고 마지막에 실행된 함수만 동작하는 것이다. 클로저에 의해 debounce함수의 컨택스트가 종료되어도 timeoutId에 접근가능하기 때문이다.
커링 함수
curring function이란 여러 개의 인자를 받아 하나의 인자만 받는 함수로 나눠서 순차적으로 호출되도록 체인 형태로 구성하는 것이다. 부분 적용 함수와 비슷하지만 다르다.
- 부분 적용 함수 : 일부 인수만 고정한 새로운 함수 리턴, 재사용성이 좋음
- 커링 함수 : 하나의 인수만 전달하는 함수 리턴, 모든 인수 전달 전에는 동작 안함
만약 5개의 인수를 처리할 커링 함수를 만들었다면 5개의 인수를 전부 전달할때까지 함수는 실행되지 않는다.
var curring = function(func){ return function(a){ return function(b){ return func(a,b); }; }; }; var getMaxWith10 = curring(Math.max)(10); console.log(getMaxWith10(8)); // 10 console.log(getMaxWith10(20)); // 20
위에서 설명한대로 두개의 인수를 전달해야지만 동작하는 모습이다. 이것도 결국 클로저에 의한 결과이다. 필요한 인자만큼 return을 시켜서 마지막에만 함수를 동작시켜주면 된다. 하지만 이렇게 작성하면 가독성이 좋지 않다. 지금은 2개밖에 안되지만 10개라고 한다면 엄청 길어질게 뻔하다.
var currying = func => a => b => c => d => e => func(a, b, c, d, e);
ES6에서는 화살표 함수가 등장하면서 return을 더 간결하게 처리할 수 있게 되었다. 그러면 한줄에 표현이 가능하고 순차적으로 인자가 필요하다는 것도 한눈에 확인할 수 있다.
그럼 커링 함수는 언제 쓰면 유용할까?
var createApiRequest = (baseUrl) => (endpoint) => (params) => { const url = `${baseUrl}${endpoint}?${new URLSearchParams(params)}`; return fetch(url).then((res) => res.json()); }; var api = createApiRequest('https://api.example.com'); var getUser = api('/user'); var getPosts = api('/posts'); getUser({ id: 1 }).then(console.log); // 사용자 정보 가져오기 getPosts({ page: 1, limit: 10 }).then(console.log); // 게시물 가져오기
커링 함수도 결국 클로저로 인해 미리 값을 저장하기 때문에 공통적으로 사용되는 값들을 지정하는 곳에 사용하면 유용하다. 위의 로직은 서버에 데이터를 요청하는 함수이다. 모든 인자가 전되지 전까지는 fetch가 동작하지 않기 때문에 공통적인 url만 설정하고 최종적으로 데이터를 요청하는 시점에서만 마지막 인자를 부여해 요청하면 된다.
마무리
클로저가 뭔지는 알고 있었는데 정확하게 공부한건 처음인것 같다. 근데 공부를 해보니 그 전에는 모르는게 당연하다. 컨택스트, this, 콜백 함수까지 모두 알고 있어야 이해가 되는 내용인 것이다. 이제라도 대략적으로 알게 되어 다행이다...
다음은 프로토타입이다. 예전에 멘토링으로 간략하게 얘기를 들었는데 쉬운 내용이 아니였다. 하지만 그때의 나와 지금의 나는 다르다! 지금은 앞의 내용을 정리하면서 적립하고 있기 때문에 낫지 않을까 싶다! 힘내자 힘!
개의 댓글
1
Clouser
Clouser란?
클로저와 메모리
클로저의 활용
콜백 함수 내부에서 외부 값 접근
접근 권한 제어, 은닉화
부분 적용 함수
커링 함수
마무리