티스토리 뷰

반응형

한 달 전, React 개발팀은 6개월 전 많은 리액트 사용자들을 설레게 한, 'useEvent'라는 실험적인 hook 개발을 취소하였다. 이후에 설명하겠지만, 'useEvent' 훅은 기존 컴포넌트 재렌더링 관련 문제를 해결할 수 있는, 하나의 은탄이 되리라 많은 기대를 모았었다. 그러나 React 팀은 저번 달 이에 대한 관련 github 논의 글을 closed 하였고, React 팀은 "다른 방식으로 사용 가능하고, 다른 문법으로, 다른 이름으로 재출시될 수 있다."라고 못 박았다. 내 귀에는 "useEvent 훅을 없애갰다."라는 의미로밖에 들리지 않았다.

기철이짤
나는 "useEvent는 미국 갔다"로 이해했다.

이게 어떤 사정이 있길 래 취소되었는지, 어떤 방향으로 새로 개발될지는 리액트 개발팀만이 알고 있겠지만, 이는 React Hooks가 가진 단점을 극복하겠다는 의지이면서, 동시에 React 함수형 컴포넌트에는 아직 큰 문제 안고 있고 이를 해결하는 것을 어렵다는 것을 알려주는 증거이다. 이번 포스팅에서는 React hook은 정말 좋은 구조인지, 함수형 컴포넌트는 정말 클래스형 컴포넌트를 완벽히 대체할 수 있는지 알아보도록 하자.

thumbnail
Thinking about React Hooks

 


1. React Hooks의 성공

React 프레임워크는 16.8버전을 배포하면서, 두 가지 항목을 주요 홍보물로 밀었었다.

 

- 1. 함수형 컴포넌트(Functional Component)

- 2. 리액트 훅(React Hooks)

 

기존의 클래스형 컴포넌트는 구성이 복잡하였고, 고차원 컴포넌트(high-order component) 혹은 renderProps같은 특별한 함수에서는 꽤나 구조가 복잡하거나, 이상한 방법으로 추상화를 해야 했다. 혹은 JS의 고질적인 문제 'this'에 대해서도 고민하여야 했다. 리액트 훅은 문제들을 한 번에 해결해주었다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 1. Class Component
export function CounterComponent extends React.Component{
    constructor(props) {
        super(props);
        this.state = {
            count : props.init
        };
    }
 
    componentDidUpdate() {
        this.setState({
            count: this.props.count
        });
    }
 
    render() {
        return (
            <div>
                <span> Count : {this.state.count} </span>
            </div>
        );
    }
};
 
-------------------------------------------------------------------------
 
// 2. Functional Component
const CounterComponent = (init) => {
    const [count, setCount] = useState(0);
 
    useEffect() => {
        setCount(init);        
    }, [init]);
 
    return (
        <div>
            <span> Count : {count} </span>
        </div>
    )
};
cs

 

위는 클래스형 컴포넌트, 아래는 함수형 컴포넌트를 나타낸 React 코드이다. 한 눈에 봐도 훅을 활용하면 코드의 길이도 짧아지고, 가독성도 좋아지며, this 바인딩의 문제에서 해방된 것을 관찰할 수 있다. 이외에도, 컴포넌트별 상호작용이나, 편리한 컴포넌트 아키텍처 등 여러 편의를 제공해준다.

 

\React와 다른 프레임워크/라이브러리 비교. 화살표가 16.8 버젼 출시 이후이다.(출처 : npmtrends.com)

16.8 pre-release 직후 React 커뮤니티 및 웹 개발 커뮤니티는 큰 반향을 일으켰다. (React Conf.에서 그 흔적을 엿볼 수 있다.) 리액트 훅은 이러한 편의성을 바탕으로 npm을 무서운 속도로 점령하였고, 현재 2022년 10월까지도 다른 경쟁자들이 따라잡지 못할 정도로 큰 점유율을 차지하며 굳건히 비슷한 npm 라이브러리 중 1등을 유지하고 있다. 위 그래프의 빨강 화살표가 React 16.8 배포 시점이다. 이전의 기울기와 이후의 기울기를 비교하면, 점차 가팔라지고 있는 모습을 띄고 있다. React 팀은 클래스형 컴포넌트를 유지하고 있지만, 새롭게 React를 입문하는 사람들에겐 클래스형 컴포넌트보다 함수형 컴포넌트를 먼저 소개할 만큼 함수형 컴포넌트와 리액트 훅은 이제 하나의 표준으로 자리 잡았다.


2. React Hooks의 실패

그러나, React Hooks가 편리하기는 하지만 이게 잘 설계된 구조인가? 에 대한 것은 아직 논란이 많다. 현재까지도  'Why react hooks are bad?'로 검색하면 정말 많은 "리액트 까기" 글들이 있다. 분명 리액트 훅은 간단하고 재사용성이 좋긴 하지만, 이에 대한 tradeoff는 반드시 존재한다. 리액트 훅의 문제는 대부분의 경우 일으키지 않지만, 이러한 단점은, 결국에는 문제를 일으키기 때문이다.

 

2.1 Thinking about 'state'

React의 철학 중 하나는 '중복되는 부분은 다시 불러오지 말고, 바뀐 부분만 업데이트하자!'이다. Vitrual DOM이란 개념도 여기에서 출발하였고, 이러한 철학은 지금까지의 업데이트에서도 잘 엿볼 수 있다. 따라서, 각 컴포넌트 안 state가 변화하면, 컴포넌트를 이때 재렌더링하자는 것이다. State의 정의로 돌아가면, 하나의 상태이다. 컴퓨터의 관점에선, "내가 다른 걸 처리하는 동안 이 상태를 [대부분의 경우] 메모리에 유지한다."이다.

 

JS에서는 이러한 개념을 함수형 프로그래밍 패러다임인 "클로져(Closure)"와 객체지향 프로그래밍 패러다임인 "this"로 구현하였다. 생각해보면, 각 함수는 그들 만의 scope안의 변수들을 기억하니, stateful 하다고 이야기할 수 있다. 각 함수를 처리하지 않으면, 그 안의 변수들은 메모리에 함수가 처리될 때까지 남아있는다. 이는 JS의 장점이자 단점으로 자리 잡게 된다.

 

클로져의 또 다른 중요한 점은 의존성이다. 프로그래밍 언어에 상관없이 생각해보면, 함수의 내부 변수는 보통 arguments에 의존하지만 JS는 내부 변수의 값이 외부 함수에 의해서 결정될 수 있다는 말이기도 하다. 결정론적 관점에서, 클로져로 인하여 함수는 항상 같은 결과를 내지 않고, 함수 호출에 의하여 메모리 안의 결과가 바뀔 수 있다는 것이므로 비결정적이라는 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
const changeInsideMe = () => {
    let val = 0;
    return {
        inc : () => val++,
        toString() : () => String(i)
    };
};
 
const h = changeInsideMe();
console.log(h.inc) // val : 1
console.log(h.inc) // val : 2  <= 같은 함수지만 val의 값의 변경되었다.
console.log(h.toString()) // val : "2"
cs

 

그래서 이게 React Hooks와 무슨 상관이냐고? 사실 리액트 훅 자체가 클로져라는 개념과 크게 연관된다. 리액트 훅의 일종인 useEffect를 사용한 다음 컴포넌트가 있다고 해보자.

 

1
2
3
4
5
6
7
const customComponent = ({ arg }) => {
  useEffect(() => {
    console.log(arg.name);
  }, []); // eslint에서 'exhaustive-deps'라고 잔소리 하는 부분.
 
  return <span>{arg.name}</span>;
}
cs

 

React Hooks의 철학은 의존성이 변할 시, side effect이 일어나게 하는 것이다. 예를 들어, useEffect, useMemo, useCallback 모두 side effect를 촉발시키는 input이 변할 때만 작동한다.

 

Reack Hooks는 이러한 철학을 클로져로 실현시킨다. 각 함수 스코프 안의 값, 위의 예시의 경우 "arg"를 감시하고 이에 대한 정보를 얻어 side effect를 발생시킨다. 그러나, 클로져를 활용한 의존은 내부에서 발생하기에, 언제 side effect을 촉발시켜야 하는지를 모른다. 이는 무수히 많은 에러의 출발점으로 작용한다.

 

따라서 useEffect에 dependency array를 채워야 하는 이유이고, React 개발자 스스로가 내부 의존성을 직접 외부 의존성으로 바꿔줘야 하는 부분이다. 이용자가 실수하면 컴포넌트가 의도한 대로 작동 안 할 가능성이 매우 크다. (C의 memory management나, JS의 type casting 등등과 같은 맥락이라 생각하면 될 듯하다.)

 

React는 이에 대하여 linter로 감시하여 'exhausive-deps'라고 잔소리 하지만, 단순히 deps array가 비어있다고만 할 뿐 맥락에 대한 고려는 웹 개발자 스스로 생각해야 한다. 그렇다고 deps array를 채워 넣으면 정말 컴포넌트가 overReact할 가능성이 있는지, underReact할 가능성이 있는지에 대한 고려를 따로 해주어야 한다. Side effect를 위한 side effect를 또다시 고려해주어야 한다. 벌써부터 머리가 아프다.

 

이에 대한 해결법이 있긴 하다. Hooks을 위한 Hooks을 만들거나, Hooks를 컴포넌트 밖으로 빼면 된다.

 

1
2
3
4
5
6
7
8
const newEffect = (func) => (...args) => useEffect(() => func(...args), args);
const useNewEffect = newEffect((val) => { console.log(val.name); })
 
const customComponent = ({ arg }) => {
  useNewEffect(arg)
 
  return <span>{arg.name}</span>;
}
cs

 

위의 경우 underReact의 가능성은 해결하였지만, 반대로 overReact에 대한 문제를 더 많이 고려해주어야 한다. 이는 useEffect의 dependency array의 감시 문제로 이어진다.

 

2.2 Thinking about 'identity' : The Butterfly (use)Effect

어느 누구도 같은 강에 두 번 들어서지 않는다. 왜냐하면 처음과 같은 강도, 같은 사람도 아니기 때문이다.
- 헤라클레이토스 , 기원전 500년의 철학자 -

 

여러 언어에서는 같다(흔히 연산자 '==')를 할 때 어디까지 검사하는지 각기 다른 방향으로 발전하였다. C언의 계열의 경우 값만 같으면 같은 것이라 판단한다. 현대 언어인 Python, JS, PHP는 비교 연산자를 구분하여, '=='와는 구분되는 연산자를 만들었다. Python의 경우 새로운 비교 연산자 'is'를 만들어 값과 메모리 주소까지 같은 지, JS의 경우 '==='를 만들어 자료형까지 같은 지 검사한다.

 

한편, JS에서는 모든 것이 객체이므로, 파이썬과 유사하게 Object.is()라는 비교 연산자가 있다. Object.is도 비교 역할을 수행하지만, '==='와 다르게 Python처럼 메모리(reference)를 비교한다.

 

useEffect는 deps array의 변화를 Object.is()를 사용하여 비교한다. 만약 비교하여 "true"라면, side effect를 실행시키지 않고, "false"라면 side effect를 실행시킨다. 자, 위의 예시에서 deps array를 추가하여서 eslint의 요구를 들어주자.

 

1
2
3
4
5
6
7
8
const customComponent = ({ arg }) => {
  useEffect(() => {
    console.log(arg.name);
  }, [arg]); // eslint : 乃乃
 
  return <span>{arg.name}</span>;
}
 
cs

 

위의 경우에, useEffect는 몇 번 작동한다고 할 수 있나? 한 번? 무한정? 정답은 "모른다"이다. Object.is()를 이용하여 비교할 때 메모리 주소 까지 비교한다고 했었다. 문제는, JS는 동적으로 메모리를 할당하기에 arg가 어느 메모리에 위치해있는지, arg가 재할당되면 어디로 재할당되는지 추적이 불가능하다는 것이다. 이는 여러 경우에 문제를 일으킬 가능성이 크다. 특히, 부모 컴포넌트가 바뀌었는데 자식 컴포넌트의 useEffect가 말썽을 일으키는 경우 대부분 이 경우에 해당된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const App1 = () => {
 
    const arg1 = { name'arg1' };
    
    return <customComponent arg={arg1} />
};
 
///////////////////////////////////////////////////////////
 
const arg2 = { name : "arg2" };
 
const App2 = () => {
 
    return <customComponent arg={arg2} />
}
 
///////////////////////////////////////////////////////////
 
const customComponent = ({ arg }) => {
  useEffect(() => {
    console.log(arg.name);
  }, [arg]); // eslint : 乃乃
 
  return <span>{arg.name}</span>;
}
 
cs

 

위의 경우에서, Hooks의 단점이 수면 위로 들어난다.

 

첫 번째 App1의 경우, 새로운 컴포넌트 안에서 새롭게 메모리 할당이 일어난다. 개발자 눈에는 같은 값으로 보이겠지만, Object.is 기준으로 메모리 주소가 달라지기에 useEffect 안의 side effect가 렌더링마다 새로 발생하고, console.log 함수가 계속 실행된다.

 

두 번째 App2의 경우, user는 항상 같은 메모리 주소를 가지고 있기에 컴포넌트가 얼마나 재렌더링 되든 console.log 함수가 딱 한 번 발생한다. 보통 두 번째 App2와 같은 경우를 기대하기에 App2의 방식으로 설계해야 의도한 대로 작동하지만, 실제 코드를 작성할 때는 이에 대하여 간과할 가능성이 매우 크다. 실제로 작성하면 이렇게 작성하는 개발자들이 많을 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
const App = ({ name, id}) => {
    const [use, setUser] = useState(null);
    const user = {name, id};
 
    useEffect(() => {
        fetch(`/teams/${user.id}/user`)
        .then((res) => res.json())
        .then((json) => { setUser(json); }
    }, [user]);
 
    return <customComponent user={user} />
};
cs

 

App 컴포넌트를 만들고 이를 직접 렌더링하면, 서버는 무수히 많은 fetch 요청에 좋아 "죽을" 것이다. useEffect의 deps인 user가 계속 생겨남에 따라, fetch가 무수히 많이 실행되고, 서버는 이에 대한 response을 처리하다가 문제가 생겨 트래픽을 감당하지 못할 가능성이 높다. 멀쩡해 보이는 이 코드가 서버 암살 React 코드가 되는 것이다.

 

그러나, 단순히 useEffect 안의 deps array를 비워놓으면 해결되는 문제가 아니다. 비워놓으면, "오래된 클로져(stale closure)" 문제가 발생하기 때문이다.


다음 코드를 보자. 정상 작동하지 않는 코드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const factoryIncrement = (incValue) => {
  let value = 0;
  const increment = () => {
    value += incValue;
    console.log(value);
  };
 
  const logMessage = `Current Value is ${value}`
  const log = () => {
    console.log(logMessage);
  };
 
  return [increment, log];
};
 
const [increment, log] = factoryIncrement(1);
increment(); // 1
increment(); // 2
increment(); // 3
// Closure mess this up
log(); // "Current Value is 0" ??????
 
cs

 

이 경우 log는 오래된 값을 지니고 있다. 클로져를 이해하고 있다면, value가 오래된 값을 기억하고 있고, 이에 대한 스코프 안에서는 value의 값은 0이기 때문이다. 이를 수정하면 다음과 같이 고칠 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const factoryIncrement = (incValue) => {
  let value = 0;
  const increment = () => {
    value += incValue;
    console.log(value);
  };
 
  const log = () => {
    console.log(`Current Value is ${value}`);
  };
 
  return [increment, log];
};
 
const [increment, log] = factoryIncrement(1);
increment(); // 1
increment(); // 2
increment(); // 3
log(); // "Current Value is 3"
cs

 

이제 작동한다! 이게 useEffect와 어떤 연관이 있냐고? 다음 예시를 보자. useEffect에 deps array를 비워놓았을 때의 문제이다. 다음 코드를 보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React from 'react';
 
export default function App() {
  const [value, setValue] = React.useState(0);
 
  React.useEffect(() => {
    setInterval(() => {
      console.log(`Value is : ${value}`);
    }, 1000);
  }, []);
 
  return (
    <div>
      {value}
      <button
        onClick={() => {
          setValue(value + 1);
        }}
      >
        Increase Value
      </button>
    </div>
  );
}
cs

 

이를 실행시키면, 다음과 같은 결과를 얻을 수 있다.

 

Value 자체의 값은 올랐지만, log된 값은 0이다.

'Increase Value' 버튼을 누르면 위와 같이 value의 값은 업데이트 되지만, log되는 value는 아직 0이다. 이 경우 value를 useEffect의 deps array 안에 넣어주면 해결할 수 있다. 

 

useEffect가 말썽이라면 useEffect를 사용하지 않고 Hooks 사용하면 되는 것 아닌가? 'useEffect를 손 보면 되는 문제이지 React Hooks의 문제인가?' 라고 생각할 수 있지만, React Hooks에는 이외의 문제들이 존재한다. (물론, 내가 생각하기에 문제가 가장 많은 Hooks는 useEffect라 생각한다.)

 

2.3 Thinking about "Rules of Hooks"

React Hooks의 공식 문서에는 React Hooks 사용시 지켜야 하는 규칙들이 있다. 이를 React에선 Rules of Hooks라고 부른다. 링크

 

예를 들면 React Hooks는 함수형 컴포넌트 내 혹은 새로운 Hooks에서 사용해야 한다든지, 조건문과 제어문 안에서는 사용 못한다던지... 공식 문서에 나와있다. 이 점이 React 성능의 발목을 잡는다.

 

다음과 같이 코드를 작성하였다고 하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
const App = () => {
  const [one, setOne] = useState(0);
  if (one === 0) { 
    const [two, setTwo] = useState(1);
  }
 
  return(
    <div>
      <span>{one}</span>
      <span>{two}</span>
    </div>
  )
}
cs

 

실제로 React 코드로 작성하면 다음과 같은 에러가 발생하고, if 문 안의 Hooks를 없애줄 것이다.

Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

React의 Hooks에는 많은 편리함을 주지만, 동시에 많은 제약을 걸기도 한다. for문 혹은 if문 안에 Hooks을 넣을 수 없다는 말은, Hooks는 정적으로 컴포넌트 안에서 관리된다는 의미이고, 이럴거면 constructor 안에 넣는 게 더 합리적이지 않나라는 생각이 든다.

 

괜히 React Hooks를 사용하려고 The Rule of Hooks를 만들바에는, 차라리 클래스형 컴포넌트의 constructor() 개념을 가져와 사용한다면 The Rule of Hooks에 이를 만들 필요가 없지 않나는 의문이 들 수 있다. 상상력을 발휘하여 다음과 같이 constructor 개념이 들어간 React 컴포넌트를 짜보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
const App = createComponent(() => {
  
  // Declare hooks in the constructor.
  const [count, setCount] = useState(0)
  
  // render
  return (props, state) => (
    <div>
      {/*...*/}
    </div>
  );
});
cs

 

이렇게 짠다면 문제가 생긴다. 당연히 생길거다! 값을 최신화 해줘야 하는 상황이 온다면?

Hooks을 왜 constructor 안에 넣어요? 가장 최신 값을 업데이트 매번 최신 값이 업데이트 될 때마다 새로 render해줘야 하는게 맞잖아요!

정확하다! 이게 Hooks의 본질이다. React Hooks는 렌더링 함수안에 반드시 존재해야 하는 이유이고, 단점으로 작용한다. 다른 말로, React Hooks는 컴포넌트 내부의 state change를 기대한다는 것이다.

 

만약 많은 state와 코드가 있다면, 모든 state가 새로 렌더링되어야 한다는 의미이다. React는 결국 컴포넌트 안의 모든 값들을 재계산 하게 되고, useMemo나 useCallback과 같은 다른 Hooks를 도입하지 않는 이상, 성능 저하는 필연적이다. 그렇다고 Hooks의 문제를 해결하기 위해 Hooks를 활용하는 것은 그렇게 좋아보이지 않는다.

 

현재 이러한 세 문제점이 있는 지금, 맨 앞에서 소개한 useEvent Hook의 제안이 나타났다.


3. The Silver Bullet : useEvent

새로 도입(하기로)하였던 useEvent Hook은 useEffect의 deps array와 Hooks의 성능 문제와 씨름하던 사람들에게 새로운 길을 열어주었다. 간단하게 말하자면, useEvent Hook을 통해 불필요한 리렌더링을 줄어고, reference를 조금 더 안정적으로 할 수 있게 해줄 수 있다! 위에서 무한 fetch 코드를 useEvent를 통해 코친다면 다음과 같이 고칠 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const App = ({ name, id}) => {
    const [use, setUser] = useState(null);
    const user = {name, id};
 
    useEffect(() => {
      useEvent(() => {
        fetch(`/teams/${user.id}/user`)
        .then((res) => res.json())
           .then((json) => { setUser(json); }
      }
    }, [user]);
 
    return <customComponent user={user} />
};
 
cs

 

많은 사람들이 useEvent의 등장으로 환호하였지만, React 팀은 모든 컴포넌트에서 이를 남용할까봐 useEvent에 추가적인 제한을 걸거나, 자동 메모이제이션을 하는 컴파일러로 연구하는 방향으로 바꾼 듯 하다. 현재 이에 대한 소식은 9월을 마지막으로 올라오고 있지 않다.


4. Abandon Ship?

결론적으로 "React" 를 버려야 하냐? 에 대한 질문에 대하여 성급하게 "아니요"라고는 하지 못하겠지만, React Hooks가 가진, 생각보다 큰 단점들에 유의하면서 React Hooks를 사용하여야 한다. 만약 이러한 점들이 걱정된다면, 새로운 라이브러리(ex. Preact의 Signals, Jotai)를 고려하거나, 다른 웹 프레임워크를 고려해 보는 것도 좋은 방법이라 생각한다. 각자가 맞는 방법으로 React Hooks를 지혜롭게 다루었으면 한다.

 

 

 

반응형
댓글
Total
Today
Yesterday
공지사항
최근에 올라온 글
최근에 달린 댓글
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함