본문 바로가기

web

챗 봇 스트리밍 통신

목차

작동 원리

구현 과정

오픈 소스 분석 중 마주친 클로저

마지막으로


 

GPT의 답변 성능과 속도가 반비례 관계에 있어서 이를 보완해야 했습니다.

openAI의 GPT3.5-turbo 모델을 쓰면 속도는 빠르지만, 답변 성능이 떨어졌습니다. 반대로, GPT4 모델을 쓰면 답변 성능은 좋았지만, 답변 시간이 오래 걸렸습니다. 답변 양에 따라 사용자가 10초 이상 로딩 화면만 보는 상황도 발생했습니다.

이 문제를 해결하기 위해 답변 성능을 위해 GPT4 모델을 사용하고, 답변 속도는 사용자 경험 개선하는 방향으로 고민했습니다. 사용자 경험은 텍스트가 화면에 실시간으로 한 글자씩 스트리밍 되는 방법으로 보완하기로 했습니다. 텍스트 스트리밍은 SSE API를 활용했습니다.

💡 Server-sent events(SSE)
sever-sent events는 클라이언트의 요청 없이 서버가 데이터를 보낼 수 있습니다. 일반적으로는 클라이언트가 서버에 데이터를 요청해야 합니다.
웹 소켓으로도 텍스트 스트리밍을 구현할 수 있지만, 서버에서만 보내면 되는 단방향 통신이므로 웹 소켓을 사용하지 않았습니다.

 

 

 

작동 원리


fetch로 구현된 SSE

SSE를 구현하기 위해 fetchEventSource 오픈소스를 사용했습니다. 해당 오픈소스는 HTML5에서 제공하는 EventSource 인터페이스를 사용하지 않고, fetch API로 직접 구현되어 있었습니다.

직접 구현한 이유는 EventSource가 request.body를 사용하지 못하는 등의 제약이 있어서, fetch API를 기반으로 SSE 인터페이스를 구현했다고 했습니다.

 

구현 방법이 궁금해서 코드를 분석해 봤습니다.

fetchEventSource 코드

스트리밍 유지 방법

fetch의 signal 인자에 스트리밍을 중단시킬 수 있는 AbortController 객체를 바인딩합니다. 응답 결과는 ReadableStream 인터페이스로 반환받아, 서버에서 넘어오는 데이터를 파싱합니다. 연결은 signal이 false가 되기 전까지 유지되며, 클라이언트에서 중단시킬 수 있습니다.

 

응답 데이터 파싱

서버로부터 ReadableStream 청크가 넘어오면, getLines와 getMessages 함수에 의해 파싱됩니다. Event stream format에 맞게 한 글자씩 객체로 반환되는데, 이 객체에는 id, event, data, retry 정보가 포함됩니다. 이 객체는 클라이언트의 onmessage 함수의 파라미터로 전달됩니다.

 

 

 

구현 과정


클라이언트

클라이언트 요청/응답

 

데이터 전송이 완료되면, 연결이 중단되도록 fetchEventSource에 AbortController 객체의 signal을 전달합니다.

서버로부터 넘어오는 객체는 onmessage 함수에서 처리합니다. 객체 유형은 다음과 같이 나뉩니다.

  • metadata가 포함되지 않은 경우: GPT가 한 글자씩 답변 중입니다.
  • metadata가 포함된 경우: 답변의 막바지로, metadata에는 GPT의 답변에 대한 출처가 포함됩니다.
  • [DONE]: 데이터 전송이 완료된 상황입니다. 연결을 중단하기 위해 ctrl.abort() 함수를 호출하고, 더 이상 데이터를 기다리지 않습니다.

서버

이벤트 스트리밍을 위해 Content-Type 헤더를 text/event-stream으로, Connection 헤더를 ‘keep-alive’로 설정하고 연결을 유지합니다. 스트리밍 데이터는 res.write로 한 글자씩 클라이언트에 응답합니다.

 

GPT 설정 또한 스트리밍으로 답변하도록 streaming 여부를 설정합니다. 그리고 callbackManager를 통해 GPT 답변을 한 글자씩 클라이언트로 전송합니다. 호출된 onTokenStream 함수는 앞서 정의한 sendData 함수입니다.

 

GPT 답변 스트리밍이 완료되면, 답변의 출처를 표시할 수 있도록 metadata를 클라이언트에 전송합니다.

 

마지막으로, [DONE] 텍스트로 클라이언트에 완료 표시를 합니다.

 

 

오픈소스 분석 중 마주친 클로저


클로저 호출 함수

 

onLine 클로저

 

onChunk 클로저

 

처음 클로저가 적용된 코드를 봤는데, 함수의 내부 변수가 마치 클래스의 내부 변수처럼 값을 유지하는 것을 보고 이해가 안 되었습니다. getLines와 getMessages 함수 둘 다 클로저가 있었습니다.

getMessages의 클로저 onLine은 getLines의 파라미터가 되고, getLines의 클로저 onChunck는 getBytes의 파라미터였습니다.

이번에 클로저를 학습하면서 렉시컬 환경이라는 개념을 배웠고, 코드 구조를 이해하는 데 도움이 됐습니다.

오픈소스 클로저의 렉시컬 환경 관계도

 

클로저인 onChunk, onLine이 여러번 실행되더라도 갱신되는 변수는 외부 렉시컬 환경에 존재하기 때문에, buffer와 message라는 변수가 값을 유지할 수 있었습니다.

💡 렉시컬 환경
코드에서 변수에 접근할 땐, 먼저 내부 렉시컬 환경을 검색 범위로 잡습니다. 찾지 못하면 참조하는 외부 렉시컬 환경으로 검색 범위를 확장하고 전역 범위까지 확대됩니다. 변수의 값은 호출된 곳이 아닌, 선언되었던 렉시컬 환경에서 갱신됩니다.

 

 

 

마지막으로


사용자 경험의 측면에서 문제를 해결해 보았습니다. SSE를 활용해 실시간으로 응답을 제공함으로써 사용자의 대기 시간을 줄일 수 있었습니다. 이로 인해 사용자 이탈을 감소시키고, 새로운 GPT 모델이 가지는 기술의 한계를 보완할 수 있었습니다. 개발자가 가지는 문제 해결 방법도 기술과 성능이 전부가 아니라는 것을 배울 수 있었습니다.

또, 클로저와 렉시컬 환경을 학습하면서 변수나 함수 선언, 그리고 코드 동작에 조금 더 논리적으로 접근해 볼 수 있었습니다. 오픈 소스를 살펴보고, 여러 기술을 이해하면서, 다른 사람의 코드를 분석하는 데 도움이 됐습니다.

'web' 카테고리의 다른 글

React 개발자가 구글 oAuth를 이해하기  (0) 2025.03.21
Core Web Vitals  (0) 2023.07.28
DOM (Document Object Model)  (0) 2023.07.09