[React] AbortController HTTP request 취소 안 되는 문제

@eunjios·2023년 11월 29일
0
post-thumbnail

문제 상황

OpenAI API의 stream 기능을 사용하면서 중간에 요청을 취소해야 하는 경우가 있었다. 이를 처리하기 위해 AbortController 를 사용해 요청을 취소하였는데 stream이 계속 이뤄지고 있었다.


내가 시도했던 (잘못된) 방법

다음은 stream을 사용했을 때 답변을 받아오는 커스텀 훅이다. 우선 HTTP 요청 취소 기능을 넣기 전 코드는 다음과 같다. 편의상 실제 코드 중 일부는 생략하였다.

기존 useStream.ts

import { useEffect, useState } from 'react';
import { ApiSate } from '../types/api';

type FetchFn<T> = (
  params: T
) => Promise<ReadableStreamDefaultReader<Uint8Array> | undefined>;

export const useStream = <T>(
  params: T,
  fetchFn: FetchFn<T>,
  initialText: string = ''
) => {
  const [answer, setAnswer] = useState(initialText);
  const [error, setError] = useState<Error>();
  const [isLoading, setIsLoading] = useState(false);

  let state: ApiSate = 'pending';
  if (isLoading) {
    state = 'loading';
  } else if (error) {
    state = 'error';
  }

  const streamReply = async () => {
    setAnswer(initialText);
    setIsLoading(true);
    try {
      const reader = await fetchFn(params);
      setIsLoading(false);
      while (reader) {
        const { done, value } = await reader.read();
        if (done) {
          break;
        }
        const replyChunk = new TextDecoder().decode(value);
        setAnswer((prevReply) => prevReply + replyChunk); // update component state
      }
    } catch (error) {
      setIsLoading(false);
      setError(error as Error);
    }
  };

  const getAnswer = async () => {
    streamReply();
  };

  return { answer, state, getAnswer };
};

이제 위 훅에 요청을 취소하는 기능을 추가하기 위해 다음과 같은 과정을 거쳤다.

  1. FetchFn 의 파라미터에 abortController 를 추가하기
  2. fetchFn 에 인자로 넘겨 받은 abortControllersignal 추가하여 요청 취소할 수 있게 하기
  3. 훅에 AbortController 인스턴스를 생성하여 취소하기 핸들러가 실행될 때 해당 인스턴스의 abort 메서드를 실행하여 signal 변경하기

useStream.ts

import { useEffect, useState } from 'react';
import { ApiSate } from '../types/api';

type FetchFn<T> = (
  params: T,
  abortController: AbortController
) => Promise<ReadableStreamDefaultReader<Uint8Array> | undefined>;


export const useStream = <T>(
params: T,
fetchFn: FetchFn<T>,
initialText: string = ''
) => {
  const [answer, setAnswer] = useState(initialText);
  const [error, setError] = useState<Error>();
  const [isLoading, setIsLoading] = useState(false);
  const abortController = new AbortController();

  const streamReply = async () => {
    setAnswer(initialText);
    setIsLoading(true);
    try {
      const reader = await fetchFn(params, abortController);
      setIsLoading(false);
      while (reader) {
        const { done, value } = await reader.read();
        if (done) {
          break;
        }
        const replyChunk = new TextDecoder().decode(value);
        setAnswer((prevReply) => prevReply + replyChunk); // update component state
      }
    } catch (error) {
      setIsLoading(false);
      setError(error as Error);
    }
  };
  
  const startAnswer = async () => {
    streamReply();
  };
  
  const stopAnswer = async () => {
    abortController.abort();
    setIsLoading(false);
    setIsError(null);
  };
  
  return { answer, state, startAnswer, stopAnswer };
};

문제 파악

당연하게도 stream으로 답변을 받아오고 있기 때문에 startAnswer 를 실행하면 answer state가 계속 변경된다. 그 때마다 useStream이 실행되고 새로운 AbortController 인스턴스가 생성된다. 즉, startAnswer 내부의 fetchFn 으로 보낸 인스턴스와 stopAnswer 를 실행할 때의 인스턴스가 달라지기 때문에 이전 요청에 대한 취소가 제대로 이뤄지지 않는 것이다.


해결 방법

stopAnswerstartAnswerAbortController 동일한 인스턴스를 참조하게 하면 된다. 이를 구현하기 위해 useRef 를 사용하면 된다.

잘못된 방법

// 매번 새로운 인스턴스 생성
const abortController = new AbortController();

해결 방법

// useRef로 기존 인스턴스 참조하도록 함
const abortControllerRef = useRef<AbortController>(new AbortController());
// 새로운 요청을 보낼 때 새로운 인스턴스 생성
const streamReply = async () => {
  // ... 중략
  try {
    const abortController = new AbortController();
    abortControllerRef.current = abortController;
    const reader = await fetchFn(params, abortControllerRef.current);
    // ... 중략
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      return; // 사용자가 '생성 멈추기' 버튼을 누른 경우 생성 오류 아님
    }
    // ... 중략
  }
}
// abortControllerRef.current로 동일한 인스턴스 접근
const stopAnswer = async () => {
  abortControllerRef.current?.abort();
  // ... 중략
};

전체 코드

✔️ 참고
isDoneanswerRef 는 UI 및 웹 접근성을 위한 것으로 위 내용에는 포함하지 않았음

import { useEffect, useRef, useState } from 'react';
import { ApiSate } from '../types/api';

type FetchFn<T> = (
  params: T,
  abortController: AbortController
) => Promise<ReadableStreamDefaultReader<Uint8Array> | undefined>;

export const useStream = <T>(
  params: T,
  fetchFn: FetchFn<T>,
  initialText: string = ''
) => {
  const [answer, setAnswer] = useState(initialText);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isDone, setIsDone] = useState(true);
  const answerRef = useRef<HTMLDivElement>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  let state: ApiSate = 'pending';
  if (isLoading) {
    state = 'loading';
  } else if (error) {
    state = 'error';
  }

  const streamReply = async () => {
    setAnswer(initialText);
    setIsLoading(true);
    setIsDone(false);
    try {
      const abortController = new AbortController(); // 요청마다 새로운 AbortController 생성
      abortControllerRef.current = abortController;
      const reader = await fetchFn(params, abortControllerRef.current);
      setIsLoading(false);
      while (reader) {
        const { done, value } = await reader.read();
        if (done) {
          setIsDone(true);
          answerRef.current?.focus();
          break;
        }
        const replyChunk = new TextDecoder().decode(value);
        setAnswer((prevReply) => prevReply + replyChunk); // update component state
      }
    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') {
        return; // 사용자가 '생성 멈추기' 버튼을 누른 경우 생성 오류 아님
      }
      setIsLoading(false);
      setError(error as Error);
    }
  };

  useEffect(() => {
    answerRef.current?.focus();
  }, [state]);

  const startAnswer = async () => {
    streamReply();
  };

  const stopAnswer = () => {
    abortControllerRef.current?.abort();
    setIsLoading(false);
    setError(null);
    setIsDone(true);
  };

  return { answer, state, isDone, answerRef, startAnswer, stopAnswer };
};
profile
growth

0개의 댓글