웹/React

React Hooks 적응하기 (useState, useRef, useEffect, useMemo, useCallback)

디정 2023. 12. 26. 21:52

제로초님의 React 무료 강의를 계속 시청하며 기초를 다져가는 중이다. 1부 수업인 로또 숫자 당첨기까지 수강했고, 중간 점검 겸 React의 Hooks들을 정리하고 넘어가려고 한다.

 

React는 두 가지 형식으로 컴포넌트 제작 방식을 제공하고 있다. 클래스 컴포넌트와 함수 컴포넌트인데, 원래는 클래스 컴포넌트를 주로 사용하다가 최근 현업에서는 거의 함수 컴포넌트만 사용하고 있다고 한다. React 공식 문서에도 'Class 컴포넌트를 삭제할 예정은 없다'라는 문구가 굳이 실려있는 것을 보면, 개발자들이 Class 컴포넌트의 삭제를 고려할 만큼 함수 컴포넌트가 대세이긴 한 것 같다. 

 

함수 컴포넌트를 사용하면 클래스 컴포넌트보다 라인 수를 줄일 수 있고, this 예약어나 class 형식을 신경쓰지 않는 등 더 단순하다는 인상을 준다. 하지만 함수 컴포넌트가 더 많은 사랑을 받게 된 보편적인 이유는 React가 16버전부터 함수 컴포넌트를 위해 도입한 Hooks 메소드들이 한 역할을 단단히 했다는 느낌이다. 

 

 

1. Class 컴포넌트와 함수 컴포넌트의 눈에 띄는 차별점, Hooks 란?

개인적으로 두 방식을 번갈아 실습해보며 느낀 바로는 두 방식 중 어느 한 방식이 월등히 좋다고 평가할 수 있는 개념은 아닌 것 같다. 다만 나에게는 함수 컴포넌트보다 Class 컴포넌트가 더 손에 빨리 익었고, 더 선호하는 방식이 되었다. 개인적인 생각으로는 복잡한 컴포넌트를 설계할 때는 Class 컴포넌트로, 비교적 소단위 컴포넌트를 작성할 때는 함수 컴포넌트로 구성하면 되지 않을까 싶다. class 컴포넌트는 매 랜더링 시 마다 render 함수의 html 구현부만 재실행되지만, 함수 컴포넌트는 함수 내 모든 요소가 재실행되기 때문이다. 몸체가 길 수록 실행 환경에 부담을 줄 수 있다.

 

<Class 컴포넌트의 장점>

- 생명주기 단계가 함수 별로 명확하게 나뉘어있다. (개별적 관리 용이)

- state가 변경될 때 마다 render함수가 재실행된다. 즉, 함수 컴포넌트처럼 클래스 내의 코드 전체가 재실행되지 않는다.

- 위 두번째 특징으로 인해 변수 및 상태(State)관리가 비교적 용이하다. 

 

여기서 상태 관리란 컴포넌트 내에서 다루어지는 자료들의 관리를 의미한다. 리액트에서 유독 상태 관리가 중요한 이유는 state라는 객체의 자료를 변경해 랜더링과 화면 변화를 제어하기 때문이다. 리액트 작동 원리의 핵심과 밀접하게 연관되어있기에, 리액트 개발자로서는 언제나 신경쓰지 않을 수 없는 문제인 것이다.

 

<함수 컴포넌트의 장점>

- 생명주기 단계를 useEffect 메소드 내에서 전부 구현한다. (통합적 관리 가능)

-  Class 컴포넌트에 비해 라인 수가 보편적으로 줄어든다. 

- 변수 및 상태(State) 관리를 위한 다양한 Hooks를 제공한다.

 

함수 컴포넌트는 매 랜더링 마다 함수 전체가 재실행되기에 마땅히 유지하고 있어야 하는 자료값들의 관리가 불편할 수 밖에 없다. React는 이 State들을 안전하고 쉽게 관리할 수 있게 하기 위해 Hooks 함수들을 만들어 도입했다. 

 

즉, Hooks란 함수 컴포넌트를 사용 시 개발자들이 보다 용이하게 상태(State) 관리를 하도록 보조하는 도구(메소드)들이라고 이해하면 되겠다. 공식 문서에서는 Hooks를 '함수 컴포넌트에서 React state와 생명주기 기능(lifecycle features)을 연동할 수 있게 해주는 함수' 라고 설명한다. 이 생명주기 단계의 연동은 위에 적은 함수 컴포넌트의 장점 첫 번째 항목과 연관되어있다.

 

처음 배울 때는 Hooks가 뭐 그리 중요한 지 감이 잘 안왔지만 개념을 하나씩 알고나니 함수 컴포넌트 애용자들에게는 이 Hooks가 아주 혁신적인 업데이트였을 것 같다.

 

 

2. Hooks 함수의 공통점

Hooks 함수들이 보편적으로 가지고 있는 특징을 먼저 이야기해보자. 모든 Hooks에 적용되는 공통점은 아니지만 눈에 띄는 특징이 두 가지 있다.

 

1) 함수 이름 앞에 'use'라는 글자가 붙는다. 

공식 문서에 따르면 Hooks 메소드는 '기본 Hook', '추가 Hook', 'Library Hook' 세 종류로 나뉜다. 그리고 모든 Hooks 함수들은 앞 글자가 use로 시작된다. 

 

2) Hooks 함수 두 번째 인자로 넘기는 배열 값을 통해 Hook의 실행을 제어한다.

이 특징은 모든 Hooks가 가진 공통점은 아니다. 주로 컴포넌트 생명주기와 보다 직접적으로 연관되어있는 Hooks들이 가진 특징인 것 같다. 배열 안의 값에는 해당 Hooks가 의지할 자료(변수, state, 값으로 치환 가능한 비교연산 등)를 넣어주면 된다. Hooks는 배열의 값을 이전 랜더 시의 값과 비교해 변동 여부에 따라 스스로를 실행할 지 말 지를 결정한다.

 

아직 공부 중인 입장이기에 모든 Hooks에 대해 알고 있지는 못해서, 지식이 늘어날 때 마다 내용을 덧붙여 갈 예정이다.

 

 

3. Hooks 의 종류

1) useState

함수 컴포넌트에서 가장 먼저 배우는 Hooks다. 컴포넌트 내에서 State로 사용할 값과 변경 함수를 리턴한다. 아래 두 가지 방식으로 사용할 수 있다. 

const [state1, setState1] = useState({1}) // (1)... 값을 배정할 경우
const [state2, setState2] = useState(()=>{}) // (2)... 함수를 배정할 경우

 

(1) 처럼 어떤 리터럴이나 객체, 배열 등의 값을 배정했을 경우는 이 값이 그대로 state1의 값으로 들어간다. 그리고 setState1 에는 state1 변수를 변경 할 수 있는 setState 함수의 주소값이 저장된다.

 

(2) 처럼 사용할 경우에는 매개변수로 넘겨준 함수의 리턴값이 state 변수의 초기값이 된다.  값을 변경하는 방법은 (1)과 똑같다.

 

아마 useState에 값을 넘겨주면, 해당 함수 원형 내에서 값과 값을 세팅할 함수를 생성하여 배열 형태로 리턴해주는 것 같다. 만약 초기값이 어떤 무거운 함수의 결과를 사용해야한다면 (2)의 방식을 사용하는게 좋다.

 

그 이유는, 위에서도 설명했듯이 Hooks는 매 랜더링 시 마다 함수의 몸체 전체가 재실행되기 때문이다. 즉 위에서 선언한 state1과 state2는 새로 리랜더링 시 계속 함수 내에 남이있지 않고 그대로 사라졌다가(컴포넌트 소멸), useState의 역할에 의해 값만 그대로 다음 state1, state2에 전달되는 것이다. 

 

2) useRef

useState가 state 변수들을 관리하는 함수라면, useRef는 State는 아니지만 함수 내에서 보관해야 할 자료들을 관리하는 함수다. 

const inputEl = useRef(null);

 

매개변수는 마찬가지로 초기 값을 넣어준다. 위 처럼 세팅하면 사용자가 관리하는 변수는 input 객체의 .current 프로퍼티에 담긴다. 공식 문서에서는 'useRef는 .current 프로퍼티에 변경 가능한 값을 담고 있는 상자와 같다.' 라고 useRef를 설명하고 있다. 

 

useRef는 자잘한 변수 관리에도 유용하지만, 주로 React에서 HTML DOM 객체에 접근해야 할 때 사용된다. 

 

<input ref={inputEl} type = "text" />

 

위 처럼 구성 시 inputEl.current에는 해당 input 태그의 레퍼런스가 담긴다. 

 

useRef를 사용하지 않아도, 어차피 함수 컴포넌트는 매 랜더링 시 마다 재실행되기 때문에, 초기값을 변경하지 않는다면 아래 (1)과 (2) 코드는 결과적으로 별 차이가 없을 것이다.

 

const FunComponent = () => 
{
    const num = useRef([1,2,3]); //...(1)
    const num = [1,2,3]; //...(2)
    
    return <></>;
}

 

하지만 동작 내부를 뜯어보면 useRef를 사용하는 것과 사용하지 않는 것의 차이는 명확하다. useRef는 배열 [1,2,3]을 가진 ref 객체를 외부에 생성 후 그 주소값을 num에 할당한다. 이후 새로 랜더링 될 때마다, num은 이 복사된 주소값만을 계속 계승받게 된다.

 

하지만 (2)는 랜더링 시 마다 새로운 배열이 생성되어 num에 저장된다. 배열의 크기가 크다면 페이지를 실행하는 입장에서는 부담이 될 수 밖에 없다. 

 

 

3) useEffect

처음 접할 때에는 꽤 복잡하게 느껴질 수 있지만 막상 알고 나면 사용이 그리 어렵지 않다. useEffect를 이해하기 위해서는 먼저 React 컴포넌트의 생명주기를 이해해야 한다.

 

<React 컴포넌트 생명 주기>

생명주기란 어떤 프로세스가 만들어지고, 역할을 수행하고, 메모리에서 제거되기까지의 여정을 뜻한다. 

출처 : React 공식 문서

 

위 그림은 React 공식 가이드에서 열람할 수 있는 React 컴포넌트 생명 주기(Life Cycle)이다. 정확히는 Class 컴포넌트를 기반으로 작성된 생명 주기 그림이다. 생명 주기에 대해서는 자세히 파고들면 별도의 포스트를 하나 뽑을 수 있을 정도로 중요한 내용이지만, 대략 이 글에서 필요한 순서만 뽑아보면 아래와 같다.

 

<Mount 단계>

(1) 컴포넌트 생성 단계

메모리에 코드를 저장 → 브라우저의 컴포넌트 호출 → 컴포넌트 생성자(Constructor) 호출 

 

(3) 컴포넌트 실행 단계 (화면에 구현)

render 함수 호출 → HTML DOM 스크립트와 refs 속성 업데이트 → componentDidMount 함수 → 컴포넌트 시각화

 

<Update 단계>

(4) State 업데이트 시 (페이지 리렌더링 : props 변경, state 변경, forceUpdate 함수 실행 등의 이유로 발생)

render 함수 호출 → HTML DOM 스크립트와 refs 속성 업데이트 → componentDidUpdate 함수 → 컴포넌트 시각화

 

<UnMount 단계>

(5) 컴포넌트 소멸 단계 (부모 컴포넌트에서 자식 컴포넌트 삭제, 갑작스런 에러 등으로 발생)

→ componentWillUnmount 함수 → 컴포넌트 소멸 & 생명 주기 종료

 

Mount란 컴포넌트가 처음 메모리에 생성되고, 컴포넌트의 요소들이 DOM 요소와 매칭되어 최종적으로 브라우저에 시각화(빌드)되기까지의 작업이다. 

 

Class 컴포넌트에서는 Mount, Update, UnMount 단계 시 마다 호출되는 함수가 각각 componentDidMount, componentDidUpdate, componentWillUnmount 로 나뉘어있다. 개발자는 첫 랜더링(마운트) 시에만 실행하고 싶은 코드는 compoentDidMount 함수에, 업데이트(리렌더링) 시 마다 실행하고 싶은 코드는 componentDidUpdate에, 컴포넌트 소멸 시 실행하고 싶은 코드는 componentWillUnmount에 저장하면 된다. 

 

useEffect는 함수 컴포넌트에서 위 세 가지 생명 주기 함수를 모두 사용할 수 있게 한 Hook이다. Hooks의 호출문 구조는 아래처럼 세 부위로 나누어진다. 

useEffect(()=>{ 
	// code 작성 ...(1)
    
    return ()=>{ /* code 작성 */ } ...(2)
}, [/* 제어값 */]} ...(3)

 

useEffect는 매개변수로 함수와 배열을 넘긴다. 그 중 배열은 선택적으로 추가할 수 있다. useEffect는 함수 컴포넌트 내의 다른 라인들 처럼 리렌더링 시 마다 재실행되는게 일반적이지만 (3)의 배열에 제어값을 설정함으로서 개발자가 useEffect를 재실행 할 케이스들을 제어할 수 있다.

 

(3)의 배열에 'num' 이라는 변수를 넣을 경우 useEffect는 num의 값이 달라졌을 때 자신을 실행한다. 만약 빈 배열을 넘겨준다면 useEffect가 의존하는 변수가 아무것도 없다는 뜻이므로, 오직 Mount 시에만 딱 한번 작동하게 된다. 이 기능은 Class 컴포넌트의 ComponentDidMount 함수와 거의 동일한 역할을 한다. 

 

반대로 배열을 넘겨주지 않는다면 의존하는 제어값이 없다는 뜻이 되어 매 랜더 시 마다 실행된다. componentDidUpdate 함수와 동일한 역할이다.

 

(2)에 작성하는 코드는 componentWillUnmount 함수의 역할을 맡고 있어, 함수 소멸 시 해당 부위의 라인이 실행된다. 

 

useEffect는 화면에 컴포넌트가 그려지기 직전에 실행되는 함수인 만큼 비동기 함수들과 함께 쓰기에 적합하다. 비동기 함수들이 컴포넌트 내의 각종 값들을 건드리고 난 후 useEffect로 변경된 사항을 화면에 표현해주는 것이다.

 

 

4) useMemo, useCallback

React.memo가 컴포넌트 자체를 최적화시켜주는 함수라면 useMemo와 useCallback은 컴포넌트 내에서 보다 세밀하게 최적화를 도와주는 함수다. 일반적으로 setState는 랜더링 시 마다 실행되어 무조건 state 값을 변경하지만, 위 두 함수는 state값이 변해야 하는 경우와 반대의 경우를 사용자가 직접 제어할 수 있게 해준다. 사용자가 새 값 할당을 허용한 경우가 아니라면 useMemo는 이전 값을 계속 가지고 있게 한다. (메모이제이션 - memoization)

 

useMemo는 리터럴, 배열, 객체 등 value의 형태로 저장되는 자료를 관리하고, useCallback은 함수 타입의 자료를 관리한다는 차이점이 있다. 

 

사용 예시는 아래와 같다.

 

<useMemo>

const value = useMemo(a+b, [a, b]); //... (1)

const value = useMemo(()=>sum(a,b), [a, b]); //... (2)

 

1과 2 모두 value에 값을 할당하고 있다. 두 번째 인자인 배열값은 useMemo의 제어자이다. a와 b의 값이 변경될 경우에만 useMemo는 새로운 값을 받아들여 갱신한다.

 

<useCallback>

const value = useCallback(()=>{ a+b; }, [a, b]);

 

value에 a와 b를 더하는 함수를 할당하고 있다. useMemo와 마찬가지로 두 번째 인자 배열의 값이 변경된 경우에만 새로운 함수를 저장하고, a와 b의 값이 그대로라면 한번 할당한 함수를 계속 소지한다.

 

만약 value에 저장한 함수의 길이가 길다면, 매 렌더링 마다 새로 생성하는 게 무척 부담스러울 수 있다. 그럴 때 함수가 갱신되어야 할 케이스를 제어자 베열로 지정해주면 훨씬 가볍게 동작할 수 있다. 

 

또한, 자식 컴포넌트에게 props로 함수를 전달할 경우에는 무조건 useCallback을 사용하는 것이 좋다고 한다. 부모 컴포넌트가 랜더링될 때 마다 자식에게 다른 주소의 함수를 넘겨주고싶은게 아니라면 말이다.

 

 

이처럼 성능 최적화 시에 애용되는 Hooks들이지만 React 공식 문서에서는 위 두 함수 없이도 프로그램이 정상적으로 작동하도록 코딩하라고 조언하고 있다. (메모이제이션을 전부 잊어버리고 매 렌더링 시 마다 새로 계산하는 방향을 고려 중인 모양이다.)

 

 

 

이렇게 최근 알게 된 Hooks들의 종류와 간단한 개념을 순서대로 정리해봤다. 아직은 Class 컴포넌트가 더 익숙하지만, Hooks를 자유롭게 다룰 수 있게 된다면 이쪽을 더 선호하게 될 지도 모르겠다.

 

 

- END.