AI 질의응답 개발기
AI 질의응답 기능을 구현한 과정을 기록합니다.
올해 초, 제가 담당하고 있는 프로덕트에서 AI 질의응답 기능을 만들게 됐습니다. 저희만이 가지고 있는 학습자료, 커리큘럼, 수강생의 학습 진도, 현재 부여된 과제 등의 데이터를 활용하여 AI가 최적화된 가이드를 제공해 학습을 돕고자 했어요.
저는 FE 개발자이기에 화면을 담당했었습니다. 평소에 해오던 개발과는 새로운 부분이 있었고, Claude, Gemini와 같이 유사 제품들의 동작 원리들을 조사하며 많은 것들을 배울 수 있게 되었어요.
AI 질의응답을 구현하기 위해 수많은 방법이 있겠지만 저희는 어떻게 구현을 했는지 써 내려가 보겠습니다.
AI 질의응답의 전체 흐름
클라이언트에서 프롬프트를 입력하면 서버가 어떻게 답변을 추론해내어 실시간으로 답변을 전달하게 될까요? 이를 위해 저희가 구현한 구조를 도식으로 표현하면 다음과 같습니다.
사용자가 prompt를 입력하면 서버에서 session, 즉 대화방을 생성해요. 이후 생성된 대화방 페이지로 이동한 다음 prompt를 전송합니다.
이후 서버는 앞서 언급한 학습자료, 커리큘럼, 학습 진도, 과제 등의 데이터들을 바탕으로 답변을 추론해요. 이 때 생성되는 응답은 스트리밍을 통해 클라이언트에 실시간으로 전송합니다.
그러면 클라이언트는 '안', '녕', '하', '세', '요'와 같이 분리되어 들어오는 데이터를 계속해서 모으고, 이 모은 데이터를 화면에 표시하면 실시간으로 AI의 응답을 화면에 표시할 수 있게 됩니다.
여기서 몇 가지만 짚어보겠습니다.
prompt 잃지 않기
영상에서 본 것처럼 메인 페이지에서 맨 처음 prompt를 입력하면 대화방을 생성하고, 대화방으로 이동한 다음 앞서 입력한 prompt를 바탕으로 AI 응답 생성을 요청하는 구조에요. 그렇다면 prompt를 어떻게 이동한 페이지에서 그대로 가지고 있을 수 있을까요?
처음엔 url에 query string으로 심어서 전달한다, local storage나 session storage 등 브라우저 storage에 저장한다 등등 클라이언트에서 처리해보려 했어요. 근데 이 방식들은 모두 문제가 있었어요.
AI 응답 생성 요청을 하기 전에 사용자가 브라우저를 닫아버리거나 데이터를 지우는 등 조작을 하면 prompt가 유실되어버린다는 점이었어요. 그래서 처음에 대화방을 생성할 때, DB에 생성되는 session에 대한 메타데이터에 맨 처음 prompt 또한 저장하는 것으로 결정했습니다.
{
"title": "새 채팅",
"initialQuestion": "내가 개념을 제대로 이해했는지 궁금해", // 맨 처음 prompt
"chatHistory": [],
"createdAt": {
"$date": "..."
},
"updatedAt": {
"$date": "..."
},
"compressedContext": "..."
}이제 맨 처음 prompt가 유실되는 경우를 방지할 수 있게 되었어요. 따로 브라우저 어딘가에 저장해둘 필요 없이, 대화방으로 이동했을 때 받아온 메타데이터에서 대화 내역(chatHistory)이 없다면 initialQuestion으로 AI 응답 생성 요청을 보내면 됩니다.
왜 스트리밍일까
이제는 너무나도 당연시 되곤 하지만... 왜 스트리밍 방식으로 응답을 보여줄까요?
LLM의 추론은 수 초에서 수십 초가 걸릴 수 있어요. 완성된 응답을 기다렸다가 한 번에 보여주면 사용자는 그 시간 동안 빈 화면만 바라보게 됩니다. 스트리밍은 서버가 토큰을 생성하는 즉시 클라이언트에 보내주고, 클라이언트는 받는 대로 화면에 이어붙여 표시해요. ChatGPT에서 글자가 타이핑되듯 보이는 것이 이 방식입니다.
서버는 응답을 한 번에 보내는 대신, data: 접두사가 붙은 JSON을 한 줄씩 스트리밍으로 보내줍니다.
data: {"type":"chat_start","data":{"sessionId":"abc"}}
data: {"type":"analyzing","data":null}
data: {"type":"searching","data":{"keywords":["React","useState"]}}
data: {"type":"content_delta","data":{"text":"안"}}
data: {"type":"content_delta","data":{"text":"녕하"}}
data: {"type":"content_delta","data":{"text":"세요"}}
data: {"type":"content_complete","data":{"text":"안녕하세요","referenceList":[...]}}여기서 type은 저희가 정의한 응답의 라이프사이클이고 대략 다음과 같아요.
1. 시작(chat_start)
2. prompt 분석(analyzing)
3. 관련 자료 검색(searching)
4. 답변 토큰 전송(content_delta)
5. 완료(content_complete)어쨌든, 이렇게 몰려오는 데이터를 어떻게 파싱해서 화면에 표시하는지 단계별로 살펴보겠습니다.
- async generator로 스트림 파싱
스트리밍 데이터를 파싱하기 위해 async generator를 사용합니다. 네트워크에서 다음 청크가 도착할 때까지 await로 기다렸다가, 파싱이 완료되면 yield로 내보내는 패턴이 필요한데, await와 yield를 동시에 쓸 수 있는 문법이 async generator(async function*)이기 때문이에요.
async function* createChatStreamIterable({ sessionId, question, signal }) {
const response = await fetch(`${API_URL}/atani/qna/generate`, {
method: 'POST',
body: JSON.stringify({ sessionId, question }),
signal,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf('\n');
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line.length > 0) {
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
yield JSON.parse(payload);
}
newlineIndex = buffer.indexOf('\n');
}
}
} finally {
reader.releaseLock();
}
}async generator는 값을 여러 번 내보낼 수 있어요. reader.read()가 Promise를 반환하기 때문에 일반 generator(function*)로는 await를 쓸 수 없고, async generator이기에 비동기로 도착하는 이벤트를 기다렸다가 파싱해서 yield할 수 있습니다.
여기서 버퍼(buffer)를 사용하는 이유가 있는데, 네트워크로 데이터가 전송될 때 서버가 보낸 메시지 단위와 클라이언트가 받는 청크 단위가 다를 수 있기 때문이에요. 서버가 이렇게 두 줄을 보냈다고 해볼게요.
data: {"type":"content_delta","data":{"text":"안"}}
data: {"type":"content_delta","data":{"text":"녕"}}이상적으로는 한 줄씩 깔끔하게 도착하면 좋겠지만, 실제로는 이런 식으로 잘려서 도착할 수 있어요.
// 첫 번째 청크
data: {"type":"content_delta","data":{"te
// 두 번째 청크
xt":"안"}}첫 번째 청크만으로는 {"te에서 끊겼으니 JSON 파싱이 불가능합니다. 그래서 버퍼에 담아두고, 다음 청크가 도착하면 합쳐서 개행 문자(\n) 기준으로 완전한 줄이 된 것만 파싱하는 거예요.
data: {"type":"content_delta","data":{"text":"녕"}}- TanStack Query의 streamedQuery
이 async generator를 TanStack Query의 experimental_streamedQuery로 감쌌어요.
import { experimental_streamedQuery as streamedQuery, queryOptions } from '@tanstack/react-query';
const createChatStreamQuery = ({ sessionId, question, timestamp }) =>
queryOptions({
queryKey: ['atani', 'chatStream', sessionId, question, timestamp],
queryFn: streamedQuery({
streamFn: ({ signal }) => createChatStreamIterable({ sessionId, question, signal }),
}),
staleTime: Infinity,
gcTime: 0,
});streamedQuery는 generator가 값을 yield할 때마다 query의 data를 누적 배열로 업데이트해줍니다. 예를 들어, 스트리밍 데이터가 다음과 같이 왔다면,
data: {"type":"chat_start","data":{"sessionId":"abc"}}
data: {"type":"analyzing","data":null}
data: {"type":"searching","data":{"keywords":["React","useState"]}}
data: {"type":"content_delta","data":{"text":"안"}}
data: {"type":"content_delta","data":{"text":"녕하"}}
data: {"type":"content_delta","data":{"text":"세요"}}
data: {"type":"content_complete","data":{"text":"안녕하세요","referenceList":[...]}}streamedQuery가 반환하는 배열은 다음과 같은 형태를 가지게 됩니다.
const { data } = useStreamedQuery(createChatStreamQuery({ sessionId, question, timestamp }));
// data: [
// { type: 'chat_start' },
// { type: 'analyzing' },
// { type: 'searching' },
// { type: 'content_delta', data: { text: '안' } },
// { type: 'content_delta', data: { text: '녕하' } },
// { type: 'content_delta', data: { text: '세요' } },
// { type: 'content_complete', data: { text: '안녕하세요', referenceList: [...] } }
// ]- Reducer로 메시지 상태 관리
서버로부터 받은 데이터를 각 type에 따라 알맞게 변환하여 화면에 표시하는 것은 reducer가 담당합니다.
type이 chat_start인 이벤트를 받았다면 reducer에서 '잠시 생각 중'이라는 UI를 띄울 수 있도록 하고, searching을 받았다면 추론 중인 키워드들을 표시하고, content_delta를 받았다면 AI 메시지를 계속 업데이트해요.
- 정리
async generator를 통해 스트리밍으로 내려받고 있는 데이터들을 올바르게 파싱해내고, streamedQuery를 통해 배열에 담습니다. 그리고 최종적으로 화면에 표시하는 것은 reducer가 담당을 하는 구조에요.
평소 잘 접해보지 못했던 스트리밍 방식도 접해보고, Claude나 Gemini의 응답 원리도 알게되면서 재미있었던 작업이었습니다.
여담으로... 무엇보다 이 기능을 만들기 전에는 '수강생분들은 그냥 Claude Code나 Gemini를 쓰는 것이 더 유용하지 않을까...'라는 우려를 스스로 매번하곤 했었는데요, 커리큘럼/학습자료/현재 진도/현재 과제 등등 저희 데이터를 바탕으로 최적화된 답변을 해주는 것이 좋다며 수강생분들이 잘 써주셔서 아주 보람찼습니다.