[React] Custom Hook 실질적 활용

SuamKang·2023년 7월 31일
0

React

목록 보기
25/34
post-thumbnail

앞서 Custom Hooks 대하여에서 다뤄봤던 커스텀 훅의 기본과 적용에 대해서 살펴보았다면
이번엔 좀 더 현실적으로 http통신도 해보며 적용해 볼 수 있는 방법을 해볼까 한다.

백엔드 API는 firebase로 프로젝트를 설정해 엔드포인트를 설정했다.
이유는 뭐 간단하면서 무료이고 쉽게 적용해볼 수 있어서 했다.

자 그럼 해당 Firebase의 api를 통해 통신하여 기존의 데이터를 저장하고 업데이트 하는 로직과 컴포넌트들을 생성하고 동일한 작업과 연관된 것들을 커스텀 훅으로 묶어 구성하고 사용해보자

1. Custom hook 구성


우선 이 프로젝트는 투두리스트처럼 할일의 양식을 인풋으로 받고 firebase에 새롭게 text를 저장한 후, 저장된 데이터들을 다시 불러올것이다.

1) 커스텀 훅 빌딩 전 코드들

NewTask.js

import { useState } from "react";

import Section from "../UI/Section";
import TaskForm from "./TaskForm";

const NewTask = (props) => {
  
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  // 새 할일 추가 요청 헨들러
  const enterTaskHandler = async (taskText) => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(
        "https://react-custom-hook-practi-8dec7-default-rtdb.firebaseio.com//tasks.json",
        {
          method: "POST",
          body: JSON.stringify({ text: taskText }),
          headers: {
            "Content-Type": "application/json",
          },
        }
      );

      if (!response.ok) {
        throw new Error("Request failed!");
      }

      const data = await response.json();

      const generatedId = data.name; // firebase 데이터 베이스의 'name'키값은 일반적인 고유의 id를 특정하고 있음
      const createdTask = { id: generatedId, text: taskText };

      props.onAddTask(createdTask);
    } catch (err) {
      setError(err.message || "Something went wrong!");
    }
    setIsLoading(false);
  };

  return (
    <Section>
      <TaskForm onEnterTask={enterTaskHandler} loading={isLoading} />
      {error && <p>{error}</p>}
    </Section>
  );
};

export default NewTask;

App.js

import React, { useEffect, useState } from "react";

import Tasks from "./components/Tasks/Tasks";
import NewTask from "./components/NewTask/NewTask";

function App() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [tasks, setTasks] = useState([]);

  // 할일 조회 요청 핸들러
  const fetchTasks = async (taskText) => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(
        "https://react-custom-hook-practi-8dec7-default-rtdb.firebaseio.com//tasks.json"
      );

      if (!response.ok) {
        throw new Error("Request failed!");
      }

      const data = await response.json();

      // 받아온 객체 데이터 순회하여 요소들 새로운 배열로 반환할 상수 loadedTasks 생성
      const loadedTasks = [];

      for (const taskKey in data) {
        loadedTasks.push({ id: taskKey, text: data[taskKey].text });
      }

      setTasks(loadedTasks);
    } catch (err) {
      setError(err.message || "Something went wrong!");
    }
    setIsLoading(false);
  };

  useEffect(() => {
    fetchTasks();
  }, []);

  // 추가된 할일 이전 할일배열데이터에 의존하여 추가후 상태 업데이트하는 함수
  const taskAddHandler = (task) => {
    setTasks((prevTasks) => prevTasks.concat(task));
  };

  return (
    <React.Fragment>
      <NewTask onAddTask={taskAddHandler} />
      <Tasks
        items={tasks}
        loading={isLoading}
        error={error}
        onFetch={fetchTasks}
      />
    </React.Fragment>
  );
}

export default App;

위 두개의 컴포넌트에서 현재 직접적으로 http통신을 위한 로직이 매우 비슷하게 구성되어있다.
이 로직들을 useFetch 커스텀훅을 생성하여 작업해 보도록 하자.


2) useFetch.js


우선, use를 꼭 붙여 네이밍 해주고 공통으로 다루고 있었던 로딩과 에러 상태와 해당 requset함수를 적용한다.

import { useState } from "react";


const useFetch = (requestConfig, applyData) => {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);
  
    const sendRequest = async (taskText) => {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch(requestConfig.url, {
            method : requestConfig.method,
            body: JSON.stringify(requestConfig.body),
            headers: requestConfig.headers
        });
  
        if (!response.ok) {
          throw new Error("Request failed!");
        }
  
        const data = await response.json();
        
        applyData(data);
      
      } catch (err) {
        setError(err.message || "Something went wrong!");
      }
      setIsLoading(false);
    };
  

    return

};
export default useFetch;

그리고 중요한것은 이 일반화 시키는 커스텀 훅에서 요청의 특성(get 또는 post)에따라 일반적인 가공을 해주어야하기 때문에 특정되는 부분들은 해당 useFetch 커스텀 훅 함수의 매개변수로 지정해준다.

그리고 첫번째 매개변수로 받은 requestConfig는 객체형태로 http통신시 상황에 따라 보내주는 속성들로 url,method,headers,body를 구성해 줬다.

이렇게 하면 이 훅을 호출하게 될 때, 요청부분로직에 대해 유연하게 전달이된다.


그리고 현재 응답 온 데이터를 형식 변환하는 로직들은 매우 구체적이라서 일반화되는 커스텀 훅 로직에 적합한 형태는 아니다고 판단했다.
따라서 여기선 적용하지 않고, 이 훅을 사용하는 해당 컴포넌트 안에서 자유롭게 변환하여 새로 정의해 사용할 수 있게 해 줄 것이다.


그것을 위해 useFetch의 두번째 매개변수로 applyData라는 함수를 추해준다. 이는 응답으로 데이터를 가져오게되면 이 훅을 사용하는 컴포넌트로부터 얻은 함수(applyData)를 실행해서 이 함수에 데이터를 넘기는 방법을 사용했다.


이제 로딩과 에러상태 그리고 요청함수가 포함 되었고, 이들은 훅을 사용하는 컴포넌트들이 접근할 수 있어야 한다.


3) 컴포넌트에서 접근할 custom hook 값들 반환하기


커스텀 훅 끝부분에 컴포넌트에서 접근 가능하게 사용될것들을 return을 해주어야 한다.
반환은 무엇이든 할 수 있기 때문에 여기선 객체를 반환할것이다.

import { useState } from "react";


const useFetch = (requestConfig, applyData) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const sendRequest = async (taskText) => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(requestConfig.url, {
        method: requestConfig.method,
        body: JSON.stringify(requestConfig.body),
        headers: requestConfig.headers,
      });

      if (!response.ok) {
        throw new Error("Request failed!");
      }

      const data = await response.json();

      applyData(data);
    } catch (err) {
      setError(err.message || "Something went wrong!");
    }
    setIsLoading(false);
  };

  // 좌측 속성명과 우측 변수명을 같이 설정했기에 자바스크립트 편의 기능상 생략 가능하다
  return {
    isLoading,
    error,
    sendRequest,
  };
};

export default useFetch;

이렇게 해서 로딩과 에러 그리고 요청하는 함수를 객체형태로 전달하도록 구성완료했다.



2. Custom hook 사용


이제 만들어진 훅을 사용해보자.
기존 App컴포넌트에서 사용되던 로딩과 에러상태와 요청핸들러는 이제 커스텀훅이 관리하게 되기때문에 삭제해준다.

그리고 이제 사용할 커스텀훅은 매개변수로 요청할 객체와 함수를 받기때문에 이걸 지정해서 적용해준다.

하지만 그전에,
전달하는 requestConfig에 속성값으로 기본값설정을 하여 get요청과 post요청에따라 조건부로 값을 발췌해줄 로직을 추가해 주어야했다.

useFetch.js


...

 const response = await fetch(requestConfig.url, {
        method: requestConfig.method ? requestConfig.method : 'GET',
        body: requestConfig.body ? JSON.stringify(requestConfig.body) : null,
        headers: requestConfig ? requestConfig.headers : {},
      });
      
...

이렇게 수정하고
다시 적용할 App컴포넌트에 돌아와서 커스텀훅의 두번째 매개변수로 전달할 함수를 정의해 주어야한다.

전달할 함수라는건 이전에 응답온 데이터를 형식 변환하는 로직을 수행할 함수를 의미한다.

App.js

...

  const [tasks, setTasks] = useState([]);

  // 커스텀 훅에 새로운 할일 데이터를 전달하기 위한 함수 정의
  const transformTasks = (taskObj) => {
    const loadedTasks = [];

    for (const taskKey in taskObj) {
      loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
    }

    setTasks(loadedTasks);
  }
  
...

이렇게 하면 Firebase에서 받는 객체데이터의 모든 작업을 필요한 구조와 유형을 갖는 객체로 변환된다.

그리고 이 함수를 두번째 매개변수에 전달한다.

이렇게 설정했을때, 주요로직은 커스텀훅에 위치하게 되고 로직에 대한 데이터는 그 데이터가 필요한 컴포넌트에 위치하게 된다.

최종적으로 설정한 App컴포넌트를 살펴보면

App.js

import React, { useEffect, useState } from "react";

import Tasks from "./components/Tasks/Tasks";
import NewTask from "./components/NewTask/NewTask";
import useFetch from "./components/hooks/useFetch";

function App() {
  const [tasks, setTasks] = useState([]);

  // 커스텀 훅에 새로운 할일 데이터를 전달하기 위한 함수 정의
  const transformTasks = (taskObj) => {
    const loadedTasks = [];

    for (const taskKey in taskObj) {
      loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
    }

    setTasks(loadedTasks);
  }
  
  // sendRequest같은 경우, 콜론을 추가하여 해당함수 포인터를 정해준다
  const { isLoading, error, sendRequest: fetchTasks} = useFetch({url: "https://react-custom-hook-practi-8dec7-default-rtdb.firebaseio.com//tasks.json"}, transformTasks);


  useEffect(() => {
    fetchTasks();
  }, []);

  // 추가된 할일 이전 할일배열데이터에 의존하여 추가후 상태 업데이트하는 함수
  const taskAddHandler = (task) => {
    setTasks((prevTasks) => prevTasks.concat(task));
  };

  return (
    <React.Fragment>
      <NewTask onAddTask={taskAddHandler} />
      <Tasks
        items={tasks}
        loading={isLoading}
        error={error}
        onFetch={fetchTasks}
      />
    </React.Fragment>
  );
}

export default App;

이렇게 하고나서 useEffect안에 랜더링후 실행할 fetchTask는 원래는 의존성배열에 포함하지 않았는데, 이제는 포함 시켜줘야 한다.

왜냐하면 그전엔 그저 상태 갱신함수만 호출하고 있었기때문에 의존성이 없더라도 리액트가 useEffect내부의 해당 이펙트 함수를 절대 바꾸지않기때문에 가능했지만, 커스텀 훅을 통해 전달받은 sendRequest함수는 이를 알지 못하기 때문에 fetchTask가 바뀔때 마다 재실행 하려면 의존성으로 추가해 주어야 한다.

하지만 이 상태에서 fetchTask를 그냥 의존성으로 추가하게 된다면 무한루프가 발생할것이다.

그 이유는 fetchTask라는 함수는 커스텀 훅으로 부터 전달받은 http요청 함수이며 그 함수 안 로직엔 상태가 업데이트 되고 있다.(setIsLoading, setError같은 갱신함수 실행중)
그렇게 되면 커스텀 훅으로부터 상태를 사용하게 될테고 이는 App컴포넌트에서 컴포넌트의 재평가를 유발하게 된다.

따라서 재평가가 되는 순간 커스텀 훅이 다시 호출되면 그안의 sendRequest함수가 재생성 되면서 또 새로운 함수객체를 반환하고 자바스크립트는 이를 같은 내용의 객체일지라도 다르게 인식하여 새로운 메모리에 저장함으로써 useEffect가 재실행 된다.


2-1) useCallback으로 해결


방법 1

그렇기 때문에 이를 방지하기 위해 useCallback 훅을 사용해줄 수 있겠다.
사용하는 커스텀훅인 useFetch.js에 돌아가서 snedRequest함수를 useCallback()으로 감싸준다.

useFetch.js

import { useCallback, useState } from "react";

const useFetch = (requestConfig, applyData) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const sendRequest = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(requestConfig.url, {
        method: requestConfig.method ? requestConfig.method : "GET",
        body: requestConfig.body ? JSON.stringify(requestConfig.body) : null,
        headers: requestConfig.headers ? requestConfig.headers : {},
      });

      if (!response.ok) {
        throw new Error("Request failed!");
      }

      const data = await response.json();

      applyData(data);
    } catch (err) {
      setError(err.message || "Something went wrong!");
    }
    setIsLoading(false);
  },[]);

  // 좌측 속성명과 우측 변수명을 같이 설정했기에 자바스크립트 편의 기능상 생략 가능하다
  return {
    isLoading,
    error,
    sendRequest,
  };
};

export default useFetch;

이렇게 래핑하고 useCallback의 의존성배열에는 함수 안에 사용되는 모든것을 나열해 준다.


바로 requestConfig객체와 applyData함수일텐데
따지고 보면 이들 또한 객체임을 알 수 있고, 그렇다면 이또한 이들을 전달받는 컴포넌트에서(App.js) 컴포넌트함수가 재실행 될때마다 이 해당 함수가 재생성이 되지 않도록 해야한다.

그럼 App컴포넌트에서 커스텀 훅으로 전달하는 applyData함수 부터 살펴보면,

  // useCallback으로 매핑해서 리랜더링 시 이 함수가 재생성하는것을 막게한다.
  const transformTasks = useCallback((taskObj) => {
    const loadedTasks = [];

    for (const taskKey in taskObj) {
      loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
    }

    setTasks(loadedTasks);
  }, []);

이렇게 transformTasks에 설정해주고 (이 함수는 커스텀훅에서 applyData로 받는 함수이다.)


그리고 이제 두번째로 전달하려는 requestConfig객체를 살펴보았을때 굳이 useFetch에서 받지않고 sendRequest 여기로 전달하도록 해보았다.

const useFetch = (applyData) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const sendRequest = useCallback(async (requestConfig) => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(requestConfig.url, {
        method: requestConfig.method ? requestConfig.method : "GET",
        body: requestConfig.body ? JSON.stringify(requestConfig.body) : null,
        headers: requestConfig.headers ? requestConfig.headers : {},
      });

      if (!response.ok) {
        throw new Error("Request failed!");
      }

      const data = await response.json();

      applyData(data);
    } catch (err) {
      setError(err.message || "Something went wrong!");
    }
    setIsLoading(false);
  },[applyData]);

기존에 useFetch훅의 첫번째 매개변수로 넣었던 requestConfig를 sendRequest의 매개변수로 넣어서 외부 의존성이 아닌 래핑된 함수의 매개변수로 설정해주어 불변성을 보장하기에 의존성배열에 추가하지 않아도 되었다.

이렇게 커스텀훅에서 설정해주었다면 다시 App컴포넌트로 가서 전달하는 useFetch() 안의 매개변수로 url객체를 제거하게되면 transformTasks가 useFetch에 전달되는 유일한 인자가 된다.

function App() {
  const [tasks, setTasks] = useState([]);

  const transformTasks = useCallback((taskObj) => {
    const loadedTasks = [];

    for (const taskKey in taskObj) {
      loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
    }

    setTasks(loadedTasks);
  }, []);

  const {
    isLoading,
    error,
    sendRequest: fetchTasks,
  } = useFetch(transformTasks);

  useEffect(() => {
    fetchTasks({
      url: "https://react-custom-hook-practi-8dec7-default-rtdb.firebaseio.com//tasks.json",
    });
  }, [fetchTasks]);

방법 2


만약 App컴포넌트에서 useCallback함수를 사용하는것이 번거롭다면 이러한 방법도 채택할 수 있을것 같다.

위 코드에서 응답으로 부터 전달받은 data를 관리해주는 transformTasks함수에도 이런 작업을 할 수도 있다.

바로 transformTasks의 useCallback을 제거하고 useEffect안의 fetchTasks의 두번째 인자로 전달해주는것이다.


function App() {
  const [tasks, setTasks] = useState([]);

  const {
    isLoading,
    error,
    sendRequest: fetchTasks,
  } = useFetch();

  useEffect(() => {
    
    const transformTasks = (taskObj) => {
      const loadedTasks = [];

      for (const taskKey in taskObj) {
        loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
      }

      setTasks(loadedTasks);
    };

	fetchTasks(
      {
        url: "https://react-custom-hook-practi-8dec7-default-rtdb.firebaseio.com//tasks.json",
      },
      transformTasks
    );
}, [fetchTasks]);

이렇게 하면 useEffect안에 이펙트 함수 안에 남아있는 외부 의존성을 사라지고 useFetch 커스텀 훅은 어떠한 매개변수 없이도 호출이 가능하게 되었다.

왜냐하면 현재 요청에 대한 설정(requestConfig)과 데이터 전송 후 적용되어야 할 데이터 변환(applyData)를 직접 보내기 때문이다.


const useFetch = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const sendRequest = useCallback(async (requestConfig, applyData) => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(requestConfig.url, {
        method: requestConfig.method ? requestConfig.method : "GET",
        body: requestConfig.body ? JSON.stringify(requestConfig.body) : null,
        headers: requestConfig.headers ? requestConfig.headers : {},
      });

      if (!response.ok) {
        throw new Error("Request failed!");
      }

      const data = await response.json();

      applyData(data);

    } catch (err) {
      setError(err.message || "Something went wrong!");
    }
    setIsLoading(false);
    
  },[]); 

그 다음에 useFetch 커스텀 훅에선 기존에 받았던 applyData를 sendRequest의 두번째 인자로 설정해 준다.
이렇게 되면 useFetch는 더이상 의존성이 필요없기 때문에 저 위치에서 useCallback을 사용할 수 있다.

즉 useFetch 커스텀 훅이 다루는 모든 데이터는 래핑된 함수(sendRequest)에서 매개변수로 받고 있기 때문에 외부 의존성은 더이상 존재하지 않는다!


결과적으로, 훅은 정상적이고 App컴포넌트도 정상적으로 작동하게 된다.

동작 결과



이젠
App컴포넌트 말고 다른 컴포넌트에서도 커스텀훅을 적용해 보도록 하자.
위와같은 방법으로 NewTask컴포넌트에도 적용해보았다.


NewTask.js

import Section from "../UI/Section";
import TaskForm from "./TaskForm";
import useFetch from "../hooks/useFetch";

const NewTask = (props) => {
  const { isLoading, error, sendRequest: sendNewTask } = useFetch();

  const createTask = (taskText, taskData) => {
    const generatedId = taskData.name; // firebase 데이터 베이스의 'name'키값은 일반적인 고유의 id를 특정하고 있음
    const addedTask = { id: generatedId, text: taskText };

    props.onAddTask(addedTask);
  };

  const enterTaskHandler = async (taskText) => {
    sendNewTask(
      {
        url: "https://react-custom-hook-practi-8dec7-default-rtdb.firebaseio.com//tasks.json",
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: { text: taskText },
      },
      createTask.bind(null, taskText) // 이는 sendRequset에서 두번째 인자로 받는 applyData이다.
    );
  };

  return (
    <Section>
      <TaskForm onEnterTask={enterTaskHandler} loading={isLoading} />
      {error && <p>{error}</p>}
    </Section>
  );
};

export default NewTask;

위 컴포넌트에서 적용한 부분중 특정한 부분중 하나는

bind() 자바스크립트 메소드를 사용하여 sendNewTask에 전달하려는 createTask에 호출을 해주는것이다.

bind()는 자바스크립트에서 함수를 사전에 구성할 수 있게 해주는 문법으로
호출 즉시 함수가 실행되진 않는다.

bind()에 보내는 첫번째 인자는 실행이 예정된 함수에서 this 예약어를 사용하게 하는것인데 여기선 쓸모가 없어 null로 지정해주고, createTask에서 직접 매개변수로 받는 taskText를 두번째 인자로 넣어준다.

이렇게 하면 나머지 인자인 taskData 또한 사전에 설정된 것이므로 실제로 호출되는 useFetch에서 전달되는 응답 data는 createTask의 두번째 인자로 추가가 될것이다.(왜냐면 createTask에서 이를 두번째 인자로 받고있다.)

useFetch.js

...

const data = await response.json();
applyData(data) // -> 이게 결국 NewTask.js에서 사용되는 createTask.bind()메소드에 두번째 인자로 설정되게 될것이다.
// data === taskData

...

이로써 이러한 활용 방안들은 모두 정규 자바스크립트 문법과 기능을 사용하여 해결한 것임을 배울 수 있었다.


정리 및 느낀점


확실히 어려웠지만,
일반적으로 http요청 로직을 중복하여 작성하는 사이드이펙트 함수들이 커스텀 훅을 사용하면서 어떻게 재활용 할 수 있었고 이게 왜 필요할것 같은지 직접적으로 느껴볼 수 있었다.

Custom hook으로 상태설정과 같은 중복되는 로직들을 아웃소싱할 수 있고, 이를 서로다른 컴포넌트에 적용해서 다양한 종류의 요청(GET 또는 POST)도 보내고, 응답 데이터에 대해서 다양한 작업과 로딩 및 오류상태를 공유하는 로직을 통해 최적화를 진행한다는 점이 인상깊었다.

profile
마라토너같은 개발자가 되어보자

0개의 댓글