Frontend/Today I Learned

[JS] 클로저(closure)

joycie416 2024. 12. 10. 21:37

※ 본 내용은 코어 자바스크립트 (정재남)의 내용을 정리한 글입니다.

 

01 클로저 Closure

 

클로저는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성이라고 한다. 그 때문인지 여러 책에서 클로저를 다양하게 정의/설명하고 있다. 참고하고 있는 '코어 자바스크립트' 교재에서 다른 클로저의 정의/설명을 소개해주었는데, 한 번 클로저 내용을 다 읽은 후 다시 살펴보니 모두 이해가 되는 글들이었다. 교재에서는 MDN에서 내린 클로저의 정의를 소개했다.

 

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

 

직접 MDN closures 문서를 확인해보니, 수정되었는지 아래와 같이 설명하고 있다.


A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives a function access to its outer scope. In JavaScript, closures are created every time a function is created, at function creation time.

 

함수가 생성될 시점의 주변 상태(lexical environment)와 묶인 함수를 뜻한다. 따라서 함수 내부에서는 함수가 생성된 시점에서의 lexical environment에 대한 정보에 접근 가능하므로, outerEnvironmentReference를 참조할 수 있다. 

 

아래와 같은 예시를 살펴보자.

const outer = () => {
  let a = 0;
  const inner = () => {
    return ++a;
  };
  return inner;
};

const outer2 = outer();

console.log(outer2()); // 1
console.log(outer2()); // 2

 

얼핏 보면 에러가 나거나 0이 출력될 것 같지만, 두 번 outer2를 실행했더니 1,2가 반환되었다. 이처럼 `inner`함수는 생성 시점의 lexicalEnvironment에 `{a:0}`이라는 정보를 가지고 있기 때문에 outer2에 그 정보가 담겨 여전히 유지되고 있는 것이다.

 

아래는 참고 사항으로, `++a`와 `a++`의 차이점에 대해 간략히 정리했다.

더보기

참고 : `++a`와 `a++`의 차이점

 

여기서 참고로 `++a`와 `a++`의 차이점을 언급하고 지나가자면 `return ++a`는 `a = a+1`을 실행한 후 return하는 것이고, `return a++`는 return 후 `a = a+1`을 한 것이다. 즉 `const b = ++a`하면 `a`, `b`에 직전 `a`에 +1된 값이 할당되고, `const b = a++`하면 현재 `a`값이 `b`에 할당된 후, `a`에 +1된다.

 

클로저란 어떤 함수 A에서 선언한 변수 a를 참조하는 내부함수 B를 외부로 전달할 경우, A의 실행 컨텍스트가 종료된 이후에도 변수 a가 사라지지 않는 현상이라고 할 수 있다.

 

02 클로저와 메모리 관리

JS에는 쓰레기 전담반(GC, Garbage Collector)이 존재해서 더이상 참조되지 않는 변수를 수거해 간다. 따라서 참조 카운트가 0이 아니면 쓰레기 전담반에서 수거해 가지 않는다. 이러한 GC의 특성을 활용한 코드가 클로저이다.

 

참조 카운트를 0으로 만들기 위해서는 식별자에 참조형이 아닌 기본형 데이터를 할당하면 된다. 보통 `null`이나 `undefined`를 사용한다고 한다.

const outer = () => {
  let a = 0;
  const inner = () => {
    return ++a;
  };
  return inner;
};

let outer2 = outer();

console.log(outer2()); // 1
console.log(outer2()); // 2

outer2 = null; // inner 함수 참조 끊기

 

 

03 클로저는 언제 사용할까?

03-1 콜백 함수 내부에서 외부 데이터를 사용하고 싶을 때

코드를 통해 살펴보자.

const fruitBox = Array.from({ length: 3 }, () => ["apple", "banana", "melon"]);

fruitBox.forEach(function (fruits, idx) { // (1)
  fruits.forEach(function (_, innerIdx) { // (2)
    if (innerIdx === idx) {
      console.log(fruits[innerIdx]);
    }
  });
});

 

위 코드에서 `fruitBox`는 각 행이 `["apple", "banana", "melon"]`로 이루어진 2차원 배열이다. 대각선에 위치한 과일을 출력하려고 한다.

 

(1)번 콜백 함수 내에서 forEach 메서드를 한번 더 사용해 (2)번 콜백 함수 내에서 행 번호(`idx`)와 열 번호 (`innerIdx`)가 일치하면 출력하도록 했다. 이때, (2)번 콜백 함수에서 외부 변수 `fruits`와 `idx`를 참조하고 있으므로 (2)번 함수는 클로저이다. (참고로 `forEach`의 콜백함수의 세 번째 인자는 자기자신이므로 굳이 `fruits`를 외부 변수로 사용하지 않아도 된다.)

 

03-2 정보 은닉

첫 번째 예시를 다시 살펴보자.

const outer = () => {
  let a = 0;
  const inner = () => {
    return ++a;
  };
  return inner;
};

const outer2 = outer();

console.log(outer2());
console.log(outer2());

 

`outer`가 `inner`를 반환함으로써 `outer`함수의 지역 변수인 `a`를 외부에서 읽을 수 있게 되었다. 이처럼 외부 스코프에서 함수 내부의 변수들 중 선택적으로 일부의 변수에 대한 접근 권한을 부여할 수 있다.

 

아래와 같이 총 10m를 먼저 도착하는 사람이 이기는 게임을 만들었다고 해보자.

const person = {
  perStep: Math.ceil(Math.random()*10 + 30), // 걸음 폭
  cm: 0, // 이동 걸이
  moves: 0, // 이동 횟수
  walk: function () {
    const steps = Math.ceil(Math.random()*5) // 한 번에 몇 걸음 걸을지
    this.cm += this.perStep*steps; // 이동 거리 추가
    this.moves++ // 이동 횟수 추가
  },
};

 

위와 같이 만들면 `person.perStep = 1000` 처럼 값을 수정해버릴 수 있어 적절하지 않다. 아래처럼 수정하면 `perStep`에 대해 직접 접근할 수 없게 되며, walk 함수 또한 직접 수정할 수 없다.

const createPerson = function () {
  const perStep = Math.ceil(Math.random() * 10 + 20);
  let cm = 0;
  let moves = 0;
  const walk = function () {
    const steps = Math.ceil(Math.random() * 5);
    cm += perStep * steps;
    moves++;
  };

  return Object.freeze({
    get cm() {
      return cm;
    },
    get moves() {
      return moves;
    },
    walk,
  });
};

const person = createPerson();

person.walk = function () {
  console.log("walk");
};

console.log(person.cm, person.moves);
person.walk();
console.log(person.cm, person.moves);

 

03-3 부분 적용 함수

부분 적용 함수는 함수의 전체 arguments n개 중 m개만 미리 받아 기억하고 있다가 남은 arguments를 받으면 원래 함수의 실행 결과를 알려주는 함수이다. 이때 미리 받은 인자를 기억하고 있기 때문에 클로저이다.

 

아래와 같이 작성하면 원하는 순서에 맞게 인자를 비워두고 채울 수 있다.

const _ = Symbol.for("EMPTY_SPACE"); // 빈 공간을 알려주는 변수(ES6 이상)

const partial = function () {
  const originalPartialArgs = arguments;
  const func = originalPartialArgs[0];
  if (typeof func !== "function") {
    throw new Error("첫 번째가 인자가 함수가 아님");
  }

  return function () {
    const partialArgs = Array.prototype.slice.call(originalPartialArgs, 1);
    const restArgs = Array.prototype.slice.call(arguments);

    for (let i = 0; i < partialArgs.length; i++) {
      if (partialArgs[i] === _) {
        partialArgs[i] = restArgs.shift();
      }
    }
    return func.apply(this, partialArgs.concat(restArgs));
  };
};

const add = function () {
  let result = 0;
  for (let i = 0; i < arguments.length; i++) {
    result += arguments[i];
  }
  return result;
};

const addPartial = partial(add, 1, 2, _, 4, 5, _, _, _, 9, 10);
console.log(addPartial(3, 6, 7, 8));

 

03-4 커링 함수 (Currying function)

커링 함수는 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출될 수 있게 체인 형태로 구성한 함수이다. 부분 적용 함수와 유사하지만, 한번에 하나의 인자만 전달한다는 차이점이 있습니다.

 

아주 단순한 구현 방법으로 인자의 개수만큼 함수를 return 하도록 중첩하는 방법이 있는데, 생각만해도 가독성이 많이 떨어진다는 것을 알 수 있다. 그래서 ES6에서 화살표 함수를 사용해 커링 함수를 쉽게 구현할 수 있게 되었다.

const curry5 = func => a => b => c => d => e => func(a,b,c,d,e);

const getA = curry5(func);
const getB = getA(a);
const getC = getB(b);
const getD = getC(c);
const getE = getD(d);
const result = getE(e);

 

 

커링 함수는 당장 필요한 정보만 받아서 전달하므로 필요한 인자를 모두 받을 때까지 함수 실행을 미룬다. 이를 함수형 프로그래밍에서 '지연 실행(lazy execution)'이라고 한다. 원하는 시점까지 미루다가 실행하는 것이 적절한 상황이라면 커링 함수를 쓰면 좋다.

 

자주 쓰이는 함수의 매개변수가 항상 비슷하고 일부만 바뀌는 경우도 커링 함수를 쓰기에 적절한 상황일 것이다. 예를 들면, 데이터를 불러올 때 baseURL과 path, id가 필요한 경우에 쓰일 수 있다.

 

const fetchData = baseURL => path => id => fetch(baseURL+path+'/'+id)

const getImage = fetchData('http://imgURL.com/')
const getProfileImg = getImage('profile')
const getProductImg = getImage('product')

const person1 = getProfileImg('personId')
const product1 = getProductImg('productId')