Web: React Hooks의 등장 배경과 의의

Heechan
HcleeDev
Published in
14 min readMay 20, 2022
Photo by Suad Kamardeen on Unsplash

iOS를 하다가 React를 처음 배웠을 때 가장 처음 찾았던게 @State 역할을 하는 녀석이었다. 그때 바로 useState에 대해서 알게 되었는데, 그때는 사용했으나 나중에 알고보니 비슷하게도 use~로 시작하는 메서드가 많다는걸 눈치챘다.

이걸 Hooks라고 부르던데, 이번주는 이 Hooks가 무엇이고 왜 등장했는지 간단히 알아보도록 하자.

Class Component와 Functional Component

기존의 React는 Class Component를 기반으로 작업했다.

class App extends Component {
render() {
return <div>안녕!</div>
}
}

정말 단순하게 생긴 컴포넌트다. 이 컴포넌트는 코드 어딘가에서 <App /> 이런 식으로 호출해서 사용될 것이다.

Class Component를 이용해 만들어낸 컴포넌트는 말그대로 하나의 객체처럼 동작할 것이다. this 를 통해 자기 자신을 칭하고, 뭔가 변화가 생기면 render() 메서드를 다시 호출해 리랜더링을 할 것이다.

그런데 객체를 사용할 때면 매번 언급되는 문제점이 있다. 객체를 이용한 프로그래밍 방식에서는 객체가 가지고 있는 상태와 메서드가 적절히 어우러지도록 설계한다. 이런 구조에서 메서드의 결과물은 상태의 영향을 받게 되는데, 장점도 분명히 있겠지만 이로 인해 생기는 불편한 점도 분명히 있다.

예를 들자면, 특정 메모리를 차지하고 있는 객체가, 지니고 있는 상태가 변경되어서 리랜더링되어야 하는 상황이라고 생각해보자. name 이라는 state 값이 원래 ‘iOS’였다가, ‘Web’으로 변경된 상황이다.

하지만 상태가 변경되기 직전에 어떤 비동기 작업이 시작되었다. 이 비동기 작업은 끝나고 나면 name 이라는 state 값을 프린트하게 되어있다.

그러면 시작할 때는 분명 ‘iOS’였을텐데, 비동기 작업이 끝났을 때 콘솔에 찍히는 값은 ‘Web’이 된다.

이렇게 상태에 따라 그 결과 값이 의도치 않게 변한다는 점이 문제가 되었고, 그 외에도 자잘한 문제들이 많다. 그냥 보일러플레이트 코드가 많다는 점이나, props 들을 직관적으로 확인하기 어렵다거나… 이런 자잘한 문제점들도 함께 있었다.

React에는 Function Component도 있다. React 컴포넌트가 함수의 결과값으로 반환되는 방식이다. 바로 아래와 같은 컴포넌트다.

const App = ({ name }) => {
return (<div>{`안녕! ${name}`}</div>)
};

이 Functional Component는 코드 어디선가 <App name={'희찬'} /> 이런 식으로 부를 것이다.

Functional Component는 위에서 말한 Class Component의 단점을 해결해주는 ‘불변성’이라는 특징이 있다.

위의 App에서 받아오는 매개변수인 name 은 그대로 변하지 않는다. 일단 이 함수형 컴포넌트가 다시 랜더링, 즉 다시 호출되기 전까지는 name 은 그대로다. 시작과 끝이 있는 한 흐름의 결과물로 컴포넌트가 만들어지기 때문에 중간에 생길 의도치 않은 변화를 방지할 수 있고, 그 내용을 바꾸기 위해서는 새로운 매개변수 값을 넣어서 다시 호출(리랜더링)해야 한다.

따라서 this가 가지고 있는 값이 어떻게 변할지 모르는 Class Component와 달리, Functional Component는 어떤 값이 Immutable하다는 확신을 가지고 구현할 수 있다. 그러면 개발자가 의도한 대로 동작할 가능성이 높고 Side Effect가 줄어들 것이다. 즉, Functional Component는 함수형 프로그래밍의 장점인 순수성, 불변성을 이용할 수 있기에 Class Component가 가지고 있는 여러 문제를 줄여준다.

위에서 말한 장점으로 인해, 많은 개발자들이 Functional Component를 활용하고 싶어했으나, 기존의 Class Component를 대체하기엔 무리가 있었다. 이는 Class Component가 강력한 몇가지 기능을 제공하고 있기 때문이었다.

첫 번째는 상태를 관리할 수 있고, 리랜더링이 용이하다는 점이었다. Class Component는 statesetState 를 이용해 상태를 관리한다.

class App extends Component {
state = { name: '' }
setName(value) {
this.setState({ name: value });
}
render() {
return <div>{this.state.name}</div>
}
}

위처럼 state 에 우리가 원하는 변수를 담은 객체를 두고, setState 를 이용해 해당 걕체를 계속 변경해준다.

두 번째는 컴포넌트의 Life Cycle에 맞춰 우리가 원하는 명령을 낼 수 있다는 점이었다.

심지어 꽤 다양하다.

class App extends Component {
state = { name: '' }
componentDidMount() {
this.setState({ name: value });
}
render() {
return <div>{this.state.name}</div>
}
}

대표적으로 많이 쓰이는 componentDidMount 를 가져왔다. Mount는 컴포넌트가 처음 랜더링되는 과정을 말하는건데, 이 componentDidMount 에 넣어둔 명령은 컴포넌트가 다 만들어지고 나면 불리게 된다.

아래 이미지에서도 확인할 수 있다.

Functional Component는 랜더링할 때마다 함수가 새롭게 불리는 만큼, 위에서 말한 상태 관리가 쉽지 않다. 그리고 Life Cycle은 어떻게 처리해야 할지 방법조차 묘연한 상황이었다.

따라서 딱히 변화가 필요없는 간단한 컴포넌트는 함수형으로 만들 수 있었지만, 이런 불편함 때문에 Class Component를 주력으로 사용할 수 밖에 없었다.

게임 체인저! React Hooks의 등장

하지만 React 16.8부터 상황이 달라졌다.

바로 Functional Component에서도 상태 관리, Life Cycle 관리를 할 수 있도록 도와주는 Hooks가 등장한 것이다.

Hooks는 컴포넌트가 매번 함수의 호출에 의해 만들어짐에도, 어떤 상태를 유지할 수 있도록 돕는다. 이를 이용하면 Functional Component에서도 Class Component 남부럽지 않은 기능을 구현할 수 있어, Class Component의 단점을 여실히 느끼고 있던 개발자에게 큰 반향을 일으켰다.

Hooks의 등장 이후 Functional Component를 사용하는 경우가 훨씬 많아진 것 같고, 웹을 시작한지 아직 1년도 되지 않은 내 입장에서 Class Component는 거의 레거시 코드에서만 만나는 녀석이 되어버렸다.

Hooks가 해결한 문제를 React 공식 문서에는 아래와 같이 말하고 있다.

  • 재사용하기 어려웠던 상태 로직

기존에는 상태 로직을 담고 있는 코드를 각 컴포넌트에 바로 붙이는게 불가능했다. 그래서 기존에는 추가적인 계층을 또 만들어서 적용해야 했다.

예를 들면 Context를 만들었을 때, 상위 컴포넌트에서 <Context.Provider> 로 하위 컴포넌트들을 Wrap한다. 그리고 하위 컴포넌트에서는 이를 사용하고자 할 때마다 <Context.Consumer> 를 사용해 Provider가 뿌려주는 Context를 참조한다. Context가 하나가 아니라 여러개가 되면 이런 Wrapper가 계속 쌓여서 Wrapper Hell이 탄생할 수 있다.

이렇게 여러 계층을 추가적으로 만들어가면서 구현해야 하는 불편한 점이 있었는데, Hooks는 이걸 한 컴포넌트 안에서 할 수 있도록 도와준다.

출처: https://react-redux.js.org/api/hooks#usestore

이건 공식 지원 Hook은 아니고 Redux에서 커스텀한 Hook의 예시로, 굳이 Consumer로 기존 컴포넌트를 덮어싸줄 필요없이 store = useStore() 한 줄로 간단히 해결할 수 있다.

  • 복잡하고 읽기 어려운 컴포넌트 코드 구조

Life Cycle을 관리할 수 있게 해주는 componentDidMount , componentDidUpdate 같은 메서드는 한 Class Component에 한 개 존재해야 한다.

그렇다보니 한 컴포넌트가 담당하고 있는 것이 많아지고 커질 수록 위 두 메서드에 들어가는 코드의 양이 많아진다.

문제점은 여러가지 상관없는 맥락의 코드들이 모두 한 메서드에 몰아넣어진다는 점이었다. a가 업데이트되었을 때 실행되어야 하는 로직과 b가 업데이트되었을 때 실행되어야 하는 로직이 다를 수도 있는데, componentDidUpdate 라는 한 메서드에 이를 다 집어넣어야 한다. 이렇게 되면 읽기도 어렵고, 테스트하기도 어렵고, 의도치 않은 동작 때문에 버그가 발생할 가능성도 높아진다.

다만 새롭게 나온 Hooks는 아래서 다룬 useEffect 를 이용해 이 문제를 해결할 수 있다.

그러면 간단히 대표적인 Hook인 useState , useEffect 에 대해서만 겉핥기로 알아보도록 하자.

state를 대신 하기 위한 Hook, useState

기존에 이렇게 생긴 Class Component가 있다.

이 코드를 보면 state 의 초기화, this.state.count 로 값을 사용하는 법, 리고 this.setState 를 이용해 상태를 업데이트하는 것까지 확인할 수 있다.

이걸 Hooks를 이용한 Functional Component로 바꾸면 아래와 같다.

뭔가 되게 간단해진 것 같다. 일단 필요없는 보일러 플레이드 코드들이 좀 정리된 모습이다.

여기서 const [count, setCount] = useState(0); 라인을 살펴보자.

useState 는 값을 담고 있는 것을 배열의 첫 번째에, 그 값을 set하기 위한 setter 메서드를 배열의 두 번째에 반환한다. 여기서는 초기 값이 0으로 설정된다.

Class Component에서의 state , setState 와 비슷하게, 여기서도 count 에 직접 접근해서 값을 할당하면 안되고, 반드시 setCount 같은 setter 메서드를 통해 값을 업데이트해야 한다. 그래야 React에서 이 컴포넌트의 상태가 변했으니 리랜더링이 필요하다는 것을 인지하고 리랜더링을 진행할 수 있다.

생애주기를 대신 하기 위한 Hook, useEffect

useEffect 는 Class Component의 componentDidMount, componentDidUpdate, 그리고 componentWillUnmount의 역할을 모두 할 수 있는 Hook이다.

const App = () => {
const [name, setName] = useState('');
useEffect(() => { // (1)
setName('희찬');
return (() => { // (2)
console.log('Cleanup');
}
}, []);
useEffect(() => { // (3)
console.log(name);
}, [name]);
return (
<div>{name}</div>
);
}

사실 두 번째 인자로 [] 를 넣어주지 않고 그냥 만드는 법도 있는데, 일단 Class Component의 Life Cycle 관리 메서드와 확실히 비교하기 위해 위처럼 예시를 만들어보았다.

먼저 (3)을 보자. name 을 출력하는 콜백이 들어가있고, 두 번째 인자로 [name] 이 들어가있다. 여기서 두 번째는 Dependancy Array로, 이 배열 안에 들어있는 값이 변경될 시 콜백이 실행되도록 되어있다.

그러면 여기서는 name 이 변경되면 console.log(name) 이 실행된다고 생각할 수 있겠다. 이는 componentDidUpdate의 기능을 대신하고 있다고 볼 수 있다.

(1)은 Dependancy Array가 [] 이다. 이러면 아예 안불린다는 뜻일까?

그건 아니고 [] 인 경우에는 컴포넌트가 최초로 랜더링 될 때 한 번만 구동된다. componentDidMount를 대체한다고 볼 수 있다. 그래서 컴포넌트가 켜질 때 첫 세팅을 하거나, 서버로부터 데이터를 받아와야 할 때 이용한다.

(1) 속에 있는 (2)를 확인해보자. 여기서도 어떤 콜백을 return하고 있다. 콜백이 실행될 때마다 뭔가 반환되는건데, 이건 어디서 사용되는걸까?

useEffect 에서 return으로 반환하는 콜백은 ‘cleanup’이라고 한다. 해당 콜백은 컴포넌트가 Unmount 될 때 동작한다고 생각하면 된다. componentWillUnmount를 역할을 대신하고 있다고 생각할 수 있다. 위 코드는 컴포넌트가 사라질 때 console.log('Cleanup') 을 실행할 것이다.

만약 useEffect 내에서 Subscribe 작업이나 비동기 작업 등 메모리 누수가 될 수 있는 작업을 실행할 경우에는 반드시 cleanup을 이용해 해당 작업들을 취소해줘야 한다.

useEffect 는 아까 말했던 문제 중 하나인 ‘복잡하고 읽기 어려운 컴포넌트 구조’를 해결하는데 큰 역할을 한다.

useState 와 마찬가지로 useEffect 는 여러 번 사용할 수 있다. componentDidUpdate 하나에 필요한 모든 업데이트 관련 작업을 몰아넣어야 하는 Class Component에 비해 적절히 나누는 것이 가능하다.

...
useEffect(() => {
axios.get('user/list').then(res => setUserList(res.data));
axios.get('user/detail').then(res => setUserDetail(res.data));
}, []);
useEffect(() => {
window.addEventListener('keydown', keydown);
return (() =>
window.removeEventListener('keydown', keydown);
);
}, [])
useEffect(() => {
console.log(userList);
}, [userList]);
...

이 3개의 API를 보자. 첫 번째는 [] 을 달고 있고, 컴포넌트에 필요한 API를 쏘는 코드를 묶어놨다. 컴포넌트가 처음 Mount 되면 API를 쏴서 데이터를 설정하는 역할을 한다.

두 번째 useEffect 는 똑같이 [] 를 달고 있다. 다만 첫 번째와는 다른 역할을 코드를 담고 있다. keydown에 대한 Event Listener를 달고 있고, cleanup에는 그 Event Listener를 없애는 정리 코드도 들어가있다.

세 번째는 userList 라는 상태가 변화했을 때만 실행되는 것으로, console.log(userList) 를 실행한다.

위처럼 useEffect 를 이용하면 componentDidUpdate를 사용할 때보다 코드를 더 깔끔하게 작성할 수 있게 된다.

결론

막상 사용은 계속 하면서 Functional Component와 Hooks에 대해서는 잘 몰랐던 것 같다. 확실히 동작 방식을 잘 알고 개발하니까 도움이 된다.

아마 한동안은 Hook에 대한 포스팅이 많아질 수도 있겠다.

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science