타입 스크립트에서 useEffect 안에서 API 통신하는 useAsync 커스텀 훅 만들기

허민(허브)·2022년 5월 8일
1

개발 동기

평소 데이터 페치를 하면서 보통은 컴포넌트 내에서 함수를 만들어 개발을 하였다. 그런데 컴포넌트의 양이 많아지고 서버 개발을 하는 분이 종종 간단한 수정이 있으면 Axios를 볼 때가 있어서 함께 개발하는 하는데 피로감이 있어보였다.

구현 내용

나의 목표는 페이지가 떴을때 위의 사진처럼 데이터 목록을 불러와서 표로 보여주는걸 원했다. 그래서 나는 useEffect 내에서 비동기적으로 data를 가져오길 원했다.

데이터 페쳐만들기

먼저 axios를 통해서 데이터를 함수를 하나 만들어 줬다.

export const getOrderInfo = (): Promise<IOrderInfo[]> => new Promise((resolve, reject) => {
	axios
		.get('http://localhost:4000/orderInfoList')
  		.then(({data}) => {
    		resolve(data);
    	})
  		.catch(reject);
});

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타낸다. 그러면 왜 Promise가 필요할까? 만약 getData()와 같은 함수를 실행하여 데이터를 가져왔다. 그런데 데이터를 받아오기도 전에 화면에서 데이터를 표시하면 오류가 발생할 것이다. 이러한 문제를 해결하기 위한 컨셉으로 Promise 객체가 만들어진 것이다.

여기서 Promise객체가 제공하는 정적 메서드 중에선 Promise.resolve()Promise.reject()이 존재한다.

Promise.resolve()
주어진 값으로 이행하는 Promise 객체를 반환합니다. 이때 지정한 값이 then 가능한(then 메서드를 가지는) 값인 경우, Promise.resolve()가 반환하는 프로미스는 then 메서드를 "따라가서" 자신의 최종 상태를 결정합니다. 그 외의 경우, 반환된 프로미스는 주어진 값으로 이행합니다. 어떤 값이 프로미스인지 아닌지 알 수 없는 경우, 보통 일일히 두 경우를 나눠서 처리하는 대신 Promise.resolve()로 값을 감싸서 항상 프로미스가 되도록 만든 후 작업하는 것이 좋습니다.

Promise.reject()
Promise.reject(reason)
주어진 사유로 거부하는 Promise 객체를 반환합니다.

Promise 함수 출처

이때 Promise에서는 만약 타입 타이핑을 해주지 않으면 Promise의 타입을 알 수 없기 때문에 Promise<unknown>을 가지게 된다. 그렇기 때문에 getOrderInfo() fetch할 data의 타입으로 제네릭 선언을 해주어야한다. 단 오류는 항상 타입이 Error이기 때문에 특별히 명시를 해주지 않는다.

제네릭 타입이란?

그렇기 때문에 이렇게 type을 따로 지정을 해주어서 사용을 해야한다. 제네릭이란 타입을 마치 함수의 파라미터처럼 사용하는 것을 의미한다. 제니릭 타입을 내가 주로 사용하는 이유는 크게 2가지이다.

  1. 함수의 인자로 들어오는 파라미터에 어떠한 값(여러가지 값)이 들어와도 그대로 반환하는 재사용 함수를 만들 경우.
  2. 1번 방법으로 사용하였는데 타입 추정이 안되어 제네릭 타입 변수가 필요할 경우.

간단한 예를 하나 들어본자면 코드를 하나 선언해보자.

function getText(text) {
  return text;
}

getText('hi'); // 'hi'
getText(10); // 10
getText(true); // true

이 함수를 제네릭 관점에서 한번 살펴보게 되면 아래와 같이 사용할 수 있다. 그럼 만약 getText<string>('hi')를 호출한다면 어떻게 될까? 그럼 해당 코드의 T의 위치에 string이 들어가는 것과 동일한 역할은 한다.

function getText<T>(text: T): T {
  return text;
}

그런데 매번 인자로 타입을 넘겨주기 번거롭기 때문에 타입 추론을 활용한 방법으로 인자를 넘겨주게 된다. const text = getText('hello'); 그런데 만약 복잡한 코드에서 자동적인 타입 추론이 어려울 경우 어떻게 할까??

제네릭 타입 변수

아래와 같이 text의 길이를 한번 로그로 찍어보면 에러가 발생할 것이다. 그 이유는 text에 .length가 있다는 단서가 어디에도 없기 때문이다. 마치 any를 지정한 것과 같은 동작을 하고 있다. 그래서 이런 경우에는 조금 더 명시적인 타입을 정의해줘야 한다.

function getText<T>(text: T): T {
  console.log(text.length); // Error: T doesn't have .length
  return text;
}
function getText<T>(text: Array<T>): Array<T> {
  console.log(text.length);
  return text;
}

그래서 나는 이렇게 타입을 따로 선언해주고 이를 Promise 타입을 선언 해주었다.

export type IOrderFood = {
  count: number;
  fileName: string;
  foodName: string;
  foodPrice: number;
};

export type IOrderInfo = {
  orderDate: string;
  orderId: number;
  orderItemList: IOrderFood[];
  orderStatus: string;
};

useEffect 내에서 사용하기

Component 내 Data Fetch하는 코드를 아래와 같이 작성해주었다. 기존과 같다면 then-catch를 통한 체이닝을 통해 작성을 하겠지만 조금 더 간단하게 await 연산자를 활용하여 작성을 해보았다.

await 연산자란?

await 연산자는 const resolvedValue = await Promise_Object(); 라고 생각을 하면 편하다.

await 연산자는 다음 코드에서 보
1. 피연선자(operand)가 Promise객체라면
2. then 메서드를 자동 호출
3. resolve 된 값을 반환.
* 피연산자가 보통의 값이라면 단순이 그 값을 반환한다.

const val1 = await Promise.resolve(1) // 1
const val2 = await 1; // 1 

하지만 await 연산자를 사용하기 위해서는 호출하는 함수 앞에 반드시 async 함수 수정자가 존재해야한다. 이런 함수를 async 함수라고 한다.

const asyncFn = async () => {
	const val1 = await Promise.resolve(1);	
}

async 함수는 일반 함수와 달리 항상 Promise 타입 객체를 반환한다. 이때 오류가 reject 되면 catch메서드를 통해 reject된 Error 타입오류를 수신 가능하다.

하지만 내가 글의 앞전에서도 말하였지만 나는 페이지가 렌더링 됐을 때 데이터를 페칭하고 싶었다. 하지만 React 훅 함수의 callback function 안에서는 async 함수를 사용하지 못한다. 그래서 이를 해결할 수 있는 방법으로 asyncFn등과 같은 중첩함수(nest function)을 만들어 줄 수 있다. 하지만 이의 문제는 asynFc 함수 내에서 reject가 발생할 경우 이에 대한 에러 핸들링을 할 수 있는 방법이 존재하지 않는다.

그렇기 때문에 catch문을 이용해 문제를 해결 할 수 도 있다.

const [error,setError] = useState<Error| null>(null);
useEffect(()=>{
	const asyncFn = async () => {
    	const data = await D.getData();
      	setData(data);
    };
  asyncFc().catch(setError);
}, []);

error 문에 대해서 error : initialState, setError : setter를 가진 useState를 생성해준다. 그리고 .catch 문을 이용해서 setter 함수를 통해 오류를 수정해줄 수 있다.

하지만 이렇게 매번 작성해주는 것은 매우 번거롭기 때문에 custom Hook을 만들어준다.

asyncCallback 함수 타입은 뭘까?

export const useAsync = (asyncCallback: 🤔, deps : any[] = []) ={};

앞서 async 함수는 Promise 객체 반환 한다고 말한 적이 있다. 그러면 typescirpt가 반환하는 Promise 타입이 Promise<T>라고 한다면 useAsync 함수는 다음과 같은 시그니처를 갖는 함수로 구현이 가능하다.

export const useAsync = <T>(
	asyncCallback: () => Promise<T>,
	deps: any[] = []
) => {};

또한 Error 객체가 reject 될 수 있는데 이때 Error 타입 객체가 반환되어야 하는 컴포넌트에서 이 오류를 사용자에게 표시 가능하다. 정상적인 값이 resolve 되면 Error 타입 객체는 발생하지 않는다. 이떄 Error 타입은 발생할 수도 있고 아닐 수도 있기 때문에 Error | null로 선언을 해준다.

이를 따라서 아래와 같이 useAsync.tsx 라는 커스텀 훅을 만들어 주었다.
useAsync.tsx

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

export const useAsync = <T>(
  asyncCallback: () => Promise<T>,
  deps: any[] = []
): [Error | null, () => void] => {
  const [error, setError] = useState<Error | null>(null);
  useEffect(() => {
    asyncCallback().catch(setError);
  }, deps);
  const resetError = useCallback(() => setError((notUsed) => null), []);
  return [error, resetError];
};

그리고 해당 함수를 실제로 아래와 같이 컴포넌트에서 사용할 수 있다. 여기서 setOrderInfos는 페치된 데이터를 아래와 같이 사용할 수 있다.

const [orderInfos, setOrderInfos] = useState<D.IOrderInfo>([]);

  const [error, resetError] = useAsync(async () => {
    setOrderInfos([]);
    //화면에 보이는 error 문구 제거 함수
    resetError(); // useCallback을 통해 만들어졌기 때문에 최적화되어 있다.
    // Error 타입 객체가 reject되는 경우 테스트할 시 주석 제거
    // await Promise.reject(new Error('some error occurs'));
    const fetchOrderInfos = await D.getOrderInfo(); // 비동기 처리로 데이터를 받아오는 부분이다.

    setOrderInfos(fetchOrderInfos);
  });

마무리

실질적으로 매우 짧은 코드였지만 promise,제네릭 타입, async함수와 await 연산자 등에 대해 다시 한번 생각해보는 계기를 갖고 블로그를 정리해보았다.

정리하자면
1. typescipt에서 Promise 타입은 resolve 된 값의 타입을 타입 변수 T로 하는 제네릭 타입이다.
2. 오류의 경우 항상 타입이 Error 이므로 명시 해주지 않는다.
3. async 함수는 일반함수와 달리 항상 Promise 타입 객체를 반환한다.
4. useEffect에서 Aync 함수를 사용하기 위해선 중첩 함수를 활용할 수 있다.

profile
Adventure, Challenge, Consistency

0개의 댓글