[❗️Error] React의 State와 특이한 함정카드

devAnderson·2023년 12월 17일
0

Error Handling

목록 보기
11/11
post-thumbnail

1. 사건의 발단

React를 사용하다보면 수도없이 사용하게 되는 것이 바로 useState일 것이고, 이를 조금 사용해봤다 싶은 사람들이라면 useState에 대해서 그렇게 어려움을 느끼진 않을 것이다.

그리고 내부 구조도 조금 뜯어보다 보고 나니 이제 useState에 대해서 완전히 파악한 줄 착각하고 있었다. 그 착각에 대해서 최대한 이해한 바를 정리하려고 한다.

일단 결론부터말하자면,

명확하게 어떤 연유로 그런 상황이 벌어졌는지에 대해서는 정확하게 파악하지 못했다.

하지만 나와 같은 비슷한 경험을 하시는 분들이 있을 것 같아서 react를 다루시면서 엣지케이스처럼 기억해두셨다가 나중에 이런 고통을 겪지 마시고 지나가시라고 남겨둔다.

내공이 더 쌓여서 명백하게 리엑트의 코드적인 구조를 파악하게 되면 최신화할 에정이다.

2. 기본적인 개념

😀 Hook과 클래스 컴포넌트

리엑트 공식문서에서 정의하는 훅은 아래와 같다.

즉 훅은 함수인데 => 함수 컴포넌트에서 React state과 생명주기 기능을 “연동(hook into)”할 수 있게 해주는 함수이다.

useState를 예로 들자면, 리엑트 내에서 "상태"를 관리할 수 있게 해주는 함수이다.

훅이 나와야 했던 역사는 클래스 컴포넌트 => 함수 컴포넌트의 전환과정과 관련이 있다.

Class 컴포넌트를 기억해보자.

class Counter extends Component {
    //컴포넌트에서 state를 설정할 때는 다음과 같이
    //constructor 메서드를 작성하여 설정한다. 
    constructor(props) {
        //현재 클래스 컴포넌트가 상속받고 있는 
        //리액트의 Component 클래스가 지닌 생성자 함수를 호출해 준다.
        //Component 클래스 내에는 componenetDidMount와 같은 lifeCycle 및
       // "state", "setState"와 같은 property들을 prototype에 삽입한다.
        super(props);
        //state의 초깃값 설정하기 
        this.state = {
            increaseNum : 0,
            decreaseNum : 100
        };
    }

    render(){
      .
      .
      

jsx로 표현된 클래스 컴포넌트는 bebel에 의해 react element 객체로 컴파일된다.
state가 정의되었을 경우, 이렇게 컴파일된 객체 내에는 state가 존재하게 된다.

그런데 중요한 점은, 저렇게 존재하는 state는 그냥 일반 property가 아니다.

react의 리랜더링을 유발시킬 수 있는, 리엑트가 감지하고 있는 특수한 값
이 된다는 점이 중요하다.

😀 Hook과 함수형 컴포넌트

함수 컴포넌트 역시 jsx 문법을 통해 react element 객체를 리턴하는 함수인 것은 변함이 없지만, 함수라는 특성 때문에 외부에서 무언가 외부에서 관리될 로직을 주입하기가 쉽지 않다.

class는 간단히 생각해보면 객체를 생성하는 "설계도"이기에 property나 prototype, 메서드를 외부에서 손쉽게 정의한 뒤 초기화시킨 후 this를 통해 접근할 수 있지만, 함수는 호출하기 전 제어블록 내부에 존재하는 레퍼런트 값을 외부에서 참조하여 구독하거나 조작하거나 하는 행위가 쉽지는 않아보인다.

그래서 초창기의 함수 컴포넌트 state는 props를 통해 폭포수처럼 외부에서 주입시키는 방식으로 개발되었다가, 이에 대한 깊은 빡침을 느끼고 앱 내 전역 클로저 환경을 활용하는 "hook"의 방식을 개발하는 방향으로 진화한 것으로 보인다.

훅 중에서도, 이번 주제에 해당하는 useState의 모듈 내 구조를 살펴보자
너무 유명한 코드이기에, 미리 뜯어보기를 겪어보셨던 분의 글(감사합니다)에서 사진을 참조하였다.

간단하게, resolveDispatcher을 호출한 후 여기에서 리턴되는 useState함수에 초기 state를 전달하는 것을 볼 수 있다.

resolveDispatcher은 ReactCurrentDispatcher.current에 존재하는 값이라고 한다.

외부 전역에서는 current라는 프로퍼티를 null로 할당하여 객체를 생성한 후, 다른 로직에서 dispatcher을 생성하여 이 current에 담아둔다.

즉, 리엑트 모듈의 전역 환경에 담기는 ReactCurrentDispatcher 내의 current에는 리엑트의 state와 생명주기와 관련된 함수들(useState,useEffect)와 같은 로직들이 담겨있는 객체를 초기화하여 current에 담아주고,

사용하는 측에서는 그 객체 내에 존재하는 훅 함수중의 하나를 호출하여 리턴된 값을 사용하거나 생명주기를 관리하거나 하는 방식이 된다.

useState의 경우, dispatcher 객체 내에 있는 useState에 초기 initialState를 인자로 넣어 호출하고, 이 후에 리턴되는 것은 외부에서 관리되는 클로져 상태와 이 클로져 상태를 업데이트시키면서 리랜더링을 유발시키는 로직이 담기는 setter가 담기는 형태가 된다.

(우리가 익히 아는 [state,setState])

3. 그런데 신기한 일도 생깁니다

여기까지만 보면, 아 그렇군요 클로져로 상태가 관리되고 있나보네요. 그리고 이 값을 setter을 통해서 업데이트시킬 수 있군요 하고 마무리 지을 수 있었다.

그런데, 이번에 겪었던 내용은 참으로 특이한 부분이었다.

상황은 대략 이러하다.

  1. ChipList라고 하는 공용 컴포넌트가 이미 존재했고, 이 컴포넌트 내부에 state가 존재한다. (그리고 이 컴포넌트의 내부 구조를 함부로 바꾸고 싶지 않았다.)
  2. 이 state에는 {...propperies, onDelete}[] 형태로 된 외부 상태가 주입되는데, onDelete를 호출하면 외부에 있는 부모 컴포넌트의 state가 변경된다.
  3. onDelete는 부모 컴포넌트의 상태를 확인해서 특정 조건에 따라 setState를 하는 로직이 존재한다.

핵심은 특정 state에 외부 state를 업데이트하는 setter와 state 참조로직이 같이 존재할 경우, 어떤 일이 벌어지는가이다.


위에서 보는 것처럼, 특정 컴포넌트에서 onDelete를 메서드로 함께 삽입하여 부모의 state에 전달하였고, 이 state를 기반으로

ChipList라는 공용 컴포넌트에게 해당 state를 전달해주어, 안에 있는 onDelete 프로퍼티가 내부 객체에 존재하는 onDelete를 활용하도록 바인딩해주었다.

이 후, ChipList라는 컴포넌트 내의 (X) 아이콘이 눌릴 경우, onDelete가 실행되고, 이 onDelete가 실행되는 순간 외부 컴포넌트가 해당 호출을 감지하여 특정 기능을 하도록 구성이 이루어졌다.

onDeleteForMultiDropdown 함수는 내부 블록에서 checkstate를 콘솔로 찍고있고,

외부에는 전역 contextAPI로 전달된 checkState를 콘솔로 따로 찍어보고 있다.

이 후에, ChipList에 존재하는 객체 내 onDelete를 차례로 눌러보면 놀랍게도

CheckState가 서로 다 다른것을 확인할 수 있었다.

간단하게 도식화해서 말하면 위 상황은 이런 상황이다.

const checkState = [];

첫 체크 객체가 등록될 때
** 1 chip
check state = [{name : '지역난방', onDelete}]

=> onDelete 함수 내부에서 참조하는 checkState는 
[]
--------------------------------------------------------
** 2 chip
check state = [{name : '지역난방', onDelete}, {name : '전기', onDelete}] 

=> onDelete 함수 내부에서 참조하는 checkState는 
[{name : '지역난방', onDelete}]
--------------------------------------------------------
** 3 chip
check state = [{name : '지역난방', onDelete}, {name : '전기', onDelete}, {name : '가스', onDelete}] 

=> onDelete 함수 내부에서 참조하는 checkState는 
[{name : '지역난방', onDelete}, {name : '전기', onDelete}] 

즉, 특정 state에 메서드가 등록이 될 때, 이 메서드의 코드 블록 내부에 다른 state를 참조하고 있다면,

이 state는 외부 클로져의 최신 state를 참조하는 것이 아니라, method가 특정 state로 들어가게 되는 순간의 "스냅샷" 에 존재하는 상태를 참조한다.

굉장히 특이한 상황이었다.

이를 해결하는 방법은 가장 간단한 것으로는 onDelete가 호출되는 순간에 참조해야 하는 최신 state를 argument로 건네주는 방법이 있을 것이다.

이런 상황이 벌어지는 내부 로직을 나중에 확인할 수 있으면 업데이트하려고 한다.

현재로서 추론되는 바는 아래와 같다.

  1. react의 함수 컴포넌트는 상태 관리를 클로져를 통해 진행한다.
  2. 클로져는 외부 렉시컬 환경을 참조하는 함수이다
  3. 클로져가 참조하는 렉시컬 환경은 클로져의 생명주기가 끝날 때까지 유지된다

이런 javascript의 특성에 따르면, checkState에 저장되었던 개별적인 onDelete 클로져들이 제각각의 state를 가지고 있었다는 의미는 결국, 자신이 상태로 등록되기 전 참조하고 있던 react dom의 외부 렉시컬 환경을 독립적으로 갖고 유지되게 된다는 소리와 같다.

명백하게 콘솔에서 보이는 것처럼, 각각의 환경이 가지고 있던 상태값이 다르다.

만약 상태로 저장되는 객체가 100개, 1000개, 10000개로 극단적으로 생각한다면 그만큼의 렉시컬 환경 역시 찌꺼기처럼 남게 된다는 소리가 된다. 이것은 메모리 효율적으로도 좋지 못하다.

그래서 setState를 통해 객체를 저장할 때 그 안에 클로져가 존재해야 한다면 내부에서 사용되야 할 값은 props를 통해서 전달받아야 한다는 교훈을 얻었다.

profile
자라나라 프론트엔드 개발새싹!

0개의 댓글