React Hooks 톺아보기

React Hooks 톺아보기

React 에는 Class 컴포넌트와 Functional 컴포넌트 등 총 2가지 형태로 구분하여 컴포넌트를 작성할 수 있다. 과거 처음 React를 이용하여 개발을 시작할 때는 Functional 컴포넌트가 Presentation 컴포넌트이자, Stateless 컴포넌트이고, Class 컴포넌트가 Container 컴포넌트이자, Stateful 컴포넌트라고 생각하였다. 하지만 공부 안한 나의 잘못된 지식을 가지고 있었다는 걸을 탓하며, Class 컴포넌트도 Presentation 컴포넌트가 될 수 있고, Functional 컴포넌트도 Container 컴포넌트가 될 수가 있었던 걸 깨달았다. 이에 또 Hooks라는 또다른 기술부채가 쌓이는 상황이 발생하게 되었다. 이참에 쌓인 기술 부채의 일부를 청산해보가 학습해보게 되었다.

Hooks의 개요

일단 Class 컴포넌트 형태로도 작성할 수 있는 데도 불구하고 Hooks를 사용하는 얻는 이점이 뭔지를 살펴보고자 한다.

1. Stateful 코드를 재사용하기가 힘들다.

Class 컴포넌트로 작성하게 되면 컴포넌트 간의 코드 재사용성을 위해 HOC(고차함수 컴포넌트) 혹은 render props 형태로 작성해야만 했다. 하지만 이러한 코드의 경우 일단 첫번째는 코드가 다소 직관적이지 않다는 단점이 있으며, React devtools에서 디버깅을 하게 되는 경우 아래와 같이 Wrapper Hell 에 빠지는 것을 쉽게 볼 수 있다.

React Wrapper hell

하지만 Hooks는 컴포넌트의 계층 구조와는 상관없이 코드의 재사용성을 높여준다. 이러한 점은 Custom Hooks를 이용하여 이점을 극대화 시킬 수 있다.

2. Class 컴포넌트의 로직을 관심사에 따라 분리할 수 있다.

Class 컴포넌트로 컴포넌트를 작성하다보면 각각의 생명 주기(Life cycle) 안에서 여러가지 로직을 추가하는 경우가 있다. 예를 들어 componentDidMount 훅에서는 컴포넌트에 필요한 초기 데이터를 위한 API를 호출하거나 EventListener 를 등록 해줄 수 있다. 뿐만 아니라 이렇게 설정된 데이터 혹은 EventListener 들은 componentWillUnmount 훅에서 제거하거나 데이터를 초기화 해줄 수 있다. 이러한 부분들이 컴포넌트의 생명주기 메소드 내의 가독성과 관심사 분리를 해칠 수 있으며, 이에 따라 버그나 무결성을 해칠 수 있는 여지가 충분하다.

이러한 이유로 인해 간단하게 작성되기 시작한 Class 컴포넌트도 시간에 따라 점점 더 비대해지며, 정작 중요한 비즈니스 코드가 눈에 한 눈에 들어오지 않게 되는 경우가 발생하게 된다. 관심사별 비즈니스 코드를 분리하기 위해 Redux나 Mobx와 같은 상태 관리 라이브러리를 사용해야 하는 경우가 생긴다. 이러한 문제점에 대해 Hooks는 로직 별로 작은 단위에 컴포넌트에 집중할 수 있는 해결점을 제공해준다.

3. Class 문법에 대한 진입 장벽을 낮춘다.

개인적으로는 React에 대한 진입 장벽은 JSX 문법이라고만 생각을 해왔는데, React 공식 문서에 따르면 React 에 대한 진입 장벽 중 하나로 Class 문법을 언급하고 있다. Class로 컴포넌트를 설계하게 되면 this를 이해하지 못하고서는 사용하기가 힘들다. 그래서 bind 메소드를 이용한 this를 바인딩하는 등의 여러가지 작업을 해줘야하는 경우가 있다. 마찬가지로 props, state 혹은 Class 컴포넌트 내의 메소드들을 참조할때도 항상 this 에 대한 스코프가 유지되어야 한다.

하지만 Hooks의 경우 Functional 컴포넌트 내에서 사용되기 때문에 Class 컴포넌트에 대한 this 장벽을 허물 수 있다는 장점을 가진다. 이뿐만 아니고도 Class 컴포넌트의 경우는 Tree shaking이 원활하게 지원되지 않는 등의 문제를 해결할 수 있다고 있다.

관련된 자세한 내용은 공식 문서의 Motivation에서 확인할 수 있다.

Hooks이란 무엇인가?

Hooks는 Functional 컴포넌트도 Hooks를 이용하여 Stateful 컴포넌트가 될 수 있도록 만들어 준다. 리액트의 공식 문서를 살펴보면 다음과 같은 문구를 확인할 수 있다.

1
The Effect Hook lets you perform side effects in function components

여기에서 이야기하는 side effects 이란, 데이터를 받아오거나, 데이터를 구독하거나 혹은 DOM을 직접 조작하는 행위를 말한다고 한다. 본격적으로 Hooks에 대해서 살펴보도록 하자.

useState

일단 기본적으로 사용하는 문법은 다음과 같다.

1
2
3
4
5
6
const FunctionalComponent = () => {
const [count, counting] = useState(10);
return (
<div> {count} </div>
)
}

useState 의 인자로는 상태의 초깃값을 주입해줄 수 있으며, 초기값은 Primitive 타입 혹은 Object 타입 상관없이 주입할 수 있다. 이 초기값은 처음 컴포넌트가 랜더링될 경우에만 사용되며, 이후부터는 useState에서 반환하는 Setter 함수를 통해 변경된 값이 State 에 담긴다. useState의 인터페이스를 살펴보면 다음과 같이 되어 있다.

1
2
3
4
5
6
7
/**
* Returns a stateful value, and a function to update it.
*
* @version experimental
* @see https://reactjs.org/docs/hooks-reference.html#usestate
*/
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

useState는 제네릭 타입 S(initialState)를 인자로 받은 후, 튜플 타입으로 첫번째 값은 State를 반환하며 두번째 값으로는 State를 업데이트할 수 있는 Dispatch 함수를 반환한다. 여기에서 중요한 것은 Dispatch 함수는 Redux의 Dispatch 함수가 아닌 리액트의 인터페이스에 정의되어 있는 Void 타입의 함수이다.

위의 인터페이서에서 살펴볼 수 있듯, useState 는 State와 Dispatch 함수로 이뤄진 배열을 반환하기 때문에 useState를 사용할 때 구조 분해 할당 구문을 통하여 다음과 같이 사용할 수 있다.

1
const [count, addCount] = useState(10);

시간을 카운팅하는 코드를 먼저 Class 컴포넌트 형태로 작성하게 되면 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ClassComponent extends React.Component {
state = {
count: 10
}
componentDidMount() {
// 최초 컴포넌트가 실행될 때, setState를 일으킨다.
setTimeout(() => this.setState({ count: this.state.count - 1}), 1000);
}
componentDidUpdate(prevProps, prevState, snapshot) {
// 이후 state의 변화에 따라 setState를 지속적으로 일으킨다.
setTimeout(() => this.setState({ count: this.state.count - 1}), 1000);
}

render () {
return (
<div>{this.state.count}</div>
)
}
}

Class 컴포넌트는 처음 컴포넌트가 마운트되며 setTimeout 함수를 한번 실행할 것 이다. 이후 State 값이 변경됨에 따라 setTimeout 함수가 재호출되며 render 함수를 통해 DOM이 리랜더링 된다.

하지만 이 코드를 Functional 컴포넌트의 useState를 이용하여 작성하면 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
const FunctionalComponent = () => {
const [count, counting] = useState(10);
setTimeout(() => counting(count-1), 1000);
return (
<div> {count} </div>
)
}

Functional 컴포넌트는 컴포넌트가 랜더링되며 setTimeout 함수를 반복적으로 호출하는 형태로 작성되어진다.

useEffect

useState Hook을 사용 하다보면 상황에 따라 Class 컴포넌트의 componentDidMount, componentDidUpdate, componentWillUnmount 등과 같은 특정 상황에 Lifecycle 안에서 특정 로직을 실행시켜 줘야 하는 경우가 있다. 예를 들어 컴포넌트가 마운트되었을 때 컴포넌트에 필요한 초기 데이터를 API를 호출을 통해 받아와야 한다던지, 혹은 DOM에 EventListener를 등록하거나 언마운트될 때 등록된 EventListener를 해제해줘야하는 상황이 있다. 이러한 경우 useEffect를 이용한다면 Class 컴포넌트에서 제공해주는 생명주기 API들을 Functional 컴포넌트에서도 마찬가지로 사용할 수 있다.

공식 문서를 살펴보면 팁으로 다음과 같이 정보를 제공해준다.

1
2
3
Tip

If you’re familiar with React class lifecycle methods, you can think of useEffect Hook as componentDidMount, componentDidUpdate, and componentWillUnmount combined.

이 말인 즉, Hook의 useEffect를 잘 이해하여 사용한다면 Class 컴포넌트의 componentDidMount, componentDidUpdate, componentWillUnmount 메소드들과 같은 효과를 볼 수 있다는 것이다. 먼저 useEffect 함수의 인터페이스를 살펴보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Accepts a function that contains imperative, possibly effectful code.
*
* @param effect Imperative function that can return a cleanup function
* @param inputs If present, effect will only activate if the values in the list change.
*
* @version experimental
* @see https://reactjs.org/docs/hooks-reference.html#useeffect
*/
function useEffect(effect: EffectCallback, inputs?: InputIdentityList): void;

// InputIdentityList 은 아래와 같이 정의되고 있다.
// type InputIdentityList = ReadonlyArray<any>;

useEffect 함수는 EffectCallback 함수를 인자로 받으며, 두번째 인자로는 state값의 배열을 받는다. 여기에서 우리가 주의깊게 봐야할 것은 첫번째 인자인 EffectCallback 은 Mandatory 값이며, 두번째 인자인 InputIndetiryList는 optional 값이라는 것이다. 만약 InputIdentityList 값이 변경된다면 EffectCallback 함수가 실행될 것이다.

useEffect로 작성된 컴포넌트를 살펴보기 전에 먼저 우리에게 익숙한 Class 컴포넌트로 해당하는 상황을 살펴보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ClassComponent extends React.Component {
state = {
user: null
}
async componentDidMount() {
const user = await fetchUser();
this.setState({ user });
}

render () {
if (!this.state.user) {
return null;
}
return (
<dl>
<dt>유저의 이름</dt>
<dt>{this.state.user.name}</dt>
<dt>유저의 나이</dt>
<dt>{this.state.user.age}</dt>
</dl>
)
}
}

처음 컴포넌트가 마운트될 때 fetchUser 라는 API 함수를 호출한다. 그 후, 받아온 user의 데이터를 setState를 이용하여 state에 저장을 해주는 형태이다. 이러한 코드를 useEffect 를 이용하여 변경하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const FunctionalComponent = () => {
const [user, setUser] = useState(null);
useEffect(() => {
async function fetchData () {
const user = await fetchUser();
setUser(user);
}
fetchData();
}, []); // <- 빈 배열임을 꼭 기억해야 한다.
return user && (
<dl>
<dt>유저의 이름</dt>
<dt>{user.name}</dt>
<dt>유저의 나이</dt>
<dt>{user.age}</dt>
</dl>
)
}

useEffect 함수가 실행되는 시점은 컴포넌트가 처음 마운트되는 시점을 포함하여 컴포넌트가 리랜더링된 이후 매번 실행된다. 하지만 위에서 설명했듯 만약 두번째 인자에 배열 형태의 값을 넣게 되면 useEffect의 콜백함수는 배열의 값에 변화가 있을 경우 실행될 것이다. 위의 예제를 살펴보면 useEffect의 두번째 인자로 빈 배열을 넣어주었는데, 빈 배열을 넣어주게 되면 더이상의 변화가 없기 때문에 EffectCallback 함수는 처음 컴포넌트가 마운트될 때 한번 실행될 것이다. 만약 이것을 감안하지 않고 아래와 같이 인자를 던지지 않으면 DOM이 변경되면서 useEffect 함수가 실행되며 계속 API를 호출하는 상황이 발생할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const FunctionalComponent = () => {
const [user, setUser] = useState(null);
useEffect(() => {
async function fetchData () {
const user = await fetchUser();
setUser(user);
}
fetchData();
}); // <- InputIdentityList를 넣어주지 않으면 계속해서 fetchData 함수가 실행되는 상황이 생긴다.
return user && (
<dl>
<dt>유저의 이름</dt>
<dt>{user.name}</dt>
<dt>유저의 나이</dt>
<dt>{user.age}</dt>
</dl>
)
}

빈 배열이 아닌 아래와 같이 작성한다면 user 데이터가 변경될 때마다 useEffect의 콜백함수가 실행될 것이다.

1
2
3
4
// ...
useEffect(() => {
console.log("User State changed!")
}, [user])

그렇다면 해제 함수를 실행 시켜줘야 할때는 어떻게 해야할까? 그런 경우는 아래와 같이 EffectCallBack 함수에서 해제 함수를 반환해주면 된다.

1
2
3
4
5
useEffect(() => {
return () => {
// 해제 함수.
}
});

이러한 해제 함수는 컴포넌트가 언마운트될 때 뿐만 아니라, 재랜더링될때도 마찬가지로 실행된다. 만약 언마운트될 때만 실행시켜주고자 한다면 위에서와 같이 두번째 인자를 빈 배열로 주입하게 되면 useEffect 의 EffectCallback 함수는 처음 마운트 될 때 한 번, 언마운드 될때 한번 해서 두번만 실행되며 DOM이 업데이트 되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
console.log("Component did mount");
async function fetchData () {
const user = await fetchUser();
setUser(user);
}
fetchData();
return () => {
console.log("Component will unmount.")
}
}, []);

끝으로

이 외에도 useContext나 useReducer 같은 또다른 Hook들도 있으며, 필요에 따라 상황에 맞게 사용하면 되지 않을까 한다. 다만 사용하기 전에 대략적으로나마 동작하는 방식에 대해 이해하고 있어야 문제가 생겼을 때 문제를 해결할 수 있지 않을까라고 생각한다.

현재 이커머스회사에서 frontend 개발자로 업무를 진행하고 있는 Martin 입니다. 글을 읽으시고 궁금한 점은 댓글 혹은 메일(hoons0131@gmail.com)로 연락해주시면 빠른 회신 드리도록 하겠습니다. 이 외에도 네트워킹에 대해서는 언제나 환영입니다.:Martin(https://github.com/martinYounghoonKim
Typescript와 NodeJS를 이용한 간단한 목킹 서버 띄우기 02
Kotlin의 Nothing 타입