이 글은 김민준(velopert)님의 리액트를 다루는 기술을 참조하였습니다.
DRF Token 연동하다가 안 되는 게 너무 화가 나서 React 속성 공부를 시작했더니 눈이 찢어질 것 같다.
목차
1. What is Hooks?
2. useState
3. useEffect
4. useReducer
5. useMemo
6. useCallback
7. React.memo
8. useRef
9. Costom Hooks
1. What is Hooks?
이전에 Component를 다룰 때, 언급된 내용이긴 하지만 좀 더 자세히 이야기 해보자.
Hooks 기능이 개발된 이유는 리액트 초기 설계 단점에서 비롯한다.
원래 State와 LifeCycle Features를 사용하기 위해서는 클래스형 컴포넌트를 사용해야만 했다.
(함수형 컴포넌트는 한 번 호출되면 메모리상에서 사라져버려 state를 유지한다는 것이 불가능 했기 때문)
문제는 Class형 컴포넌트의 단점이 점차 React 개발 목적을 부정할 정도로 뚜렷해지기 시작했다.
애초에 리액트란 최소한의 컴포넌트 단위로 나누어 대규모 플랫폼을 유지 및 보수가 용이하도록 만들어졌는데,
클래스형 컴포넌트는 목적에 따른 분리가 제대로 되지 않아 컴포넌트간의 중복이 많아지고
이로 인해 규모가 큰 컴포넌트들이 우후죽순 생겨나기 시작했다.
이런 문제를 종식시키기 위해 render props나 HOC(High Order Component, 고차 컴포넌트)를 사용해 컴포넌트를 쪼개어 재사용하고자 하였으나 기존 컴포넌트 위에 라우터를 감싸고 또 다른 컴포넌트를 감싸는 과정에서 래퍼헬(wrapper hell)이 발생했다.
조금 풀어서 설명해보면 자손 컴포넌트는 직접적으로 라우터를 받지 못하다 보니 로직을 쪼개야만 했고, 이게 반복되다보니 많은 레이어로 둘러쌓이게 된 것이다.
이런 문제(컴포넌트 재사용)에도 불구하고 state, lifecyle 기능을 위해 class 컴포넌트를 사용해야만 하는 실정이었다.
🪝Hooks의 시작
결국 문제의 근본을 따지면 결국 state와 lifecycle 기능이 클래스 컴포넌트에만 국한되어 있기 때문이었다.
그래서 함수형 컴포넌트에서도 다른 방식으로나마 비슷한 기능을 구현할 수 있도록 Hooks 기능을 도입했다.
(일각에서는 Hooks와 side-effect로 인해 리액트가 본질을 잃었다고도 평가하는데 난 거기까진 모르겠다.)
결론은 덕분에 가독성이 훨씬 좋아졌다는 것이다.
2. useState
이미 이전에 언급한 내용이므로 useState는 간략하게 되짚고 넘어가자.
props가 외부자를 위한 데이터라면, state는 내부 컴포넌트를 위한 데이터다.
"왜 굳이 변수가 아니라 state냐?"라고 한다면 리액트는 변수의 값을 직접적으로 바꾸는 것은 변경 사항으로 인식하지 않는다.
그로 인해, 사용자가 직접 새로고침을 하기 전까지는 변경사항이 적용되지 않는 (잘못하면 변경사항이 렌더링 되는 게 아니라 값이 초기화 되어서 원하는 결과가 아예 화면에 출력되지 않을 수도..?) 불상사가 벌어진다.
따라서 useState함수가 리턴하는 setter 함수를 이용해서 Virtual DOM이 변경 사항을 인지하게 만들어 렌더링을 유도한다.
useState는 여러개 쓴다고 문제가 되지 않는다.
import { useState } from 'react';
import './App.css';
function App() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const onChangeName = (e) => {setName(e.target.value)}
const onChangeAge = (e) => {setAge(e.target.value)}
return (
<>
<div>
<input value={name} onChange={onChangeName} />
<input value={age} onChange={onChangeAge} />
</div>
<div>
<b>이름: </b> {name} <br/>
<b>나이: </b> {age}
</div>
</>
);
}
export default App;
리액트의 조화과정은 새로고침과는 다르다.
실제로 새로고침을 하면 별다른 장치를 마련해주지 않으면 state는 모두 날아간다.
3. useEffect
리액트 컴포넌트가 렌더링될 때마다 특정한 작업(side-effect)을 수행하도록 한다.
side effect는 컴포넌트가 렌더링된 이후 비동기로 처리되어야 하는 부수적인 효과를 뜻하는데,
이 기능 덕분에 함수형 컴포넌트에서도 생명주기 메서드를 사용할 수 있게 되었다.
Mount(마운트)란?
DOM 객체가 생성되고 브라우저에 나타나는 것을 말한다.
클래스 컴포넌트의 경우 constructor → getDerivedStateFromProps → render → componentDidMount 순으로 함수가 실행된다.
반대로 컴포넌트가 사라지는 것을 언마운트라고 한다.
useEffect 훅은 클래스형 컴포넌트의 componentDidMount(컴포넌트를 만들고, 첫 렌더링을 다 마친 후 실행)와 componentDidUpdate(리렌더링을 완료할 후 실행. render()가 업데이트될 때마다 실행)을 합친 형태로 보아도 무방하다.
useEffect(function, deps)
deps는 배열 형태로써 function을 실행시킬 조건을 넣어주면 된다.
import { useState, useEffect } from 'react';
import './App.css';
function App() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
useEffect(()=>{
console.log("렌더링이 완료되었습니다!");
console.log({name, age});
})
(...)
}
export default App;
useEffect 효과를 넣고 리액트를 실행해보자.
따로 조건을 걸지 않았더니 업데이트가 실행될 때마다 useEffect가 작동한다.
맨 처음 렌더링될 때만 실행하도록 조건을 걸기 위해서는 함수의 두 번째 파라미터에 비어있는 배열을 던져주면 된다.
# 마운팅될 때만 실행하고 싶다면
useEffect(()=>{
console.log("마운팅될 때만 실행됩니다.");
}, []);
이렇게 하면 처음 렌더링될 때만 console에 출력이 된다.
# 특정 값이 업데이트될 때만 실행하고 싶은 때
useEffect(()=>{
console.log(name);
}, [name]);
두 번째 파라미터로 name을 던져주면 useEffect 실행 조건을 걸 수 있다.
이 경우엔 name의 값이 업데이트되면 실행된다.
배열 안에는 state도 괜찮고 props로 전달된 값을 넣어주어도 된다.
지금까지는 useEffect가 렌더링되고 난 직후에 실행되는 경우만 확인했는데,
컴포넌트가 언마운트 되기 전이나 업데이트 되기 전에 작업을 수행하고 싶다면 뒷정리(cleanup) 함수를 반환한다.
# 뒷정리하기
useEffect(()=>{
console.log('effect');
console.log(name)
return ()=>{
console.log('cleanup');
console.log(name);
}
}, [name]);
글자를 입력하므로써 새롭게 렌더링이 되면 cleanup이 출력된다.
이걸 이용해서 visible 기능을 구현해보면 다음과 같다.
(...)
function App() {
const [visible, setVisible] = useState(false);
return (
<div>
<button onClick={()=>{setVisible(!visible)}}>
{visible ? '숨기기' : '보이기'}
</button>
<hr/>
{visible && <View />}
</div>
);
}
View는 아까까지 위에서 NameChange하던 것들을 빼놓은 것.
이번에도 숨기기/보이기 버튼을 누르고 이름을 입력해보면 렌더링이 될 때마다 뒷정리 함수가 실행되는 것을 알 수 있는데,
오직 언마운트될 때만 뒷정리 함수가 호출되게 하기 위해서는 2번째 인자로 빈 배열을 넘겨주면 된다.
4. useReducer
이후 리덕스에 대한 개념을 배우게 될 텐데, 그 전에 간단하게 뭐 하는 놈인지만 알고 가자.
useReducer은 useState처럼 상태를 업데이트를 해주는 Hooks다. 그렇다면 차이는 뭘까?
useState는 설정하고 싶은 다음 상태를 setter함수로 직접 지정해주는 방식으로 업데이트를 진행하는 반면,
useReducer은 'action'이라는 객체에 업데이트를 필요한 정보를 담아 업데이트를 한다.
useReducer은 특이한 기능이 하나 있는데 state를 업데이트 해주는 로직은 컴포넌트에서 분리시켜
다른 함수로 정의하고, 심지어 다른 파일에서 작성 후 불러올 수도 있다. (가독성 증가 및 재활용 가능)
function reducer(state, action) { // reducer : 상태 업데이트 함수
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1
default:
return state; // 불변성을 지켜주어야 한다
}
}
reducer 함수는 현재 state와 action을 파라미터로 받아 새로운 상태를 반환해주는데, 이때 불변성이 지켜져야 한다.
참고로 useReducer에서 사용하는 action 객체는 반드시 type을 지니고 있을 필요는 없다.
const [number, dispatch] = useReducer(reducer, 0);
useReducer는 첫 번째 파라미터에 reducer 함수가 들어가고 두 번째에 기본 값(숫자, 문자열, 객체..)이 들어간다.
dispatch는 action을 발생시키는 함수다. '보내다'라는 의미를 가진 영어임을 생각하면 연관짓기 쉽다.
import { useState, useEffect, useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1
default:
return state;
}
}
function Count() {
const [number, dispatch] = useReducer(reducer, 0);
return (
<div>
<h1>현재 숫자 : {number}</h1>
<button onClick={() => dispatch({type: 'INCREMENT'})}>+1</button>
<button onClick={() => dispatch({type: 'DECREMENT'})}>-1</button>
</div>
)
}
function App() {
const [visible, setVisible] = useState(false);
return (
<div>
<button onClick={()=>{setVisible(!visible)}}>
{visible ? '숨기기' : '보이기'}
</button>
<hr/>
{visible && <Count />}
</div>
);
}
export default App;
정상적으로 state(여기선 number)을 관리할 수 있음을 확인할 수 있다.
이번에는 useState에서 input을 관리했던 것처럼 useReducer로 구현해보자.
function reducer(state, action) {
return {
...state,
[action.name]: action.value
}
}
const Contents = () => {
const [state, dispatch] = useReducer(reducer, {
name: '',
emial: ''
})
const { name, email } = state;
const onChange = (e) => {dispatch(e.target);}
return (
<div>
<div>
<input name="name" value={name} onChange={onChange} />
<input name="email" value={email} onChange={onChange} />
</div>
<b>이름: </b> {name} <br/>
<b>이메일: </b> {email} <br/>
</div>
)
}
export default Contents
이후 App.js에서 <Contents/>를 불러오게끔 만들면 input 태그가 관리되는 것을 알 수 있다.
useReducer에서 액션은 어떤 값을 던져도 받을 수 있기 때문에 이벤트 객체를 통채로 던져도 됨을 확인할 수 있다.
💡 useState VS useReducer ?
사실 둘을 비교하는 행위가 모순이다. 어떨 때는 useState가, 어떨 때는 useReducer가 더 편할 수도 불편할 수도 있다.
다만, 재활용도가 높고 코드가 복잡한 경우에는 useReducer을 사용하는 것이 더 나을 것 같다.
5. useMemo
useMemo, useCallback, React.memo 이 셋은 최적화를 위한 함수라고 보면 된다.
최적화라는 건 생각보다 단순하지 않다. 오히려 최적화 함수를 사용했다가 코드만 길어지고 효과는 없을 수도 있다.
내가 이 훅을 사용하려는 서비스의 데이터 처리양에 대해 고민해보고 적절히 사용하도록 하자.
이전에 Virtual DOM과 Fiber의 알고리즘 원리에 대해 간략하게 설명하면서 잠깐 지나가듯이 언급했던 내용인데,
필요없는 변화 사항까지 렌더링하지 않겠다는 의미로 사용한다.
예를 들어 이전까지 input에 값을 입력하면 문자 하나 입력할 때마다 렌더링을 했는데 이건 의미가 없는 짓이다.
그래서 useMemo Hooks를 사용해 특정 값이 바뀔 때만 렌더링하고, 그 외에는 이전 연산 결과를 다시 사용하는 방식이다.
import { useState, useMemo } from 'react';
const getAverage = (numbers) => {
console.log("평균 계산...");
if (numbers.length === 0) return 0;
const sum = numbers.reduce((a, b)=>a+b);
return sum / numbers.length;
}
function App() {
const [list, setList] = useState([]);
const [number, setNumber] = useState('');
const onChange = (e) => { setNumber(e.target.value); }
const onInsert = () => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
}
const avg = useMemo(() => getAverage(list), [list]);
return (
<div>
<input value={number} onChange={onChange}/>
<button onClick={onInsert}>등록</button>
<ul>
{list.map((value, index) => {
<li key={index}>{value}</li>
})}
</ul>
<b>평균값 : </b> {avg}
</div>
);
}
export default App;
useMemo의 첫 번째 파라미터에는 함수를 넣고, 두 번째는 useEffect와 같은 deps 배열을 넣어준다.
이렇게 하면 list가 바뀌기 전까지는 렌더링이 일어나지 않게 된다.
6. useCallback
이 함수도 최적화의 기능인데, 이번엔 만들어 놓은 함수를 재사용하는 방법이다.
useMemo는 원하는 렌더링을 할 항목을 직접 지정하지만, useCallback은 함수를 위한 Hook이라고 보면 된다.
예를 들어 위에서도 onChange와 onInsert라는 함수를 선언했는데,
리스트의 값이 변경되어 렌더링이 될 때마다 함수가 새롭게 만들어지게 된다.
최적화란 말 그대로 최적화다. 안 써도 그만이지만 컴포넌트 렌더링이 자주 일어나거나 개수가 많은 부분을 최적화 해주어야 대규모 플랫폼의 경우엔 속도가 느려지지 않는다.
물론 함수 정도 새로 선언한다고 해서 CPU나 메모리에서 리소스를 많이 차지하진 않기 때문에 엄~청 중요한 작업은 아니지만 알고는 있자. (언제 쓸지 모르니까)
방법은 정말 간단하다. 만들어 놓은 함수를 useCallback Hook으로 감싸주고, 두 번째 파라미터로 배열을 넣으면 된다.
import { useState, useMemo, useCallback } from 'react';
const onChange = useCallback((e) => { setNumber(e.target.value); }, []);
const onInsert = useCallback(() => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
}, [number, list]); // number, list가 바뀐 경우엔 함수 다시 생성
7. React.memo
React.memo 함수를 이용하면 컴포넌트에서 리렌더링이 불필요하면 이전 렌더링 결과를 재사용할 수 있다.
컴포넌트를 내보내는 export default에서 컴포넌트를 Reat.memo()로 감싸주면 props가 바뀌었을 때만 리 렌더링 해준다.
다만, 컴포넌트 전체를 재사용하겠다는 발상은 아무래도 내부 로직에 수정이 불가피하다는 것을 감으로 알 수 있다.
8. useRef
컴포넌트 안의 변수를 선언하기 위해 사용하는 훅이다. 어라, let으로 대충 변수를 지정하면 되지 않아요?
state에서도 언급했지만 let, const로 선언한 변수는 리렌더링 될 때 값이 초기화된다.
그렇다고 useState로 관리하려고 보니 useState는 또 상태를 바꾸면 컴포넌트가 리렌더링된다.
이렇게 값의 변경이 일어나도 굳이 리렌더링을 하지 않고, 계속 그 값을 관리해야 하는 경우가 존재한다.
useRef 훅은 해당 값의 주소를 직접적으로 참조하여 리액트가 값의 변화를 인지하지 못 하게 만든다.
즉, 관리하는 값은 바뀌어도 컴포넌트가 렌더링되지 않는다는 것을 기억하면 된다.
import { useRef } from 'react'
const nextId = useRef(5);
const onCreate = () -> {
nextId.current += 1;
}
이걸 대체 언제 쓸까? 싶지만 id값을 기억하거나, 외부 라이브러리를 통해 생성된 인스턴스를 담고,
또 Scroll의 위치를 알고 있어야 할 때 사용할 수가 있다.
✒️ 조금만 더 들어가볼까?
JavaScript에서 특정 DOM을 선택하고자 할 때 getElementById()나 querySelector()함수를 사용했었다.
React에서는 그 역할을 바로 useRef가 수행할 수 있다.
스크롤 바 위치를 가져오거나, 포커스를 설정하는 등.
예를 들어, 위에서 input 태그에 입력이 끝나도 포커싱이 input 바에 맞춰지도록 적용해보자.
3줄만 추가하면 된다.
import { useState, useMemo, useCallback, useRef } from 'react';
function App() {
const inputEl = useRef('');
(...)
const onChange = useCallback((e) => { setNumber(e.target.value); }, []);
const onInsert = useCallback(() => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
inputEl.current.focus();
}, [number, list]); // number, list가 바뀐 경우엔 함수 다시 생성
const avg = useMemo(() => getAverage(list), [list]);
return (
<div>
<input value={number} onChange={onChange} ref={inputEl}/>
<button onClick={onInsert}>등록</button>
<ul>
{list.map((value, index) => {
<li key={index}>{value}</li>
})}
</ul>
<b>평균값 : </b> {avg}
</div>
);
}
useRef를 불러오고 inputEl은 초기값으로 아무 값이나 던져준다.
그리고 onInsert 함수가 실행되면 inputEl이 현재 참조하고 있는 DOM에 포커싱 함수를 실행하는데
참조하고 있는 DOM을 가리키기 위해선 input바에 "ref={inputEl}"을 추가함으로써 useRef가 자신을 가리키도록 만든다.
useRef는 DOM이나 로컬 변수 모두 렌더링을 발생시키지 않고 관리할 수 있다.
9. Costom Hooks
이 세상에 존재하지 않는 나만의 Hook을 구현해서 만들 수도 있다.
라고 하면 뭔가 어려울 것 같지만 그냥 Hook을 이용해서 내가 원하는 함수를 만들면 된다.
예를 들어, input 태그를 관리하는 함수는 (지금은 아니지만) 굉장히 많이 사용된다.
즉, 반복적으로 사용되는 코드이기 때문에 이런 경우엔 따로 함수를 분리시키는 것이 클린 코드 설계 원칙에 부합한다.
위에서 사용하던 코드에서 반복되는(앞으로 반복될 수 있는) 요소라고 한다면 reducer Hook 파트에서 사용한 이름과 나이를 입력할 때 썼던 View 컴포넌트를 다시 들고 와보자.
function reducer(state, action) {
return {
...state,
[action.name]: action.value
};
}
function View() {
const [state, dispatch] = useReducer(reducer, {name: '', age: ''});
const { name, age } = state;
const onChange = (e) => { dispatch(e.target); };
return (
<>
<div>
<input name="name" value={name} onChange={onChange} />
<input name="age" value={age} onChange={onChange} />
</div>
<div>
<b>이름: </b> {name} <br/>
<b>나이: </b> {age}
</div>
</>
);
}
기존의 View Component는 이렇게 생겼었는데 아무래도 reducer을 불러와서 onChange로 값을 변경하는 것은
반복되는 작업일 것 같으니 useInput이라는 커스텀 훅을 만들어보자.
src 디렉토리에 Hooks 디렉토리를 만들고 useInput.js 파일을 만들어 App.js에서 import 해주자.
import { useReducer } from 'react'
function reducer(state, action) {
return {
...state,
[action.name]: action.value
}
}
export default function useInput(initialForm) {
const [state, dispatch] = useReducer(reducer, initialForm);
const onChange = (e) => {
dispatch(e.target);
};
return [state, onChange];
}
import useInput from './Hooks/useInput';
function View() {
const [state, onChange] = useInput({
name: '',
age: ''
})
const { name, age } = state;
return (
<>
<div>
<input name="name" value={name} onChange={onChange} />
<input name="age" value={age} onChange={onChange} />
</div>
<div>
<b>이름: </b> {name} <br/>
<b>나이: </b> {age}
</div>
</>
);
}
지금은 단순히 App.js의 코드가 조금 더 짧아졌다는 시각적 효과밖에 얻지 못 했지만,
Custom 훅을 잘 사용한다면 반복되는 작업을 분리해냄으로써 효율성과 가독성 모두 챙길 수 있다.