[React] 커스텀 훅으로 HTTP 요청 처리하기, useEffect 무한 루프 해결 방법

@eunjios·2023년 10월 11일
1
post-thumbnail

1. Axios 설치 및 Base URL 설정

1-1. Axios 설치하기

패키지 매니저에 맞게 axios 를 설치한다. 그 외의 환경은 axios docs 를 참고하자.

npm install axios
yarn add axios

1-2. Base URL 설정하기

index.js 파일에 다음 코드를 추가하면 Base URL을 설정할 수 있다. 혹은 이 과정을 스킵하고 HTTP 요청 시 전체 URL을 사용해도 된다.

index.js

환경 변수를 설정한 경우 다음과 같이 BASE_API_URL을 지정한다.

import axios from 'axios';

axios.defaults.baseURL = process.env.BASE_API_URL;

// 기존 코드

혹은 직접 BASE_API_URL을 하드 코딩할 수도 있다.

import axios from 'axios';

axios.defaults.baseURL = 'https://api.example.com';

// 기존 코드

2. 커스텀 훅 만들기

2-1. 커스텀 훅은 왜 필요한가?

사실 커스텀 훅을 사용하지 않아도 동일한 기능을 구현할 수 있다. 그럼에도 커스텀 훅을 사용해야 하는 이유는 뭘까? 바로 유지보수성 때문이다. 새로운 기능이 추가될 때마다 하나의 state를 변경하는 컴포넌트가 많아지고 그에 따라 로직도 복잡해진다. 이러한 로직이 여러 컴포넌트에 걸쳐있다면 이를 수정하기가 매우 까다로울 것이다. 따라서 이를 하나의 훅으로 관리할 필요가 있다. 커스텀 훅을 사용하면 새로운 기능을 추가하거나 수정할 때 해당 훅만 수정하면 되기 때문에 관리가 비교적 쉬워질 것이다. 또한 비슷한 로직은 코드의 중복을 막을 수 있기 때문에 커스텀 훅을 사용하는 것은 좋은 선택일 것이다.

2-2. 커스텀 훅 설계하기

그러면 실전으로 넘어가자. Axios를 사용하여 HTTP 요청을 처리하는 커스텀 훅을 만들어 보자.

✔︎ 여기서 다룰 모든 HTTP 요청은 다음과 같은 공통의 로직을 필요로 한다.

  1. HTTP 요청의 상태 (로딩 중? 에러? 성공? ...)
  2. 요청 및 응답 처리 순서 (HTTP 요청 → HTTP 응답 기다리기 → 응답에 따라 상태 변화 → 응답 처리 → ... )

✔︎ 요청에 따라 달라질 수 있는 부분은 다음과 같다. 이 부분은 커스텀 훅의 인자로 받아 다르게 처리할 수 있다.

  1. Request config
  2. HTTP 응답 데이터 처리 방법

위를 토대로 대략적으로 훅을 설계해보면 다음과 같다.

  • HTTP 상태: isLoading error
  • HTTP 요청 로직: sendRequest
  • sendRequest 인자: requestConfig applyData

hooks/use-http.js

const useHttp = () => {
  // 공통O: HTTP 요청 관련 상태
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  
  // 공통O: 로직 순서
  const sendRequest = async (requestConfig, applyData) => {
    // 공통O: 로딩 및 에러 초기화
    try {
      // 공통X: HTTP 요청 
      // 공통X: 응답 처리
    } catch (err) {
      // 공통O: 에러 상태 업데이트
    }
    // 공통O: 로딩 끝
  }
  
  return { isLoading, error, sendRequest };
}

2-3. 커스텀 훅 만들기

위에서 대략적으로 설계한 커스텀 훅을 실제 코드로 구현해보자.

NOTE : 훅 이름은 use로 시작해야 한다. ex) useSomething

hooks/use-http.js

import axios from 'axios';
import { useState } from 'react';

const useHttp = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const sendRequest = async (requestConfig, applyData) => {
    const { method, url, data, headers } = requestConfig;
    setIsLoading(true);
    setError(null);
    try {
      const response = await axios({
        url: url || '',
        method: method || 'GET',
        data: data || null,
        headers: headers || {},
      });
      const fetchedData = response.data;
      applyData(fetchedData);
    } catch (err) {
      setError(err.message || 'Error');
    }
    setIsLoading(false);
  }
  
  return { isLoading, error, sendRequest };
};

export default useHttp;

3. 커스텀 훅 사용하기 (GET)

3-1. 커스텀 훅의 state와 함수 불러오기

커스텀 훅은 컴포넌트에서 import 후 다음과 같이 사용할 수 있다.

const MyComponent = () => {
  const { isLoading, error, sendRequest } = useHttp();
  // ... 기존 코드
}

혹은 해당 컴포넌트에서 사용될 이름을 지정할 수도 있다. sendRequest 를 fetchDatas 로 사용하자.

const MyComponent = () => {
  const { isLoading, error, sendRequest: fetchDatas } = useHttp();
  // ... 기존 코드
}

3-2. sendRequest 사용하기

sendRequestrequestConfigapplyData 를 인수로 받고 있다. 각 인자의 예시를 살펴보자.

requestConfig 예시

위 코드에서 requestConfig 는 다음과 같은 형태일 수 있다.

const requestConfigExample1 = {
  method: 'POST',
  url: 'users'
  data: { name: 'eunjios', job: 'student' },
  headers: { 'Content-Type': 'application/json' }
};

아래 예시는 method, data, headers 키가 없지만 sendRequest 에서 default 값을 지정했기 때문에 에러가 발생하지 않는다.

const requestConfigExample2 = {
  url: 'users'
};

applyData 예시

다음과 같이 응답된 데이터를 처리하는 함수를 applyData 로 넘겨주면 된다.

const applyDataExample = (fetchedData) => {
  const datasList = [];
  for (const key in fetchedData) {
    datasList.push({
      id: key,
      name: fetchedData[key].name,
      job: fetchedData[key].job
    });
  }
  setDatas(datasList); // applyDataExample 외부에 state를 업데이트 하는 setDatas 가 정의되었다고 가정 
}

sendRequest 사용하기

다음과 같이 정의한 requestConfigapplyData 를 파라미터로 넘기면 useHttp 커스텀 훅의 sendRequest 함수를 사용할 수 있다.

sendRequest(requestConfig, applyData);

3-3. useHttp 커스텀 훅을 사용하여 데이터 가져오기

import { useState } from 'react';
import useHttp from './hooks/use-http';

const MyComponent = () => {
  const { isLoading, error, sendRequest: fetchDatas } = useHttp();
  const [datas, setDatas] = useState([]);
  
  useEffect(() => {
    const requestConfig = {
  	  url: 'users'
	};
    const applyData = (fetchedData) => {
  	  const datasList = [];
  	  for (const key in fetchedData) {
        datasList.push({
      	  id: key,
      	  name: fetchedData[key].name,
      	  job: fetchedData[key].job
        });
  	  }
  	  setDatas(datasList); 
    };
    fetchDatas(requestConfig, applyData);
  }, [fetchDatas]); // ❌❌❌ 무한 루프 
};

export default MyComponent;

여기서 fetchDatas 는 외부(useHttp)에서 온 함수이므로 의존성 배열에 추가해야 한다. 그러나 fetchDatas 를 dependencies에 추가하게 되면 무한 루프가 발생한다. 왜 그럴까? 🤔


🛠️ useEffect 와 무한 루프

이는 useEffectuseState 의 동작 원리 때문이다.

NOTE : useEffect의 콜백함수는 컴포넌트가 처음 마운트 될 때, 의존성 배열이 바뀔 때 실행된다. (의존성 배열이 비어있지 않은 경우)

NOTE: useState의 state가 업데이트 되면, 해당 state가 포함된 컴포넌트가 재평가된다.

위 코드가 어떻게 동작하는지 살펴보자.

  • MyComponent가 맨 처음 마운트 될 때 콜백 함수가 실행된다.
  • setDatas에 의해 MyComponent의 state인 datas가 업데이트 된다.
  • state 업데이트는 해당 state가 속한 컴포넌트를 재평가(실행)한다.
  • fetchDatas 가 바뀌면 useEffect의 콜백함수가 실행된다.

fetchDatas 가 바뀌면 useEffect의 콜백함수가 실행된다 !! 이 때문에 무한 루프에 빠진다.

fetchDatas ? 뭐가 바뀌었다는 거지 ? 코드 그대론데 ?

라고 말할 수도 있지만 useEffect는 그 기준이 메모리다.

  • fetchDatas 는 useHttp 에 정의된 함수다. 즉, fetchDatas 는 참조 타입이므로 useHttp가 실행될 때마다 새로운 메모리에 생성된다.
  • 반면, useEffect 는 메모리 주소로 dependencies의 변화를 감지한다. 즉, fetchDatas 의 내부 코드와 관계 없이 메모리가 변경되었으므로 fetchDatas 가 바뀌었다고 인지한다.
  • fetchDatas 가 바뀌었으니 useEffect의 콜백함수가 실행되고, state가 업데이트 되고, 또 컴포넌트가 실행되고, fetchDatas 가 바뀌고, ... 이렇게 무한 루프에 빠지게 되는 것이다.

🛠️ 무한 루프 에러 고치기

무한 루프에서 빠져나올 수 있는 방법은 useCallback 을 사용하는 것이다. useCallback 은 함수 내부가 바뀌지 않는 한 기존 메모리를 유지한다.

앞서 무한 루프가 발생했던 이유는 계속해서 바뀌는 fetchDatas 의 메모리 였다. 따라서 fetchDatas 로 불러온 useHttp 훅의 sendRequestuseCallback 으로 감싸서 동일한 메모리를 유지하도록 만들어 주면 된다.

sendRequest 는 외부 의존성이 없기 때문에 빈 배열로 둔다.

  const sendRequest = useCallback(async (requestConfig, applyData) => {
    // 기존 코드 
  }, []);

3-4. 전체 코드

hooks/use-http.js

import axios from 'axios';
import { useState, useCallback } from 'react';

const useHttp = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const sendRequest = useCallback(async (requestConfig, applyData) => {
    const { method, url, data, headers } = requestConfig;
    setIsLoading(true);
    setError(null);
    try {
      const response = await axios({
        url: url || '',
        method: method || 'GET',
        data: data || null,
        headers: headers || {},
      });
      const fetchedData = response.data;
      applyData(fetchedData);
    } catch (err) {
      setError(err.message || 'Error');
    }
    setIsLoading(false);
  }, []);
  
  return { isLoading, error, sendRequest };
};

export default useHttp;

MyComponent.js

import { useState } from 'react';
import useHttp from './hooks/use-http';
import List from './List';

const MyComponent = () => {
  const { isLoading, error, sendRequest: fetchDatas } = useHttp();
  const [datas, setDatas] = useState([]);
  
  useEffect(() => {
    const requestConfig = {
  	  url: 'users'
	};
    const applyData = (fetchedData) => {
  	  const datasList = [];
  	  for (const key in fetchedData) {
        datasList.push({
      	  id: key,
      	  name: fetchedData[key].name,
      	  job: fetchedData[key].job
        });
  	  }
  	  setDatas(datasList); 
    };
    fetchDatas(requestConfig, applyData);
  }, [fetchDatas]); // 무한 루프 탈출 !
  
  return (
    <div>
      {isLoading && <p>로딩중...</p>}
      {error && <p>{error}</p>}
      <List datas={datas} />
    </div>
  );
};

export default MyComponent;

List.js

const List = props => {
  const datasList = props.datas.map(data => (
    <li>
      <h2>data.name</h2>
      <p>data.job</p>
    </li>
  );
  return (
    <ul>{dataList}</ul>
  );
};

export default List;

4. 커스텀 훅 사용하기 (POST)

POST 메서드를 사용하는 폼 컴포넌트를 만들어보자. 간단한 입력과 submit 버튼이 있다. 데이터를 { text: 입력값 } 으로 설정하여 /posts 에 HTTP 요청을 전송해보자. 개념은 위에서 다뤘으니 여기선 간단히 코드만 구현해볼 예정이다. 앞서 구현한 useHttp 커스텀 훅을 동일하게 사용한다.

hooks/use-http.js

import axios from 'axios';
import { useState, useCallback } from 'react';

const useHttp = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const sendRequest = useCallback(async (requestConfig, applyData) => {
    const { method, url, data, headers } = requestConfig;
    setIsLoading(true);
    setError(null);
    try {
      const response = await axios({
        url: url || '',
        method: method || 'GET',
        data: data || null,
        headers: headers || {},
      });
      const fetchedData = response.data;
      applyData(fetchedData);
    } catch (err) {
      setError(err.message || 'Error');
    }
    setIsLoading(false);
  }, []);
  
  return { isLoading, error, sendRequest };
};

export default useHttp;

PostComponent.js

import { useState, useRef } from 'react';
import useHttp from './hooks/use-http';
import List from './List';

const PostComponent = () => {
  const { isLoading, error, sendRequest: sendData } = useHttp();
  const [posts, setPosts] = useState([]);
  const inputRef = useRef();
  
  const createData = (text, data) => {
    const id = data.name;
    const newPost = { id, text };
    setPosts(prevPosts => prevPosts.concat(newPost));
  };
  
  const submitHandler = async (event) => {
    event.preventDefault();
    
    const text = inputRef.current.value;
    const requestConfig = {
      url: 'posts',
      method: 'POST',
      data: { text },
      headers: { "Content-Type": "application/json" }
    };
    const applyData = (data) => {
      createData(text, data);
    };
    sendData(requestConfig, applyData);
  }
  
  return (
    <>
      <form onSubmit={submitHandler}>
        <input type='text' ref={inputRef} />
        <button type='submit'>등록</button>
      </form>
	  <List 
	    posts={posts} 
	    isLoading={isLoading} 
	    error={error} 
	  />
    </>
  );
};

export default PostComponent;

List.js

const List = (props) => {
  return (
    <>
      {props.isLoading && <p>로딩중</p>}
      {props.error && <p>{props.error}</p>}
      <ul>
        {props.posts.map((post) => (
          <li key={post.id}>{`${post.text} (${post.id})`}</li>
        ))}
      </ul>
    </>
  );
};

export default List;

References

profile
growth

0개의 댓글