[코어 자바스크립트]5. 클로저

Donghun Seol·2022년 11월 24일
0

코어자바스크립트

목록 보기
5/7

클로저에 관해선 예전에 포스팅한 적이 있는데, 다시 보니 잘 알지 못한채로 작성했고, 클로저의 중요한 내용을 빠트린 것 같다. 책을 학습하면서 좀 더 알고 넘어가도록 노력하고, 정리해야겠다.

클로저의 의미 및 원리 이해

클로저의 의미

A closure is the combination of a function and the lexical environment within which that function was declared - MDN

그러니깐 클로저는 함수와 해당 함수가 선언될때의 lexical environment의 조합이다. 클로저는 외부함수 안에서 선언된 내부함수가 외부함수의 스코프에서 선언된 변수를 참조할때 발생된다. 외부함수에서 선언된 변수는 유효범위나 생명주기가 지나더라도 클로저에서 해당 변수를 참조할 수 있다. JS엔진의 GC는 해당 변수를 클로저가 참조하므로 수거대상으로 지정하지 않는다. 따라서 일반적인 생명주기에서는 수거되어 참조할 수 없어야하는 변수가 클로저내에서는 참조가능해진다. 함수의 선언방식에 따라 결정되는 것은 아니고, 어떻게 호출되느냐에 따라 클로저가 형성될지 여부가 결정된다.

아래의 코드에서는 클로저 현상이 발생되지 않는다. 또는 클로저 함수가 생성되지 않는다.

var outer = function () {
  var a = 1;
  var inner = function () {
    console.log(++a);
  };
  inner();
};

outer();

반면 아래에서는 클로저가 생성된다.
outer()의 생명주기가 종료되었음에도 inner()를 호출시 outer()의 스코프에 있는 변수 a에 접근 가능하고, 값이 지속적으로 유지됨을 확인할 수 있다. 함수의 실행컨텍스트가 종료된 후에도 LexicalEnvrionment가 GC의 수거대상이 안된다면 클로저현상이 발생한다.

var outer = function () {
  var a = 1;
  var inner = function () {
    console.log(++a);
  };
  return inner;
};

var outer2 = outer(); // outer2 is inner function
console.log(outer2()); // 2
console.log(outer2()); //3

return이 없어도 클로저가 발생할 수 있다. ❓

아래는 window의 메서드인 setInterval에 전달할 콜백함수 내부에서 지역변수 a를 참조하므로 클로저다.

(function () {
  var a = 0;
  var intervalId = null;
  var inner = function () {
    if (++a >= 10) {
      clearInterval(intervalId);
    }
    console.log(a);
  };
  intervalId = setInterval(inner, 1000);
})();

아래 역시 외부객체인 DOM의 메서드에 콜백으로 전달하는 함수 내부에서 지역변수 count를 참조하므로 클로저다.
💡 아하. 이벤트리스너에 콜백을 전달하는 호출된 익명함수는 호출되고 생성주기가 끝난다. 하지만 해당 함수내의 지역변수인 count는 이벤트리스너에 콜백으로 전달된 함수에서 참조하므로 GC의 수거대상이 되지 않고, 클로저의 lexical environment에 지속적으로 전달된다. 백그라운드 태스크 큐에 있는 함수에서 참조하고 있어도 GC는 수거하지 않는 것이다. 따라서 클로저다. 😎

(function () {
  var count = 0;
  var button = document.createElement('button');
  button.innerText = 'click me';
  button.addEventListener('click', () => {
    console.log(++count, 'times clicked');
  });
  document.body.appendChild(button);
})();

클로저와 메모리 관리

클로저는 의도적으로 특정변수를 GC의 수거대상이 되지 않게 참조를 유지하면서 생성한다. 의도적인 메모리 소모라고도 할 수 있다. 따라서 메모리를 효율적으로 관리하기 위해서는 의도적인 메모리 소모가 끝난 시점에서 해당 변수에 대한 참조카운트를 0으로 해주면 된다. 그러면 GD가 해당 변수를 자동으로 수거한다.

클로저 활용 사례

중요하므로 꼭꼭 숙지하고 넘어가고, 이해가 안가는 부분은 복습하자.

콜백 함수 내부에서 외부 데이터를 사용하고자 할 때

아래의 예시에서 A는 클로저가 아니지만 B에서 클로저가 형성된다. 콜백함수 바깥에 있는 변수인 fruit를 B에서 참조하고 있기 때문이다. 따라서 클릭으로 인한 콜백함수가 호출될때 fruit를 참조해야 하므로 fruit는 forEach함수에 의해 실행되는 A가 종료되더라도 GC에 의해 수거되지 않는다.

var fruits = ['apple', 'banana', 'peach'];
var $ul = document.createElement('ul');

fruits.forEach(function (fruit) { // A
	var $li = document.createElement('li');
  $li.textContent = fruit;
  $li.addEventListener('click', function () {
  	alert('your choice is ' + fruit); // B
  });
  $ul.appendChild($li);
});

document.body.appendChild($ul);

그런데 좀 지저분하다. 중복되는 내용이 많으므로 코드를 깔끔하게 작성하고 싶다면 아래와 같이 작성하는 것을 고려해 볼 수 있다.

var alertFruit = function (fruit) {
  alert('Your choice is ' + fruit);
};

fruits.forEach(function (fruits) {
  var $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListener('click', alertFruit);
  $ul.appendChild($li);
});

document.body.appendChild($ul);
alertFruit(fruits[1]);

띠용~🤯 이렇게 작성하면 alertFruits가 fruit를 출력하는 것이 아니라 이벤트객체를 출력한다.

addEventListener라는 사전에 정의된 고차함수에서 콜백의 첫번째 인자는 이벤트객체가 되도록 내부에서 bind 혹은 call로 호출해주기 때문이다.

따라서 아래와 같이 고쳐야 의도한 대로 동작한다.
$li.addEventListener('click', alertFruit.bind(null, fruit));

당연하지만 종종 헷갈리는 내용은 alertFruit(fruit)와 같이 함수의 호출값을 고차함수의 콜백으로 넣으면 안된다는 점이다. bind 메서드의 결괏값은 새로운 함수이지 함수의 결괏값이 아니다.

아니면 아래와 같이 클로저를 활용하는 방법이 있다.

var alertFruitBuilder = function (fruit) {
  return function () {
    alert('your choice is ' + fruit);
  };
};

fruits.forEach(function (fruit) {
  var $li = document.createElement('li');
  $li.innerText = fruit;
  $li.addEventListener('click', alertFruitBuilder(fruit));
  $ul.appendChild($li);
}

이벤트 리스너를 추가해주는 부분을 주목해 보면, 함수의 호출이 발생함을 볼 수 있는데 이 경우는 괜찮다. 왜냐하면 해당 함수의 결괏값은 함수이기 때문이다. 또한 함수의 결괏값으로 튀어나온 함수는 클로저를 형성해서 자신의 외부에 있는 변수인 fruit를 참조할 수 있기 때문에 개발자의 의도대로 잘 작동한다. 함수 생성과정에서 fruit라는 변수를 생성되는 함수 외부의 lexical env에 포함시킨 채로 함수를 만들기 때문이다. alertFruitBuilder함수의 생명주기가 지나도 레퍼런스 카운트가 0가 되지 않기때문에 fruit변수는 GC의 수거대상이 되지 않는다. 결과적으로 (클로저와 고차함수를 이해한 사람한테만 보이는) 가독성과 재사용성이 좋은 코드가 작성되었다.😎

접근 권한 제어(정보 은닉)

자바스크립트에는 객체지향 언어에서 변수의 접근 스코프를 제어하는 public, private, protected가 없다. 하지만 클로저의 특성을 활용해서 public과 private를 구현하는 것은 가능하다.
교재에서는 자동차게임을 구현한 코드를 예제로 준다.

아래와 같은 객체는 모든 정보가 노출되어있는 위험한 코드다.

var car = {
  fuel: Math.ceil(Math.random() * 10 + 10),
  power: Math.ceil(Math.random() * 3 + 2),
  moved: 0,
  run: function () {
    var km = Math.ceil(Math.random() * 6);
    var wasteFuel = km / this.power;
    if (this.fuel < wasteFuel) {
      console.log('이동 불가');
      return;
    }
    this.fuel -= wasteFuel;
    this.moved += km;
    console.log(km + 'km 이동 (총 ' + this.moved + 'km)');
  }
}

위의 코드를 아래와 같이 클로저를 활용해 작성하면 fuel, power 변수는 private으로, moved는 getter로 읽기전용 속성을 부여할 수 있다.

get이라는 키워드도 처음 접한다. 객체 내에서 get으로 수식된 함수는 obj.getter 를 통해서 그값을 속성처럼 읽어 올 수있다. 변경은 불가하며 변경하기 위해서는 set으로 동일한 이름의 함수를 따로 선언해 주면 객체외부에서 속성값을 변경하듯이 해당 속성을 변경 가능하다. 객체의 프로퍼티 설정과 관련된 레퍼런스는 모던 JavaScript 튜토리얼을 참고했다.

var createCar = function () {
  var fuel = Math.ceil(Math.random() * 10 + 10);
  var power = Math.ceil(Math.random() * 3 + 2);
  var moved = 0;
  return {
    get moved() {
      return moved
    },
    set moved(dist) {
      moved += dist;
      return moved;
    },
    run: function () {

      var km = Math.ceil(Math.random() * 6);
      var wasteFuel = km / power;
      if (fuel < wasteFuel) {
        console.log('이동 불가');
        return;
      }
      fuel -= wasteFuel;
      moved += km;
      console.log(km + 'km 이동 (총 ' + moved + 'km)', `남은 연료 ${fuel}`);
    }
  }
}

여기서도 public 메서드인 run의 값을 오버라이드 가능하므로 Ojbect.freeze()를 활용한 다음 public 객체들을 return하면 run 메서드를 오버라이드 할 수 없다.

부분 적용 함수

부분적용함수란 특정함수의 일부 인자를 미리적용시켜 놓은 함수이다. bind메서드를 활용해서 부분적용함수를 만들 수 있지만, 메서드를 부분적용함수로 만드는 것은 조금 복잡하다. 메서드에 바인딩된 this 컨텍스트를 보존해야되기 때문이다.

교재에 나온 코드다. 요렇게 작성하면 된다. 코드에 대한 해설은 코드블럭 바로 밑에...

var partial = function () {
  var originalPartialArgs = arguments;
  var func = originalPartialArgs[0];
  if (typeof func !== 'function') {
    console.log('first arg is not a function');
    // throw new Error('first arg is not a function');
  }
  return function () {
    var partialArgs = Array.prototype.slice.call(originalPartialArgs, 1);
    var restArgs = Array.prototype.slice.call(arguments);
    return func.apply(this, partialArgs.concat(restArgs)); 
    // apply binds this and call with array of args instantly
  }
}

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));

var dog = {
  name: "강아지",
  greet: partial(function (prefix, suffix) {
    return prefix + this.name + suffix;
  }, '멋진')
}
console.log(dog.greet('바이바이')); // 멋진 강아지 바이바이

originalPartialArgs라는 유사배열객체에 배열메서드를 적용하고 배열로 바꾸기 위해 Array.prototype.slice.call을 사용했다.(🤔 이런것도 타입스크립트의 타입이 적용되면, arguments가 배열인줄 알고 slice를 적용했다가 생기는 런타임오류가 방지되겠군. 타입스크립트도 곧 배워봅시다.)
다시 한번 강조하지만 함수의 평가 값이 아닌 함수 선언 자체를 리턴해줌을 주의하자.

dog객체의 메서드인 greet은 partial함수의 결과로 반환된 함수다. 🤔partial 함수는 함수 공장이다. 커스텀한 함수를 받아서 규격에 맞게 변화시킨 버젼을 뱉어낸다. 이 함수는 prefix를 바인딩했고, this컨텍스트도 보존함으로 dog의 메서드다. suffix만을 입력으로 받아 스트링을 리턴한다. 보통 부분적용함수를 생성하는 팩토리함수를 클로저로 구현하는 것은 이렇게 작성하면 된다. 하지만 인자들을 순서에 맞게 정확히 전달해야만 하는 단점이 있다.

책에서는 이 문제를 커스텀 오브젝트_를 글로벌스코프에 정의해주고, 빈 공간을 _로 placehold 해줌으로써 해결했다. 물론 앞선 경우와 같이 순서대로 전달되는 prebinded 인자에도 정상적으로 적용된다. 아래와 같은 코드다. 새로 정의한 오브젝트를 이렇게 활용하다니 너무 재밌다.😄😄😄

Object.defineProperty(globalThis, '_', {
  value: 'EMPTY_SPACE',
  writable: false,
  configurable: false,
  enumerable: false,
});

var partial2 = function () {
  var originalPartialArgs = arguments;
  var func = originalPartialArgs[0];
  if (typeof func !== 'function') {
    throw new Error('First arg must be a function');
  }
  return function () {
    var partialArgs = Array.from(originalPartialArgs).slice(1);
    var restArgs = Array.from(arguments);
    for (var i = 0; i < partialArgs.length; i++) {
      if (partialArgs[i] === _) {
        partialArgs[i] = restArgs.shift();
      }
    }
    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 = partial2(add, 1, 2, _, 4, 5, _, _, 8, 9);
console.log(addPartial(3, 6, 7, 10));

var dog = {
  name: '멍뭉이',
  greet: partial2(function () {
    var result = this.name;
    for (var i = 0; i < arguments.length; i++) {
      result += arguments[i] + ', ';
    }
    return result
  }, '1', _, '3', '4', _, '6')
}

console.log(dog.greet('두번째 인자', '다섯번째 인자')); // 멍뭉이, 1, 두번째 인자, 3, 4, 다섯번째 인자, 6,

🤔그런데 왜 화살표함수은 arguments가 없을까? 렉시컬 스코프와 this바인딩이랑 관련있는 것 같은데. arguments라는 키워드는 this가 바인딩되어야 제대로 출력되는건가? agruments를 출력하면 글로벌 스코프의 argument가 출력이되는데 아마 그래서 그런것 아닐까? 전역공간에서 호출되는 화살표 함수의 this는 글로벌이므로. 확인해보니 맞네. 😎

디바운스와 쓰로틀❓

프론트엔드에서 성능최적화를 위해 종종 적용하는 기술이다.
실무에서는lodash 라이브러리를 활용해서 심플하게 구현한다.
내부동작은 이해를 시도했으나 못하고 넘어갔다. setTimout, clearTimeout를 활용해서 마지막에clearTimeout이 호출되지 않은 이벤트만 이벤트 큐에 남겨서 백그라운드에서 실행해주는 로직이었는데, 브라우저에서 직접 실행해보니 예상했던대로 동작하지 않았다. 프론트엔드 뿐만 아니라 , 서버에 지속적으로 요청하는 로직에 적용할 경우 서버최적화에도 도움이 되었다. 🤔그런데 클라이언트 단에서만 쓰로틀링을 구현해 놓으면 악의적인 사용자는 이를 우회가능하므로 서버사이드에서도 체킹하는 로직이 필요하겠다는 생각이 든다.

🤔디바운스와 쓰로틀을 서버사이드에도 적용할 수 있는 방법이 있을건데, node.js 교과서에서 본 적 있는 것같다. 🤔 나중에 돌아와서 코드와 함께 복습할 필요가 있다.

커링 함수

함수형 프로그래밍에서 핵심적인 내용이므로 잘 이해하고 넘어가자.

커링함수의 정의

자바스크립트에만 존재하는 것이 아니라 함수형 프로그래밍 패러다임을 따르는 다른 언어에서도 활용가능한 개념으로, 커링의 개념을 발전시킨 미국의 수학자 Haskell Brooks Curry의 이름으로부터 명명되었다.

커링함수란 여러개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠 순차적으로 호출될 수 있게 체인형태로 바꾼 것이다. 핵심은 하나의 인자, 순차적 호출 이다. 앞서 공부했던 부분적용함수는 인자의 개수에 제한이 없다.

커링함수 예제 - 1

var curry3 = function (func) {
  return function (a) {
    return function (b) {
      return func(a, b);
    };
  };
};

var getMaxWith10 = curry3(Math.max)(10);
console.log(getMaxWith10(8));
console.log(getMaxWith10(25));

// bind를 활용한 부분적용 함수
var getMaxWith10Bound = Math.max.bind(null, 10);
console.log(getMaxWith10Bound(50));

커링을 통해 인자를 하나씩 조합해서 바인딩된 함수를 만들 수 있다. 같은 함수를 bind를 활용하면 아래의 예시와 같이 만들 수 있다. 🤔 그렇다면 bind를 사용해도 되는데, curry함수를 활용하는 이유가 분명 있을건데 뭘까? 🤔this 컨텍스트와 관련이 있을까?

여러 인자를 순차적으로 받는 커리함수는 콜백지옥처럼 가독성이 떨어진다는 단점이 있는데, 화살표 함수로 깔끔하게 작성 가능하다.

const curry5 = func => a => b => c => d => e => func(a, b, c, d, e);
const getMaxWith1To4Bound = curry5(Math.max)(1)(2)(3)(4);
console.log(getMaxWith1To4Bound(5)) // 5

위와 같이 구현하면 다음과 같은 커리함수의 작동 순서를 이해하기 쉽다. 함수에 값을 차례로 넘겨주면서 넘겨진 변수들은 마지막 호출에서 모두 참조하므로 클로저가 형성되어 변수에 접근이 보장된다. 마지막 함수가 호출되면서 실행컨텍스트가 종료되면 GC에서 수거한다.

아래의 두 경우에 커리함수가 유용하게 쓰인다.
1. 지연실행. 당장 필요한 정보만 받아서 전달하고 마지막 인자가 넘어갈 때 까지 함수실행을 미루는 것. sㅓ(setTimeOut을 활용한 지연실행과는 다르다.)

  1. 프로젝트에서 자주 쓰이는 함수의 매개변수가 항상 비슷하고 일부만 바뀌는 경우

Lazy Execution

책에는 예시가 없어 직접 작성해본 예시, 스트링을 받아서 전체를 concat한 결과를 반환하는 함수.
혹은 트리구조의 결괏값을 출력할때도 유용해 보인다. 🤔 함수 내에서 클로저를 형성하면서 자연스럽게 memoization이 동반되고, 상황에 알맞은 GC도 동작시킬 수 있는 것 같은데. 🤔이 생각은 검증 못해보고 있다.

const concatAll = (first, second, third) => first + second + third;

const lazyCurryFunc = func => first => second => third => func(first, second, third);

const lazyConcat = lazyCurryFunc(concatAll); // 함수를 먼저 만든다.

const lazyConcat1 = lazyConcat('가');//첫번째 인자를 전달한 함수를 저장한다
/* some logics to get produce next string */
const lazyConcat2 = lazyConcat1('나');
/* some logics for final string */
const lazyConcatResult = lazyConcat2('다'); 
console.log(lazyConcatResult); // '가나다'

Common Params Function Factory

REST api와 통신하는 클라이언트에서 fetch요청시 유용하게 활용할 수 있다.
첫번째 줄의 커리함수 정의를 잘 기억하자. 간단한 문법으로 요긴하게 사용가능하다.

// func definition for fetch request with better structure
var getInformation = baseUrl => path => id => fetch(baseUrl + path + '/' + id);

var imageUrl = 'http://imageAddress.com/';
var productUrl = 'http://productAddress.com/';

var getImage = getInformation(imageUrl);
var getEmoticon = getImage('emoticon');
var getIcon = getImage('icon');

var getProduct = getInformation(productUrl);
var getFruit = getProduct('fruit');
var getVegetable = getProduct('vegetable');

// acutal request func
var emoticon1 = getEmoticon(100);
var vegetable1 = getBegetable(456);

// without currying, should be like below
var emoticon1NoCurrying = fetch('http://imageAddress.com/emoticon/100)
var vegetable1NoCurrying = fetch('http://productAddress.com/vegetable/456)

Redux 미들웨어에서 커링 활용예시

// Redux Middleware 'Logger'
const logger = store => next => action => {
  console.log('dispatching', action);
  console.log('next state', store.getState());
  return next(action);
};

// Redux middleware 'thunk'
const thunk => store => next => action => {
  return typeof action === 'function'
  	? action(dispatch, store.getState);
    : next(action);
};

두 미들웨어 모두 store, next, action 순서로 인자를 받는다. store는 프로젝트 내에서 바뀌지 않는 속성이고, next역시 마찬가지지만 action의 경우 매번 달라진다. 따라서 store와 next값이 결정되면 Redux 내부에서 logger또는 thunk에 store, next를 바인드 해 놓고, 이후에는 action값만 받아서 처리할 수 있게 하였다.

💡 우와 정말 멋지다. 커링을 활용해서 (불변하는)중요한 정보를 캡슐화한 후 정말로 필요한 인자만 받아서 간단하게 동작시킬 수 있는 함수를 작성했다. 작성된 함수의 사용자는 복잡한 구조 대신 간편한 인터페이스만 제공받으므로 버그없고 가독성 좋은 코드로 함수를 활용할 수 있게된다. 이런 코드를 작성할 수 있는 능력을 기르고 싶다.🔥

lodash 라이브러리의 _.curry는 같이 래퍼를 반환할 때 함수가 보통 때처럼 또는 partial 적으로 호출하는 것을 허용하는 상식으로도 구현되어있다. 다시 말하면 커리함수로 호출하거나 원래 다중인자를 받는 함수로 호출하는 것 모두 가능한 방식의 함수를 반환한다.

profile
I'm going from failure to failure without losing enthusiasm

0개의 댓글