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 };
};
이제 위 훅에 요청을 취소하는 기능을 추가하기 위해 다음과 같은 과정을 거쳤다.
FetchFn
의 파라미터에 abortController
를 추가하기 fetchFn
에 인자로 넘겨 받은 abortController
의 signal
추가하여 요청 취소할 수 있게 하기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
를 실행할 때의 인스턴스가 달라지기 때문에 이전 요청에 대한 취소가 제대로 이뤄지지 않는 것이다.
stopAnswer
와 startAnswer
의 AbortController
동일한 인스턴스를 참조하게 하면 된다. 이를 구현하기 위해 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();
// ... 중략
};
전체 코드
✔️ 참고
isDone
과answerRef
는 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 };
};