분석 결과를 채팅처럼 한 말풍선씩 흘려보내기
분석에는 수십 초가 걸려요. 다 끝날 때까지 기다리게 하지 않고, 준비된 것부터 대화처럼 보여주기 위해 서버와 화면을 어떻게 이었는지 정리했어요.
WhoIsLoafing의 분석은 빠르면 몇 초, 길면 1분 가까이 걸려요. GitHub에서 자료를 받고, 사람별로 수치를 계산하고, 참여자마다 AI 분석을 돌리기 때문이에요.
이 시간을 로딩 화면 하나로 버티게 하고 싶지 않았어요. 레포 소개는 3초면 나오는데, 마지막 결과가 준비될 때까지 숨겨 둘 이유가 없어요. 그래서 준비된 것부터 한 말풍선씩 보내기로 했어요.
서버가 먼저 말을 거는 방법
보통의 요청은 한 번 묻고 한 번 답해요. 결과를 여러 번에 나눠 보내려면 다른 방법이 필요해요. 고른 것은 서버 전송 이벤트(SSE)예요.
SSE는 응답을 끝내지 않고 열어 둔 채로 글을 계속 흘려보내는 방식이에요. 형식은 아주 단순해요. data: 로 시작하는 줄에 내용을 적고, 빈 줄로 메시지 하나를 끝내요.
data: {"type":"text","text":"3명이 함께한 프로젝트네요!"}
data: {"type":"done"}
서버에서는 메시지를 보내는 함수 하나만 있으면 돼요.
const emit = (event: ChatEvent) => {
if (!isClosed()) write(`data: ${JSON.stringify(event)}\n\n`)
}
웹소켓을 쓰지 않은 이유는 필요가 없어서예요. 분석 중에는 서버만 말하고 브라우저는 듣기만 해요. 한쪽으로만 흐르는 대화에는 SSE가 훨씬 간단해요.
브라우저에서 직접 읽기
브라우저에는 SSE를 받는 EventSource가 있지만 쓰지 않았어요. EventSource는 GET 요청만 보낼 수 있는데, 분석 요청은 레포 주소와 선택한 옵션을 본문에 담아 POST로 보내야 했기 때문이에요.
대신 fetch로 요청을 보내고 응답 본문을 조금씩 읽어요.
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary: number
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const chunk = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) onEvent(JSON.parse(line.slice(6)))
}
}
}
조심할 점은 네트워크가 메시지 경계에 맞춰서 데이터를 잘라 주지 않는다는 것이에요. 메시지 하나가 두 조각으로 나뉘어 오기도 하고, 두 메시지가 한꺼번에 오기도 해요. 그래서 받은 글을 버퍼에 쌓아 두고, 빈 줄이 나올 때마다 메시지 하나를 꺼내요.
decode에 stream: true를 주는 것도 같은 이유예요. 한글 한 글자는 여러 바이트인데, 그 가운데에서 잘려 오면 글자가 깨져요. 이 옵션을 주면 덜 온 바이트를 다음 조각과 이어서 풀어 줘요.
연결이 조용히 끊기지 않게
AI 분석을 기다리는 동안에는 수십 초 동안 보낼 것이 없어요. 중간의 프록시는 한동안 아무것도 오가지 않는 연결을 죽은 것으로 보고 끊어 버리기도 해요.
그래서 15초마다 의미 없는 줄을 하나씩 보내요.
const heartbeat = setInterval(() => {
if (!isClosed()) write(': ping\n\n')
}, 15000)
SSE에서 :로 시작하는 줄은 주석이라, 받는 쪽은 그냥 무시해요. 응답 헤더에도 중간에서 응답을 모았다가 한꺼번에 보내지 말라는 표시를 해 둬요.
export const SSE_HEADERS = {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
}
받은 대로 바로 보여주지 않아요
서버가 보낸 메시지를 받는 즉시 화면에 올리면 대화처럼 느껴지지 않아요. 캐시된 결과는 메시지 열 개가 한순간에 쏟아지기도 해요.
그래서 받은 메시지를 큐에 쌓아 두고, 하나씩 꺼내면서 그 사이에 입력 중 표시를 보여줘요.
const TYPING_DELAY_MS = 700
while (!stopped) {
const event = queue.shift()
if (!event) {
if (finished) break
await new Promise<void>((resolve) => (wake = resolve))
continue
}
if (event.type === 'done') break
await sleep(TYPING_DELAY_MS)
append({ from: 'bot', event })
}
큐가 비어 있으면 새 메시지가 올 때까지 잠들어 있다가, 메시지가 오면 깨어나요. 네트워크의 속도와 화면의 속도를 분리한 셈이에요. 서버가 아무리 빨리 보내도 화면은 사람이 읽을 수 있는 속도로 움직여요.
말풍선 안의 글도 타자를 치듯 한 글자씩 나타나고, 글이 다 나온 다음에 카드나 그래프가 따라 나와요. 반대로 저장된 채팅을 다시 열 때는 이런 연출 없이 바로 보여줘요. 이미 본 내용을 다시 기다리게 할 이유가 없기 때문이에요.
화면은 멈추고 서버는 계속 가요
분석 결과가 한 번에 스무 개씩 올라오면 읽기 벅차요. 그래서 순위, 맡은 기능, 코드 스타일 단계가 끝날 때마다 계속하기 버튼을 보여주고 멈춰요.
여기서 서버까지 멈추면 사용자가 버튼을 누른 뒤에 다시 기다려야 해요. 그래서 서버는 멈추지 않아요. pause라는 메시지만 하나 보내고 분석을 계속해요. 화면 쪽 큐가 pause를 만나면 버튼을 보여주고 기다려요.
if (event.type === 'pause') {
setPaused(event.next)
await new Promise<void>((resolve) => (resumeRef.current = resolve))
setPaused(null)
continue
}
사용자가 앞 단계를 읽는 동안 다음 단계의 결과가 큐에 쌓여요. 버튼을 누르면 기다림 없이 바로 이어져요.
중간에 그만두기
오래 걸리는 작업에는 멈추는 방법이 꼭 있어야 해요. fetch에 AbortController의 신호를 넘겨 두고, 중단하기를 누르면 연결을 끊어요.
서버는 연결이 끊긴 것을 알아채고 더 이상 글을 쓰지 않아요. 다만 그때까지 화면에 올라간 말풍선은 저장까지 마쳐요. 그래야 새로 고쳐도 보던 곳까지 다시 열 수 있어요.
화면을 완전히 떠날 때는 조금 더 조심해야 해요. 떠난 뒤에 도착한 메시지가 새 화면에 끼어들면 안 되기 때문이에요. 요청마다 번호를 매겨 두고, 떠날 때 번호를 올려요. 예전 번호의 요청은 메시지를 받아도 화면을 건드리지 않아요.
정리
- 오래 걸리는 작업은 준비된 것부터 보내요.
- 한쪽으로만 흐르는 대화에는 SSE가 간단해요.
- POST로 보내야 하면
fetch로 직접 읽고, 메시지 경계는 버퍼로 맞춰요. - 네트워크의 속도와 화면의 속도는 큐로 분리해요.
- 화면이 멈춰도 서버는 계속 가게 하면 기다림이 줄어요.