[모던 리액트 Deep Dive] 03장 리액트 훅 깊게 살펴보기

H Kim·2024년 2월 25일
0

기술 책 읽기

목록 보기
14/20
post-thumbnail
post-custom-banner

3.1 리액트의 모든 훅 파헤치기

  • 클래스형 컴포넌트에서만 가능했던 state, ref 등 리액트의 핵심적인 기능을 함수에서도 가능하게 만들었고, 무엇보다 클래스형 컴포넌트보다 간결하게 작성할 수 있어 훅이 등장한 이래로 대부분의 리액트 컴포넌트는 함수형으로 작성되고 있을 정도로 많은 사랑을 받고 있다.


3.1.1 useState

  • 함수형 컴포넌트 내부에서 상태를 정의하고, 이 상태를 관리할 수 있게 해주는 훅이다.

  • useState의 인수로는 사용할 state의 초깃값을 넘겨준다. 아무런 값을 넘겨주지 않으면 초깃값은 undefinted다. useState 훅의 반환 값은 배열이며, 배열의 첫 번째 원소로 state 값 자체를 사용할 수 있고, 두 번째 원소인 setState 함수를 사용해 해당 state 값을 변경할 수 있다.

  • usetState는 자바스크립의 특징 중 하나인 클로저에 의존해 구현돼 있을 것이라는 사실을 짐작해 볼 수 있다. 클로저를 사용함으로써 외부에 해당 값을 노출시키지 않고 오직 리액트에서만 쓸 수 있었고, 함수형 컴포넌트가 매번 실행되더라도 useState에서 이전의 값을 정확하게 꺼내 쓸 수 있게 됐다.


  • useState에 변수 대신 함수를 넘기는 것을 게으른 초기화(lazy initialization)라고 한다.

  • 리액트 공식 문서에서 이러한 게으른 초기화는 useState의 초깃값이 복잡하거나 무거운 연산을 포함하고 있을 때 사용하라고 돼 있다. 이 게으른 초기화 함수는 오로지 state가 처음 만들어질 때만 사용된다.

  • 만약 useState 인수로 자바스크립트에 많은 비용을 요구하는 작업이 들어가 있다면 이는 계속해서 실행될 위험이 존재할 것이다. 그러나 우려와는 다르게 useState 내부에 함수를 넣으면 이는 최초 렌더링 이후에는 실행되지 않고 최초의 state 값을 넣을 때만 실행된다.
    만약 Number.parseInt(window.localStorage.getItem(cacheKey))와 같이 한 번 실행되는 데 어느 정도 비용이 드는 값이 있다고 가정해 보자. useState의 인수로 이 값 자체를 사용한다면 초깃값이 필요한 최초 렌더링과, 초깃값이 있어 더 이상 필요 없는 리렌더링 시에도 동일하게 계속 해당 값에 접근해서 낭비가 발생한다. 따라서 이런 경우에는 함수 형태로 인수에 넘겨주는 편이 훨씬 경제적일 것이다. 초깃값이 없다면 함수를 실행해 무거운 연산을 시도할 것이고, 이미 초깃값이 존재한다면 함수 실행을 하지 않고 기존 값을 사용할 것이다.
    그렇다면 게으른 최적화는 언제 쓰는 것이 좋을까? 리액트에서는 무거운 연산이 요구될 때 사용하라고 한다. 즉, localStorage나 sessionStorage에 대한 접근 map, filter, find같은 배열에 대한 접근, 혹은 초깃값 계산을 위해 함수 호출이 필요할 때와 같이 무거운 연산을 포함해 실행 비용이 많이 드는 경우에 게으른 초기화를 사용하는 것이 좋다.



3.1.2 useEffect

  • 알려진 것처럼 생명주기 메서드를 대체하기 위해 만들어진 훅도 아니다. useEffect의 정의를 정화갛게 내리자면 useEffect는 애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 메커니즘이다. 그리고 이 부수 효과가 '언제' 일어나는지보다 어떤 상태값과 함께 실행되는지 살펴보는 것이 중요하다.

  • 클린업 함수는 이전 counter 값, 즉 이전 state를 참조해 실행된다는 것을 알 수 있다. 클린업 함수는 새로운 값과 함께 렌더링된 뒤에 실행되기 때문에 위와 같은 메시지가 나타난다. 여기서 중요한 것은, 클린업 함수는 비록 새로운 값을 기반으로 렌더링 뒤에 실행되지만 이 변경된 값을 읽는 것은 아니라 함수가 정의됐을 당시에 선언됐던 이전 값을 보고 실행된다는 것이다.

  • 함수형 컴포넌트의 useEffect는 그 콜백이 실행될 때마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행한다. 따라서 이벤트를 추가하기 전에 이전에 등록했던 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는 것이다. 이렇게 함으로써 특정 이벤트의 핸들러가 무한히 추가되는 것을 방지할 수 있다.
    이처럼 클린업 함수는 생명주기 메서드의 언마운트 개념과는 조금 차이가 있는 것을 볼 수 있다. 언마운트는 특정 컴포넌트가 DOM에서 사라진다는 것을 의미하는 클래스형 컴포넌트의 용어다. 클린업 함수는 언마운트라기보다는 함수형 컴포넌트가 리렌더링됐을 때 의존성 변화가 있었을 당시 이전의 값을 기준으로 실행되는, 말 그대로 이전 상태를 청소해 주는 개념으로 보는 것이 옳다.

  • 만약 빈 배열을 둔다면 리액트가 이 useEffect는 비교할 의존성이 없다고 판단해 최초 렌더링 직후 실행된 다음부터는 더 이상 실행되지 않는다. 아무런 값도 넘겨주지 않는다면 이때는 의존성을 비교할 필요 없이 렌더링할 때마다 실행이 필요하다고 판단해 렌더링이 발생할 때마다 실행된다.


// 1
function Component() {
	console.log('렌더링됨');
}

// 2
function Component() {
	useEffect(() => {
    	console.log('렌더링됨;);
    });
}
  • 두 코드의 차이점
    • 서버 사이드 렌더링 관점에서 useEffect는 클라이언트 사이드에서 실행되는 것을 보장해 준다. useEffect 내부에서는 window 객체의 접근에 의존하는 코드를 사용해도 된다.
    • useEffect는 컴포넌트 렌더링의 부수 효과, 즉 컴포넌트의 렌더링이 완료된 이후에 실행된다. 반면 직접 실행은 컴포넌트가 렌더링되는 도중에 실행된다. 따라서 위와는 달리 서버 사이드 렌더링의 경우에 서버에서도 실행된다. 그리고 이 작업은 함수형 컴포넌트의 반환을 지연시키는 행위다. 즉, 무거운 작업일 경우 렌더링을 방헤ㅐ하므로 성능에 악영향을 미칠 수 있다.
  • useEffect의 effect는 컴포넌트의 사이드 이펙트, 즉 부수 효과를 의미한다는 것을 명심하자. useEffect는 컴포넌트가 렌더링된 후에 어떠한 부수 효과를 일으키고 싶을 때 사용하는 훅이다.

  • 핵심은 의존성 배열의 이전 값과 현재 값의 얕은 비교다. Object.is를 기반으로 하는 얕은 비교를 수행한다. 이전 의존성 배열과 현재 의존성 배열의 값에 하나라도 변경 사항이 있다면 callback으로 선언한 부수 효과를 실행한다. 이것이 useEffect의 본질이다.


  • useEffect를 사용할 때 주의할 점
    • eslint-disable-line react-hooks/exhausive-deps 주석은 최대한 자제하라
      useEffect는 반드시 의존성 배열로 전달한 값의 변경에 의해 실행돼야 하는 훅이다. 그러나 의존성 배열을 넘기지 않은 채 콜백 함수 내부에서 특정 값을 사용한다는 것은, 이 부수 효과가 실제로 관찰해서 실행돼야 하는 값과는 별개로 작동한다는 것을 의미한다. 즉, 컴포넌트의 state, props와 같은 어떤 값의 변경과 useEffect의 부수 효과가 별개로 작동하게 된다는 것이다. useEffect에서 사용한 콜백 함수의 실행과 내부에서 사용한 값의 실제 변경 사이에 연결 고리가 끊어져 있는 것이다.
    • useEffect의 첫 번째 인수에 함수명을 부여하라
      useEffect의 수가 적거나 복잡성이 낮다면 이러한 익명 함수를 사용해도 큰 문제는 없다. 그러나 useEffect의 코드가 복잡하고 많아질수록 무슨 일을 하는 useEffect 코드인지 파악하기 어려워진다. 함수명을 부여하는 것이 어색해 보일 수 있지만 useEffect의 목적을 명확히 하고 그 책임을 최소한으로 좁힌다는 점에서 굉장히 유용하다.
    • 거대한 useEffect를 만들지 마라
      비록 useEffect가 컴포넌트의 렌더링 이후에 실행되기 때문에 렌더링 작업에는 영향을 적게 미칠 수 있지만 여전히 자바스크립트 실행 성능에 영향을 미친다는 것은 변함없다. 만약 부득이하게 큰 useEffect를 만들어야 한다면 적은 의존성 배열을 사용하는 여러 개의 useEffect로 분리하는 것이 좋다.
    • 불필요한 외부 함수를 만들지 마라
      useEffect 내부에서 state를 결과에 따라 업데이트하는 로직이 있다고 가정해 보자. 만약 useEffect의 인수로 비동기 함수가 사용 가능하다면 비동기 함수의 응답 속도에 따라 결과가 이상하게 나타날 수 있다. 극당적인 예제로 이전 state 기반의 응답이 10초가 걸렸고, 이후 바뀐 state 기반의 응답이 1초 뒤에 왔다면 이전 state 기반으로 결과가 나와버리는 불상사가 생길 수 있다. 이러한 문제를 useEffect의 경쟁 상태(race condition)라고 한다.
      즉, 비동기 useEffect는 state의 경쟁 상태를 야기할 수 있고 cleanup 함수의 실행 순서도 보장할 수 없기 때문에 개발자의 편의를 위해 useEffect에서 비동기 함수를 인수로 받지 않는다고 볼 수 있다.


3.1.3 useMemo

  • 비용이 큰 연산에 대한 결과를 저장(메모제이션)해 두고, 이 저장된 값을 반환하는 훅이다.

  • 첫 번째 인수로는 어떠한 값을 반환하는 생성 함수를, 두 번째 인수로는 해당 함수가 의존하는 값의 배열을 전달한다. useMemo는 렌더링 발생 시 의존성 배열의 값이 변경되지 않았으면 함수를 재실행하지 않고 이전에 기억해 둔 해당 값을 반환하고, 의존성 배열의 값이 변경됐다면 첫 번째 인수의 함수를 실행한 후에 그 값을 반환하고 그 값을 다시 기억해 둘 것이다. 이러한 메모이제이션은 단순히 값뿐만 아니라 컴포넌트도 가능하다.

  • useMemo 등 메모이제이션을 활용하면 무거운 연산을 다시 수행하는 것을 막을 수 있다는 장점이 있다.



3.1.4 useCallback

  • useMemo가 값을 기억했다면, useCallback은 인수로 넘겨받은 콜백 자체를 기억한다. 쉽게 말해 useCallback은 특정 함수를 새로 만들지 않고 다시 재사용한다는 의미다.

  • useMemo와 useCallback의 유일한 차이는 메모이제이션을 하는 대상이 변수냐 함수냐일 뿐이다. 자바스크립트에서는 함수 또한 값으로 표현될 수 있으므로 이러한 코드는 매우 자연스럽다고 볼 수 있다. 다만 useMemo로 useCallback을 구현하는 경우 다음과 같이 불필요하게 코드가 길어지고 혼동을 야기할 수 있으므로 리액트에서 별도로 제공하는 것으로 추측해 볼 수 있다.

const handleClick1 = useCallback(() => {
	setCounter((prev) => prev + 1);
}, []);

const handleClick2 = useMemo(() => {
	return () => setCounter((prev) => prev + 1);
}, []);


3.1.5 useRef

  • useRef는 useState와 동일하게 컴포넌트 내부에서 렌더링이 일어나도 변경 가능한 상태값을 저장한다는 공통점이 있다. 그러나 useState와 구별되는 큰 차이점 두 가지를 가지고 있다.
    • useRef는 반환값인 객체 내부에 있는 current로 값에 접근 또는 변경할 수 있다.
    • useRef는 그 값이 변하더라도 렌더링을 발생시키지 않는다.
  • useRef는 컴포넌트가 렌더링될 때만 생성되며, 컴포넌트 인스턴스가 여러 개라도 각각 별개의 값을 바라본다.


3.1.6 useContext

  • 이러한 props 내려주기를 극복하기 위해 등장한 개념이다. 콘텍스트를 사용하면 이러한 명시적인 props 전달 없이도 선언한 하위 컴포넌트 모두에서 자유롭게 원하는 값을 사용할 수 있다.

  • useContext를 사용하면 상위 컴포넌트 어딘가에서 선언된 <Context.Provider />에서 제공한 값을 사용할 수 있게 된다. 만약 여러 개의 Provider가 있다면 가장 가까운 Provider의 값을 가져오게 된다.

  • useContext를 함수형 컴포넌트 내부에서 사용할 때는 항상 컴포넌트 재활용이 어려워진다는 점을 염두에 둬야 한다. useContext가 선언돼 있으면 Provider에 의존성을 가지고 있는 셈이 되므로 아무데서나 재활용하기에는 어려운 컴포넌트가 된다.

  • 컨텍스트가 미치는 범위는 필요한 환경에서 최대한 좁게 만들어야 한다.

  • 일부 리액트 개발자들이 콘텍스트와 useContext를 상태 관리를 위한 리액트의 API로 오해하고 있다는 것이다. 엄밀히 따지면 콘텍스트는 상태를 주입해 주는 API다. 상태 관리 라이브러리가 되기 위해서는 최소한 다음 두 가지 조건을 만족해야 한다.

    • 어떠한 상태를 기반으로 다른 상태를 만들어 낼 수 있어야 한다.
    • 필요에 따라 이러한 상태 변화를 최적화할 수 있어야 한다.
  • 그러나 콘텍스트는 둘 중 어느 것도 하지 못한다. 단순히 props 값을 하위로 전달해 줄 뿐, useContext를 사용한다고 해서 렌더링이 최적화되지는 않는다.



3.1.7 useReducer

  • useReducer는 useState의 심화 버전으로 볼 수 있다. useState와 비슷한 형태를 띠지만 좀 더 복잡한 상태값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있다.

  • 복잡한 형태의 state를 사전에 정의된 dispatcher로만 수정할 수 있게 만들어 줌으로써 state 값에 대한 접근은 컴포넌트에서만 가능하게 하고, 이를 업데이트하는 방법에 대한 상세 정의는 컴포넌트 밖에다 둔 다음, state의 업데이트를 미리 정의해 둔 dispatcher로만 제한하는 것이다. state 값을 변경하는 시나리오를 제한적으로 두고 이에 대한 변경을 빠르게 확인할 수 있게끔 하는 것이 useReducer의 목적이다.
    일반적으로 단순히 number나 boolean과 같이 간단한 값을 관리하는 것은 useState로 충분하지만 state 하나가 가져야 할 값이 복잡하고 이를 수정하는 경우의 수가 많아진다면 state를 관리하는 것이 어려워진다. 또 여러 개의 state를 관리하는 것보다 때로는 성격이 비슷한 여러 개의 state를 묶어 useReducer로 관리하는 편이 더 효율적일 수도 있다. 이렇게 useReducer를 사용해 state를 관리하면 state를 사용하는 로직과 이를 관리하는 비즈니스 로직을 분리할 수 있어 state를 관리하기가 한결 쉬워진다.

  • useReducer나 useState 둘 다 세부 작동과 쓰임에만 차이가 있을 뿐, 결국 클로저를 활용해 값을 가둬서 state를 관리한다는 사실에는 변함이 없다. 따라서 리액트 개발자는 필요에 맞게 useReducer나 useState를 취사선택해 사용하면 될 것이다.



3.1.8 useImperativeHandle

  • ref는 useRef에서 반환한 객체로, 리댁트 컴포넌트의 props인 ref에 넣어 HTMLElement에 접근하는 용도로 흔히 사용한다. forwardRef는 ref를 전달하는 데 있어서 일관성을 제공하기 위해 탄생했다. 어떤 props 명으로 전달할지 모르고, 이에 대한 완전한 네이밍의 자유가 주어진 props 보다는 forwardRef를 사용하면 좀 더 확실하게 ref를 전달할 것임을 예측할 수 있고, 또 사용하는 쪽에서도 확실히 안정적으로 받아서 사용할 수 있다.

  • useImperativeHandle은 부모에게서 넘겨받은 ref를 원하는 대로 수정할 수 있는 훅이다.



3.1.9 useLayoutEffect

  • '모든 DOM의 변경 후에 useLayoutEffect의 콜박함수 실행이 동기적으로 발생'한다. 여기서 말하는 DOM 변경이란 렌더링이지, 브라우저에 실제로 해당 변경 사항이 반영되는 시점을 의미하는 것은 아니다.

  • 실행순서

    • 리액트가 DOM을 업데이트
    • useLayoutEffect를 실행
    • 브라우저에 변경 사항을 반영
    • useEffect를 실행
  • 순서상으로는 useEffect가 먼저 선언돼 있지만 항상 useLayoutEffect가 useEffect보다는 먼저 실행된다. 이는 useLayoutEffec가 브라우저에 변경 사항이 반영되기 전에 실행되는 반면 useEffect는 브라우저에 변경 사항이 반영된 이후에 실행되기 때문이다.
    그리고 동기적으로 발생한다는 것은 리액트의 useLayoutEffect의 실행이 종료될 때까지 기다린 다음에 화면을 그린다는 것을 의미한다. 즉, 리액트 컴포넌트는 useLayoutEffect가 완료될 때까지 기다리기 때문에 컴포넌트가 잠시 동안 일시 중지되는 것과 같은 일이 발생하게 된다. 따라서 이러한 작동 방식으로 인해 웹 애플리케이션 성능에 문제가 발생할 수 있다.

  • useLayoutEffect의 특징상 DOM은 계산됐지만 이것이 화면에 반영되기 전에 하고 싶은 작업이 있을 때와 같이 반드시 필요할 때만 사용하는 것이 좋다. 특정 요소에 따라 DOM 요소를 기반으로 한 애니메이션, 스크롤 위치를 제어하는 등 화면에 반영되기 전에 하고 싶은 작업에 useLayoutEffect를 사용한다면 useEffect를 사용했을 때보다 훨씬 더 자연스러운 사용자 경험을 제공할 수 있다.



3.1.10 useDebugValue

  • 오직 다른 훅 내부에서만 실행할 수 있음에 주의해야 한다. 만약 컴포넌트 레벨에서 실행한다면 작동하지 않을 것이다. 따라서 공통 훅을 제공하는 라이브리러리나 대규모 웹 애플리케이션에서 디버깅 관련 정보를 제공하고 싶을 때 유용하게 사용할 수 있다.


3.1.11 훅의 규칙

  • 최상위에서만 훅을 호출해야 한다. 반복문이나 조건문, 중첩된 함수 내에서 훅을 실행할 수 없다. 이 규칙을 다라야만 컴포넌트가 렌더링 될 때마다 항상 동일산 순서로 훅이 호출되는 것을 보장할 수 있다.

  • 훅을 호출할 수 있는 것은 리액트 함수형 컴포넌트, 혹은 사용자 정의 훅의 두 가지 경우뿐이다. 일반 자바스크립트 함수에서는 훅을 사용할 수 없다.

  • useState나 useEffect는 모두 순서에 아주 큰 영향을 받는다.

  • 리액트 훅은 파이버 객체의 링크드 리스트의 호출 순서에 따라 저장된다. 그 이유는 각 훅이 파이버 객체 내에서 순서에 의존해 state나 effect의 결과에 대한 값을 저장하고 있기 때문이다. 이렇게 고정된 순서에 의존해 훅과 관련된 정보를 저장함으로써 이전 값에 대한 비교와 실행이 가능해진다.

  • 이렇게 조건이나 다른 이슈로 인해 훅의 순서가 깨지거나 보장되지 않을 경우 리액트 코드는 에러를 발생시킨다.

  • 그러므로 훅은 절대 조건문, 반복문 등에 의해 리액트에서 예측 불가능한 순서로 실행되게 해서는 안 된다. 항상 훅은 실행 순서를 보장받을 수 있는 컴포넌트 최상단에 선언돼 있어야 한다. 조건문이 필요하다면 반드시 훅 내부에서 수행해야 한다.



3.2 사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야 할까?


3.2.1 사용자 정의 훅

  • 서로 다른 컴포넌트 내부에서 같은 로직을 공유하고자 할 때 주로 사용되는 것이 바로 사용자 정의 훅이다.

  • 이 사용자 정의 훅의 규칙 중 하나는 이름이 반드시 use로 시작하는 함수를 만들어야 한다는 것이다. 리액트 훅의 이름은 use로 시작한다는 규칙이 있으며,사용자 정의 훅도 이러한 규칙을 준수함으로써 개발 시 해당 함수가 리액트 훅이라는 것을 바로 인식할 수 있다는 장점도 있다.

  • 사용자 정의 훅은 내부에 useState와 useEffect 등을 가지고 자신만의 원하는 훅을 만드는 기법으로, 내부에서 useState와 같은 리액트 훅을 사용하고 있기 떄문에 당연히 앞서 언급한 리액트 훅의 규칙을 따라야 한다.

  • 이러한 사용자 정의 훅은 리액트 커뮤니티에서 다양하게 찾아볼 수 있었는데, 유명한 저장소는 아래와 같다.



3.2.2 고차 컴포넌트

  • 고차 컴포넌트(HOC, Higher Order Component)는 컴포넌트 자체의 로직을 재사용하기 위한 방법이다.

  • 고차 함수(Higher Order Function)의 일종으로, 자바스크립트의 일급 객체, 함수의 특징을 이용하므로 굳이 리액트가 아니더라도 자바스크립트 환경에서 널리 쓰일 수 있다.

  • 고차 함수의 사전적인 정의를 살펴보면 '함수를 인수로 받거나 결과로 반환하는 함수'라고 정의돼 있다.



3.2.3 사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야 할까?

  • 사용자 정의 훅과 고차 컴포넌트 모두 리액트 코드에서 어떠한 로직을 공통화해 별도로 관리할 수 있다는 특징이 있다. 애플리케이션 전반에 필요한 중복된 로직을 별도로 분리해 컴포넌트의 크기를 줄이고 가독성을 향상시키는 데 도움을 줄 수 있다.

  • 단순히 useEffect, useState와 같이 리액트에서 제공하는 훅으로만 공통 로직을 격리할 수 있다면 사용자 정의 훅을 사용하는 것이 좋다. 사용자 정의 훅은 그 자체로는 렌더링에 영향을 미치지 못하기 때문에 사용이 제한적이므로 반환하는 값을 바탕으로 무엇을 할지는 개발자에게 달려 있다. 따라서 컴포넌트 내부에 미치는 영향을 최소화해 개발자가 훅을 원하는 방향으로만 사용할 수 있다는 장점이 있다.

  • 반면 withLoginComponent는 고차 컴포넌트가 어떤 일을 하는지, 어던 결과물을 반환할지는 고차 컴포넌트를 직접 보거나 실행하기 전까지는 알 수 없다. 대부분의 고차 컴포넌트는 렌더링에 영향을 미치는 로직이 존재하므로 사용자 정의 훅에 비해 예측하기가 어렵다. 따라서 단순히 컴포넌트 전반에 걸쳐 동일한 로직으로 값을 제공하거나 특정한 훅의 작동을 취하게 하고 싶다면 사용자 정의 훅을 사용하는 것이 좋다.

  • 어차피 loggedIn이 false인 경우 렌더링해야 하는 컴포넌트는 동일하지만 사용자 정의 훅만으로는 이를 표현하기 어렵다. 사용자 정의 훅은 해당 컴포넌트가 반환하는 렌더링 결과물에까지 영향을 미치기는 어렵기 때문이다. 그리고 이러한 중복 처리가 해당 사용자 정의 훅을 사용하는 애플리케이션 전반에 걸쳐 나타나게 될 것이므로 사용자 정의 훅보다는 고차 컴포넌트를 사용해 처리하는 것이 좋다.

post-custom-banner

0개의 댓글