ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React로 CLOVA X 클론코딩하기
    Front-End/react 2024. 10. 27. 23:50

    CLOVA X, ChatGPT 같은 생성형 AI 기반 채팅 서비스는 어떻게 만드는 걸까요? 랭체인, 라마인덱스 등 생성형 AI 개발을 위한 백엔드 프레임워크들은 다양한 방식으로 많이 쏟아져 나오고 있는데 정작 UI/UX나 프론트엔드 쪽으로는 생성형 AI 관련 아티클들을 많이 못 본 것 같아 아쉬움이 많습니다.

     

    사실 CLOVA X같이 AI 답변 결과가 스트리밍 형식으로 렌더링 되는 UI를 구현하려면 프론트엔드도 신경 써야 할 부분이 생각보다 많은데요. 이번 포스팅에서는 React를 사용하여 CLOVA X 같은 생성형 AI 기반 채팅 서비스는 어떻게 만들 수 있는지에 대해 공유드리도록 하겠습니다.

     


     

    🧑🏻‍🎨 UI 모델링

    먼저 서비스를 개발하기 전에는 UI모델링을 어떻게 가져가면 좋을지 설계하는 것이 가장 중요합니다. UI 모델링을 할 때는 다른 페이지에서 공통적으로 사용할 만한 모듈인가를 판단하고 렌더링이 얼마나 자주 일어나는지를 고려해 보시면 좋습니다.

     

    저는 위와 같은 상황들을 고려해서 CLOVA X를 다음과 같이 분리해서 생각해 보았습니다. 해당 화면은 실제 CLOVA X와 채팅할 수 있는 서비스 화면입니다.

     

    CLOVA X 채팅 에이전트 UI 분석

     

    거의 보편적으로 생성형 AI 기반 채팅 에이전트가 CLOVA X와 비슷한 구조로 구성되어 있다고 보시면 됩니다. 왼쪽에는 내비게이션바가 존재하여 에이전트와 히스토리를 볼 수 있는 영역이 있고 오른쪽에는 채팅창 영역과 대화를 입력할 수 있는 입력창이 존재합니다.

     

    메시지 영역을 따로 모델링하는 이유는 스트리밍 중에 결과 값을 주기적으로 업데이트해줘야 하기 때문인데요. 이 부분을 어떻게 구현해야 할지 고민해 보는 포인트가 바로 AI 기반 채팅 에이전트 개발의 핵심이라고 볼 수 있습니다.

     


     

    🤔 API는 어디서 호출해야 될까?

    앞서 AI 기반 채팅 에이전트는 스트리밍 렌더링이 중요하다고 말씀드렸었는데 과연 React에서 생성형 AI를 호출하기 위한 API는 아래 영역 중 어디서 선언해야 할까요?

     

    CLOVA X 호출 API는 어디서 관리

     

     

    통상적으로는 사용자가 입력창에서 메시지를 입력하니까 API도 해당 부분에 같이 있어야 할 것 같지만 실제로는 전체 영역에 해당하는 부분에서 정의가 되어야 합니다.

     

    입력창 영역에서도 물론 API를 호출할 수 있는 훅을 만들어서 관리를 할 수 있지만 API 호출 후 스트리밍 되는 결과를 채팅창에 실시간으로 반영해야 하기 때문에 생성형 AI 기반 채팅 스트리밍 로직은 최종적으로 메시지 상태값에 의존적일 수밖에 없습니다.

     

    입력창에서 API 호출 시 부모 컴포넌트를 거쳐서 상태 업데이트 필요

     

    따라서 이 경우에는 다음과 같이 채팅창 영역과 입력창 영역을 아우르는 부모 컨테이너에서 상태관리 하는 것으로 변경하여 관리하는 것이 확장성 측면에서 좋습니다.

     

    메세지 상태 및 API 호출 함수는 채팅 에이전트 최상위 컴포넌트에서 관리

     

    이때 메시지 상태 업데이트 함수도 그대로 props로 내려받아 사용하지 않고 API 호출 함수를 별도로 만들어서 props로 내리는 것이 좋습니다. 만약 상태 변경함수를 직접 사용하면 코드 간의 결합도만 높아지게 되고 재사용이 불가능해지기 때문에 이럴 때는 자식 컴포넌트에서 필요한 함수들을 부모로부터 꺼내 쓸 수 있도록 만드는 것이 좋은 방향입니다.

     

    이렇게 채팅창 내 최상위 컴포넌트에 여러 비즈니스 기능들을 구현해 놓고 hook 또는 context로 만들어서 모듈화 해놓으면 다양한 채팅 에이전트들에서 사용할 수 있도록 구현할 수 있습니다. 실제로 많은 사람들이 이런 식으로 hook을 만들어서 제공하고 있는 것 같습니다. npm에서 openai를 검색해 보면 사람들이 만들어놓은 다양한 라이브러리들을 확인해 보실 수 있습니다.

     

     

    openai-streaming-hooks

    React Hooks for streaming connections to OpenAI APIs. Latest version: 2.0.0, last published: a year ago. Start using openai-streaming-hooks in your project by running `npm i openai-streaming-hooks`. There are no other projects in the npm registry using ope

    www.npmjs.com

     

    하지만 이 경우에도 다음과 같이 스트리밍 중일때 잦은 리렌더링이 발생한다는 성능상의 이슈가 존재하기 때문에 주의를 해주셔야 되는데요.

     

    스트리밍 중에 전체 UI가 리렌더링 되는 현상 주의

     

     만약 위의 상황처럼 전체 컴포넌트에서 리렌더링이 발생하는 게 싫다면 메시지 컴포넌트에서 직접 API 호출 로직을 구현하셔도 됩니다. useEffect() 훅을 활용하면 메시지 내에서만 렌더링이 일어날 수 있도록 구현이 가능합니다.

     

    대신 메시지 컴포넌트가 API 호출의 주체가 되면 이 API를 실행시킬 수 있는 트리거가 필요합니다. 화면에서 트리거에 해당하는 부분은 입력창에서의 엔터키 또는 실행버튼이기 때문에 결국 메시지 컴포넌트는 부모의 변화를 감지하여 API를 실행하는 구조가 되어 부모 의존성이 생기게 됩니다.

     

    이렇게 구현하면 물론 스트리밍 중에 렌더링 되는 메시지의 상태를 분리해서 관리할 수 있기 때문에 성능상 이점이 있을 수 있지만 코드가 많이 복잡해지고 이해하기 어려운 코드가 되어버립니다.

     

    따라서 메시지 상태는 입력창도 메시지 컴포넌트도 아닌 부모 컴포넌트에서 관리되는 것이 조금 더 좋은 방법이라고 볼 수 있습니다. 대신 성능에 문제가 생기지 않도록 실행이 끝난 히스토리용 메시지 컴포넌트들에 대해서는 메시지 상태변화를 기반으로 React.memo(), useMemo() 등 메모이제이션 기법과 useCallback() 함수를 적절하게 사용하여 최적화해 주는 것이 중요합니다.

     


     

    🪄 context, hook을 활용해서 코드 리팩토링 하기

    앞서 UI 모델링을 마쳤다면 다음으로는 context와 hook을 정의해야 합니다. 지금까지 살펴본 바로는 채팅창과 입력창의 상태 및 업데이트 함수들이 대부분 상위 컴포넌트에서 관리되어야 하는데 이 요소들을 전부 하위 컴포넌트들에게 props drilling 방식으로 내려준다면 이는 코드 복잡성이 매우 높아지고 지저분해질 가능성이 높습니다.

     

    따라서 이 경우에는 context로 만들어서 관리하면 좋습니다. 가장 기본적인 API 호출에 필요한 데이터들을 예시로 context 구성하는 방법에 대해 살펴보도록 하겠습니다. 앞서 살펴본 CLOVA X는 보안상 form 타입으로 API를 호출하고 있어 payload 정보를 정확히 알 수는 없지만 네이버클라우드 LLM 빌더 서비스인 CLOVA Studio에서 테스트앱을 발급받으면 예시로 사용할 수 있는 API 스키마 정보를 알 수 있습니다.

     

    CLOVA Studio Completion API Spec

     

    CLOVA Studio에서 발급받은 API를 사용할 경우 다음과 같이 전송을 할 수 있는데요. 여기서 messages 옵션이 앞서 UI에서 보았던 채팅창의 메시지 부분에 해당합니다. API 호출시 필요한 옵션들이므로 모두 ContextProvider에서 상태로 관리할 수 있도록 구현 해보겠습니다.

     

    const ChatContext = createContext();
    
    export const useChatContext = () => {
        return useContext(ChatContext);
    };
    
    export const ChatCompletionProvider = ({ children }) => {
        const [state, dispatch] = useStore(chatReducer, {
            parameters: {
                topP: 0.8,
                topK: 0,
                ...
            }
        });
    
        return (
            <ChatContext.Provider value={{
                parameters: state.parameters
            }}>
                {children}
            </ChatContext.Provider>
        );
    };

     

    위와 같이 ChatContext를 만들어 놓으면 채팅 에이전트 컴포넌트의 메인함수에서 ChatCompletionProvider를 import하여 다음과 같이 감싸서 사용할 수 있게 됩니다.

     

    const Main = () => {
      const classes = useStyles();
    
      return (
        <div className={classes.root}>
            <ChatCompletionProvider>
                <ChatAgent />
            </ChatCompletionProvider>
        </div>
      );
    };
    
    export default Main;

     

    이제는 ChatCompletionProvider 하위 컴포넌트들에서는 API 호출 옵션들을 props drilling 해줄 필요 없이 다음과 같이 깔끔하게 사용이 가능해집니다.

     

    const Message = ({ message }) => {
        const classes = useStyles({ isHuman: message.role === 'user' });
        
        return (
            <div className={classes.messageContainer}>
                <div className={`${classes.message} ${message.meta.loading ? 'loading' : ''}`}>
                    {message.content}
                </div>
            </div>
        );
    };

     


     

    🚀 SSE API 호출 hook 만들기

    다음은 실제 API를 호출하는 hook을 만들어보도록 하겠습니다. 생성형 AI 채팅 에이전트의 경우 서버로부터 AI 모델이 생성해 낸 결괏값을 스트리밍 형식으로 내려받아야 하기 때문에 일반적인 API 호출방식과는 조금 달리 복잡할 수 있습니다. 

     

    따라서 생성형 AI 호출 로직은 hook으로 만들어서 관리하는 것이 좋습니다. 앞서 만든 ChatContext안에 포함해서 넣을 수도 있긴한데 로직이 복잡해질 가능성도 크고 채팅 에이전트뿐만 아니라 다른 에이전트들에서도 사용할 가능성도 많은 로직이기 때문에 이 경우에는 공통적으로 사용이 가능한 hook으로 분리해 놓는 것이 좋습니다.

     

    스트리밍 응답값을 받을 수 있는 라이브러리는 기본적으로 Web API 중에 EventSource가 있지만 EventSource는 기본적으로 헤더값 조작이 불가능합니다. 따라서 헤더에서 API 키값을 요구하는 CLOVA Studio API를 직접적으로 사용할 수 없게 되는데 이러한 문제점은 fetch의 getReader()함수를 사용하거나 microsoft(Azure)에서 오픈소스 라이브러리로 제공하고 있는 Fetch Event Source를 사용하여 해결할 수 있습니다.(헤더 설정의 예시로 들었지만 API 키는 보안상 서버에서 관리하는게 좋습니다.)

     

     

    @microsoft/fetch-event-source

    A better API for making Event Source requests, with all the features of fetch(). Latest version: 2.0.1, last published: 4 years ago. Start using @microsoft/fetch-event-source in your project by running `npm i @microsoft/fetch-event-source`. There are 169 o

    www.npmjs.com

     

    저는 fetch-event-source 위 라이브러리를 이용하여 hook을 다음과 같이 만들어 보았습니다.

     

    import { useState, useCallback } from 'react';
    import { fetchEventSource } from '@microsoft/fetch-event-source';
    
    export const useChatCompletion = (apiParams) => {
        const [messages, _setMessages] = useState(apiParams.messages || []);
        const [loading, setLoading] = useState(false);
        const [controller, setController] = useState(null);
    
        ...
    
        const invoke = useCallback(async (inputMessages) => {
            ...
    
            const newController = new AbortController();
            setController(newController);
    
            await fetchEventSource("/chat/completions", {
                method: 'POST',
                headers: { 
                    "Connection": "keep-alive", 
                    "Content-Type": "application/json", 
                    "Cache-Control": "no-cache", 
                    "changeOrigin": true
                },
                body: JSON.stringify({
                    ...apiParams,
                    messages: messages
                }),
                signal: newController.signal,
                onopen: (response) => {
                    console.log("open...");
                },
                onmessage: (stream) => {
                    let data = JSON.parse(stream.data);
                    if (data.choices[0].delta.content) {
                        handleNewData(data.choices[0].delta.content);
                    }
                },
                onclose: () => {
                    console.log("close...");
                },
                onerror: (error) => {
                    console.error("error...", error);
                }
            });
        }, [messages]);
    
        /* 필요한 상태 및 함수 */
        return {
            messages,
            loading,
            invoke,
            abort,
            resetMessages,
            setMessages
        };
    };

     

    이렇게 hook으로 만들어 놓으면 여러 에이전트 컨텍스트들에서 다음과 같이 사용할 수 있게 됩니다. 앞서 만들어놓았던 채팅 에이전트 컨텍스트 Provider를 가지고 와서 매핑해 보면 다음과 같이 사용할 수 있게 됩니다.

     

    alue={{
                parameters: state.parameters,
                ...useChatCompletion(state.parameters) /* API hook 호출 */
            }}>
                {children}
            </ChatContext.Provider>
        );
    };

     


     

    🌠 회고하기

    React로 CLOVA X와 같은 생성형 AI기반 채팅 에이전트는 어떻게 개발해야 좋을지 한번 살펴보았는데요. 이게 꼭 정답이다고 말씀드릴 수는 없지만 AI 업계에서 일하시는 프론트엔드 개발자분들은 어떤 식으로 개발하고 계신지 궁금하기도 하고 한 번쯤은 정리해보고 싶었던 주제가 아니었나 싶습니다.

     

    특히나 요즘, AI로 서비스 하나 뚝딱 만들 수 있는 시대라 프론트엔드 개발이 가벼워 보일 수 있지만 이게 사실 협업 단위로 들어가게 된다면 구조부터 꼼꼼히 설계해야 추후에 다른 사람들과 협업했을 때 문제가 발생할 수 있는 요소가 줄어들게 됩니다.

     

    지금은 비록 단일챗의 가벼운 에이전트로 예시를 들었지만 OpenAI 플랫폼에서 제공되고 있는 플레이그라운드나 구글의 Vertext AI 보시면 단순한 채팅이 아닌 더 복잡한 형태의 UI로 구성되어 있습니다. 이 때는 이중 스트리밍에 스크롤, 중지 기능 등 신경써야할 거리가 많은데 다음에 기회가 된다면 포스팅해 보도록 하겠습니다.

    댓글

Designed by Tistory.