Effect 를 무작정 추가하지 말자 2

POST Request 를 보내는 경우

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: 컴포넌트가 렌더링되기 때문에 이 로직은 Effect 내에 들어가야 하는게 맞다.
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Effect 내에 이벤트 특화 로직
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

위의 경우 POST Request 가 2개의 useEffect 내에 쓰였다.

useEffect 1편 에서 알아봤던 것처럼 위의 코드만으로 알기 어려우나 '/analytics/event' 의 경우 해당 form 은 화면에 display 되어야 하는 컴포넌트라 Effect 내에 쓰이는게 맞다.

반면에 두번째 '/api/register' 의 경우 버튼을 클릭할때만 실행되기 원하는 동작이기에 이벤트 특화라 useEffect 가 아닌 이벤트 핸들러로 처리해줘야 맞다.

// 수정후
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: 컴포넌트가 렌더링되기 때문에 이 로직은 Effect 내에 들어가야 하는게 맞다.
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: 이벤트 특화 로직은 이벤트 핸들러 내에 위치시킨다.
    post('/api/register', { firstName, lastName });
  }
  // ...
}

이벤트 핸들러 혹은 Effect 내에 어떤 로직을 둬야 하는지 고민이라면 유저의 관점에서 무엇으로부터 비롯된 로직인지 생각해보자. 만약 특정 상호작용에 의한 것이라면 이벤트 핸들러 에 넣어야 하고 유저가 화면의 컴포넌트를 보는 것으로 인한 것이라면 Effect 에 넣어야 한다.

계산을 연속적으로 할때

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 Avoid: 서로를 트리거하는 Effect 체인
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

엄청나게 비효율적인 코드다.

setCard → render → setGoldCardCount → render → setRound → render → setIsGameOver → render

비록 눈에 보이지 않을 정도로 빠르게 재렌더링된다고 해도 불필요한 렌더링을 굳이 발생시킬 필요는 없다. 이 경우 렌더링 도중 연산이 이루어 지도록 상태를 이벤트 핸들러 내로 조정하자.

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ 렌더링 할 수 있는동안 계산
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ 모든 다음 상태를 이벤트 핸들러에서 계산
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

이벤트 핸들러 내에서의 상태는 스냅샷처럼 행동한다.

? 무슨 소리일까 스냅샷이라면 RDS 에서 백업 저장할때 본것 같은데..

상태변수는 자바스크립트 변수처럼 보고 수정할 수 있을것 같다. 하지만 상태는 스냅샷처럼 행동하는데 만약 상태를 바꾸려 하면 값이 바뀌는 대신 재렌더링을 유발한다.

리액트에서 렌더링은 컴포넌트라는 함수를 호출하는 것과 같다. 이 함수가 반환하는 JSX 는 함수를 호출한 순간의 UI 의 스냅샷과 같다. 이 렌더링이 이루어지는 순간의 상태를 이용해서 이 컴포넌트의 props, 이벤트 핸들러, 지역 변수를 계산한다.

컴포넌트 메모리에서 상태는 일반적인 변수처럼 함수가 반환된 뒤에 사라지지 않는다.

리액트가 컴포넌트를 호출할 때 그 특정 렌더링때의 상태의 스냅샷을 제공한다. 그래서 렌더링때 이 렌더링이 되는 순간의 상태를 이용해서 JSX 내의 props, 이벤트 핸들러를 계산하는 것이다.

자주 봤을법한 예시를 볼까

옆으로 드래그하면 실행화면을 볼 수 있다.

버튼을 클릭할때 0 에서 3으로 바뀌는 것을 기대했지만 1이 출력된다.

이는 각 setNumber 가 호출되는 레너링 시점에서 찍힌 스냅샷을 보면 이해할 수 있다. 버튼이 클릭된 시점의 렌더링에서의 number state 는 0이다. 그래서 3회 호출한것 같지만 사실상 setNumber(0 + 1); 을 3번 한 것과 같아서 1이 나온 것이다.

이 경우는 어떨까?

자바스크립트는 한줄한줄 실행되는 인터프리터 언어이기에 setNumber 로 number 가 5로 바뀌니 alert 에 5가 나타나길 희망할 수 있으나 이 역시 0이 alert 되고 화면이 재렌더링 되면서 화면의 숫자가 0 에서 5로 바뀌는 것을 볼 수 있다.

마찬가지 이유로 버튼을 클릭하는 순간의 state 가 0 이라 그런 것이다.

비동기로 하면 다를까? setTimeout 을 사용해보자

어림없지. 여전히 0이 alert 된다.

이를 통해 state 가 snapshot 처럼 행동한다는 말을 이해할 수 있을 것이다. 특정 렌더링에서 props, eventhandler 의 계산은 그 순간의 state 를 갖고 이루어진다.

상태가 스냅샷처럼 행동한다는 건 이해가 된다. 그런데 컴포넌트 메모리에서 상태가 왜 사라지지 않을까

리액트를 자바스크립트로 구현하든 타입스크립트로 구현하든 그 기본은 자바스크립트다. 자바스크립트에서 함수 스코프에서의 변수는 함수스코프를 갖는다. 따라서 함수 내에서 선언된 변수는 지역변수로 함수 바깥에서 쓸 수 없다.

그런데 리액트에서는 함수인 컴포넌트 안에서 state 라는 변수를 사용하고 있는데 이 함수 스코프에서 지역 변수인 state 는 재렌더링 될때, 함수 바깥에서 어떻게 state 를 가져다 쓰는걸까

리액트가 컴포넌트를 호출할 때 그 특정 렌더링때의 상태의 스냅샷을 제공한다. 라는 말을 봤을때 무작정 그렇구나~ 할게 아니라 어떻게 제공한거지? 라는 생각이 들었다.

진짜 어떻게 제공한거지?

useState 훅은 함수형 컴포넌트의 상태관리를 클로저를 통해 처리한다.

Hook 은 상태를 갖는 행동과 유저 인터페이스의 부수효과를 캡슐화하기 쉬운 방법이다. 그러나 여기엔 자바스크립트의 클로저에 대한 이해가 필요하다.

클로저란?

클로저는 함수가 lexical scope 바깥에서 실행중이어도 lexical scope 를 기억하고 접근할 수 있는 경우를 말한다.

스코프 : 식별자가 유효한 범위, 현재 실행중인 컨텍스트

  • 컨텍스트 : 값과 표현식이 표현되거나 참조될 수 있다.
  • 스코프는 계층적 구조를 갖기에 하위 스코프는 상위 스코프에 접근할 수 없으나 반대는 가능하다.
  • 함수 스코프 : 생성된 함수 내에서만 사용할 수 있다.
    (if문, loop 문등의 다른 유형의 블록은 함수 스코프가 아니다. )
    • JS 에서 var 키워드는 함수 스코프, 전역스코프만 스코프로 취급한다.
    • 범위는 함수 안
  • 블록 스코프 : 모든 블록이 스코프가 된다. ( 블록은 {} 의 집합이다 )
    • let, const 키워드는 블록 스코프를 갖는다. 이말은 어떤 블록에서 선언되든 해당 블록을 스코프로 취급한다는 의미이다.
    • 범위는 중괄호 안
  • 렉시컬 스코프 (lexical scope, static scope) : 중첩된 함수 그룹에서 내부 함수가 상위 범위의 변수 및 리소스에 액세스 할 수 있다. 함수를 어디서 선언했는지에 따라 상위 스코프를 결정한다. 이는 함수를 어디서 호출했는지는 스코프 결정에 아무런 영향을 주지 않는다는 것을 의미한다.
if (Math.random() > 0.5) {
  var x = 1; // var 키워드는 함수 스코프, 전역 스코프만 스코프로 취급하기에 중괄호가 사용되어도 함수가 아니면 전역 스코프이다.
} else {
  var x = 2;
}
console.log(x); // 1 또는 2 출력

if (Math.random() > 0.5) {
  const x = 1; // let, const 는 블록스코프를 취급하므로 중괄호 내를 스코프로 갖는다.
} else {
  const x = 2;
}
console.log(x); // ReferenceError: x is not defined

클로저는 이 모든 스코프의 변수를 캡쳐 할 수 있다.

다음 예시를 볼까

function makeFunc() {
  var name = "Mozilla";
  function displayName() {
    console.log(name);
    return name;
  }
  return displayName;
}

var myFunc = makeFunc();
//myFunc변수에 displayName을 리턴함
//유효범위의 어휘적 환경을 유지
console.log(myFunc());
//리턴된 displayName 함수를 실행(name 변수에 접근)

var 키워드는 함수, 전역 스코프를 갖는다 일반적으로 생각해 볼때 함수 스코프를 갖게 되니 makeFunc() 실행이 끝나면 name 변수에 접근할 수 없게 된다고 예상이 된다.

그런데 위의 경우 함수를 리턴했고 리턴하는 함수가 클로저를 형성했기 때문에 함수가 선언된 렉시컬 스코프 환경은 클로저가 생성된 시점의 유효범위 내에 있는 모든 지역 변수로 구성된다.

myFuncmakeFunc() 가 실행될 때 생성된 displayName 함수의 인스턴스에 대한 참조를 갖는다. displayName 인스턴스는 변수 name 이 있는 렉시컬 스코프에 대한 참조를 유지하기에 myFunc 가 호출될 때 변수 name 은 사용할 수 있는 상태로 남았던 것이다.

다른 예시를 볼까

add5, add10 는 모두 클로저다. 이들은 같은 함수를 공유하지만 서로 다른 맥락적 환경을 저장한다. add5 의 맥락에서 클로저 내부의 x = 5 이나 add10 의 맥락에서 클로저 내부의 x = 10 이다. 또한 리턴되는 함수에서 초기값이 1 로 할당된 y 에 접근하여 y 값을 100 으로 수정하는 것을 볼 수 있다.

다음 gif 를 통해 디버깅하는 과정을 보자.

add5에 인자로 2를 전달할때 분명히 makeAdder 함수 바깥에 있다. 이때 디버깅하는 과정을 보면 이미 사라졌을 것으로 생각했고 var 키워드를 사용했기에 함수 스코프를 사용했음이 분명한 y 값을 1 에서 100으로 업데이트하는 것을 볼 수 있다.

음..클로저가 과연 이해되었을까? 아직은 조금 부족한 것 같다.

클로저 정말 이해해보자

클로저는 함수를 선언할 때 만들어지는 유효 범위다.

함수는 클로저를 통해 자신이 선언될 때 속해 있던 유효 범위 내의 변수와 함수를 사용할 수 있고 변수의 경우 그 값을 변경할 수도 있다.

다음 예시를 볼까

<script>
  var outerValue = "ninja";

  var later;

  function outerFunction() {
  	// 이 변수의 유효범위는 함수 내부로 제한되고 함수 외부에서는 접근할 수 없다.
    var innerValue = "samurai";

    function innerFunction() {
      if (outerValue) console.log("I can see the ninja.");
      if (innerValue) console.log("I can see the samurai.");
    }

  	// later 변수에 innerFunction 의 참조를 저장한다. 
  	// later 가 전역 변수이니 later 변수를 사용해서 
  	// innerFunction 을 호출할 수 있다.
    later = innerFunction;
  }

  // outerFunction 을 실행하여 내부적으로 innerFunction 을 선언한 뒤 
  // 참조를 later 변수에 저장한다.
  outerFunction();

  // later 변수를 이용해서 innerFunction 을 호출한다.
  // innerFunction 의 유효 범위는 outerFunction 내부로 제한되어 있으니
  // 외부에서 직접 호출할 수 없다.
  later();
</script>

innerFunction 내에 outerValue 는 전역변수이니 반드시 성공한다.

그럼 두번째 출력에서 innerValue 는 참일까? later() 를 통해 innerFunction 을 호출할 때 이미 outerFunction 내부의 유효범위는 이미 사라졌고 later 로 함수를 실행시킨 시점에서는 outerFunction 내부 유효범위는 사라진 것으로 보인다. 그러니 innerValue 를 읽지 못할 것으로 추측할 수 있다.

실행결과

둘다 출력된다.

innerFunction 의 클로저는 해당 함수가 존재하는 한 함수의 유효범위와 관련된 모든 변수를 가비지 컬렉션으로부터 보호한다.

outerFunction 내에서 innerFunction 을 선언했을 때 함수만 정의된 게 아니라 그 시점에 같은 유효 범위에 있는 모든 변수를 포함하는 클로저도 생성된다. 그 뒤에 innerFunction 을 실행하면 해당 함수가 속해 있던 유효범위가 사라지고 난 뒤에 실행이 되었어도 클로저를 통해 함수가 정의된 원래 유효 범위에 접근할 수 있다.

클로저는 보호막과 같다.

이 보호막은 함수가 선언된 시점의 유효 범위에 있는 모든 함수와 변수를 갖고 있으며 필요할 때 그것들을 사용할 수 있고 이 보호막은 함수가 동작하는 한 관련 정보를 유지한다.

앞에서 클로저 개념을 뭐라 했는지 다시 볼까

클로저는 함수가 lexical scope 바깥에서 실행중이어도 lexical scope 를 기억하고 접근할 수 있는 경우를 말한다.

렉시컬 스코프는 문맥상의 흐름을 유지하는 범위다. 그러니 함수가 선언된 환경이 사라져 내부 유효 범위가 사라졌어도 문맥상 참조하고 있는 함수가 선언될 당시의 유효범위와 관련된 모든 함수와 변수는 클로저가 이들을 기억해서 함수가 유효하는 한 가비지 컬렉션이 이들을 제거하지 못하는 것이다.

이제 클로저가 이해된다. 클로저에 대해 좀더 깊은 이해를 하고 싶으니 추후 클로저를 주제로한 다른 포스트를 써봐야겠다. ( 자바스크립트 닌자 비급에 클로저에 대해 매우 자세하고 이해하기 쉽게 설명이 되어 있다. 개인적으론 mdn 문서의 클로저 설명보다 좀더 이해가 잘 되었다 )

useState 는 어떻게 동작할까?

useState 는 다음과 같이 구성되어 있는 함수다.

코드 출처 : swyx 의 Deep dive: How do React hooks really work?

// Example 0
function useState(initialValue) {
  var _val = initialValue // _val is a local variable created by useState
  function state() {
    // state is an inner function, a closure
    return _val // state() uses _val, declared by parent funciton
  }
  function setState(newVal) {
    // same
    _val = newVal // setting _val without exposing _val
  }
  return [state, setState] // exposing functions for external use
}
var [foo, setFoo] = useState(0) // using array destructuring
console.log(foo()) // logs 0 - the initialValue we gave
setFoo(1) // sets _val inside useState's scope
console.log(foo()) // logs 1 - new initialValue, despite exact same call

위의 코드를 보면 알 수 있듯이 useState 의 state 도 함수로 동작하는 것을 볼 수 있다.

여기에 보면 앞에서 살펴봤던 클로저의 형태가 흐릿하게 보이지 않는가?

statesetState 는 innerFunction 이다.

innerFunction 인 state 는 지역변수인 innerValue 에 해당하는 _val 을 반환한다.

innerFunction 인 setState 는 입력받은 값으로 지역변수인 innerValue 에 해당하는 _val 에 새로운 값을 할당한다.

이 함수들은 useState 라는 outerFunction 의 내부에서 선언되어 선언된 당시에 같은 내부 유효범위를 갖는 함수, 변수들을 기억하는 클로저를 동시에 생성한다.

그래서 useState 를 외부에서 호출했을때 이 innerFunction 을 참조하는 state, setState 가 유효하는 한 클로저가 이 내부 유효범위를 기억하는 것이다.

사실 위의 코드는 useState 에 부합하지 않다. state 는 변수인데 getter 방식의 함수로 구현되어 있기도 하고 상태 값을 유지하도록 하려면 외부에 state 를 선언해야 한다. useState 의 자세한 동작 원리는 추후 따로 포스팅으로 정리할 예정이다.

그럼 react.dev 에서의 문구를 다시 볼까?

As a component’s memory, state is not like a regular variable that disappears after your function returns. State actually “lives” in React itself—as if on a shelf!—outside of your function.

react.dev/learn/state-as-a-snapshot 에 나오는 설명중 일부이다.

자바스크립트의 변수가 함수가 리턴될 때 사라지는 것과 달리 React 에서의 state 는 함수 바깥에서도 살아있다는 말 이제 이해가 된다.

정리하다보니 useEffect 본연의 주제보다 useState 와 클로저에 대해 더 자세히 살펴봤던 것 같다. 이어서 정리해보자

TODO

  • 클로저 포스팅
  • useState 동작원리 자세히

레퍼런스

blog
Eun-Ng - 함수, 블록, 렉시컬 스코프
swyx Deep dive: how do React hooks really work?
[SO's CODE - useState 의 동작 원리와 클로저]
docs
mdn - closure
react.dev - useEffect
book
자바스크립트 닌자 비급

profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

0개의 댓글