React Hook 원리 파헤쳐보기 1

devstone·2022년 5월 8일
13

React Study

목록 보기
7/7
post-thumbnail

🙈 Prologue

앞서 React Hook 클로저 탈출기를 통해 React Hook을 사용할 때 필연적으로 마주하게 되는 클로저 의존성을 어떻게 잘 탈피할 수 있을까에 대한 포스팅을 갈겼었다. 그래서 이번엔 React Hook은 어떻게 돌아가길래 그러한 의존성을 갖게되는지에 대해 정리해보려고 한다 ! 크게 두 번에 걸쳐서 정리를 해보려고 하는데 일단 이번 포스팅에선 기본적으로 원리를 이해하기 위해 알아야하는 사전지식과, 기본 hook들의 구현을 중심으로 하려고 한다.

1. 사전지식

React Hook을 직접 구현하며 원리를 이해하는 과정에서 필요한 지식들을 찾아보았다. 실제 구현에 앞서 미리 알아야 할 요소들을 먼저 정리해보았다.

1-1. 클로저

⭐️ 전제

  1. 함수를 호출해 실행하면 → 새로운 렉시컬 환경이 만들어짐
  2. 함수는 호출 중인 동안에 함수를 위한 내부 렉시컬 환경과, 그 내부의 렉시컬 환경이 가리키는 외부 렉시컬 환경을 갖게됨 (내부 렉시컬 환경은 외부 렉시컬 환경을 참조함 → 내부 렉시컬 환경에서 원하는 변수를 찾지 못하면 검색 범위를 확장)
  3. 모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억함 (함수엔 [[Environment]]
    라 불리는 숨김 프로퍼티가 있는데 그곳의 렉시컬 환경에 대한 참조가 저장된다) → 함수 생성 시 고정되어 바뀌지 않는다.

⭐️ 예시로 이해해보기!


function makeCounter() {
  let count = 0;

  return function() {
		// [[Environment]]를 숨김 프로퍼티로 가짐 
    return count++;
  };
}

// counter 리턴 함수값이 담김 
let counter = makeCounter();
counter()
  • counter()를 호출할 때마다 새로운 렉시컬 환경이 생성됨
  • 생성된 렉시컬 환경은 counter함수 내부의 숨김 프로퍼티 [[Environment]]에 저장된 렉시컬 환경을 외부 렉시컬 환경으로서 참조함
  • 실행 흐름이 counter()로 넘어왔을 때 프로세스 과정
    1. count 변수를 찾게되는데 자체 렉시컬 환경 내부에 해당 변수가 없으므로 그 내부 렉시컬 변수에서 참조하는 외부 렉시컬 변수를 슬쩍 보게됨
    2. makeCounter() 내부의 count변수를 발견 ! → 외부 변수에 접근
    3. count++가 실행되면서 변수값이 갱신되는데, 변수값 갱신은 변수가 저장된 렉시컬 환경에서 이뤄짐
    4. 그렇기 때문에 위와 같은 경우 counter()를 호출할수록 count값이 누적되어 올라간다!

⭐️ 한 판 정리

  • 일반적인 클로저의 정의 : 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미
  • js에서 클로저란?
    • 모든 함수가 숨김 프로퍼티인 [[Environment]] 를 가짐 → 자신이 어디에서 만들어졌는지를 기억함
    • [[Environment]] 에서 참조하는 외부 렉시컬 변수를 찾음으로써 외부 변수에 접근함

📍 가비지 컬렉션

함수호출이 끝나면 함수에 대응하는 렉시컬 환경이 메모리에서 제거된다. 함수 호출이 끝나면 관련 변수를 참조할 수 없는 이유가 여기에 있다 → 자바스크립트에서 모든 객체는 도달 가능한 상태일 때에만 메모리에 유지된다 !

그러나 호출이 끝난 이후에도 도달 가능한 중첩함수가 있다면 렉시컬 환경에 메모리에 유지된다.

function f() {
  let value = Math.random();

  return function() { alert(value); };
}

// 배열 안의 세 함수는 각각 f()를 호출할 때 생성된
// 렉시컬 환경과 연관 관계를 맺는다. (모든 렉시컬 환경이 메모리에 유지되기 때문)
let arr = [f(), f(), f()];

렉시컬 환경 객체는 기본적으로 도달 불가능 하면 메모리에서 삭제되는데, 이 렉시컬 환경 객체를 어디서든 참조하는 함수가 하나라도 있으면 사라지지 않는다.

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g가 살아있는 동안엔 연관 렉시컬 환경도 메모리에 살아있습니다.

g = null; // 도달할 수 없는 상태가 되었으므로 메모리에서 삭제됩니다.

그렇기 때문에 클로저를 잘못 남발하면 메모리누수를 유발하기 쉽다. 왜냐하면 렉시컬 환경 객체를 참조하는 함수가 하나라도 있다면, 그 렉시컬 환경 객체는 메모리에서 삭제되지 않은 채 남아있기 때문이다. 그럼에도 클로저는 값을 은닉화시키는 것에 매우 적합하다.

📍 자바스크립트 엔진의 최적화 프로세스

이론적으로는 함수가 살아있으면 외부 변수들이 모두 메모리에 유지되어있어야 하지만, 자바스크립트엔진은 변수 사용을 분석해 외부 변수가 사용되지 않는다고 판단되면 메모리에서 이를 제거한다. → V8엔진의 주요 부작용이다. 디버깅 시 이를 고려해야 한다.

1-2. 모듈패턴

객체 핸들링을 위한 방법론. 객체에 유효범위를 줘서 캡슐화를 한다. 해당 객체 내부의 값에 대해서 외부에서 접근하지 못하게 값을 숨기고, 정의한 내부 함수를 통해 접근할 수 있도록 모듈화 시킬 수 있다.

📍 즉시 실행 함수(IIFE)를 이용하는 방법

let devstone = "devstone's devlog";

const velog = (function(){
  let devstone = "타꼬야끼엔 네기를 추가해 먹어야 해";
  return {getDevstone: devstone};
})()

console.log(devstone); //devstone's devlog
console.log(velog.getDevstone); //타꼬야끼엔 네기를 추가해 먹어야 해
console.log(velog.devstone); //undefined
  • private한 함수, 변수를 만드는 것이 가능하다
  • 스코프가 겹치지 않기 때문에 같은 이름의 변수가 존재해도 외부 스코프와 충돌할 문제가 전혀 없다.

📍 Export 키워드를 이용하는 방법

ES6 등장 이후로 모듈 패턴 구현 방식이 매우 간단해졌다 !

//velog.js
const devstone = "devstone's devlog";
const getDevstone = () => devstone;
export {getDevstone};

//index.js
import { getDevstone } from '..';
const devstone = '타꼬야끼엔 네기를 추가해 먹어야 해';
console.log(devstone); // 타꼬야끼엔 네기를 추가해 먹어야 해
console.log(getStories(); // "devstone's devlog"

📍 js class를 이용하는 방법

ES2020에서는 #키워드를 이용해 private을 구현해낼 수 있는 방식이 추가되었는데, 브라우저와의 호환성을 점검할 필요가 있다 !

const devstone = "devstone's devlog";
class Velog {
  #devstone = '타꼬야끼엔 네기를 추가해 먹어야 해';
  get devstone(){
		return this.#devstone;
	}
}

const velog = new Velog();
console.log(devstone); //devstone's devlog
console.log(velog.devstone); //타꼬야끼엔 네기를 추가해 먹어야 해
console.log(velog.#devstone); //Uncaught SyntaxError: Private field '#devstone' must be declared in an enclosing class
  • +) 타입스크립트 이용하기
    const devstone = "devstone's devlog";
    class Velog {
      private devstone = '타꼬야끼엔 네기를 추가해 먹어야 해';
      get Devstone(){
    		return this.#devstone;
    	}
    }
    
    const velog = new Velog();
    console.log(devstone); //devstone's devlog
    console.log(velog.Devstone); //타꼬야끼엔 네기를 추가해 먹어야 해
    console.log(velog.devstone); //타꼬야끼엔 네기를 추가해 먹어야 해
    → 값이 출력 가능하긴 하지만, 타입스크립트를 사용하면 이런 상황에서 에러를 보여준다.

2. useState 구현하기

2-1. 클로저를 이용해 가장 간단한 꼴의 useState 만들기

function useState(initialValue) {
    //useState의 렉시컬 스코프를 state(), setState() 함수가 기억하고 있기 때문에 _val에 담김
    let _val = initialValue;
    function state(){
        return _val
    }
    function setState(newVal){
        _val = newVal
    }
    return [state,setState]
}

const [foo,setFoo] = useState(0);
console.log(foo()) // 0
setFoo(1)
console.log(foo()) // 1

그러나 이건 state값에 접근하기 위해 getter함수를 실행시켜야하는 꼴로, React와는 사뭇 다르다. react처럼 구현해보려면 어떻게 해야할까 ?

2-2. 모듈형식으로 useState 만들기

const naheeReact = (()=>{
    let _val;
    return{
        render(Component){
            // Component 함수를 받음
            const Comp = Component();
            Comp.render()
            return Comp
        },
        useState(initialValue){
            _val = _val || initialValue;
            function setState(newVal){
                _val = newVal;
            }
            return [_val, setState]
        },
    }
})();

const Counter = () => {
    const [count, setCount] = naheeReact.useState(0);
    return{
        click: () => setCount(count+1),
        render: () => console.log('render:', {count})
    }
}
let App
// 컴포넌트로 보낸 함수의 리턴값을 App이 받음
App = naheeReact.render(Counter) // render: { count: 0 }
App.click()
App = naheeReact.render(Counter) // render: { count: 1 }

2-3. 모듈 형식으로 useEffect만들기

const naheeReact = (()=>{
    // _deps는 기존의 상태를 저장하고 있음 -> 업데이트 되면 인지하고 useEffect의 callback함수를 실행시킴
    let _val, _deps;
    return{
        render(Component){
            // Component 함수를 받음
            const Comp = Component();
            Comp.render()
            return Comp
        },
        useEffect(callback, depArray){
            const hasNoDeps = !depArray;
            // depArray의 요소 중에 기존의 _deps와 다른 게 있으면 변경된 것이므로 업데이트
            // _deps가 빈 값이면 무조건 true, 변경된 애가 없으면 false가 뜸
            const hasChangedDeps = _deps? !depArray.every((el, i)=> el === _deps[i]) : true;
            if (hasNoDeps || hasChangedDeps) {
                callback();
                _deps = depArray;
            }
        },
        useState(initialValue){
            _val = _val || initialValue;
            function setState(newVal){
                _val = newVal;
            }
            return [_val, setState]
        },
    }
})();

const Counter = () => {
    const [count, setCount] = naheeReact.useState(0);
    naheeReact.useEffect(()=>{
        console.log('effect', count);
    },[count])
    return{
        click: () => setCount(count+1),
        noop: () => setCount(count),
        render: () => console.log('render:', {count})
    }
}
let Appㅣ
// 컴포넌트로 보낸 함수의 리턴값을 App이 받음
App = naheeReact.render(Counter); 
// effect 0
// render: { count: 0 }
App.click();
App = naheeReact.render(Counter); 
// effect 1
// render: { count: 1 }
App.noop();
App = naheeReact.render(Counter);
// effect는 실행되지 않음 (noop는 어떠한 count값도 변경시키지 않으므로)
// render: { count: 1 }
App.click();
App = naheeReact.render(Counter);
// effect 2
// render: { count: 2 }

2-4. 여러 개의 상태를 받을 수 있는 꼴로 useEffect, useState 구현하기

const naheeReact = (()=>{
    // 여러 번 사용하기 위해서 배열 형태로 기존 상태를 저장함
    let hooks = [];
    let currentHook = 0;
    return{
        render(Component){
            // Component 함수를 받음
            const Comp = Component();
            Comp.render()
            // 다음 렌더를 위해 초기화시켜둠 ()
            currentHook = 0;
            return Comp
        },
        useEffect(callback, depArray){
            const hasNoDeps = !depArray;
            // 기존의 상태를 불러와서 저장해둠
            const deps = hooks[currentHook];
            
            // depArray의 요소 중에 기존의 deps와 다른 게 있으면 변경된 것이므로 업데이트
            // deps가 빈 값이면 무조건 true, 변경된 애가 없으면 false가 뜸
            const hasChangedDeps = deps? !depArray.every((el, i)=> el === deps[i]) : true;
            if (hasNoDeps || hasChangedDeps) {
                callback();
                // currentHook 
                hooks[currentHook] = depArray;
            }
            // 각 훅에 대한 작업을 완료하기 위해서 
            currentHook++;
        },
        useState(initialValue){
            // ||의 특성 이용, 기존 값이 없으면 InitialValue를 넣는다
            hooks[currentHook] = hooks[currentHook] || initialValue;
            // 기존의 currentHook인덱스를 저장 (setState의 클로저를 위해)
            // 이렇게 사용하지 않으면 setState함수에서 currentHook 변수가 덮어씌워진다.
            const setStateHookIndex = currentHook;
            function setState(newVal){
                hooks[setStateHookIndex] = newVal;
            }
            // hooks[currentHook]을 리턴한 뒤 currentHook의 값을 1 증가시킴
            return [hooks[currentHook++], setState]
        },
    }
})();

const Counter = () => {
    const [count, setCount] = naheeReact.useState(0);
    const [text, setText] = naheeReact.useState('devstone');
    // Counter 함수가 실행되면 useEffect함수는 무조건 실행됨 -> 그 때 디펜던시 값을 클로저에 갇혀있는 디펜던시 값과 비교하여 상태 업데이트
    naheeReact.useEffect(()=>{
        console.log('effect', count, text);
    },[count, text])
    return{
        click: () => setCount(count+1),
        type: (txt) => setText(txt),
        noop: () => setCount(count),
        render: () => console.log('render:', {count, text})
    }
}
let App;
// 컴포넌트로 보낸 함수의 리턴값을 App이 받음
App = naheeReact.render(Counter); 
// effect 0 devstone
// render: { count: 0, text: 'devstone' }
App.click();
App = naheeReact.render(Counter); 
// effect 1 devstone
// render: { count: 1, text: 'devstone' }
App.type('흠냐링');
App = naheeReact.render(Counter); 
// effect 1 흠냐링
// render: { count: 1, text: '흠냐링' }
App.noop();
App = naheeReact.render(Counter);
// effect는 일어나지 않음 (디펜던시 변한 것 없으므로)
// render: { count: 1, text: '흠냐링' }
App.click();
App = naheeReact.render(Counter);
// effect 2 흠냐링
// render: { count: 2, text: '흠냐링' }

배열 형태로 여러 개의 상태를 관리하는 꼴에 있어서 가장 핵심적인 부분은 currentHook 부분이다. useState함수 내부를 보면 currentHook을 별도의 setStateHookIndex 변수에 할당하여 사용하고 있는데, setState함수가 호출되는 시점에 currentHook 변수는 오래된 클로저 문제를 일으켜 예상대로 동작하지 않기 때문이다.

위 내용들은 https://www.cookieparking.com/share/hdctv9xF 의 레퍼런스들을 바탕으로 정리하고 이해한 내용이다. 이번 포스팅에선 정리와 이해를 중심으로 해보았다면 다음 포스팅에선 다른 모듈 패턴 방식을 이용해 좀 더 다양한 Hook들을 구현해 볼 예정이다!

profile
개발하는 돌멩이

4개의 댓글

comment-user-thumbnail
2022년 5월 9일

궁금했던 부분이었는데, 잘 정리되어 있어서 보고 가요. 감사합니다 : )

답글 달기
comment-user-thumbnail
3일 전

잘봤습니다!

답글 달기
comment-user-thumbnail
약 12시간 전

useState 정리를 특히 잘하신것 같습니다b

답글 달기
comment-user-thumbnail
약 9시간 전

멋있다 나희야! 나있다 멋히야!

답글 달기