안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 useAsyncWithLoading이라는 custom hook을 구현한 방법에 대해 글을 작성해 보겠습니다.
💻 개발 환경
React-native(expo) + TypeScript로 진행되었습니다.
React 환경에서도 당연히 사용이 가능합니다.
🧐 구현하게 된 계기
프로젝트를 진행하다 보면 사용자에게 좋은 경험을 남기기 위해선 서버와 통신 시에 무언가 처리 중이다라고 사용자에게 알려주는 것이 굉장히 중요하다고 생각합니다. 개발자 입장에서야 API 요청을 보냈다고 알 수 있지만 사용자에겐 에러로 느낄 수 있습니다.
이런 상황을 사용자에게 느끼지 않도록 하기 위해선 우리에겐 Loading 중이다라는 것을 알리는 선택지가 존재합니다.
그러므로 API 요청 시 요청 전에 Loading 컴포넌트를 사용자에게 보여주고 API 요청에 대한 결과를 받는다면 Loading 컴포넌트를 지우는 작업이 필수라고 생각합니다.
이러한 로딩 아이콘 하나로 우린 사용자에게 기다려주세요를 말한 것과 똑같다 생각합니다.
본론으로 들어가자면 로딩 아이콘을 컨트롤할 수 있는 건 코드입니다.
저는 로딩 아이콘을 위해
const API요청함수 = async () => {
setIsLoading(true);
try {
const 결과 = await API요청();
} catch (error) {
// error
} finally {
setIsLoading(false);
}
};
이러한 코드를 반복해서 사용했었습니다.
이런 반복되는 코드를 개선하고자 useAsyncWithLoading라는 custom hook을 만들게 되었습니다.
🤔 useAsyncWithLoading 개발을 위한 설계
저 같은 경우 API 요청을 axios를 사용해서 하고 있습니다.
axios에는 interceptors라는 기능이 이미 내장이 되어 있습니다.
interceptors의 기능을 사용하여 요청이 전달되기 전에 Loading을 활성화하고 응답 인터셉터에서 Loading 비활성화하는 식의 처리를 하는 식의 처리를 할 수 있을 것처럼 느껴지지만 제 생각에는 좋지 않은 방법이라고 생각합니다.
그 이유는?
API 요청에 대한 비동기 처리를 직렬 처리만 하는 프로젝트에서는 가능하지만 병렬로 처리하는 부분이 있다면 사용자에게 좋지 않은 경험을 남길 수 있다.
제가 저렇게 작성해 놓은 걸 모든 케이스 통하여 설명드리도록 하겠습니다.
예제 코드
import React, { useState } from 'react';
import { Button, Text, View } from 'react-native';
const Screen = () => {
const [로딩을켤까요, 로딩상태변경] = useState(false);
const API대체 = (요청에걸리는시간: number): Promise<boolean> => {
//axios의 인터셉터를 흉내내기 위해 이 곳에서 로딩을 켭니다.
로딩상태변경(true);
return new Promise((응답) => {
setTimeout(() => {
응답(true);
//axios의 인터셉터를 흉내내기 위해 이 곳에서 로딩을 끕니다.
로딩상태변경(false);
}, 요청에걸리는시간);
});
};
const 응답1초걸려요 = () => {
return API대체(1000);
};
const 응답2초걸려요 = () => {
return API대체(2000);
};
const 응답3초걸려요 = () => {
return API대체(3000);
};
const 제출 = async () => {
try {
const 결과1 = await 응답1초걸려요();
const 결과2 = await 응답2초걸려요();
const 결과3 = await 응답3초걸려요();
} catch (error) {}
};
return (
<View>
<Button title='제출하기' onPress={제출} />
{로딩을켤까요 && <Text>로딩 중이에요</Text>}
</View>
);
};
export default Screen;
API 호출은 'API대체'라는 함수로 대체하였습니다.
API 호출에 대한 비동기 처리를 하실 때 이런 형태의 코드로 작성하시는 분들이 계실 것 같습니다.
저는 이런 코드가 좋다고 생각하지 않습니다.
그 이유는
이해하시기 쉽도록 흐름도를 가볍게 준비했습니다.
순차적으로 직렬로 처리하게 된다면 사용자는 요청 후 1초 + 2초 + 3초 즉 6초 후에 원하는 결과를 받을 수 있습니다.
이런 상황을 해결하기 위해선 직렬이 아닌 병렬로 처리해야 합니다.
병렬로 처리하기 위해서 Promise.all을 사용해서 처리해 보도록 하겠습니다.
예제 코드
import React, { useState } from 'react';
import { Button, Text, View } from 'react-native';
const Screen = () => {
const [로딩을켤까요, 로딩상태변경] = useState(false);
const API대체 = (요청에걸리는시간: number): Promise<boolean> => {
//axios의 인터셉터를 흉내내기 위해 이 곳에서 로딩을 켭니다.
로딩상태변경(true);
return new Promise((응답) => {
setTimeout(() => {
응답(true);
//axios의 인터셉터를 흉내내기 위해 이 곳에서 로딩을 끕니다.
로딩상태변경(false);
}, 요청에걸리는시간);
});
};
const 응답1초걸려요 = () => {
return API대체(1000);
};
const 응답2초걸려요 = () => {
return API대체(2000);
};
const 응답3초걸려요 = () => {
return API대체(3000);
};
const 제출 = async () => {
try {
const [결과1, 결과2, 결과3] = await Promise.all([
응답1초걸려요(),
응답2초걸려요(),
응답3초걸려요(),
]);
} catch (error) {}
};
return (
<View>
<Button title='제출하기' onPress={제출} />
{로딩을켤까요 && <Text>로딩 중이에요</Text>}
</View>
);
};
export default Screen;
Promise.all을 사용하여 병렬 방식으로 처리하게 된다면
사용자는 총 3초 후에 1초, 2초, 3초 요청에 대한 결과를 받을 수 있습니다. 이유는 모든 API 호출을 동시에 요청을 보냈고 가장 오래 걸리는 3초가 응답이 오는 동안 이미 1초 요청에 대한 응답과 2초 요청에 대한 응답은 받았기 때문입니다.
이러한 방식으로 저희는 사용자의 시간을 단축해 줬습니다.
하지만 axios의 인터셉터를 통해 Loading 아이콘을 관리를 한다면 문제가 발생합니다.
const API대체 = (요청에걸리는시간: number): Promise<boolean> => {
//axios의 인터셉터를 흉내내기 위해 이 곳에서 로딩을 켭니다.
로딩상태변경(true);
return new Promise((응답) => {
setTimeout(() => {
응답(true);
//axios의 인터셉터를 흉내내기 위해 이 곳에서 로딩을 끕니다.
로딩상태변경(false);
}, 요청에걸리는시간);
});
};
이 코드를 통해 보여드리자면 Promise.all을 통하여 axios를 사용하여 API 요청이 3개를 보냈습니다.
여기서 API 요청을 보내기 전에 로딩상태변경을 통하여 true로 값을 할당해 주었습니다.
응답이 오게 된다면 로딩상태변경을 false로 할당하게 되는데 이 부분에서 문제가 발생합니다.
1초가 걸리는 요청은 1초 뒤에 요청이 끝나서 자동으로 로딩 상태를 false로 변경하지만 3초가 걸리는 요청은 아직 Pending 즉 응답을 받지 못해 대기하고 있습니다.
이렇게 된다면 사용자 입장에서 로딩이 사라져서 액션이 끝났다고 생각하게 되지만 아무런 다음 행동이 없으니 버그가 났다고 생각하기 쉽니다.
이러한 케이스까지 고려해서 axios의 인터셉터를 활용하지 않고 custom hook으로 만들게 되었습니다.
👀 Custom Hook(CODE)
useAsyncWithLoading.ts
import { useRecoilState } from 'recoil';
import { LoadingState } from '../store/atoms';
const useAsyncWithLoading = () => {
const [isLoading, setIsLoading] = useRecoilState(LoadingState);
const executeAsyncTask = async <T>(
callback: () => Promise<T>
): Promise<T> => {
setIsLoading(true);
try {
return await callback();
} finally {
setIsLoading(false);
}
};
return executeAsyncTask;
};
export default useAsyncWithLoading;
코드 설명
저 같은 경우 해당 프로젝트에서 recoil을 사용하여 LoadingState를 관리하고 있어서 환경에 맞게 작성했습니다.
export const LoadingState = atom({
key: 'LoadingState',
default: false,
});
글을 읽으시는 분에 환경에 맞게 저 부분은 수정하면 좋을 것 같습니다.
useRecoilState Hook을 사용하여 Recoil로부터 isLoading 상태와 이 상태를 변경하는 함수인 setIsLoading을 가져옵니다. 이 로딩 상태는 로딩 컴포넌트 렌더링 여부를 결정합니다.
executeAsyncTask라는 함수는 콜백 함수를 매개변수로 받아서 실행하고, 콜백은 Promise를 반환해야 합니다. 결괏값 타입이 항상 같을 수 없기에 제네릭 타입으로 구현했습니다.
executeAsyncTask 내에서 비동기 작업 전에 로딩 상태를 true로 설정하고, 비동기 작업 후에 false로 설정합니다. 따라서 비동기 작업 도중에 예외가 발생하더라도 finally 블록에서 로딩 상태가 항상 false로 설정되어 화면이 계속 로딩 중인 것처럼 보이지 않게 됩니다.
✨ 사용법
비동기 작업 1개
const executeAsyncTask = useAsyncWithLoading();
const requestHandler = async () => {
executeAsyncTask(async () => {
try {
const result = await api();
console.log(result);
} catch (error) {
console.log('error ');
console.log(error);
}
});
};
return (
<Container>
<Button label='requestButton' onPressHandler={requestHandler} />
</Container>
);
비동기 작업 여러 개
const executeAsyncTask = useAsyncWithLoading();
const requestHandler = async () => {
executeAsyncTask(async () => {
try {
const result = await Promise.all([api(), api2(), api3()]);
console.log(result);
} catch (error) {
console.log('error ');
console.log(error);
}
});
};
return (
<Container>
<Button label='requestButton' onPressHandler={requestHandler} />
</Container>
);
⏱ 개인적으로 개선하고 싶은 부분
이 hook을 개발을 진행하는 도중 개인적으로 개선을 하고 싶은 부분이 생겼습니다. 저 hook의 내용은 아니지만 생각을 공유하고자 글을 작성해 봅니다.
문제의 상황은 다음과 같습니다.
한 페이지에서 여러 번 비동기 작업을 실행한다면(예: 페이지네이션), 이전 작업의 로딩 상태가 새 작업에 영향을 줄 수 있습니다.
저런 상황이 실제로 제 프로젝트에서도 현재 발생하고 있습니다.
예시로 아래 사진과 같은 상황입니다.
추가적인 설명을 남기자면
유저 정보는 첫 입장 시 1번
제품 목록은 첫 입장 시 1번과 인피니티 스크롤을 활용하여 스크롤 끝 지점에 도달하면 불러오고 있습니다.
유저 주소 정보는 첫 입장 시 1번과 Header의 Dropdown을 펼치면 불러오고 있습니다.
이런 경우 제품 목록을 불러오려고 하는 도중에 유저 주소 정보를 사용자의 액션에 의하여 요청하게 된다면 Promise.all과 같은 처리를 할 수 없게 됩니다. 그 뜻은 두 요청이 동시에 오지 않는 이상 로딩이 먼저 꺼지는 경우가 발생한다는 것입니다.
이와 같은 케이스를 방지하는 방법을 틈날 때마다 고민을 하고 있었습니다만 이번에 정답(?)은 아닐지라도 나쁘지 않은 답을 생각해 내서
이곳에 공유해 보도록 하겠습니다.
바로 LoadingState를 boolean이 아닌 number로 관리하는 것입니다.
즉 로딩을 켜야 하는 상황이 오면 Loading State를 +1 씩 꺼야 하는 상황이 온다면 -1 씩 하는 것입니다.
이렇게 변경한다면 여러 개의 비동기 요청이 한 번에 들어와도 다른 작업의 비동기 처리 시작과 종료에 영향을 받지 않고 로딩 컴포넌트를 관리할 수 있겠다는 생각이 들었습니다.
Loading State가 0이라면 렌더링 하지 않고 0보다 크다면 렌더링 하는 방식으로요
Promise.all을 사용하지 못하는 상황에 맞게 예시를 설명해 보겠습니다.
먼저 지금처럼 Boolean으로 설정했을 때입니다.
사진을 보시면 아실 수 있지만 비동기 Task2가 끝나기 전인데 로딩 상태는 false로 변했고 사용자의 화면에서는 로딩 아이콘이 사라졌습니다. 하지만 아직 비동기 Task2는 처리 중입니다.
제가 생각한 number로 바꿔서 관리를 하게 된다면
이런 식으로 0인 경우에 로딩이 사용자 화면에 보이지 않도록 구현한다면 비동기 Task2가 끝날 때까지 사용자는 로딩이 활성화되어 있는 화면을 볼 수 있습니다.
저는 이런 상황을 고려해서 number로 관리하는 것이 더 유용하게 느껴져서 조만간 리팩토링 진행을 계획했습니다.
다른 분들도 이 글을 읽으신 뒤 괜찮다 판단이 드신다면 한 번 적용해 보시는 것도 좋을 것 같습니다.
😁 글 작성 후기
제가 이전에 작성했던 글에서도 적었던 말이지만 "너무 어려운 로직을 위한 hook도 훌륭하지만 개인적으로 이렇게 간단한 코드로 개발 편의성을 올려주는 hook도 굉장히 훌륭하다고 생각합니다." 이 말에 딱 맞는 hook의 종류가 아닐까 싶습니다.
오히려 코드가 길어서 보기 싫다는 이유만으로 custom hook에 모든 걸 때려 넣어서 만든 hook은 추후 원하는 로직을 변경하기 위해 코드를 파악하는데 시간이 더 소요가 된다고 생각합니다.
저는 그런 custom hook은 최악이라고 생각합니다.
제 생각에 custom hook이나 util 함수를 잘 구현하는 사람들의 특징은 자기의 입장에서만 필요하다가 아닌 다른 사람들은 이것을 어떤 상황에 활용할 것이고 어떻게 사용할 수 있도록 하면 편할지 고민을 많이 하시고 만드는 티가 많이 납니다.
저도 그래서 custom hook이나 util을 만들 때 생각을 하면서 만드려고 노력을 많이 하고 최대한 경험을 쌓기 위해 많이 만들어 보려고 하는 편입니다.
이 글을 읽는 다른 분들도 기회가 온다면 주저 없이 시도해 보셔도 좋을 것 같습니다.
'개발' 카테고리의 다른 글
React Native 채팅방 만들기 (2) | 2023.10.12 |
---|---|
React Native Picker (2) | 2023.09.19 |
React Native RadioButton 구현하기 (2) | 2023.09.01 |
React Native useOverlay를 만들어 리팩토링 하기 이 글은 진짜 떠야 해 (0) | 2023.08.22 |
React Native 채팅방 목록 구현하기 (0) | 2023.08.14 |