[Wanted]_Week4-1_클로저

hanseungjune·2023년 7월 20일
0

Wanted

목록 보기
18/21
post-thumbnail

클로저란

가장 쉽게 할 수 있는 설명은 "클로저는 자신이 생성될 때의 환경을 기억하고, 그를 사용하는 함수이다" 라고 말할 것 같다.

function makeAddNumFunc(num) {
  const toAdd = num

  return function (num) {
    return num + toAdd
  }
}

const add5 = makeAddNumFunc(5)

add5(3) // 8
add5(8) // 13
add5(15) // 20

위 예시에서 makeAddNumFunc가 리턴하는 익명함수는 본인이 정의될 때의 환경인 makeAddNumFuncLexical Environment를 기억하고 있다. 따라서, toAdd에 할당된 값을 기억하고 익명함수가 호출 될 때마다
인자로 받은 숫자와 toAdd에 할당되어있던 숫자를 더해서 리턴한다.

위 상황에서 add5는 자신이 생성될 때의 환경을 기억하는 함수라고 할 수 있다. 이러한 함수를 바로 클로저라고 부른다.

위의 예시에서 보면 리턴된 익명함수는 toAdd를 사용하고 있지만 toAdd는 익명함수 내부에 속박되어있지 않고 외부에 있다. 이런 변수를 자유변수라고 한다.

클로저의 원리

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

환경이라고 계속 말하고 있는 것을 좀 더 명확하게 설명하자면 이 환경은 Lexical Environment를 의미한다.

Lexical Environment는 실행 컨텍스트의 구성요소 중 하나로서, 식별자와 식별자에 바인딩 된 값, 상위 스코프에 대한 참조를 기록하는 객체이다.

그런데, 여기서 의문이 생길 수 있다. 실행 컨텍스트는 전역코드, 모듈, eval, 그리고 함수가 호출 될 때 생성되고 개발자가 보통 자주 실행 컨텍스트를 만드는 상황은 함수를 호출하는 상황이다.

그리고 함수의 실행 컨텍스트는 함수가 호출 될 때 콜스택에 쌓였다가 함수의 실행이 종료되면 콜스택에서 제거된다. 그런데 어떤 원리로 클로저는 이미 콜스택에서 없어진 Lexical Environment를 기억할 수 있는 것일까?

그 해답은, Lexical Environment는 실행 컨텍스트의 구성요소 중 하나지만, 엄밀히 말하면 실행 컨텍스트와는 별개의 존재이다. Lexical Environment는 객체일 뿐이고, 실행 컨텍스트에서 해당 객체에 대한 참조를 가지고 있는 것 뿐이다.

그렇다면 참조카운트가 0이 되면 Garbage Collecting 대상이 되어서 메모리에서 사라지는 자바스크립트의 특징 상 실행컨텍스트가 제거되면 참조카운트가 0이 되면서 Lexical Environment도 메모리에서 없어져야 정상이겠지만, 실행컨텍스트가 제거되더라도 그 함수에서 리턴한 클로저가 해당 Lexical Environment를 참조하고 있다면 Lexical Environment는 GC 대상이 되지 않는다.

이런 원리에서 클로저가 생성될 때의 Lexical Environment를 기억할 수 있는 것이고, 이미 제거된 실행 컨텍스트의 Lexical Environment는 클로저말고는 접근할 수 있는 방법이 없으므로 정보의 은닉이라는 장점 또한 얻게된다.

그런데, 여기서 또 다른 의문이 생길수도 있다. 클로저는 함수이다. 그리고 함수의 실행 컨텍스트는 함수가 호출될 때 생성된다. 그런데 어떻게 클로저는 본인의 실행 컨텍스트가 생성되기도 전인 본인이 정의된 Lexical Environment를 기억하는 것일까?
이는 Javascript가 함수를 생성하는 방식과 함수의 실행컨텍스트에서 outerEnvironmentRecordReference를 결정하는 방식에 연관되어 있다.

Javascript에서 함수는 사실 객체이다. 함수를 생성하는 것은 결국 함수 형태의 객체를 만드는 것이다. Javascript 엔진은 함수 객체를 만들 때 함수 객체의 [[Environment]] 내부 슬롯에 현재 실행중인 실행 컨텍스트의 LexicalEnvironment를 할당한다. 여기서 말하는 내부슬롯, 내부메서드는 ECMA-script 명세에서 이 명세에 따르는 자바스크립트를 구동하는 엔진이 구현해야 하는 동작들을 추상화시켜서 설명하는 일종의 Pseudo Code이다.

그러면 함수 객체는 본인의 내부 슬롯에 생성될 때의 Lexical Environment를 기억하고 있게 된다. 그리고 함수가 호출되어서 실행 컨텍스트가 생성될 때 스코프 체인을 결정짓는 outerEnvironmentRecordReference에 이 함수객체의 [[Environment]]가 참조하고 있는 객체를 할당한다.
이러한 동작으로 인해 함수가 호출될 때의 Lexical Environment를 기억할 수 있게 되고, 클로저의 동작이 성립하게 되는 것이다.

클로저의 활용 예시

  • 상태 기억
const factorializeWithCache = (function (){
  const cache = {};
   
  return function factorialize(num) {
    if (num < 0) throw new Erorr("0보다 큰 숫자를 입력해주세요")
    
    if(cache[num]) return cache[num]
    
    cache[num] = num === 0 ? 1 : num * factorialize(num - 1)
    return cache[num]
  }
})()
const squareWithCache = (function () {
  const cache = {};
  
  return function squareOf(num){
    console.log("cache", cache)
    if(cache[num]) return cache[num];
    
    console.log("calc")
    cache[num] = num * num;
    console.log("cache after calc", cache)
    return cache[num];
  }
})()
  • 상태 은닉
function makeAddNumFunc(num) {
  const toAdd = num

  return function (num) {
    return num + toAdd
  }
}

const add5 = makeAddNumFunc(5)
  • 상태 공유
const makeStudent = ((classTeacherName) => {
  return function (name) {
    return {
      name: name,
      getTeacherName: () => classTeacherName,
      setTeacherName: (newTeacherName) => {
        classTeacherName = newTeacherName;
      },
    };
  };
})("연욱");

const 철수 = makeStudent("철수");
const 영희 = makeStudent("영희");

console.log(철수.getTeacherName()); // 연욱
console.log(영희.getTeacherName()); // 연욱

영희.setTeacherName("김봉두");
console.log(철수.getTeacherName()); // 김봉두

실제 활용

클로저란 개념은 사실 우리가 개발하면서 늘 사용하고 있는 개념입니다. 실제로 useStateuseEffect 등 함수 컴포넌트에서 사용하는 모든 훅들은 클로저를 기반으로 동작하고 있습니다.

그리고 우리가 Hook을 사용할 때 지켜야하는 첫번째 규칙은 사실상 이 클로저를 이용한 구현방법의 특징으로 인해 생겨난 것입니다.

최상위(at the Top Level)에서만 Hook을 호출해야 합니다. 반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출하지 마세요. 이 규칙을 따르면 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장됩니다

실제 useState와 useEffect의 코드는 리액트의 렌더링을 주관하고, 여러 컴포넌트에 적용되어야 하기에 구현이 비교적 복잡하고 추상화 레벨이 높아서 코드를 뜯어보면서 파악하는데 시간이 많이 걸릴 수 있기에 간단하게 직접 클로저의 개념을 이용해서 useState와 useEffect hook을 만들어보며 동작방식에 대해 이해해보겠습니다.

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

export const { useState, useEffect } = (function makeMyHooks() {
  let hookIndex = 0; // Hook을 호출할 때마다 증가하는 인덱스
  const hooks = []; // 모든 hook 상태를 저장하는 배열

  // useState 구현
  const useState = (initialValue) => {
    const state = hooks[hookIndex] || initialValue; // 현재 hook 상태를 가져오거나 초기값 설정
    hooks[hookIndex] = state; // 상태 배열 업데이트

    // setState 함수 구현
    const setState = (function () {
      const currentHookIndex = hookIndex; // 현재 hook 인덱스를 캡처

      return (value) => {
        hooks[currentHookIndex] = value; // 상태 업데이트
        hookIndex = 0; // 인덱스 초기화
        render(App, "root"); // 컴포넌트 다시 렌더링
      };
    })();

    increaseIndex(); // 다음 hook을 위해 인덱스 증가
    return [state, setState]; // [상태, 상태 설정 함수] 형태로 반환
  };

  // useEffect 구현
  const useEffect = (effect, deps) => {
    const prevDeps = hooks[hookIndex]; // 이전 의존성 배열

    if (isFirstCall(prevDeps) || isDepsChanged(prevDeps, deps)) {
      effect(); // effect 실행
    }

    hooks[hookIndex] = deps; // 의존성 배열 업데이트
    increaseIndex(); // 다음 hook을 위해 인덱스 증가
  };

  // 컴포넌트 렌더링 함수
  const render = (Component, elementName) => {
    const element = document.getElementById(elementName); // 루트 element 가져오기
    if (element) {
      ReactDOM.render(<Component />, element); // 컴포넌트 렌더링
    }
  };

  // 인덱스 증가 함수
  const increaseIndex = () => {
    log(); // 상태 로깅
    hookIndex++; // 인덱스 증가
  };

  // 상태 로깅 함수
  const log = () => {
    console.group(`currentHookIndex: ${hookIndex}`);
    console.log("hooks", hooks); // 현재 hook 상태 출력
    console.groupEnd();
  };

  // 처음 호출인지 확인하는 함수
  const isFirstCall = (prevDeps) => {
    return prevDeps === undefined; // 의존성 배열이 없으면 처음 호출
  };

  // 의존성 배열이 변경되었는지 확인하는 함수
  const isDepsChanged = (prevDeps, deps) => {
    return deps.some((dep, idx) => dep !== prevDeps[idx]); // 의존성 배열의 요소가 변경되면 true
  };

  // useState와 useEffect를 반환
  return { useState, useEffect };
})();

useState

  • 상태 로깅 출력 함수
    • 현재 인덱스, hooks 배열 출력하기
  • 인덱스 증가 함수
    • 로깅 함수 호출, hookIndex++
  • 렌더링 함수
    • document.getElementById(elementName) 이 있다면, ReactDOM.render(<Component />, document.getElementById(elementName))
  • 기본값을 가져오면서 인덱스를 +1로 만들어 준다.
  • currentHookIndex는 계속 메모리에 기억된다. (실행 콘텍스트와 관련 없음)
  • setState는 클로저로 만들어지고, 인자를 받을 때, 변경하고자 하는 값을 받는다.
return (value) => { `hooks[currentHookIndex]` = value; > hookIndex = 0; 렌더링() }

useEffect

  • prevDeps라는 이전 의존성 배열 요소 가져오기
  • 첫 호출인지, 의존성 배열이 변경되었는지 확인
    • 첫 호출은 prevDeps === undefined 면, 첫 호출임 그래서 true 반환
    • 의존성 배열 바뀌었는지 확인은 받아온 deps를 순회해서 prevDeps[idx] 와 다르다면 true를 반환
  • effect() 라는 받아온 함수를 실행
  • 그러고 의존성 배열을 다시 업데이트 한다.
  • 그리고 다음 훅을 위해서 인덱스를 증가해서 useEffect 함수를 적용해도 되는지 확인한다.
import { useState, useEffect } from "./hooks";

function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");
  const [toggle, setToggle] = useState(false);

  useEffect(() => {
    console.log("useEffect!");
  }, [text, count]);

  return (
    <>
      <section>
        <h1>count: {count}</h1>
        <button onClick={() => setCount(count + 1)}>up</button>
        <button onClick={() => setCount(count - 1)}>Down</button>
        <button onClick={() => setToggle(!toggle)}>Toggle</button>
      </section>
      <br />
      <section>
        <h1 h1>text: {text}</h1>
        <input value={text} onChange={(e) => setText(e.target.value)} />
      </section>
    </>
  );
}

export default App;
profile
필요하다면 공부하는 개발자, 한승준

1개의 댓글

comment-user-thumbnail
2023년 7월 20일

소중한 정보 잘 봤습니다!

답글 달기