๊ด€๋ฆฌ ๋ฉ”๋‰ด

๐Ÿ’ป๐Ÿ’ญ๐ŸŽง๐ŸŒ

๋ฆฌ์•กํŠธ React SSE ์‘๋‹ต ์ฒ˜๋ฆฌํ•˜๊ธฐ (ChatGPT stream) ๋ณธ๋ฌธ

์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ

๋ฆฌ์•กํŠธ React SSE ์‘๋‹ต ์ฒ˜๋ฆฌํ•˜๊ธฐ (ChatGPT stream)

adorableco 2024. 2. 13. 22:11
๋ฐ˜์‘ํ˜•

๊ธฐ์กด์— ๊ทธ๋ƒฅ ์™„์„ฑ๋œ ๋‹ต๋ณ€์œผ๋กœ ๋ฐ›์•˜๋˜ ChatGPT ์‘๋‹ต์„ SSE(Serve-Sent-Event) ๋ฅผ ์ด์šฉํ•ด ์‹ค์‹œ๊ฐ„์œผ๋กœ ์™„์„ฑํ•˜๋Š” ๊ฑธ๋กœ ๋ณ€๊ฒฝํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ๋ฐ›์•„์™€์•ผํ• ์ง€ ์—ฌ๋Ÿฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด๋Š”๋ฐ SSE ํ‘œ์ค€์€ ์• ์ดˆ์— POST ๋ฐฉ์‹์€ ์ง€์›์„ ํ•˜์ง€ ์•Š๋Š”๋‹ค๊ณ  ํ•œ๋‹ค. (๊ทธ๋ž˜์„œ SSE๋Š” ๋ณดํ†ต EventSource๋ฅผ ์ด์šฉํ•ด ์‘๋‹ต์„ ๋ฐ›์ง€๋งŒ ๋‹จ์ˆœ GET์ธ ๊ฒฝ์šฐ์—๋งŒ ๊ฐ€๋Šฅํ•˜๋‹ค.) ๊ทธ๋Ÿฐ๋ฐ ๋‚˜๋Š” ๋‚ด๊ฐ€ ์š”์ฒญํ•œ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ๋‹ต๋ณ€์„ ๋ฐ›์•„์•ผํ•˜๋Š” ๊ฒƒ์ด๋ฏ€๋กœ... ์‘์šฉ ๋ฐฉ์‹์„ ์—ฌ๋Ÿฟ ์ฐพ์•„๋ณด์•˜๋‹ค.

๊ทธ๋Ÿฌ๋˜ ์ค‘ ๋‚˜์˜ ์ƒํ™ฉ์— ์•„์ฃผ ๋”ฑ ๋งž๋Š” ์œ ํŠœ๋ฒ„์˜ ์„ค๋ช… ๋“ฑ์žฅ... ์ตœ๊ณ ์„ธ์š”...
https://www.youtube.com/watch?v=JxIQCOrsxxg

๋ฌผ๋ก  ์ด ๋ถ„์€ ChatGPT ๊ธฐ๋Šฅ์„ ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ฐ”๋กœ ๊ตฌํ˜„ํ•œ๊ฑฐ๋ผ ์•ฝ๊ฐ„ ์ฐจ์ด์ ์ด ์žˆ์—ˆ๊ธฐ์— ์ด์Šˆ๋Š” ๋ฌผ๋ก  ๋ฐœ์ƒํ–ˆ๋‹ค! (ใ…Žใ…Ž)

 

 


 

๊ธฐ์กด ์ฝ”๋“œ

  const onSendMsg = async (event) => {
    event.preventDefault();
    const li = document.createElement("li");
    li.className = styles.quest;
    li.innerText = msg;
    const ul = document.getElementById("msgList");
    ul.appendChild(li);
    try {
      setIsLoading(true);
      await axios
        .post(
          process.env.REACT_APP_API_URL + `/gpt/${chatId}`,
          {
            prompt: msg,
            gptConfigInfo: options,
          },
          {
            headers: {
              "Content-Type": "application/json",
              Authorization: "Bearer " + storedJwtToken,
            },
          },
        )
        .then((res) => {
          setResult(res.data.answer);
          setChatId(res.data.chat_room_id);
          setIsLoading(false);
        });
    } catch (error) {
      console.error(error);
      alert(error.message);
    }
  };
    • ๋‹จ์ˆœํ•˜๋‹ค! ๋ฉ”์‹œ์ง€๋ฅผ body์— ๋‹ด์•„ POST ๋กœ api์— ์š”์ฒญ์„ ํ•˜๋ฉด response์— ChatGPT์˜ ๋‹ต๋ณ€, ์ฑ„ํŒ…๋ฐฉ ๋ฒˆํ˜ธ๊ฐ€ ๋‹ด๊ธฐ๊ณ  useState ๋ณ€์ˆ˜์ธ isLoading์„ false๋กœ ์ „ํ™˜ํ•˜์—ฌ ๋กœ๋”ฉ์„ ์ •์ง€ํ•œ๋‹ค. ChatGPT์˜ ๋‹ต๋ณ€์€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ useState ๋ณ€์ˆ˜์ธ result์— ๋‹ด๊ธด๋‹ค.

โœ… ์—ฌ๊ธฐ์—์„œ ์ดˆ๋ฐ˜์— ์ƒ์„ฑ๋˜๋Š” li ํƒœ๊ทธ๋Š” ๋‚ด๊ฐ€ ๋ณด๋‚ด๋Š” ๋ฉ”์‹œ์ง€๋ฅผ ๋„์šฐ๊ธฐ ์œ„ํ•œ ์šฉ๋„์ด๋‹ค.

 

useEffect(() => {
    if (result != undefined) {
      const li = document.createElement("li");
      li.className = styles.response;
      li.innerText = result;
      document.getElementById("msgList").appendChild(li);
      setMsg("");
    }
  }, [result]);
  • ๊ทธ๋ฆฌ๊ณ  result ๊ฐ’์˜ ๋ณ€ํ™”๋ฅผ ๊ฐ์ง€ํ•˜์—ฌ, ๋ณ€ํ™”๊ฐ€ ์ƒ๊ธธ ๋•Œ๋งˆ๋‹ค, ์ฆ‰ ์ƒˆ๋กœ์šด ์‘๋‹ต ๋ฉ”์‹œ์ง€๊ฐ€ ์˜ฌ ๋•Œ๋งˆ๋‹ค li ํƒœ๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  innerText๋ฅผ result๋กœ ์„ค์ •ํ•ด์ฃผ๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ฉ”์‹œ์ง€ ๋‹ต๋ณ€์ด ์™„์„ฑ๋˜๋Š” ํ˜•ํƒœ์˜€๋‹ค.

 


 

SSE ์ ์šฉํ•˜๊ธฐ

์ „๋ฐ˜์ ์ธ ํ˜•ํƒœ๋Š” ๋น„์Šทํ•˜๋‚˜ ์กฐ๊ธˆ์”ฉ ๊ฝค ๋งŽ์ด ๋‹ฌ๋ผ์กŒ๋‹ค.

const onSendMsg = async (event) => {
    event.preventDefault();
    const li = document.createElement("li");
    li.className = styles.quest;
    li.innerText = msg;
    const ul = document.getElementById("msgList");
    ul.appendChild(li);

    await fetch(process.env.REACT_APP_API_URL + `/gpt/${chatId}`, {
      method: "POST",
      headers: {
        Authorization: "Bearer " + storedJwtToken,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        prompt: msg,
        gptConfigInfo: options,
      }),
    }).then(async (response) => {
      setMsg("");


      const reader = response.body
        .pipeThrough(new TextDecoderStream())
        .getReader();

      const li = document.createElement("li");
      li.className = styles.response;

      setExistLi(li);

      document.getElementById("msgList").appendChild(li);
      setMsg("");
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        let str = JSON.stringify(value);
        str = str.replace(/\n/gi, "\\r\\n");
        let json = JSON.parse(str);

        const cleanedStr = json.replace(/data:|\n/g, "").trim();

        let chatRoomId;
        const matchResult = cleanedStr.match(/\d+/);
        if (matchResult) {
          chatRoomId = matchResult[0];
        }

        let simpleText;
        simpleText = cleanedStr.replace(/\\r\\n/g, "").trim();
        const cleanedText = simpleText.replace(/chat_room_id:.*/, "");
        setResult((prev) => prev + cleanedText);

        if (chatRoomId) {
          setChatId(chatRoomId);
          setIsLoading(false);
        }
      }
      setResult("");
      setExistLi(null);
    });
  };
  • ๊ธฐ์กด ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•˜๋˜ axios ๋Œ€์‹  fetch ๋ฅผ ์‚ฌ์šฉํ–ˆ๋‹ค. axios ๊ฐ€ ์ŠคํŠธ๋ฆผ ์ฒ˜๋ฆฌ๊ฐ€ ์•„์˜ˆ ์•ˆ๋˜๋Š”๊ฑด์ง„ ๋ชจ๋ฅด๊ฒ ์ง€๋งŒ ์ฐธ์กฐํ•œ ์ฝ”๋“œ๋“ค ์ค‘์—์„œ axios๋ฅผ ์‚ฌ์šฉํ•œ๊ฑด ์—†์—ˆ๋‹ค.
  • ๊ธฐ์กด ์ฝ”๋“œ์—์„œ๋Š” ๋‹ต๋ณ€์ด ์˜ค๋ฉด ๊ทธ ๋ฉ”์‹œ์ง€๋กœ ํ•˜๋‚˜์˜ ๋‹ต์žฅ ๋ฉ”์‹œ์ง€ li๋ฅผ ๋งŒ๋“ค๋ฉด ๋ผ์„œ ๋ฌธ์ œ๊ฐ€ ์—†์—ˆ๋Š”๋ฐ SSE๋Š” ์ฒซ ์‹œ์ž‘์—๋Š” li ํƒœ๊ทธ๋ฅผ ์ƒˆ๋กœ ๋งŒ๋“ค์–ด์•ผ ํ•˜๊ณ , ๋‹ต๋ณ€์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ๊ณ„์† ๊ฐ™์€ li ํƒœ๊ทธ ๋‚ด์—์„œ innerText๋ฅผ ๋ฐ”๊ฟ”์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์— (๊ทธ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๋‹ต๋ณ€๊ณผ๋„ ๊ตฌ๋ถ„์ด ํ•„์š”ํ•จ) ์ด๊ฑธ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ์ง€๊ฐ€ ๊ณ ๋ฏผ์ด์—ˆ๋‹ค.
  • ๋‚˜๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด ํ˜„์žฌ li ํƒœ๊ทธ๊ฐ€ ์กด์žฌํ•จ์„ ์•Œ ์ˆ˜ ์žˆ๊ฒŒ, ๊ทธ๋ฆฌ๊ณ  ๋‹ต๋ณ€ ์ŠคํŠธ๋ฆผ์ด ์˜ฌ ๋•Œ๋งˆ๋‹ค ํ•ด๋‹น li ํƒœ๊ทธ์˜ innerText ๊ฐ€ ๊ณ„์† ๋ฐ”๋€” ์ˆ˜ ์žˆ๋„๋ก useState๋กœ ๋ณ€์ˆ˜๋ฅผ ํ•˜๋‚˜ ์ƒˆ๋กœ ๋งŒ๋“ค์—ˆ๋‹ค.
    const [existLi, setExistLi] = useState(null);
  • ๊ทธ๋ฆฌ๊ณ  ๋‹ต๋ณ€ ์ŠคํŠธ๋ฆผ์˜ done์ด true๊ฐ€ ๋˜์–ด์„œ ์ข…๋ฃŒ๊ฐ€ ๋  ๋•Œ, existLi ๋ฅผ ๋‹ค์‹œ null๋กœ ๋งŒ๋“ค์–ด์ฃผ๋ฉด ๋‹ค์Œ ๋‹ต๋ณ€์—์„œ๋Š” ์ƒˆ๋กœ์šด li ํƒœ๊ทธ๋ฅผ ๋งŒ๋“ค์–ด์„œ ๋‹ต๋ณ€ ๋ฐ›๊ธฐ๋ฅผ ์‹œ์ž‘ํ•  ๊ฒƒ์ด๋‹ค.

 

๊ทธ๋ž˜์„œ useEffect ์—์„œ result ๊ฐ’์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค li ํƒœ๊ทธ๋ฅผ ์ƒˆ๋กœ ๋งŒ๋“ค์—ˆ๋˜ ๊ธฐ์กด๊ณผ๋Š” ๋‹ฌ๋ฆฌ

 useEffect(() => {
    if (existLi != null) {
      existLi.innerText = result;
    }
  }, [result]);

 

๋‹ต๋ณ€ ๋ฐ›๊ธฐ๊ฐ€ ์‹œ์ž‘๋œ li ํƒœ๊ทธ๊ฐ€ ์žˆ๋‹ค๋ฉด (existLi ํƒœ๊ทธ๊ฐ€ ์กด์žฌํ•œ๋‹ค๋ฉด), ๊ทธ ํƒœ๊ทธ์˜ innerText๋ฅผ ๊ฐฑ์‹ ๋œ result๊ฐ’์œผ๋กœ ์น˜ํ™˜ํ•ด์ฃผ๋Š” ๊ฒƒ์œผ๋กœ ๋ณ€๊ฒฝํ•˜์˜€๋‹ค.

 


 

์‚ฌ์‹ค ์‘๋‹ต์—์„œ (ChatGPT์˜) ๋‹ต๋ณ€์„ ์ถ”์ถœํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ํž˜๋“  ๊ณผ์ •์ด์—ˆ๋‹ค

์•„๋ž˜์˜ while ๋ฌธ์€ reader.read() ์—์„œ ๊ตฌ์กฐ ๋ถ„ํ•ด ํ• ๋‹นํ•œ done ๊ฐ’์ด true๊ฐ€ ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€ ๋ฐ˜๋ณต๋˜๋Š” ๊ตฌ์กฐ์ด๋‹ค.
์—ฌ๊ธฐ์„œ reader ์€ ์œ„์—์„œ ๋ณผ ์ˆ˜ ์žˆ๋“ฏ์ด response.body๋ฅผ pipeThrough(new TextDecoderStream()) ์œผ๋กœ ์ŠคํŠธ๋ฆผ์„ ํ†ตํ•ด ์˜ค๋Š” ํ…์ŠคํŠธ๋ฅผ ๋””์ฝ”๋”ฉํ•˜๊ณ , .getReader() ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์„ ์ˆ˜ ์žˆ๋Š” ๋ฆฌ๋”(Reader)๋ฅผ ๊ฐ€์ ธ์˜จ ๊ฒƒ์ด๋‹ค.

while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        let str = JSON.stringify(value);
        str = str.replace(/\n/gi, "\\r\\n");
        let json = JSON.parse(str);

        const cleanedStr = json.replace(/data:|\n/g, "").trim();

        let chatRoomId;
        const matchResult = cleanedStr.match(/\d+/);
        if (matchResult) {
          chatRoomId = matchResult[0];
        }

        let simpleText;
        simpleText = cleanedStr.replace(/\\r\\n/g, "").trim();
        const cleanedText = simpleText.replace(/chat_room_id:.*/, "");


        setResult((prev) => prev + cleanedText);

        if (chatRoomId) {
          setChatId(chatRoomId);
          setIsLoading(false);
        }
      }
      setResult("");
      setExistLi(null);
    });
  • ์œ„ ์ฝ”๋“œ์— replace ๊ฐ€ ๊ต‰์žฅํžˆ ๋งŽ์€ ์ด์œ ๋Š”... ์ฒ˜์Œ์— ๊ทธ๋ƒฅ value ๋ฅผ ์ฝ˜์†”์— ์ฐ์–ด๋ณด๋‹ˆ ์•„๋ž˜์™€ ๊ฐ™์ด ์•ž์— 'data: ' ๊ฐ€ ์ฐํ˜€์„œ ๋‚˜์™”๋‹ค


์•„๋ฌด๋ฆฌ ์ƒ๊ฐํ•ด๋„ ์„œ๋ฒ„ ์ธก์—์„œ 'data: ' ๋ฅผ ๋„˜๊ฒจ์คฌ์„๋ฆฌ๋Š” ์—†์„ํ…Œ๊ณ  ๋‹น์—ฐํžˆ json ํ˜•์‹์ด๊ฒ ์ง€ ํ•˜๊ณ  value.data ๋ฅผ ์ฐ์œผ๋‹ˆ ์ด๋ฒˆ์—” undefined ๊ฐ€ ๋‚˜์™”๋‹ค. ๊ต‰์žฅํžˆ ํ˜ผ๋ž€์Šค๋Ÿฌ์› ๋‹ค.. ๊ทธ๋ž˜์„œ 'data: '๋ฅผ replace๋กœ ์—†์• ๋ ค๊ณ  ํ•ด๋ณด๊ธฐ๋„ ํ•˜๊ณ  ๋ณ„์ง“์„ ๋‹คํ•˜๋‹ค๊ฐ€ value๋ฅผ JSON.stringfy() ๋กœ ๋ฌธ์ž์—ดํ™”ํ•ด๋ณด๋‹ˆ data ๊ฐ’์— ๋‹ต๋ณ€๊ณผ ๋‹ค๋Ÿ‰์˜ ๊ฐœํ–‰๋ฌธ์ž๊ฐ€ ์„ž์—ฌ ์žˆ์Œ์„ ์•Œ๊ฒŒ ๋˜์—ˆ๋‹ค.....

 

json์— ๊ฐœํ–‰๋ฌธ์ž๊ฐ€ ์žˆ์œผ๋ฉด json์œผ๋กœ ์ธ์‹์„ ํ•  ์ˆ˜๊ฐ€ ์—†๋‹ค๊ณ  ํ•œ๋‹ค. ์ด๋Ÿฐ ๊ฒฝ์šฐ์—๋Š” JSON.Stringfy() ํ•œ ๋’ค์— ๊ฐœํ–‰๋ฌธ์ž๋ฅผ ๋ชจ๋‘ ์—†์• ๊ณ  ๋‹ค์‹œ JSON.parse() ๋กœ ๋ฐ”๊ฟ”์„œ json์œผ๋กœ ์ธ์‹ํ•˜๋„๋ก ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๋‹ค๊ณ  ํ•œ๋‹ค. ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

 

let str = JSON.stringify(value);
str = str.replace(/\n/gi, "\\r\\n");
let json = JSON.parse(str);

 

๊ทธ๋ฆฌ๊ณ  ๋ช‡์ฐจ๋ก€ ๋” replace๋กœ ์น˜ํ™˜์„ ํ•ด์คฌ๋Š”๋ฐ ์‚ฌ์‹ค ์ผ๋‹จ ๊ฒ‰์œผ๋กœ ๋ณด์ด๋Š”๊ฒŒ ์ •์ƒ์œผ๋กœ ๋ณด์ผ ๋•Œ๊นŒ์ง€ ์ด๊ฒƒ์ €๊ฒƒ ํ•œ๊ฑฐ๋ผ ๋‹ค์‹œ ๋ณด๋‹ˆ ์–ด๋–ป๊ฒŒ ํ•œ๊ฑด์งˆ ๋ชจ๋ฅด๊ฒ ๋‹ค..

 

 const cleanedText = simpleText.replace(/chat_room_id:.*/, "");
  • chat_room_id ๊ฐ€ value.data.chat_room_id ๊ฐ™์€ ํ˜•์‹์œผ๋กœ ์ฐธ์กฐํ•˜๋ฉด ๋  ๊ฒƒ์ด๋ผ๊ณ  ์ƒ๊ฐํ–ˆ๋Š”๋ฐ ์ด์ƒํ•˜๊ฒŒ 'data: ์š”data:chat_room_id:105' ์™€ ๊ฐ™์ด ๋งˆ์ง€๋ง‰ ๋‹ต๋ณ€ ๋ฉ”์‹œ์ง€์— ์‚ฌ์ด ์ข‹๊ฒŒ ๊ผญ ๋ถ™์–ด์„œ ์™€๋ฒ„๋ ค์„œ ๊ทธ๋ƒฅ replace๋กœ ์–ต์ง€๋กœ ๋–ผ๋ฒ„๋ ธ๋‹ค...^^ ๊ทธ๋ฆฌ๊ณ  ๊ทธ๋ƒฅ ์•ŒํŒŒ๋ฒณ d ์™€ ๋งค์นญ์ด ๋˜๋ฉด ๋’ท ๋ฒˆํ˜ธ๋ฅผ chatRoomId ๋ณ€์ˆ˜์— ์ €์žฅํ•˜๋„๋ก ํ–ˆ๋‹ค. ์•„๋ฌด๋ž˜๋„ ์•ผ๋งค์ง€๋งŒ ์ง€๊ธˆ ์ƒํ™ฉ์—์„  ์ตœ์„ ์ด๋ผ๊ณ  ์ƒ๊ฐํ–ˆ๋‹ค!

 

๊ฒฐ๊ณผ๋ฌผ์ด ์•„์ฃผ ๋งŒ์กฑ์Šค๋Ÿฝ๋‹ค!

๋ฐ˜์‘ํ˜•