ChatGPT 의 시대가 도래하면서, 근래 소규모 스타트업 개발자들이 많이 받는 요구사항은 아마도 GPT로 채팅 구현해주세요 가 아닐까 한다.
자연스러운 UX 흐름을 위해서 챗봇이 주는 응답은 텍스트가 모두 추출될때까지 기다리지 않고, 작은 단위로 쪼개서 사용자에게 생성되는 즉시 보내져 UI 상에서 나타난다.
우리가 유튜브에서 크고 긴 고화질의 동영상을 끊김 없이 시청할 수 있는것과 비슷한 원리와 같다. 스트리밍이다.
GPT 나 Gemini 와 같이 API 를 간편하게 사용할 수 있게 만들어놓은 SDK 들에서는 모두 stream 형태로 응답을 받을 수 있는 인터페이스를 제공하고 있다.
하지만 대부분 서버용 SDK 이기 때문에, 인터페이스의 제약이나 보안상 이유로 인해서 이를 클라이언트에서 바로 사용하기는 어렵고, 정상적으로 사용하기 위해서는 서버 API 를 통해서 클라이언트에 다시 stream 으로 응답을 내려줘야 한다.
Client 👉
request
👉 Server 👉request
👉 GenAI SDKClient 👈
stream response
👈 Server 👈stream response
👈 GenAI SDK
기본적으로 Fetch API 는 stream 을 지원한다.
따라서 웹 환경에서는 이러한 구현에 큰 제약을 받지는 않지만, React-Native 의 Fetch 는 안타깝게도 stream 이 지원되지 않는다.
socket 을 사용해도 되겠지만 RN 이 몇년된 라이브러리인가.. 누군가는 과거에 이러한 기능이 필요했었고 커뮤니티에 이를 위한 구현체가 존재한다.
https://github.com/react-native-community/fetch
설명에서부터 text streaming 이 필요한 경우가 아니면 사용하지 말라는 살발한 경고
사용을 위해서는, 필요한 여러 빌트인 객체들이 React-Native 런타임에는 없기때문에 polyfill 들을 추가해줘야 한다.
워낙 오래되다 보니까 호환이 특정 버전에서만 되니까, 아래를 잘 참고하자.
fetch 구현체와 폴리필을 쉽게 사용가능하게 도와주는 유틸성 라이브러리 설치
yarn add react-native-fetch-api react-native-polyfill-globals
빌트인 객체들의 폴리필
yarn add text-encoding@^0.7.0 url-parser@^0.0.1 web-streams-polyfill@^3.3.3
앱 로드 시점에 아래와 같이 폴리필을 호출해주면 된다.
require('react-native-polyfill-globals/src/fetch').polyfill();
require('react-native-polyfill-globals/src/encoding').polyfill();
require('react-native-polyfill-globals/src/readable-stream').polyfill();
타입 지원이 필요하다면 아래 코드를 복사하여 프로젝트의 글로벌 스코프에 적용될 수 있게 .d.ts
로 추가해주자.
// Decoder
type BufferSource = ArrayBufferView | ArrayBuffer;
interface TextDecoderCommon {
readonly encoding: string;
readonly fatal: boolean;
readonly ignoreBOM: boolean;
}
interface TextDecodeOptions {
stream?: boolean;
}
interface TextDecoder extends TextDecoderCommon {
decode(input?: BufferSource, options?: TextDecodeOptions): string;
}
declare const TextDecoder: {
prototype: TextDecoder;
new (label?: string, options?: TextDecoderOptions): TextDecoder;
};
// Fetch
interface RequestInit {
reactNative?: Partial<{ textStreaming: boolean }>;
}
아래와 같이 옵션을 주면, readable stream 으로 response 를 받아올 수 있다.
const response = await fetch("https://api.com/genai/request", {
method: 'post',
headers,
body: JSON.stringify(body),
reactNative: { textStreaming: true }
});
const stream = (await response.body) as ReadableStream<Uint8Array> | null;
if (stream) {
// do something...
}
이제 받아온 스트림을 처리하는 방법에 대해서 알아보자.
function handleStream(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
}
getReader()
함수를 호출하면, 스트림에 대해서 단일 점유를 보장해주는 Reader 를 생성해서 반환해준다.
이제 이 reader 를 통해서 stream 으로부터 쪼개진 data chunk 를 받아올 수 있다.
function handleStream(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
async function readChunk() {
const { done, value } = await reader.read();
}
}
여기서 done
은 스트림이 끝났다는 flag 이고, value 는 data chunk 이다.
data chunk 를 TextDecoder 를 이용해서, 읽을 수 있는 텍스트로 변환을 할 수 있다.
function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
let text = '';
const reader = stream.getReader();
async function readChunk() {
const { done, value } = await reader.read();
if (done) {
return text;
} else {
text += new TextDecoder().decode(value);
return readChunk();
}
}
return readChunk();
}
const text = await readStream(stream);
이렇게 하면 스트림 청크들을 모아 모아서 Promise<string>
으로 리턴하는 함수를 만들 수 있다.
우리가 원하는것은 실시간으로 업데이트를 하고싶은것이니, 아래와 같이 조금 디자인을 바꿔서 사용할수도 있다.
function listenStream(stream: ReadableStream<Uint8Array>) {
let onReadListener = (value: string) => {};
let onDoneListener = () => {};
const eventHandler = {
onRead(listener: typeof onReadListener) {
onReadListener = listener;
return eventHandler;
},
onDone(listener: typeof onDoneListener) {
onDoneListener = listener;
return eventHandler;
},
};
const reader = stream.getReader();
async function readChunk(): Promise<void> {
const { done, value } = await reader.read();
if (done) {
onDoneListener();
return Promise.resolve();
} else {
onReadListener(new TextDecoder().decode(value));
return readChunk();
}
}
readChunk();
return eventHandler;
}
function ChatScreen() {
const [isTyping, setIsTyping] = useState(false);
const [message, setMessage] = useState("");
async function onRequestAPI(text: string) {
const stream = await api.chat.sendMessage(text, history);
setIsTyping(true);
listenStream(stream)
.onRead((value) => setMessage(value));
.onDone(() => setIsTyping(false));
}
// ...
}
React-Native 에서 stream 을 사용하여 GenAI 의 응답을 스트리밍으로 처리하는 방법에 대해서 간략하게 알아보았다.
물론 서버와 주고받으려면 어느정도의 정형화된 형태와 그에 맞는 처리가 추가적으로 필요하긴 하지만, 그 부분은 알아서 잘 해보자!
오늘의 교훈. 내가 겪은 문제는 이미 누군가 겪어서 해결까지해놨다. ㅋㅋ