application/src/common/api at dev · Hongpung/application

↑ 본문에서 설명하는 코드가 있는 디렉토리↑

장황한 설명 건너뛰기


전역 상태로 관리하는 값에 대한 HTTP 요청이 있을 때마다 값을 갱신하는 경우가 많아 코드가 점점 길어져 혼자서는 유지보수하기 벅찬 수준으로 코드가 많아졌다.

릴리스를 마치고 리팩토링 과정에서 여러 레포지토리를 참고했고, 그중 RTK를 통한 상태관리에서 보이는 함수 이름의 추상화를 보고 RTK의 쿼리 빌더를 보고 모사를 해봐야겠다는 생각을 하였다.

기존 API Builder 패턴

처음에는 단순한 API Builder와 Custom Hook Builder로 시작했다. 이 패턴은 다음과 같은 구조로 되어 있었다:

1. 기본 API Builder

const buildApi = async <T>({ url, params, method, body, transformResponse, withAuthorize, options }: BuildOption<T>): Promise<T> => {
    // URL 파라미터 처리
    const urlWithParams = new URL(url);
    if (params) {
        Object.entries(params).forEach(([key, value]) => {
            if (typeof value === 'undefined') return;
            else if (typeof value === 'number' || typeof value === 'string') {
                urlWithParams.searchParams.append(key, String(value));
            } else if (Array.isArray(value)) {
                value.forEach((item) => urlWithParams.searchParams.append(key, String(item)));
            }
        });
    }

    // 인증 토큰 처리
    const fetchOptions: RequestInit = {}
    if (options) {
        if (withAuthorize) {
            const token = getToken('token')
            fetchOptions.headers = { ...fetchOptions.headers, ...options.headers, 'Authorization': `Bearer ${token}` }
        }
        else
            fetchOptions.headers = { ...fetchOptions.headers, ...options.headers }
    }

    if (body) fetchOptions['body'] = JSON.stringify(body)

    const response = await fetch(urlWithParams.toString(), {
        method,
        ...fetchOptions
    });

    const data = await response.json();
    return transformResponse ? transformResponse(data) : (data as T);
};

2. Custom Hook Builder

const useRequest = <T, P>() => {
    const [isLoading, setLoading] = useState<boolean>(false);
    const [error, setError] = useState<Error | null>(null);

    const request = useCallback(async ({ options, transformResponse, body, ...requestParams }: RequestParams<T> & { body: P }) => {
        const controller = new AbortController();
        const signal = controller.signal;
        const timeoutId = setTimeout(() => controller.abort(), options?.timeout ? options.timeout : 5000);
        try {
            setLoading(true);
            const result = await buildApi<T>({ options: { ...options, signal }, ...requestParams, body: body ?? undefined });
            setError(null);
            return transformResponse ? transformResponse(result) : result;
        } catch (err) {
            setError(err as Error);
            throw err;
        } finally {
            setLoading(false);
            clearTimeout(timeoutId)
        }
    }, []);

    return { isLoading, error, request };
};

const useFetch = <T>({ url, params, transformResponse, options }: FetchParams<T>) => {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
        const fetchDataAsync = async () => {
            const controller = new AbortController();
            const signal = controller.signal;
            const timeoutId = setTimeout(() => controller.abort(), options?.timeout ? options.timeout : 5000);

            try {
                setLoading(true);
                const result = await buildApi<T>({ url, params, method: 'GET', options: { ...options, signal }, });
                setData(transformResponse ? transformResponse(result) : result);
            } catch (err) {
                setError(err as Error);
                throw err;
            } finally {
                setLoading(false);
                clearTimeout(timeoutId)
            }
        };

        fetchDataAsync();
    }, [url, params, transformResponse]);

    return { data, isLoading, error };
};

3. Recoil 통합

기존에는 Recoil을 사용하여 전역 상태 관리를 했다:

const useRequestWithRecoil = <T, P>({ recoilState }: { recoilState: RecoilState<T | null> }) => {
    const setData = useSetRecoilState<T | null>(recoilState);
    const [isLoading, setLoading] = useState<boolean>(false);
    const [error, setError] = useState<Error | null>(null);

    const request = useCallback(async ({ options, transformResponse, body, ...requestParams }: RequestParams<T> & { body: P }) => {
        // ... 요청 로직
        const result = await buildApi<T>({ options: { ...options, signal }, ...requestParams, body: body ?? undefined });
        setData(result);
        // ...
    }, [recoilState]);

    return { isLoading, error, request };
};

React Query로의 전환

기존 API Builder 패턴이 잘 작동하고 있었지만, 몇 가지 문제점들이 있었다:

  1. 수동 상태 관리의 복잡성: 로딩, 에러, 데이터 상태를 매번 수동으로 관리해야 함
  2. 캐싱 부재: 같은 데이터를 여러 번 요청해도 매번 새로 가져옴