[RN CheatSheet] Fetch Text Streaming 사용하기

HYUNGU, KANG·2024년 5월 20일
2

React-Native-CheatSheet

목록 보기
19/24

ChatGPT 의 시대가 도래하면서, 근래 소규모 스타트업 개발자들이 많이 받는 요구사항은 아마도 GPT로 채팅 구현해주세요 가 아닐까 한다.

자연스러운 UX 흐름을 위해서 챗봇이 주는 응답은 텍스트가 모두 추출될때까지 기다리지 않고, 작은 단위로 쪼개서 사용자에게 생성되는 즉시 보내져 UI 상에서 나타난다.

우리가 유튜브에서 크고 긴 고화질의 동영상을 끊김 없이 시청할 수 있는것과 비슷한 원리와 같다. 스트리밍이다.

GPT 나 Gemini 와 같이 API 를 간편하게 사용할 수 있게 만들어놓은 SDK 들에서는 모두 stream 형태로 응답을 받을 수 있는 인터페이스를 제공하고 있다.

하지만 대부분 서버용 SDK 이기 때문에, 인터페이스의 제약이나 보안상 이유로 인해서 이를 클라이언트에서 바로 사용하기는 어렵고, 정상적으로 사용하기 위해서는 서버 API 를 통해서 클라이언트에 다시 stream 으로 응답을 내려줘야 한다.

Client 👉 request 👉 Server 👉 request 👉 GenAI SDK

Client 👈 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-fetch-api

사용을 위해서는, 필요한 여러 빌트인 객체들이 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... 
}

Stream

이제 받아온 스트림을 처리하는 방법에 대해서 알아보자.

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 의 응답을 스트리밍으로 처리하는 방법에 대해서 간략하게 알아보았다.
물론 서버와 주고받으려면 어느정도의 정형화된 형태와 그에 맞는 처리가 추가적으로 필요하긴 하지만, 그 부분은 알아서 잘 해보자!

profile
JavaScript, TypeScript and React-Native

1개의 댓글

comment-user-thumbnail
2024년 5월 26일

오늘의 교훈. 내가 겪은 문제는 이미 누군가 겪어서 해결까지해놨다. ㅋㅋ

답글 달기