React Native에서 OpenAI 어시스턴트 API 스트리밍 구현

NARARIA03·2025년 1월 10일
post-thumbnail

개요

최근 진행중인 팀 프로젝트의 주된 요구사항 중 하나가 어시스턴트 API를 활용해 사용자에게 오늘 하루동안 있었던 일을 물어보고 공감하며 이야기를 끌어내도록 하는 챗봇 서비스 연동이었다.

어시스턴트 API 프롬프트는 이미 만들어져 있었고, 앱에서 연동해서 호출만 하면 되는 건데, 개인적인 욕심으로 스트리밍까지 구현해보려고 하던 도중 발생한 문제 해결 과정을 정리해보려고 한다.

앱은 ExpoRN으로 구현했고, openai 라이브러리를 활용해 구현하다 스트리밍을 위해 최종적으로 react-native-sse 라이브러리까지 도입해서 해결할 수 있었다.

처음부터 자세하게 이야기 해보겠다.


Why Expo?

처음 RN을 접했을 때만 해도 Expo는 많은 라이브러리와 네이티브 모듈을 지원하지 않는다는 이유로 사용하지 말라는 의견이 지배적이었다.

하지만, 최근 국내 / 해외 커뮤니티 투표 결과나 추천 글을 보면 RN CLI 대신 Expo를 사용하라는 의견이 많아졌다.

왜 그런지 궁금해서 Expo 공식 문서를 살펴보았더니 눈에 띄는 이유가 몇 가지 보였다.

  1. 예전과 달리 대부분의 라이브러리를 eject 없이 바로 사용 가능 (prebuild)

  2. 다양하고 편리한 Expo SDK, EAS를 활용한 클라우드 코드 업데이트(코드푸시) 등의 편의성

  3. RN 측에서 공식적으로 Expo를 밀어줌

  4. 개발자 도구가 강력 (Element, Network, React Devtools 등 활용 가능)

Expo vs bare React Native project (Reddit)

A: Expo는 더 이상 어떤 것에도 제한을 두지 않습니다. 그러니 사용하지 않을 이유가 없습니다.

A: 예전 Expo는 여러분이 할 수 있는 일을 제한하곤 했습니다. 일부 네이티브 라이브러리와 함께 제공되었지만, 추가적인 네이티브 라이브러리는 사용할 수 없었습니다. 하지만 더 이상 그렇지 않습니다. 이제 Expo에서 원하는 네이티브 라이브러리와 사용자 지정 네이티브 코드를 사용할 수 있습니다. RN CLI에서 할 수 있는 모든 것을 할 수 있고 그 이상을 할 수 있습니다. 그러니 사용하지 않을 이유가 없습니다.

내가 알고 있던 단점이 사라지고 장점만 남은 것 같아, 이번 프로젝트에서 앱 개발에 Expo를 사용하기로 마음 먹었다.

또한, 최근 개인적으로 관심을 가지고 있는 패키지 매니저인 pnpm을 쉽게 사용할 수 있다는 점도 맘에 들었다.

예전에는 CLI로 시작해 pnpm을 사용하려면 추가적인 설정과 라이브러리를 사용해야 하는 것으로 보였으나(참고), RN이 심볼릭 링크를 실험적으로 지원하도록 업데이트되었다는 정보도 있어 혼란스러웠다.

반면, Expo에서는 pnpm으로 시작하는 공식 문서가 존재해 마음이 한결 편했다. pnpm 외에도 npm, yarn, yarn pnp, bun 등을 모두 안내해주고 있다.

지금 생각해보면, RN에서 심볼릭 링크를 지원하도록 업데이트 되고, 이에 맞춰 Expo가 업데이트한 뒤 공식 문서에 반영한 것 같다.
.npmrc 파일만 추가해서 node-linker=hoisted라고 명시해주면 pnpm 설정이 끝난다.


Expo로 프로젝트 생성하기

공식 문서를 따라가면 된다!

Expo Go 대신, Development build(expo-dev-client)를 활용할 것이다.

전환이 가능하긴 하지만, Expo Go는 Expo SDK 외의 네이티브 모듈이 포함되면 실행되지 않으며 딥링크/oAuth 등을 지원하지 않는다. 샌드박스 환경에서 실행시키기 때문이다. Expo 개발자 블로그

굳이 Expo Go가 필요한 환경이라면 앱을 설치하지 않고 디바이스에서 테스트를 해야 하는 상황 정도인데, 나라면 그냥 선 연결해서 설치할 것 같다. 또, Expo 측에서도 유연하고 강력한 개발 환경을 위해 Development build를 추천한다.

EAS를 사용하지 않고 Development build를 사용하는 경우 Xcode, Watchman, Android studio, JDK 등의 설치가 필요하다. 설치 방법은 공식 문서에 잘 소개되어 있으므로 패스하겠다.

pnpm dlx create-expo-app@latest

이후 프로젝트 이름을 입력해주면 주르륵 진행된다.

✅ Your project is ready!

To run your project, navigate to the directory and run one of the following pnpm commands.

- cd blog-app
- pnpm run android
- pnpm run ios
- pnpm run web

CLI에 비해 깔끔하고 빠르며 군더더기 없는 느낌이 들었다.. 좋다!

Development build를 사용할 것이기 때문에 expo-dev-client를 설치하고 빌드해줘야 한다.

.gitignore에 android/ios/ 폴더를 추가해주면 좋다.

cd blog-app

pnpm dlx expo install expo-dev-client

pnpm dlx expo run:ios|android
pnpm dlx expo run:ios
› Apple bundle identifier: com.anonymous.blog-app
✔ Created native directory
✔ Updated package.json
✔ Finished prebuild
✔ Installed CocoaPods
› Planning build
› Executing react-native Pods/hermes-engine
...

Apple bundle identifier도 자동으로 넣어주고, CocoaPods 설치도 알아서 수행해준다..!! 이후 컴파일링을 쭉 수행한 뒤 완료되면 에뮬레이터에서 실행시켜준다.

앞으로는 네이티브 모듈을 수정하게 되면 pnpm dlx expo run:ios|android를 통해 재빌드 해주면 되고, 그 외에는 pnpm run start를 통해 metro 개발 서버에서 에뮬레이터를 열면 된다.

app, components, hooks 등의 폴더에 기본 코드가 작성되어 있는 것을 알 수 있는데, 짜임새가 좋아서 한 번 읽어보는 것도 괜찮은 것 같다.

기본 코드들을 전부 지우고 싶다면 pnpm run reset-project 명령어를 사용하면 된다. 사용하자.

ESLint가 기본적으로 비활성화 되어 있는데, 활성화하려면 pnpm run lint 명령어를 사용하면 된다.
Expo 프로젝트에 적절하게 자동으로 설정해준다. 린터는 쓰는게 무조건 이득이라고 생각하므로 사용하자.


에러 발생과 해결

› 0 error(s), and 5 warning(s)

Starting Metro Bundler
Error: Cannot find module '@expo/server/build/vendor/http'

@expo/server 라는 모듈을 찾을 수 없다는 에러가 발생했다. 이 에러가 개인적으로 여러 번 발생했고, 관련 이슈가 거의 없어 명확한 원인을 찾기 위해 계속 실험하다 해결책을 찾을 수 있었다.

expo-dev-client를 설치한 뒤, pnpm dlx expo run:ios 대신 pnpm dlx expo prebuildpnpm run ios 명령어를 사용하면 정상적으로 빌드가 수행된다. 아마 일시적인 버그인 것으로 보이며, 추후 해결될 것 같다.

최종적으로 25-01-07 기준, pnpm을 활용해 Expo 프로젝트를 생성하는 명령어는 아래와 같다.

pnpm dlx create-expo-app@latest

cd project-name

pnpm dlx expo install expo-dev-client

pnpm dlx expo prebuild

pnpm run ios|android

# 정상 빌드 확인 후 예시 코드 제거
pnpm run reset-project
# ESLint 활성화
pnpm run lint

package.json을 보면, pnpm run ios 명령어와 pnpm dlx expo run:ios가 사실상 동일해 보이는데, 실행 결과는 왜 다른지 잘 모르겠다.

"scripts": {
  "start": "expo start",
  "android": "expo run:android",
  "ios": "expo run:ios",
  },

문제의 원인은 잘 모르겠으나, 예상하기로는 심볼릭 링크로 인해 라이브러리를 못 불러오는 것이거나 hoisting되지 않은 node_modules 구조로 인해 발생하는게 아닐까 예상하고 있다.


OpenAI 어시스턴트 API 정리

출처

어시스턴트 API의 동작에 대해 공식 문서의 흐름대로 정리해보면 다음과 같다.

  1. 어시스턴트를 생성한다.

    • 어시스턴트를 매 번 새로 생성하면 계속 새로운 어시스턴트가 생성된다

    • 따라서 어시스턴트는 한 번만 생성한 뒤, 어시스턴트 리스트를 가져오는 함수를 활용해 어시스턴트 id를 확보하도록 로직을 짜는게 좋아 보인다

    • 아니면, 어시스턴트 id를 상수로 관리해도 괜찮을 것 같다

  2. 사용자가 채팅방에 접속하면, 스레드를 가져오거나 생성한다.

    • 진행 중인 프로젝트의 경우 새벽 4시를 기점으로 스레드를 삭제하고 새로 생성하는 방법을 사용했다

    • 하지만, 본 포스트에서는 앱에 접속할 때마다 새 스레드를 만든다고 가정하겠다

  3. 사용자가 TextInput에 텍스트를 입력하고 전송 버튼을 누르면, 스레드에 메시지를 추가한 뒤 어시스턴트에 넣고 Run을 수행한다.

    • 메시지를 추가할 때 스레드 id가 필요하고, Run을 수행할 때 스레드 id어시스턴트 id가 필요하다

    • 스트리밍 없이 구현하려면 Polling(클라이언트가 서버에게 처리가 끝났는지 주기적으로 요청을 보내 확인하는 것)을 수행해야 하는데, OpenAI SDK를 활용하면 함수 하나로 간단하게 구현 가능하다

    • 스트리밍을 수행하려면, OpenAI SDK로는 정상적으로 동작하지 않아 react-native-sse 라이브러리를 활용해야 한다

  4. Run이 끝나면 스레드에 어시스턴트가 생성한 메시지가 담겨있다.

    • state를 활용해 적절히 화면에 렌더링하면 된다

OpenAI SDK 설치

먼저 openai 라이브러리를 설치하자.

pnpm add openai@latest
pnpm run ios

그리고, 하나의 OpenAI 객체를 활용하는 여러 메소드와 객체를 export하기 위한 openai.ts 코드를 작성한다.

// openai.ts

import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.EXPO_PUBLIC_API_KEY,
});

export default openai;

환경변수에는 본인의 OpenAI API KEY를 기입하면 된다.

주의! 앱 내에 API KEY와 같은 민감 정보를 기입하는 것은 주의해야 한다. RN 공식 문서

앱 소스코드를 뜯으면, API KEY를 그대로 탈취할 수 있기 때문이다.
이러한 이유로 해당 프로젝트도 추후 프록시 서버를 거치도록 수정해 API KEY가 노출되지 않도록 할 예정이다.


어시스턴트 생성 / 가져오는 함수 구현

어시스턴트 생성은 지침이 주어진 LLM을 정의하는 것으로, API 또는 Playground에서 가능하다.

API로 간단한 어시스턴트를 생성하는 함수를 추가해보자.

// openai.ts

export const createAssistant = async () => {
  try {
    await openai.beta.assistants.create({
      name: "chatbot",
      instructions:
        "당신은 사용자의 이야기에 짧게 응답한 뒤, 관련된 짧고 가벼운 질문을 수행하는 친구입니다.",
      model: "gpt-4o-mini",
    });
    console.log("어시스턴트 생성 완료");
  } catch (e) {
    console.error(e);
    console.log("어시스턴트 생성 실패");
  }
};

그리고, 모든 어시스턴트 리스트를 반환하는 API를 활용해 name이 chatbot인 어시스턴트의 id를 반환하는 함수도 추가하자.

// openai.ts

export const getChatbotAssistantId = async () => {
  try {
    const { data } = await openai.beta.assistants.list();
    return data.find((assistant) => assistant.name === "chatbot")?.id || null;
  } catch (e) {
    console.log("챗봇 어시스턴트 id 가져오기 실패");
    return null;
  }
};

타입 내로잉을 할 수 있도록 내부에서 try-catch로 런타임 에러는 잡되, 에러가 발생하면 null을 반환하고 에러가 없으면 어시스턴트 id를 반환하도록 함수를 구성했다.

이제 앱의 진입점에서 createAssistant 함수를 한 번 실행한 뒤, 생성된 어시스턴트 id를 가져오도록 useEffect를 활용하면 될 것 같다.

// app/index.tsx

import { createAssistant } from "@/openai";
import { useEffect } from "react";
import { Text, View } from "react-native";

export default function Index() {
  useEffect(() => {
    createAssistant();
  }, []);
  
  return (
    <View>
      <Text>hello</Text>
    </View>
  );
}
(NOBRIDGE) LOG  어시스턴트 생성 완료

"어시스턴트 생성 완료" 로그를 확인한 뒤, getChatbotAssistantId 함수를 활용해 아래와 같이 어시스턴트 id를 가져와 state에 저장하는 코드로 변경하자.

// app/index.tsx

import { getChatbotAssistantId } from "@/openai";
import { useEffect, useState } from "react";
import { Text, View } from "react-native";

export default function Index() {
  const [assistantId, setAssistantId] = useState<string | null>(null);

  useEffect(() => {
    const init = async () => {
      const id = await getChatbotAssistantId();
      if (id) setAssistantId(id);
    };

    init();
  }, []);

  return (
    <View>
      <Text>hello</Text>
    </View>
  );
}

스레드 생성 함수 구현

스레드 생성은 간단하게 말해 LLM과 유저 간의 대화 내역을 만들고 유지하는 것이다.

OpenAI의 설명에 의하면 하나의 스레드당 메시지는 최대 100,000개로 제한된다. 그리고 메시지의 양이 모델의 컨텍스트를 초과하면, 스레드는 메시지를 스마트하게 요약하려 시도하며, 그 후 중요도가 낮다고 판단되는 메시지부터 완전히 제거한다고 한다.

이를 통해 컨텍스트 크기 초과 등의 문제를 고려하지 않고, 이전 대화 맥락을 손쉽게 유지할 수 있다.

스레드 사용 시 주의할 점이 있다. 유저가 입력한 텍스트는 직접 스레드에 메시지 형태로 추가해줘야 하지만, LLM이 생성해서 반환한 텍스트는 추가하지 않아도 자동으로 스레드에 들어간다는 점이다.

그럼 이제 스레드를 생성하는 함수를 추가해보자.

// openai.ts

export const createThread = async () => {
  try {
    const newThread = await openai.beta.threads.create();
    return newThread.id;
  } catch (e) {
    console.log("스레드 생성 실패");
    return null;
  }
};

스레드에 메시지를 추가하고 Run을 수행하는 작업에는 스레드 id만 필요하므로, id를 반환하도록 해줬다. 만약 런타임에서 에러가 발생하면 null을 반환한다.


메시지 생성 / 스레드에 추가하는 함수 구현

사용자가 입력한 텍스트와 스레드 id를 입력받아, 스레드에 메시지를 추가하는 함수를 추가해보자.

// openai.ts

export const addMessageByThreadId = async (input: string, threadId: string) => {
  try {
    await openai.beta.threads.messages.create(threadId, {
      role: "user",
      content: input,
    });
    return true;
  } catch (e) {
    console.log("스레드에 메시지 추가 실패");
    return false;
  }
};

비동기 처리의 성공/실패 여부를 boolean으로 반환하도록 해줬다.


스레드 메시지 리스트를 반환하는 함수 구현

대화 내역을 가져오기 위해 필요한 함수다. 스레드에 저장되어 있던 모든 메시지를 반환하는 함수를 추가해보자.

// openai.ts

export const getMessageByThreadId = async (threadId: string) => {
  try {
    const { data } = await openai.beta.threads.messages.list(threadId);
    return data.reverse().map((msg) => ({
      id: msg.id,
      role: msg.role,
      text: msg.content[0].text.value as string,
    }));
  } catch (e) {
    console.log("스레드 내 메시지 가져오기 실패");
    return null;
  }
};

openai.beta.threads.messages.list 함수가 반환하는 메시지 리스트는 역순으로 저장되어 있다. 이 리스트를 reverse 함수를 통해 뒤집어준 뒤 map 함수로 필요한 값만 뽑아내 반환하는 코드다.

비동기 작업이 실패하면 null을 반환하도록 해줬다.


간단한 UI 구성

동작만 확인할 수 있도록 최소한의 레이아웃을 잡아줄 것이다. 스타일링은 styled-components를 활용했다.

크게 세 가지 UI가 필요하다.

  1. 텍스트 입력을 받기 위한 TextInput

  2. 입력받은 텍스트를 제출하기 위한 TouchableOpacity

  3. 채팅 내역을 렌더링하기 위한 ScrollView

전체 레이아웃을 SafeAreaView로 감싼 뒤, 내부에 ScrollView와 View를 배치한다.

  • ScrollView: chats를 렌더링한다

  • View: TextInput과 TouchableOpaicty를 배치해 텍스트 입력과 제출을 수행한다

코드 블럭 가독성을 위해 styled-components 코드는 제거했다.

// app/index.tsx

import { createThread, getChatbotAssistantId } from "@/openai";
import { useEffect, useState } from "react";
import { Text } from "react-native";
import styled from "styled-components/native";

type Chat = {
  id: string;
  role: "assistant" | "user";
  text: string;
};

export default function Index() {
  const [assistantId, setAssistantId] = useState<string | null>(null);
  const [threadId, setThreadId] = useState<string | null>(null);

  const [input, setInput] = useState<string>("");
  const [chats, setChats] = useState<Chat[]>([]);

  const handleInputChange = (text: string) => setInput(text);
  
  const handleRun = () => {};

  useEffect(() => {
    const init = async () => {
      const assistantId = await getChatbotAssistantId();
      if (assistantId) setAssistantId(assistantId);

      const threadId = await createThread();
      if (threadId) setThreadId(threadId);

      console.log("어시스턴트id, 스레드id 준비 완료");
    };

    init();
  }, []);

  return (
    <Box>
      <ScrollBox>
        {chats.map((chat) => (
          <ChatBox key={chat.id} $role={chat.role}>
            <Text>
              {chat.role}: {chat.text}
            </Text>
          </ChatBox>
        ))}
      </ScrollBox>

      <InputBox>
        <Input onChangeText={handleInputChange} value={input} />
        <Button onPress={handleRun}>
          <Text>전송</Text>
        </Button>
      </InputBox>
    </Box>
  );
}

TextInput에는 input state를 연결하고, TouchableOpacity는 Run을 실행하는 핸들 함수 handleRun을 연결했다.

chats를 렌더링할 때 key는 API가 반환한 메시지 고유 id를 활용하고, role 값이 assistant면 좌측, user면 우측에 배치하기 위해 styled-components로 $role props를 넘겨줬다.

마지막으로 useEffect에서 컴포넌트 최초 마운트 시 어시스턴트 id를 가져오고, 스레드를 생성해 스레드 id를 가져오도록 구현했다.

(NOBRIDGE) LOG  어시스턴트id, 스레드id 준비 완료

정상적으로 동작하는 것을 알 수 있다!


Polling 방식으로 Run 구현

Polling 방식은 클라이언트가 서버에게 지속적으로 "끝났어?" 라고 물어보는 거라고 이해하면 된다. OpenAI 서버에게 요청을 날리고 텍스트 생성이 끝났는지 주기적으로 물어보다가, 완료되면 받아오는 방식이다.

이를 쉽게 구현할 수 있도록 OpenAI SDK에서 함수를 지원한다. createAndPoll 함수를 활용해서 Polling 방식으로 Run을 수행하는 함수를 구현해보자.

// openai.ts

export const runByPolling = async (assistantId: string, threadId: string) => {
  try {
    let run = await openai.beta.threads.runs.createAndPoll(threadId, {
      assistant_id: assistantId,
    });

    if (run.status === "completed")
      return await getMessageByThreadId(run.thread_id);

    return null;
  } catch (e) {
    console.log("runByPolling 에러 발생");
    return null;
  }
};

상태가 completed가 되면 getMessageByThreadId 함수를 호출해 메시지 리스트를 반환하고, 그 외의 상황에는 null을 반환하도록 구현했다.

이 함수를 TouchableOpacity와 연결된 handleRun 함수에서 호출해준다.

// app/index.tsx

export default function Index() {
  ...

  const handleRun = async () => {
    if (!assistantId || !threadId || !input) return;

    await addMessageByThreadId(input, threadId); // 스레드에 메시지 추가
    setChats((prev) => [...prev, { id: "1", role: "user", text: input }]); // 미리 업데이트
    setInput(""); // input state 비우기
    const newMessages = await runByPolling(assistantId, threadId); // polling 방식으로 Run 수행

    if (!newMessages) return;

    setChats(newMessages); // Run이 끝난 뒤 받은 messages 값으로 업데이트
  };

  useEffect(() => {
    ...
  }, []);

  return (
    <Box>
      <ScrollBox>
        {chats.map((chat) => (
          <ChatBox key={chat.id} $role={chat.role}>
            <Text>
              {chat.role}: {chat.text}
            </Text>
          </ChatBox>
        ))}
      </ScrollBox>

      <InputBox>
        <Input onChangeText={handleInputChange} value={input} />
        <Button onPress={handleRun}>
          <Text>전송</Text>
        </Button>
      </InputBox>
    </Box>
  );
}

어시스턴트 id와 스레드 id를 받아오는 과정부터 Polling 방식으로 Run을 수행하는 코드를 실행해본 결과, 잘 작동하는 것을 알 수 있다!

하지만 답변을 받기까지 시간이 꽤 오래 걸리고, 이는 사용성 저하로 이어지기 때문에 스트리밍 방식으로 변경하기로 마음먹게 되었다.


스트리밍 방식으로 Run 구현

스트리밍 방식으로 구현하는 경우에도 OpenAI SDK에서 함수를 제공하기 때문에 별 다른 걱정 없이 코드를 수정했다.

// app/index.tsx의 handleRun 함수

const handleRun = async () => {
  if (!assistantId || !threadId || !input) return;
  console.log("handleRun 함수 실행");

  const run = openai.beta.threads.runs
  .stream(threadId, {
    assistant_id: assistantId,
  })
  .on("textCreated", (text) => console.log("textCreated!"))
  .on("textDelta", (textDelta, snapshot) => console.log("textDelta", textDelta.value));
  };

그러나 아무리 시도해봐도 textCreated와 textDelta 이벤트 콜백은 실행되지 않았다.

요청은 정상적으로 날려지는 것으로 보이지만(네트워크탭) 이벤트 콜백에서 받아오질 못 하는 것으로 보였고, 나와 유사한 케이스가 있는지 찾아보기 시작했다.

그러던 중 stackoverflow에서 가장 유사한 상황을 발견했고, 답변을 보니 polyfill을 추가해야 한다거나 react-native-sse 라이브러리를 활용하라는 내용이 존재했다.

좀 더 조사하던 중 openai-react-native 라이브러리의 코드를 뜯어보니 이 역시 react-native-sse를 활용해서 구현되었다는 것을 알 수 있었다.

따라서 나는 react-native-sse를 사용해 구현하기로 마음먹었다.


react-native-sse로 server-sent-event 받기

OpenAI 공식 문서를 살펴보면 스트리밍 방식으로 Run을 수행하는 API 호출 방법을 알 수 있다.

curl https://api.openai.com/v1/threads/thread_123/runs \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "OpenAI-Beta: assistants=v2" \
  -d '{
    "assistant_id": "asst_123",
    "stream": true
  }'

이 엔드포인트로 react-native-sse를 활용해 요청을 날리고 이벤트 리스너를 연결해주면 된다.

먼저 라이브러리를 설치하고, Pods 종속성 등을 다시 잡기 위해 재빌드 해준다.

pnpm add react-native-sse
pnpm run ios

그 후 handleRun 함수를 수정하자. EventSource 부분은 아까 언급했던 stackoverflow 답변을 참고하며 구현했다.

// app/index.tsx

...
import EventSource from "react-native-sse";

...

const handleRun = async () => {
  if (!assistantId || !threadId || !input) return;
  console.log("handleRun 함수 실행");

  // 메시지를 스레드에 추가
  const res = await addMessageByThreadId(input, threadId);
  if (!res) return;

  const es = new EventSource(
    `https://api.openai.com/v1/threads/${threadId}/runs`,
    {
      headers: {
        "Content-Type": "application/json",
        "OpenAI-Beta": "assistants=v2",
        Authorization: `Bearer ${process.env.EXPO_PUBLIC_API_KEY}`,
      },
      method: "POST",
      body: JSON.stringify({
        assistant_id: assistantId,
        stream: true,
      }),
    }
  );

  es.addEventListener("open", (e) => console.log("open"));
  es.addEventListener("message", (e) => console.log(e));
  es.addEventListener("error", (e) => console.log(e));
};

header에 OpenAI-Beta를 넣어줘야만 어시스턴트 API를 사용할 수 있다.
스레드 id는 쿼리 파라미터에 들어가고, 어시스턴트 id는 body에 들어간다.

실행하면 open 로그가 한 번 뜨고 message 이벤트가 여러 번 발생하며 로그가 주르륵 찍히거나, 에러 로그가 찍힐 것으로 예상된다. 한 번 봐보자!

엥..뭔가 동작이 이상하다. open 이벤트만 주기적으로 한 번씩 실행되고 별 다른 액션이 없다..


어시스턴트API는 일반 API와 이벤트명이 다르다!

꽤 한참 삽질을 하다가 기본으로 돌아가자는 마음에 네트워크 탭에서 직접 결과를 가져와서 읽어봤다.

어..? 이벤트 명이 open, message가 아니다? OpenAI 공식 문서 이곳 저곳을 살펴보다 이벤트가 잘못되었다는 것을 알 수 있었다... (그럼.. openai-react-native 라이브러리는 뭐지?)

OpenAI 공식 문서에 의하면 총 12개의 이벤트가 존재한다. 이 중 실제로 사용할 만한 이벤트는 thread.message.created, thread.message.delta, done 3가지다.

  • thread.message.created: input을 초기화하고, 어시스턴트의 빈 메시지를 미리 만들어두기 위해 사용

  • thread.message.delta: 어시스턴트가 생성한 텍스트 하나하나 담겨있는 이벤트로, chats의 마지막 원소의 text 필드에 한 글자씩 추가시키기 위해 사용

  • done: 텍스트 생성이 끝났을 때 발생하는 이벤트로, 이벤트리스너와 EventSource 연결을 해제하는 등의 작업을 수행하기 위해 사용

react-native-sse 공식 문서에는 이벤트 타입을 커스텀하는 방법이 나와있다. 타입을 선언하고 제네릭으로 집어넣으면 된다!

이를 기반으로 handleRun 함수를 다시 구현해보자!

// app/index.tsx

import EventSource from "react-native-sse";

...

type AssistantEvents =
  | "thread.run.created"
  | "thread.run.queued"
  | "thread.run.in_progress"
  | "thread.run.step.created"
  | "thread.run.step.in_progress"
  | "thread.message.created"
  | "thread.message.in_progress"
  | "thread.message.delta"
  | "thread.message.completed"
  | "thread.run.step.completed"
  | "thread.run.completed"
  | "done";

...

const handleRun = async () => {
  if (!assistantId || !threadId || !input) return;
  console.log("handleRun 함수 실행");

  // 메시지를 스레드에 추가
  const res = await addMessageByThreadId(input, threadId);
  if (!res) return;

  // input, chats state에 미리 반영
  setInput("");
  setChats((prev) => [
    ...prev,
    { id: (prev.length + 1).toString(), role: "user", text: input },
  ]);

  const es = new EventSource<AssistantEvents>(
    `https://api.openai.com/v1/threads/${threadId}/runs`,
    {
      headers: {
        "Content-Type": "application/json",
        "OpenAI-Beta": "assistants=v2",
        Authorization: `Bearer ${process.env.EXPO_PUBLIC_API_KEY}`,
      },
      method: "POST",
      body: JSON.stringify({
        assistant_id: assistantId,
        stream: true,
      }),
    }
  );

  // thread.run.step.in_progress 이벤트 리스너 (어시스턴트 원소를 chats 배열 마지막에 추가)
  es.addEventListener("thread.run.step.in_progress", () => {
    console.log("run 생성 완료! chat 데이터에 어시스턴트 필드 추가");
    setChats((prev) => [
      ...prev,
      { id: (prev.length + 1).toString(), role: "assistant", text: "" },
    ]);
  });

  // thread.message.delta 이벤트 리스너 (chats 배열 마지막 원소의 text 필드를 업데이트)
  es.addEventListener("thread.message.delta", (event) => {
    console.log("스트림 메시지 수신");
    if (event.data) {
      const data = JSON.parse(event.data);
      setChats((prev) => {
        const last = prev[prev.length - 1];
        const newLast = {
          ...last,
          text: last.text + data.delta.content[0].text.value,
        };
        prev.pop();
        return [...prev, newLast];
      });
    }
  });

  // done 이벤트 리스너 (모든 이벤트 리스너와 EventSource를 제거)
  es.addEventListener("done", () => {
    console.log("모든 run 수행 완료! 이벤트 리스너와 see 연결 해제");
    es.removeAllEventListeners();
    es.close();
  });
};

...

(prev.length + 1).toString() 와 같은 형태로 id 필드를 관리할 때 꼼수를 쓰게 되었는데, 이는 리액트 렌더링 효율 저하로 이어질 수 있어 좋지 않다. 실제로는 uuid 등을 활용해 key가 고유성과 안정성을 가지도록 해주는 것이 중요하다.

물론, 배열의 순서가 바뀔 일은 아직 없기 때문에 렌더링 효율 저하는 발생하지 않을 것으로 보이긴 한다.. ^^

이제 다시 메시지를 주고받아보자!

스트리밍이 이제 잘 작동하는걸 확인할 수 있다!!!


전체 코드 소개

// openai.ts

import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.EXPO_PUBLIC_API_KEY,
});

export const createAssistant = async () => {
  try {
    await openai.beta.assistants.create({
      name: "chatbot",
      instructions:
        "당신은 사용자의 이야기에 짧게 응답한 뒤, 관련된 짧고 가벼운 질문을 수행하는 친구입니다.",
      model: "gpt-4o-mini",
    });
    console.log("어시스턴트 생성 완료");
  } catch (e) {
    console.error(e);
    console.log("어시스턴트 생성 실패");
  }
};

export const getChatbotAssistantId = async () => {
  try {
    const { data } = await openai.beta.assistants.list();
    return data.find((assistant) => assistant.name === "chatbot")?.id || null;
  } catch (e) {
    console.log("챗봇 어시스턴트 id 가져오기 실패");
    return null;
  }
};

export const createThread = async () => {
  try {
    const newThread = await openai.beta.threads.create();
    return newThread.id;
  } catch (e) {
    console.log("스레드 생성 실패");
    return null;
  }
};

export const addMessageByThreadId = async (input: string, threadId: string) => {
  try {
    console.log("addMessageByThreadId 함수 실행");
    await openai.beta.threads.messages.create(threadId, {
      role: "user",
      content: input,
    });
    return true;
  } catch (e) {
    console.log("스레드에 메시지 추가 실패");
    return false;
  }
};

export const getMessageByThreadId = async (threadId: string) => {
  try {
    const { data } = await openai.beta.threads.messages.list(threadId);
    return data.reverse().map((msg) => ({
      id: msg.id,
      role: msg.role,
      text: msg.content[0].text.value as string,
    }));
  } catch (e) {
    console.log("스레드 내 메시지 가져오기 실패");
    return null;
  }
};

export const runByPolling = async (assistantId: string, threadId: string) => {
  try {
    console.log("runByPolling 함수 실행");
    let run = await openai.beta.threads.runs.createAndPoll(threadId, {
      assistant_id: assistantId,
    });

    if (run.status === "completed")
      return await getMessageByThreadId(run.thread_id);

    return null;
  } catch (e) {
    console.log("runByPolling 에러 발생");
    return null;
  }
};

export default openai;
// app/index.tsx

import {
  addMessageByThreadId,
  createThread,
  getChatbotAssistantId,
} from "@/openai";
import { useEffect, useState } from "react";
import { Text } from "react-native";
import EventSource from "react-native-sse";
import styled from "styled-components/native";

type Chat = {
  id: string;
  role: "assistant" | "user";
  text: string;
};

type AssistantEvents =
  | "thread.run.created"
  | "thread.run.queued"
  | "thread.run.in_progress"
  | "thread.run.step.created"
  | "thread.run.step.in_progress"
  | "thread.message.created"
  | "thread.message.in_progress"
  | "thread.message.delta"
  | "thread.message.completed"
  | "thread.run.step.completed"
  | "thread.run.completed"
  | "done";

export default function Index() {
  const [assistantId, setAssistantId] = useState<string | null>(null);
  const [threadId, setThreadId] = useState<string | null>(null);

  const [input, setInput] = useState<string>("");
  const [chats, setChats] = useState<Chat[]>([]);

  const handleInputChange = (text: string) => setInput(text);

  const handleRun = async () => {
    if (!assistantId || !threadId || !input) return;
    
    const res = await addMessageByThreadId(input, threadId);
    if (!res) return;

    setInput("");
    setChats((prev) => [
      ...prev,
      { id: (prev.length + 1).toString(), role: "user", text: input },
    ]);

    const es = new EventSource<AssistantEvents>(
      `https://api.openai.com/v1/threads/${threadId}/runs`,
      {
        headers: {
          "Content-Type": "application/json",
          "OpenAI-Beta": "assistants=v2",
          Authorization: `Bearer ${process.env.EXPO_PUBLIC_API_KEY}`,
        },
        method: "POST",
        body: JSON.stringify({
          assistant_id: assistantId,
          stream: true,
        }),
      }
    );

    es.addEventListener("thread.run.step.in_progress", () => {
      setChats((prev) => [
        ...prev,
        { id: (prev.length + 1).toString(), role: "assistant", text: "" },
      ]);
    });

    es.addEventListener("thread.message.delta", (event) => {
      if (event.data) {
        const data = JSON.parse(event.data);
        setChats((prev) => {
          const last = prev[prev.length - 1];
          const newLast = {
            ...last,
            text: last.text + data.delta.content[0].text.value,
          };
          prev.pop();
          return [...prev, newLast];
        });
      }
    });

    es.addEventListener("done", () => {
      es.removeAllEventListeners();
      es.close();
    });
  };

  useEffect(() => {
    const init = async () => {
      const assistantId = await getChatbotAssistantId();
      if (assistantId) setAssistantId(assistantId);

      const threadId = await createThread();
      if (threadId) setThreadId(threadId);
    };

    init();
  }, []);

  return (
    <Box>
      <ScrollBox>
        {chats.map((chat) => (
          <ChatBox key={chat.id} $role={chat.role}>
            <Text>
              {chat.role}: {chat.text}
            </Text>
          </ChatBox>
        ))}
      </ScrollBox>

      <InputBox>
        <Input onChangeText={handleInputChange} value={input} />
        <Button onPress={handleRun}>
          <Text>전송</Text>
        </Button>
      </InputBox>
    </Box>
  );
}

const Box = styled.SafeAreaView`
  width: 100%;
  height: 100%;
`;

const ScrollBox = styled.ScrollView`
  width: 90%;
  height: 90%;
  padding: 8px;
  align-self: center;
`;

const InputBox = styled.View`
  width: 90%;
  display: flex;
  flex-direction: row;
  height: 8%;
  align-self: center;
  padding: 10px;
  gap: 12px;
`;

const Input = styled.TextInput`
  flex: 0.8;
  border: 1px solid black;
`;

const Button = styled.TouchableOpacity`
  flex: 0.2;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px solid black;
`;

const ChatBox = styled.View`
  align-self: ${(props: { $role: "assistant" | "user" }) =>
    props.$role === "assistant" ? "flex-start" : "flex-end"};
  width: 40%;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px solid black;
  padding: 8px;
`;

정리

이번에 어시스턴트 API를 스트리밍 형태로 구현하며 레퍼런스가 많지 않은 문제를 해결하기 위해 원인을 좁혀가며 해결하는 경험을 할 수 있었다.

OpenAI 공식 문서에 이벤트 종류들이 명시되어 있긴 했지만, 대부분의 react-native-sse 활용 레퍼런스에서 message 이벤트를 수신하길래 나는 open 이후에 발생하는 이벤트는 message로 수신하는 거라고 이해했었는데, 이는 잘못된 생각이었다.

현재 진행중인 프로젝트에서 앞으로도 몇 가지 기술적인 도전을 하게 될 것 같은데, 마무리가 잘 되고 나면 지금과 같은 형태로 정리해보는 시간을 가지려고 한다.

긴 글 읽어주셔서 감사합니다. 혹시 어시스턴트 API 스트리밍 구현을 시도하신다면 도움이 되었기를 바랍니다.

이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다..!


profile
신입 프론트엔드 개발자입니다. React와 RN 생태계를 좋아합니다.

0개의 댓글