티스토리 뷰

[ 클로저란(Closure)? ]

클로저는 JS라는 언어에 제약된 개념이 아니라 `함수를 일급객체로 사용하는 모든 언어`에서 사용하는 특성이다. 

클로저는 언어 자체만 보면 "폐쇄"의 의미를 가지고 있으며 접근할 수 있는 변수의 범위와 대상을 제한하는 느낌에서 closure이라고 하는 것이 아닌가 싶다.

A closure is the combination of a function and environment within which that function was declared.  -MDN 클로저-

쉽게 표현하자면, 클로저는 `자신이 생성될 때의 환경(lexical environment)을 기억하고, 그를 사용하는 함수` 이다.

// 클로저 예시
function makeAddNumFunc(num) {
  const toAdd = num;
  return function (num) {
    // 익명함수가 호출될 때 toAdd 변수를 기억하고 있는 거
    return num + toAdd;
  };
}

const add5 = makeAddNumFunc(5);
  • 위의 예시처럼 생성되는 함수가 기억하는 환경에서 사용하는 변수들을 `free variables(자유 변수)`라고 부른다. => 해당 코드에서는 toAdd가 free variable 
  • 익명함수에서 toAdd 라는 변수에 접근하려고 하면 makeAddNumFunc가 호출될 때의 환경에서 정의된 toAdd에만 접근할 수 있다.
  • 스코프 체인은 제일 가까운 스코프로부터 상위로 올라가므로 이미 제일 가까운 환경에서 toAdd라는 변수를 찾았으므로 그 외의 스코프로는 접근x 

[ 클로저의 원리 ]

클로저는 본인이 생성될 때의 환경을 기억한다. 그리고 본인이 호출될 때 그 환경에 있는 변수들을 참조할 수 있게 된다.

여기서 환경이란 Lexical Environment를 의미한다. 

  • Lexical Environment란 실행 컨텍스트의 구성요소 중 하나로서, 식별자와 식별자에 바인딩된 값, 상위 스코프에 대한 참조를 기록하는 객체이다.
  • 따라서, 클로저가 기억하는 것은 자신이 생성될 때의 lexical environment인 것이다. 
  • [부연 설명] 참조 카운트가 0이 되면, `가비지 컬렉팅`의 대상이되어 메모리에서 사라지는 자바스크립트의 특징상 실행컨텍스트가 제거되면 참조 카운트가 0이 되면서 lexical envrionment도 메모리에서 없어져야 정상이다. 하지만 실행 컨텍스트가 제거되더라도 그 함수에서 리턴한 클로저가 해당 lexical environment를 참조하고 있다면 이 lexical environment는 CG(가비지 컬렉팅)대상이 되지 않는다. 
  • 위의 원리로 클로저가 생성될 때의 lexical environment를 기억할 수 있는 것이고, 이미 제거된 실행 컨텍스트의 lexical environment는 클로저 말고는 접근할 수 있는 방법이 없으므로 `정보의 은닉`이라는 장점 또한 얻게된다.
  • 함수는 원래 값을 기억할 수 없는데 마치 class의 private 변수처럼 값을 기억하도록 만드는 것이라고 클로저를 이해할 수 있다. 

[ 클로저 활용 ]

// TODO: squareOf 함수는 제곱값을 리턴한다 
function squareOf(num) {
  // key : value (해시 테이블 구조)
  //? 해시테이블이란 key, value로 데이터를 저장하는 자료구조 중 하나로 데이터를 빠르게 검색할 수 있는 자료구조이다.
  const cache = {};

  if(cache[num]) return cache[num];
//  이때, 이전에 연산한 값은 새로 연산하는 것이 아니라 cache에 저장해두고 값을 바로 return하도록 만듦

  cache[num] = num * num;
  console.log('cache', cache);

  return cache[num];
}

squareOf(2);
  • 위의 로직은  이상적으로 보이나 치명적인 로직 결함이 있다. 바로, 함수가 매번 초기화된다는 점이다. 
  • 원래 의도대로라면 cache객체 안에는 2값이 들어가 있어야 하는데 항상 {} 라는 빈객체로 초기화된다.  즉, 함수는 값을 기억할 수 없다는 뜻
  • 개선하려면 어떻게 해야하는가? => 제일 간단하게는 cache 변수를 함수에서 빼서 분리하는 것이다. e.g) 전역스코프로 const cache = {}를 뺀다. 하지만 이 방법도 문제가 있다. 바로, 누구에게나 공개된 프로퍼티라 값이 수정될 수 있다는 점이다. 개발자 중 한명이 실수로 cache[2] = 8 이라고 조작하는 코드를 썼을 때, 우리가 원하지 않는 오류가 발생한다.  
  • 위와 같은 예시 상황에서 원하는 값을 기억하도록 하면서 정보를 외부환경으로부터 은닉하게 만들려면 "클로저"를 사용하면 된다. 

위의 예시 코드를 클로저를 활용해서 리팩토링

function outer() {
  const cache = {};

  return function squareOf(num) {
    console.log('cache', cache);
    if (cache[num]) return cache[num];

    console.log('calc?');
    cache[num] = num * num;

    return cache[num];
  };
}

const squareOfWithCache = outer();
squareOfWithCache(2);
squareOfWithCache(4);
cache[2] = 8; // ReferenceError 전역스코프에 있는 변수가 아니라 outer의 스코프에 존재하는 변수이기 때문
// squreOf라는 클로저만 cache에 접근가능하다.
  • 기존 squreOf 내부에 존재하던 cache 변수를 outer함수의 변수로 옮겨줬다. 이렇게 하면 글로벌 스코프가 아니라 cache값을 외부로부터 안전하게 보호할 수 있으며 cache에 누적된 값을 기억해둘 수 있다. 

클로저의 활용 예시로 currying(커링) 기법을 들 수 있다. 자세한 내용은 잘 모르기 때문에 따로 공부하시길..

ref: https://ko.javascript.info/currying-partials

 

커링

 

ko.javascript.info

// 커링 예시 코드
const sum = (x) => (y) => x + y;

const sum5 = sum(5);
sum5(10);

즉시실행함수 표현(IIFE, Immediately Invoked Function Expression)

함수는 보통 선언부와 호출부를 나눠서 사용하게 되는데, 둘을 나누지 않고 바로 실행시키는 JS 표현 기법이 있다.

IIFE 함수 선언부를 () 소괄호를 통해 값으로 만들어주고 그 뒤에 ()소괄호를 붙여 호출해준다. 매개변수가 필요하면 뒤에 소괄호에 넣어 호출해주면 된다. 

  • 즉시 실행함수는 이름이 필요없기 때문에 생략해도 된다. 
  • 예시 코드를 만들어보면 아래와 같다.
(function (x, y) {
  return x + y;
})(5, 5);

즉시실행함수 표현은 클로저를 사용할 때 자주 같이 사용되기 때문에 알아두면 좋다. 

예를 들어, 클로저 예시 코드에서 outer 함수는 cache를 저장하기 위한 임의의 함수로 딱히 어떤 작업을 하고 있지않다. => 그렇기 때문에 outer 함수 부분을 즉시실행함수로 변경해서 사용하면 원하는 동작을 짧은 코드로 동일하게 구현할 수 있다. 

// IIFE ver
const squareOfWithCacheIIFE = (function () {
  const cache = {};

  return function squareOf(num) {
    console.log('cache', cache);
    if (cache[num]) return cache[num];

    console.log('calc?');
    cache[num] = num * num;

    return cache[num];
  };
})();

squareOfWithCacheIIFE(5);

[ 클로저의 활용 예시 ]

  • 상태 기억
  • 상태 은닉 
  • 상태 공유 : 클래스와 유사하게 사용하는 용도인데 IIFE 와 클로저를 결합해서 클래스 문법을 대체하여 사용할 수 있다. 
FE 개발자이면서 React 를 주 라이브러리로 사용하고 있다면, 잘 알아야 한다. useState, useRef, useEffect 등 React에서 제공하는 Hook들은 이전 상태값을 기억하고 반환하는 기능을 한다. 이 hooks들은 함수인데 어떻게 값을 기억할까? 결국 내부적으로 클로저를 활용하고 있다는 말이 된다. 

[ useState, useEffect 만들어보기 ]

// TODO: useEffect, useState 간단히 직접 구현해보기
// 1. 클로저, IIFE 사용해서 return => 구조 분해 할당 후 export 까지
// ! 항상 동일한 순서로 hook들이 호출되어야 하기 때문에 최상위에서 호출해야 한다. => 원하는 값을 제대로 뽑아오게 보장하기 위함

import { render } from "../main";

// interface of useState
// [state, setState] 튜플형식으로 리턴
// state -> saved state || initialValue
// setState: (value) => void;
// saved state = value;
// re-render

//? key point: state는 여러번호출 될 수 있다. state가 여러개 => 배열(array)에 저장
// 함수가 호출될 때마다 값이 초기화되면 안되니까 바깥에(클로저활용) hooks라는 배열을 만들어서 저장

export const { useState, useEffect } = (function makeMyHooks() {
  const hooks = [];
  let hookIndex = 0; // 몇번째 배열에 어떤 값이 매칭되어있는 지 알아야 하기 때문에

  function useState(initialValue) {
    if (hooks[hookIndex] === undefined) {
      hooks[hookIndex] = initialValue;
    }

    //initialValue가 함수이고 맨 처음 실행되는 거라면 함수 실행
    //리액트에서 초기값으로 콜백함수를 허용하는데 연산이 크거나 복잡한 값을 최초 렌더링 첫번째에만 실행하고 그 뒤로는 저장된 값을 사용하게 한다.
    if (typeof initialValue === "function") {
      if (hooks[hookIndex] === undefined) {
        hooks[hookIndex] = initialValue();
      }
    }
    const state = hooks[hookIndex];

    // setStaet 내부에도 클로저를 만들어서 이 함수에 해당하는 state값의 index를 고정시켜줘야 한다.
    // setState는 아래 함수를 즉시실행(IIFE)시킨 리턴값
    // 실행시점: useState가 호출될 때
    const setState = (function () {
      const currentIndex = hookIndex;
      return function (value) {
        console.log("currentIndex", currentIndex);
        console.log("hookIndex", hookIndex);
        hooks[currentIndex] = value;
        //re-render, 그리고 re-render됐으면 hookIndex를 초기화
        render();
        //hookIndex를 초기화시켜주지 않으면 hookIndex의 값이 점점늘어난다
        hookIndex = 0;
      };
    })();

    hookIndex++;
    return [state, setState];
  }

  // interface

  // 클린업함수 제외
  // effect함수, deps 배열
  // isFirstCall -> effect();
  // isDepsNotProvided -> effect();
  // hasDepsChanged -> effect();

  // useEffect에서 기억해야하는 값?
  // -> 의존성 배열 안의 값!
  function useEffect(effect, dpes) {
    const prevDeps = hooks[hookIndex];

 
    const isFirstCall = () => prevDeps === undefined;
    const isDepsNotProvided = () => deps === undefined;
    const hasDepsChanged = () =>
      deps.some((dep, index) => dep !== prevDeps[index]);

    // OR연산자는 앞에서 하나라도 true면 이후 것들은 평가하지 않음 위에 함수 지연평가를 같이 활용한 예
    if (isFirstCall() || isDepsNotProvided() || hasDepsChanged()) {
      effect();
    }

    // 검증이 다 끝나고 나서 deps 업데이트
    hooks[hookIndex] = deps;
    console.log("hooks", JSON.stringify(hooks));
    hookIndex++;
  }

  return { useState, useEffect };
})();
  • 내장 배열 메서드 중 some()함수의 콜백은 순회하는 배열의 값 중 하나라도 true를 리턴하면 true를 반환한다. (forEach, map 과 비슷)
  • 함수 지연 평가 개념 잘 활용하면 좋음 => 변수로 평가해도되긴하는데 함수로 만들어쓰는 건 유연성이 더 높을수도.. 개인 취향에 따라서 하면 된다. 
  • 클로저는 IIFE 형태로 많이 사용되는 듯.. 함수에서 값을 기억하려면 클로저를 활용하자, 함수가 호출되는 시점과 초기화되는 시점 등 잘 알고 써야 할 듯
  • 예시 중 hooks를 크롬에서 찍어볼 때, 값이 null로 뜨는데 막상 열어보면 값이 찍혀있는 경우가 있음 그래서 JSON.stringify를 통해서 콘솔에 찍어봄 이 부분은 크롬 개발자들이 의도한거라 자세한 건 찾아봐야 할듯
댓글