본문 바로가기
JS/React 강의

[React] 24. 커스텀 hook으로 useHttp훅 만들어보기

by GiraffePark 2024. 2. 14.

 

이전 강의에 커스텀훅을 제작하는 방법에 대해 알아봤습니다.

(이전 강의 : https://arnopark.tistory.com/855)

 

이번엔 커스텀 훅을 현실적으로 제작해서 사용하는 시나리오를 가정하고, 어느 프로젝트에서든 정말 유용하게 사용할 수 있는 커스텀 훅을 제작해보겠습니다.

 

 

 

 


가상 시나리오 가정하기

위 이미지와 같은 구조의 리액트 앱이 있습니다.

 

 

 

 

input 창에 task 내용을 적은 후, 오른쪽의 Add Task 버튼을 누릅니다.

 

 

 

그러면 서버에 task가 업로드됩니다. (firebase - realtime database 사용)

 

 

 

 

서버에 올라온 tasks 목록을 받아와서, Input 창 하단에 리스트로 출력합니다.

 

 

 

 


useHttp 1차 제작

서버로 Task를 보내고 받는 과정에서 http 리퀘스트가 발생합니다. 이 과정을 http 커스텀 훅으로 만들 수 있습니다.

그리고 이 커스텀 훅 안에서, '로딩과 에러처리'까지 한 번에 해주는 것을 목표로 합니다.

 

 

 

useHttp는 첫 번째로 requestConfig라는 객체 타입 인수를 받습니다.

requestConfig
{
url : http 리퀘스트가 전달될 경로
method : 어떠한 종류의 http 리퀘스트인지 (ex: POST, GET)
headers : http 리퀘스트의 헤더
body : http 리퀘스트의 body (본문)
}

위와 같은 구조의 객체를 받는 이유는, fetch 함수에 있습니다.

 

 

 

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

위와 같은 형태로, fetch 함수의 첫 번째 인수에 requestConfig의 url 프로퍼티를 전달하고, fetch 함수의 두 번째 인수에 requestConfig의 나머지 프로퍼티들을 전달해줍니다.

 

 

 

 

useHttp는 두 번째로 applyData라는 함수 타입 인수를 받습니다.

applyData는 서버의 응답(response)을 관리해주는 함수입니다.

서버에서 데이터를 가져온 후에, applyData 함수에 이 데이터를 넘겨줍니다.

const data = await response.json();
applyData(data);

 

 

 

 

 

useHttp는 아래와 같은 객체를 반환합니다.

    return {
      isLoading,
      error,
      sendRequest,
    };

 

{
isLoading
: http 요청이 진행 중인지의 여부를 <useHttp 훅을 사용하고 있는 컴포넌트>에게 알려줍니다.
error : http 요청에 error가 발생했을 때, 무슨 error인지를 알려줍니다.
sendRequest : useHttp 내부의 sendRequest 함수를 <useHttp 훅을 사용하는 컴포넌트>가 직접 활성화하고 요청을 보낼 수 있도록 해줍니다.
}

 

 

 

 

여기까지 완성된 use-http.js의 코드 전문

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

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

      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 useHttp;

 

이제 useHttp를 컴포넌트에 적용시켜 사용해보겠습니다.

 

 

 

 

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

  const transformTasks = (tasksObj) => {
    const loadedTasks = [];

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

  const { isLoading, error, sendRequest: fetchTasks } = useHttp(
    { url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json" },
    transformTasks
  );

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

 

 

 

  const { isLoading, error, sendRequest: fetchTasks } = useHttp(
    { url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json" },
    transformTasks
  );
  const transformTasks = (tasksObj) => {
    const loadedTasks = [];

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

useHttp의 첫 번째 인수로는 서버에 요청을 보낼 정보가 담긴 객체를, 두 번째 인수로는 서버로부터 받은 데이터를 처리하는 함수를 보내줍니다. 

위의 transfromTasks()함수처럼 데이터를 처리해주는 함수를 미리 컴포넌트에서 제작한 후 커스텀 훅으로 넘겨주는 방식으로 코딩을 하면, http 리퀘스트의 주요 로직은 커스텀 훅에 위치하게 되고, applyData() - tansformTasks() 로직이 적용된 데이터는 컴포넌트에 위치하게 됩니다.

 

 

 

 


현 useHttp의 문제점 - 무한루프

  const { isLoading, error, sendRequest: fetchTasks } = useHttp(
    { url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json" },
    transformTasks
  );

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

이때 useHttp가 반환하는 값들을 구조 분해 할당 해준 다음에, 여기서 sendReqeust 함수를 fetchTasks로 이름만 바꿔준 후, useEffect에 연결해줍니다.

useEffect의 의존성 배열에는 아무것도 추가하지 않았으므로, 처음 컴포넌트 함수가 렌더링된 이후로는 fetchTasks가 작동되지 않을 것입니다. 다만, 의존성 배열에 아무것도 추가하지 않는 것은 최선의 방법이 아니기에 수정이 필요합니다.

 

 

 

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

useEffect의 의존성 배열에는 모든 함수랑 변수가 담겨야 하지만, 위처럼 넣어버리면 무한루프가 발생합니다.

컴포넌트가 재생성되면서 fetchTasks가 재실행되고 (함수는 객체타입 value라서 같은 코드임에도 불구하고 새로 만든 함수라고 엔진이 여긴다.), fetchTasks가 재실행되면서 sendRequest 안의 setLoading과 setError가 실행되고, 그러면서 App 컴포넌트가 재생성되고, App 컴포넌트 안의 useEffect가 다시 컴포넌트를 재생성하고, 컴포넌트가 재생성되면서 fetchTasks가 재실행되고...

 

 

 

 

 


useHttp 2차 제작

이를 막기 위해서 sendRequest 함수를 useCallback으로 감싸야 한다.

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

      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);
  }, [requestConfig, applyData]);
};

위처럼 말입니다. 그런데 여기서 또 문제가 발생합니다.

useHttp의 인수로 받는 requestConfig와 applyData 또한 각각 Object와 Function으로 객체 타입 value입니다.

이러면 useHttp가 컴포넌트 함수를 따라서 재렌더링 될 때마다, useHttp가 인수로 전달받는 requestConfig와 applyData가 다시 생성됩니다.

여기서 useCallback 무한루프가 발생합니다.

 

 

 

이럴 때 해결법은, requestConfig와 applyData()를 useHttp()의 인수로 받아오는 것이 아니라, 그냥 sendRequest()의 인수로 받아오게 끔 만들어서, 필요할 때만 데이터를 호출하도록 만드는 것입니다.

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

  const sendRequest = async () => {
      {...}
  }, [requestConfig, applyData]);
};
// 후
const useHttp = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const sendRequest = useCallback(async (requestConfig, applyData) => {
    {...}
  }, []);
};

이러면 sendRequest() 외부(useHttp)에서 선언된 값을 쓰는 게 아니라, 함수의 인수를 사용하는 구조라서 useCallback의 의존성 배열에 넣지 않아도 됩니다.

(useCallback 함수의 인수로 사용된 값은, 함수가 인수와 함께 호출되는 그 즉시 최신값으로 갱신된다고 보장이 되기 때문에, 따로 의존성 배열에 추가하지 않아도 됩니다.)

 

 

 

 

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

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

    fetchTasks(
      {
        url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json",
      },
      transformTasks
    );
  }, [fetchTasks]);

1. sendRequest(fetchTasks)의 첫 번째 인수(requestConfig)로 넣어줄 객체를 useEffect 안에서 생성한 후 전달해줍니다.

2. sendRequest(fetchTasks)의 두 번째 인수(applyData)로 넣어줄 transformTasks 함수도 useEffect 안에서 생성한 후 전달해줍니다.

그러면, fetchTasks() 안의 인수로 전달되는 첫 번째 인수와 두 번째 인수 모두 useEffect 내부에서 선언되므로, 의존성 무한루프 문제가 완전히 해결됩니다. 

 

 

 

 

use-http.js 코드 전문

import {useState, useCallback} from 'react';

const useHttp = () => {
  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',
        headers: requestConfig.headers ? requestConfig.headers : {},
        body: requestConfig.body ? JSON.stringify(requestConfig.body) : null,
      });

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

      const data = await response.json();
      return applyData(data);
    } catch (err) {
      setError(err.message || 'Something went wrong!');
    }
    setIsLoading(false);
  }, []);

  return {
    isLoading,
    error,
    sendRequest,
  };
};

export default useHttp;

 

 

 

 

 


컴포넌트에 useHttp 호출하기

이제 useHttp를 실제 컴포넌트에 적용해보겠습니다.

 

 

 

위 두 컴포넌트 중에서,

 

 

 

 

(새 입력창) 이 부분이 NewTask.js 컴포넌트

 

 

 

 

(task 목록) 이 부분이 Tasks.js 컴포넌트 입니다.

 

 

 

 

// App.js 중
  useEffect(() => {
    const transformTasks = (tasksObj) => {
      const loadedTasks = [];

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

    fetchTasks(
      {
        url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json",
      },
      transformTasks
    );
  }, [fetchTasks]);

최상위 컴포넌트인 App.js에서 위처럼 tasks 목록을 받아오는 코드를 작성합니다.

 

 

 

 

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

그리고 하위 컴포넌트인 Tasks로, useHttp를 통해 전달받은 isLoading, error, 데이터처리함수를 전부 보냅니다.

 

 

 

 

만약 useHttp가 로딩 중이거나, 에러를 반환한다면,

// Tasks.js
  if (props.error) {
    content = <button onClick={props.onFetch}>Try again</button>;
  }

  if (props.loading) {
    content = 'Loading tasks...';
  }

위 코드를 통해 상황에 맞는 결과물을 보여줄 것입니다.

 

 

 

 

 

이제 Add Task 버튼을 누를 때, useHttp가 실행돼서, 데이터베이스에 task가 저장이 되도록 구현해보겠습니다.

// Task 버튼이 담긴 컴포넌트 NewTask.js
const NewTask = (props) => {
  const { isLoading, error, sendRequest: sendTaskReqeust } = useHttp();

  const createdTask = (taskText, taskData) => {
    const generatedId = taskData.name; // firebase-specific => "name" contains generated id
    const createdTask = { id: generatedId, text: taskText };

    props.onAddTask(createdTask);
  };

  const enterTaskHandler = async (taskText) => {
    sendTaskReqeust(
      {
        url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json",
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: { text: taskText },
      },
      createdTask.bind(null, taskText)
    );
  };
  return (
    <Section>
      <TaskForm onEnterTask={enterTaskHandler} loading={isLoading} />
      {error && <p>{error}</p>}
    </Section>
  );
};

export default NewTask;

 

 

 

  const enterTaskHandler = async (taskText) => {
    sendTaskReqeust(
      {
        url: "https://react-http-6e4f6-default-rtdb.firebaseio.com/tasks.json",
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: { text: taskText },
      },
      createdTask.bind(null, taskText)
    );
  };

이번에는 sendRequest(sendTaskRequest) 함수에 POST 요청을 보냅니다.

그리고 두 번째 인수(applyData)로 전달될 createdTask() 함수가 taskText 값을 전달받을 수 있도록 bind()를 적용시켜줍니다.

 

 

 

const createdTask = (taskText, taskData) => {
    const generatedId = taskData.name; // firebase-specific => "name" contains generated id
    const createdTask = { id: generatedId, text: taskText };

    props.onAddTask(createdTask);
  };

createdTask() 함수는 taskText, taskData를 각각 인수로 받습니다. bind()를 이용해서 taskText 값을 받고, 서버로부터 받은 response-data로 taskData 값을 받습니다. 

 

 

 

 

이때 taskData.id가 아니라 taskData.name을 사용하는 이유는, 파이어베이스가 Id를 Name이라고 부르기 때문입니다.

파이어베이스는 POST 요청을 보낸 후 응답을 받으면, POST로 전달한 데이터의 id의 값을 'name' 프로퍼티에 담아서 줍니다. 그래서 위 코드에서, taskData.name으로 id에 접근한 후,  generatedId라는 상수에 담아둔 것입니다.

 

그 이후, generatedId와 taskText를 하나의 객체로 담은 후에, tasks state에 push 해줍니다.

(서버에 또 다시 tasks 목록을 받아오는 통신을 요청하지 않기 위해서, 현재 클라이언트에 남아있는 tasks state에 방금 Add task한 값만 바로 push하는 방식으로 tasks state를 업데이트해줍니다.)

 

 

 

 

끝!

 

 

반응형