본문 바로가기

FrontEnd/React

useEffect와 useLayoutEffect훅 이해하기

컴포넌트 생명 주기란?

 리액트 프레임워크에서 컴포넌트를 생성하고 렌더링하다 특정 시점에 소멸하는 과정을 컴포넌트의 생명주기(lifecycle)이라고 한다.  컴포넌트의 생명주기는 클래스 컴포넌트가 더 직관적이므로 클래스 컴포넌트로 생명주기에 관해 알아보자.

 

클래스 컴포넌트에서 상태 구현하기

 클래스 컴포넌트 코드를 추가하자.  다만 클래스 컴포넌트에서 사용하는 상태는 state라는 이름의 멤버 속성으로 구현해야 하는 제약조건이 있다.

import {Component} from 'react'
import {Title} from '../components'

export default class ClassLifecycle extends Component {
  // 클래스 컴포넌트에서는 상태를 state라는 이름의 멤버속성으로 구현해야 하는 제약조건이 있다.
  state = {  
    today: new Date()
  }
  render() {
    const {today} = this.state  // 비구조화 할당
    return (
      <section className="mt-4">
        <Title>ClassLifecycle</Title>
        <div className="mt-4 flex flex-col item-center">
          <p className="font-mono text-3xl">{today.toLocaleDateString()}</p>
          <p className="font-mono text-3xl">{today.toLocaleTimeString()}</p>
        </div>
      </section>
    )
  }
}

 

 컴포넌트 마운트

  리액트 컴포넌트는 가상 DOM객체 형태로 생성되어 어떤 시점에 물리 DOM 트리의 멤버 객체가 된 후 처음 렌더링이 일어나는데 이 시점을 컴포넌트가 마운트(mount) 되었다고 표현한다.

 

  리액트 클래스 컴포넌트에 componentdidMount() 메서드를 선언하면 마운트 되는 시점에 해당 메서드를 호출한다. 
 콜백_함수의 경우 모든 클래스 컴포넌트의 부모 클래스인 Component 클래스가 제공하는 setState() 메서드가 필요하다.

export default class ClassLifecyle extends Component {
    componentdidMount() {
        const duration = 1000
        const intervalId = setInterval(콜백_함수, duration)
    }
    ... 생략 ...
}

 

 setState() 메서드

  Component 클래스는 setState() 메서드를 제공한다.  setState()는 클래스가 state라는 이름의 멤버 속성을 가지고 있다는 가정으로 설계된 메서드이다.  그러므로 클래스 컴포넌트의 상태정보는 state라는 멤버 속성에 담겨 있어야 한다.

// setState() 정의
setState<K extends keyof S>(
    state: ((prevState: Readonly<S>, props: Readonly<P>) =>
               (Pick<S,K> | S | null)) | (Pick<K,S> | S | null),
               callback?: void() => void):void;
               
// setState() 호출 예
const intervalId = setinterval(콜백_함수, 시간_간격)
// this.setState({intervalId : intervalId})과 동일하다.
this.setState({intervalId})

 

 클래스 컴포넌트의 언마운트

  리액트에서는 컴포넌트가 물리 DOM 객체로 있다가 소멸하는 것을 언마운트(unmount) 되었다고 한다.  클래스 컴포넌트는 componentWillUnmount() 메서드를 선언 시 언마운트가 일어나기 직전에 메서드를 호출한다.

 

  즉 언마운트 시점에 메모리 누수가 발생할 수 있는 부분이 있다면 처리해 줄 수 있다.  이를 통해 마운트와 언마운트 시 처리하는 코드를 추가하자.

import {Component} from 'react'
import {Title} from '../components'

export default class ClassLifecycle extends Component {
  state = {
    today: new Date(),
    intervalId: null as unknown as NodeJS.Timer // 타입스크립트가 요구하는 구현방식
  }

  componentDidMount(): void {
    const duration = 1000
    const intervalId = setInterval(() => this.setState({today: new Date()}), duration)
    this.setState({intervalId})  
  }
  componentWillUnmount(): void {
    clearInterval(this.state?.intervalId)
  }
  render() {
    const {today} = this.state // 비구조화 할당
    return (
      <section className="mt-4">
        <Title>ClassLifecycle</Title>
        <div className="mt-4 flex flex-col item-center">
          <p className="font-mono text-3xl">{today.toLocaleDateString()}</p>
          <p className="font-mono text-3xl">{today.toLocaleTimeString()}</p>
        </div>
      </section>
    )
  }
}

 

실행결과로 this.setState를 통해 컴포넌트가 랜더링되는 것을 확인할 수 있다.

 

 

useLayoutEffect와 useeffect 훅 알아보기

 react 패키지는 useEffect와 useLayoutEffect 훅을 제공하며 두 훅의 사용법은 같으며 콜백 함수는 훅이 실행될 때 처음 한 번은 반드시 실행된다.  이런 특징으로 의존성 목록이 빈 배열[]일지라도 한 번은 콜백함수를 호출하는 것이다.

// useEffect와 useLayoutEffect 훅 임포트
import {useEffet, useLayoutEffect} from 'react'

// useEffect와 useLayoutEffect 훅 사용법
useEffect(콜백_함수, 의존성_목록)
useLayoutEffect(콜백_함수, 의존성_목록)
콜백_함수 = () => {}

 

  두 훅의 콜백 함수는 함수를 반환할 수도 있는데, 이때 반환 함수는 컴포넌트가 언마운트 될 때 한 번만 호출된다.

콜백_함수 = () => {
    return 반환_함수  // 언마운트될 때 한 번만 호출
}

 

 useLayoutEffect와 useEffect 훅의 차이점

  리액트 프레임워크는 useLayoutEffect 훅은 동기(synchronous)로 실행하고, useEffect 훅은 비동기(asynchronous)로 실행된다.  즉 useLayoutEffect는 콜백 함수가 끝날 때까지 다른동작을 할 수 없게 프레임워크가 기다린다는 의미이다.

 

  반면 useEffect는 콜백 함수의 종료를 기다리지 않고 다른 동작들도 진행된다.  리액트 공식문서에서는 useEffect를 권장하고 useEffect로 구현이 안될 때 useLayoutEffect 훅을 사용하라고 권장한다.

 

 useInterval 커스텀 훅 고찰해 보기

  useInterval은 다음과 같은 형태로 사용되므로 의존성 목록에 [callback, duration] 이 포함되어 있으며 빈배열이든 의존성 목록에 의존성이 주입된 경우든 한번만 실행되는 것의 차이는 없다.  그러므로 setInterval() 함수는 한 번만 실행된다.

// useInterval 커스텀 훅
import {useEffect} from 'react'

export const useInterval = (callback: () => void, duration: number = 1000) => {
    useEffect(() => {
        const id = setInterval(callback, duration)
        return () => clearInterval(id)  // 컴포넌트가 소멸 전 콜백
    }, [callback, duration])
}

// 실제 사용부
...(생략)...
const [today, setToday] = useState<Date>(new Date())
useInterval(() => setToday(new Date())
...(생략)...

 

 useEventListener 커스텀 훅 만들기

  리액트 개발은 가끔 DOM이나 window객체에 이벤트 처리기를 부착해야 할 때가 있다.  HTMLElement와 같은 DOM type들은 EventTarget 타입의 상속 타입으로 addEventListener()와 removeeventListener()라는 메서드를 제공한다.

 

  setInterval함수의 경우에도 clearInterval을 통해 메모리 누수를 방지했듯이 addEventListener() 메서드를 호출시하면 반드시 removeEventListener() 메서드를 호출해 이벤트를 제거해야 메모리 누수가 발생하지 않는다.

 

  해당 문제를 손 쉽게 제거하기 위해 useEventListener라는 커스텀 훅 코드를 추가한다.  해당 커스텀 훅은 target과 callback이 존재할 경우에만 addEventListener를 호출하는 방식으로 구현한다.  다만 target이나 callback의 초기값이 null이였지만 어떤 값으로 바뀔 때를 대비 해 useEffect의 의존성 목록에 담아두었다.

import {useEffect} from 'react'

export const useEventlistener = (
  target: EventTarget | null,
  type: string,
  callback: EventListenerOrEventListenerObject | null
) => {
  useEffect(() => {
    if (target && callback) {
      target.addEventListener(type, callback)
      return () => target.removeEventListener(type, callback)
    }
  }, [target, type, callback])
}

 

  useWindowReseize 커스텀 훅 만들기

  데스크톱의 웹 브라우저의 크기는 사용자가 바꿀 수 있다.  예를들어 반응형 웹일 경우 브라우저 크기가 변경됨에 따라 HTML요소들의 크기와 위치를 변하게 해야하기 때문에 이런 기능을 해줄 커스텀 훅 코드를 추가한다.

 

  window객체의 타입 Window는 EventTarget을 상속하므로 useEventListener의 target 매개변수를 사용할 수 있다.  웹 페이지 크기는 window 객체의 innerWidth, innerHeight 속성값을 알 수 있고 이벤트 타입을 resize로 하면 웹 페이지 크기가 변경을 탐지할 수 있다.

 

  useState 훅으로 창의 크기를 상태로 만들고, 컴포넌트가 마운트 될 때 useEffect를 통해 창의 크기를 상태에 저장한다.  그 후 resize 이벤트 처리기를 설치해 window 크기가 변경될 때 바뀐 값으로 상태를 변경한다.

import {useState, useEffect} from 'react'
import {useEventlistener} from './useEventListener'

export const useWindowResize = () => {
  const [widthHeight, setWidthHeight] = useState<number[]>([0, 0])

  useEffect(() => {
    setWidthHeight(notUsed => [window.innerWidth, window.innerHeight])
  }, []) // 컴포넌트가 마운트될 때 창 크기 설정

  useEventlistener(window, 'resize', () => {
    setWidthHeight(notUsed => [window.innerWidth, window.innerHeight])
  })

  return widthHeight
}

 

  앞서만든 커스텀 훅들을 통해 창 크기를 실시간으로 표시하는 코드를 추가한다.

import {Title, Subtitle} from '../components'
import {useWindowResize} from '../hook'

export default function WindowResizeTest() {
  const [width, height] = useWindowResize()
  return (
    <section className="mt-4">
      <Title>WindowResizeTest</Title>
      <Subtitle className="mt-4">
        width: {width}, Height:{height}
      </Subtitle>
    </section>
  )
}

 

실행결과 : 브라우저 창 사이즈 변경시 width, height사이즈가 변경된다.

 

 

 fetch() 함수와 Promise 클래스 고찰해 보기

  fetch() 함수와 Promise 클래스는 자바스크립트 엔진에서 기본으로 제공하는 API이다.  fetch()는 HTTP 프로토콜의 GET, POST, PUT, DELETE 같은 HTTP 메서드를 사용할 수 있다.  

 

  fetch() API의 타입 정의는 blob(), json(), text()와 같은 메서드가 있는 Response 타입 객체를 Promise방식으로 반환한다.

function fetch(input: RequestInfo, init?: RequestInit): Promise<Response>

interface Response {
    blob() : Promise<Blob>;
    json() : Promise<any>;
    text() : Promise<string>;
}

 

  여기서 fetch()의 첫 번째 매개변수 RequestInfo 타입의 값은 Request | string; 이며 보통 HTTP GET 메서드를 사용하는데 HTTP GET 메서드를 사용할 때 fetch()는 다음 형태로 사용되고 Promise<Response> 타입 객체를 반환한다.  Promise 클래스는 비동기 콜백 함수를 쉽게 구현하려고 만들었으며 then(), catch(), finally() 메서드를 제공한다.

fetch('https://randomuser.me/api/')
    .then(res => res.json())  // then()메서드의 콜백함수가 값이나 또 다른 Promise 객체를 반환
    .then((data: unknown) => console.log(data))  // then() 메서드를 다시 호출
    .catch((err: Error) => console.log(err.massage))
    .finally(() => console.log('always called'))
메서드 설명
then() 모든 게 정상일 때 설정된 콜백함수를 호출한다.  then() 메서드의 콜백 함수가 값이나 또 다른 Promise 객체를 반환할 때는 then() 메서드를 다시 호출해 콜백 함수가 반환한 값을 얻을 수 있다.
catch()  오류가 발생할 때 자바스크립트엔진이 기본으로 제공하는 Error타입의 값을 콜백함수의 입력 매개변수로 전달해 호출해 준다.
finally()  then()  이나 catch()의 콜백함수가 호출된 다음 항상 자신에 설정된 콜백함수를 호출한다.

 

  위의 Promise 클래스와 fetch() 함수를 통해 구현한 코드를 추가한다.  해당 코드는 필요한 데이터 셋만 갖고오도록 설정되어 있다. (email, name, picture)

export type IRandomUser = {
  email: string
  name: {title: string; first: string; last: string}
  picture: {large: string}
}

const convertRandomUser = (result: unknown) => {
  const {email, name, picture} = result as IRandomUser
  return {email, name, picture}
}
export const fetchRandomUser = (): Promise<IRandomUser> =>
  new Promise((resolve, reject) => {  // Promise를 사용해 성공 resolve, 실패 reject를 처리
    fetch('https://randomuser.me/api/')
      .then(res => res.json())
      .then((data: unknown) => {
        console.log(data)
        const {results} = data as {results: IRandomUser[]}
        resolve(convertRandomUser(results[0]))  
      })
      .catch(reject)
  })

 

 API 서버에서 가져온 사용자 정보 화면에 표시하기

  일반적으로 원격지 API 서버에서 데이터를 가져올 때 시간이 걸릴수도 있고 통신 오류가 발행할 수도 있다.  이를 고려해 초기값과 세터함수를 호출해 API 서버에서 데이터를 가져오는 코드를 예상해보자.

// API 서버에서 데이터를 가져올 때 상태와 초기값
import {useToggle} from '../hooks'  
import * as D from '../data'

// useToggle은 상태값을 true/false로바꾸는 커스텀 훅
const [loading, toggleLoding] = usetoggle(false)
const [randomUser, setRandomUser] = useState<D.IRandomUser | null>(null)
const [error, setError] = useState<Error | null>(null)

// API 서버에서 데이터 가져오기
const getRandomUser = useCallback(() => {
    toggleLoading()
    D.fetchRandomUser().then(setRandomUser).catch(setError).finally(toggleLoading)
}, [])

 

 JSX문에서 error나 randomUser는 널 값일 수 있으므로 {조건 && ()} 패턴으로 코드를 작성해야 한다.

{error && <p>{error.message}</p>} // 오류 발생시 JSX 예

 

  해당 내용을 토대로 코드를 추가한다.

import {useState, useCallback, useEffect} from 'react'
import {useToggle} from '../hook'
import {Title, Avatar, Icon} from '../components'
import * as D from '../data'

export default function FetchTEst() {
  const [loading, toggleLoading] = useToggle()
  const [randomUser, setRandomUser] = useState<D.IRandomUser | null>(null)
  const [error, setError] = useState<Error | null>(null)

  const getRandomUser = useCallback(() => {
    toggleLoading()
    D.fetchRandomUser().then(setRandomUser).catch(setError).finally(toggleLoading)
  }, [toggleLoading])
  
  // useEffect는 컴포넌트가 처음 렌더링될 때(마운트) 한번 실행함
  // 이후 의존성목록을 통해 getRandomUser 함수의 내용이 변경될 때 실행된다.
  useEffect(getRandomUser, [getRandomUser])
  return (
    <section className="mt-4">
      <Title>FetchTest</Title>
      <div className="flex justify-center mt-4">
        <button className="btn btn-sm btn-primary" onClick={getRandomUser}>
          <Icon name="get_app" />
          <span>get random user</span>
        </button>
      </div>
      {loading && (
        <div className="flex justify-center item-center">
          <button className="btn btn-circle loading"></button>
        </div>
      )}
      {error && (
        <div className="p-4 mt-4 bg-red-200">
          <p className="text-3xl text-red-500 text-bold">{error.message}</p>
        </div>
      )}
      {randomUser && (
        <div className="flex justify-center p-4 m-4">
          <Avatar src={randomUser.picture.large} />
          <div className="ml-4">
            <p className="text-xl text-bold">
              {randomUser.name.title}. {randomUser.name.first}
              {randomUser.name.last}
            </p>
            <p className="italic text-gray-600">{randomUser?.email}</p>
          </div>
        </div>
      )}
    </section>
  )
}

실행결과 : 버튼 클릭시 loading button이 활성화되어 하단의 사용자정보를 갱신한다.