새로고침해도 이어지는 AI 답변 만들기
2026-05-31
생성 중인 답변을 보다가 새로고침하면 답변이 사라졌다. 폰에서 질문을 보낸 뒤 PC에서 같은 방을 열어도, 다른 쪽에서는 지금 답변이 생성되고 있다는 사실조차 알 수 없었다.
이번 작업 뒤에는 같은 채팅방을 여러 탭이나 기기에서 열어둬도 스트리밍 상태가 같이 움직이고, 생성 도중 새로고침하거나 탭을 닫았다가 다시 들어와도 진행 중인 답변에 이어서 붙을 수 있게 됐다.
개선 후 — 여러 탭과 기기에서 스트리밍 상태를 공유하고, 새로고침 후에도 진행 중인 답변에 다시 붙을 수 있다.
처음에는 재연결만 추가하면 될 거라고 봤다. 새로고침 뒤에 진행 중인 스트림을 찾아 다시 붙으면 된다고 생각했다. 그런데 당시 구조에서는 재연결할 대상 자체가 브라우저 연결과 함께 사라지고 있었다.
모델의 응답 스트림을 HTTP response로 바로 흘려보내고 있었기 때문이다.
브라우저
│
│ POST
▼
메시지 서버
│
│ LLM 생성
▼
HTTP Response Stream
│
▼
브라우저이 구조에서 브라우저 연결이 끊기면 생성 중인 답변도 그 연결의 수명에 같이 묶인다. 새로고침은 화면만 다시 그리는 동작이 아니라 스트림 자체를 끊는 동작이었다.
문제는 UX에서 끝나지 않았다. 일부 사용자는 생성 중에 새로고침하거나 탭을 닫는 식으로 답변을 다시 뽑고 있었다. 서버에서는 이미 LLM 비용이 발생했지만 최종 저장과 재화 차감까지 가지 못했고, 결과적으로 무료 reroll처럼 쓸 수 있는 경로가 생겨 있었다.
재연결을 제대로 만들려면 브라우저가 없어져도 생성은 계속 살아 있어야 했다.
Redis에 남겨둔 생성 중 스트림
가장 먼저 모델 스트림을 브라우저와 분리했다. resumable-stream과 ioredis 어댑터를 사용해 생성되는 데이터를 기존 HTTP 응답과 Redis 버퍼 두 곳으로 흘렸다.
┌─▶ 기존 HTTP 응답
모델 스트림 ─────┤
└─▶ Redis 버퍼처음 질문을 보낸 브라우저는 이전처럼 HTTP 스트림을 바로 받는다. 동시에 같은 데이터가 Redis에도 쌓인다. 기존 스트리밍 경로를 크게 바꾸지 않으면서, 연결이 사라진 뒤 다시 읽을 수 있는 복사본을 남기는 방식이었다.
재연결에서는 Redis에 남아 있는 스트림을 다시 연다.
const stream =
await getStreamContext()
.resumeExistingStream(streamId);
if (!stream) {
return new Response(null, {
status: 204,
});
}
return new Response(stream, {
headers:
UI_MESSAGE_STREAM_HEADERS,
});여기까지 바꾸고 나니 다음으로 필요한 정보가 바로 드러났다. 사용자가 채팅방에 다시 들어왔을 때 현재 이 방에서 어떤 스트림이 생성 중인지 알아야 했다.
그래서 Redis에 방별 active stream을 따로 저장했다. 키에는 roomId만 넣지 않았다. 서비스에서 roomId가 전역 유일값이 아니어서 실제 유일성 범위를 키에 그대로 반영했다.
{namespace}{domain}:room:
{userId}:{characterId}:{roomId}:
active-streamactive stream에는 TTL도 뒀다. 서버 이상이나 비정상 종료로 정리가 누락된 키가 계속 남는 것을 막기 위해서다. 긴 답변은 TTL보다 오래 생성될 수 있으니 정상 생성 중에는 주기적으로 연장했다.
정상 생성 중
→ TTL 연장
서버 이상 / 정리 실패
→ 시간이 지나면 자동 제거처음에는 여기에 streamId만 저장했다. 그런데 새로고침하면 최종 저장 전의 사용자 메시지도 화면에서 사라질 수 있었다. AI 답변만 복구하면 사용자는 자신이 어떤 질문을 보냈는지 없는 상태에서 답변만 이어서 보게 된다.
그래서 userPrompt, redoTargetMessageId처럼 화면을 복원할 때 필요한 문맥도 active stream에 같이 저장했다. 다시 붙는 대상은 스트림 데이터만이 아니라, 그 스트림이 화면에서 어떤 상태였는지까지 포함하고 있었다.
새로고침과 다른 기기를 위한 하나의 재연결 경로
새로고침은 채팅방에 들어올 때 active stream을 조회하면 처리할 수 있었다.
채팅방 진입
↓
active stream 조회
↓
streamId 발견
↓
resume 요청
↓
기존 스트림에 합류다른 탭이나 다른 기기는 조금 달랐다. PC에서 이미 채팅방을 열어둔 상태에서 폰으로 질문을 보내면, PC 쪽은 새 생성이 시작됐다는 사실을 알 방법이 없다.
Go API 서버에는 이미 방 단위 SSE relay가 있었기 때문에 별도의 실시간 시스템을 만들지는 않았다. 기존 이벤트에 generation_start, generation_end 두 가지를 추가했다.
generation_start
generation_end메시지 서버가 생성을 시작하면 같은 방을 보고 있는 클라이언트에 generation_start를 보낸다. 다른 탭이나 기기는 이 이벤트를 받은 뒤 resume을 시도한다. 채팅방 재진입과 실시간 이벤트의 시작점은 다르지만, 이후에는 같은 resume 경로를 사용한다.
여기서 순서 때문에 레이스가 한 번 생겼다. 처음에는 generation_start를 먼저 보내고 그 뒤에 Redis stream을 등록했다.
generation_start
↓
다른 탭이 resume 요청
↓
Redis stream 등록이벤트를 받은 클라이언트가 빠르게 resume을 요청하면 아직 stream이 없어서 204가 떨어진다. 그런데 시작 이벤트는 이미 한 번 소비한 뒤다.
그래서 준비 순서를 아래처럼 고정했다.
1. resumable stream 등록
2. active stream 저장
3. owner 저장
4. generation_start 전파이벤트를 받은 쪽이 바로 후속 요청을 보낼 수 있으니, 알림보다 사용할 리소스가 먼저 준비돼 있어야 했다.
streamId와 스트림 접근 권한
resume API가 생기면서 권한 검증도 추가했다. streamId가 추측하기 어려운 값이어도 ID를 아는 것 자체를 권한으로 볼 수는 없었다.
스트림을 시작할 때 owner 정보를 저장하고, resume 요청마다 현재 사용자와 비교했다.
const owner =
await getStreamOwner(streamId);
if (
!owner ||
owner.userId !== userId ||
owner.characterId !== characterId
) {
return new Response(null, {
status: 204,
});
}처음에는 userId만 확인했다. 같은 사용자가 다른 캐릭터의 스트림까지 접근할 수 있어서 characterId도 함께 검증하도록 바꿨다.
메시지 서버가 Go 서버에 생성 상태를 알리는 내부 API도 일반 사용자 인증과 분리했다. resume endpoint 하나를 추가하는 작업이었지만, 실제로는 스트림의 소유권과 서비스 사이의 신뢰 경계까지 같이 손봐야 했다.
여러 종료 케이스를 하나의 settle로
AI 생성은 정상 finish만 있는 작업이 아니다. 모델 API 오류, 사용자 취소, 검열, stream abort, 네트워크 예외 등 여러 경로로 끝날 수 있다. 어떤 경우든 active stream은 제거돼야 하고, 다른 클라이언트에는 generation_end가 전달돼야 했다.
둘 중 하나라도 빠지면 이미 끝난 답변이 계속 생성 중인 것처럼 남는다.
그래서 done, error, cancel을 모두 하나의 settle 경로로 모았다. finalize도 streamId 기준으로 멱등 처리했다.
if (
finalizedStreams.has(
ctx.streamId
)
) {
return;
}
finalizedStreams.add(
ctx.streamId
);정상 finish와 별도 정리 로직이 같이 실행되더라도 종료 이벤트는 한 번만 나간다.
서버 쪽 흐름은 이렇게 됐다.
[생성 시작]
message-server
│
├─ resumable stream 등록
├─ active / owner 저장
└─ generation_start
│
▼
Go API
│
▼
Redis Pub/Sub
│
▼
같은 방의 클라이언트재연결에서는 active stream을 찾고 owner를 확인한 뒤 Redis stream을 다시 읽는다.
채팅방 진입
↓
active stream 조회
↓
owner 검증
↓
Redis stream 재생생성이 끝나는 경로에서는 원인이 무엇이든 settle로 들어온다.
done / error / cancel
↓
settle
↓
active 삭제 + generation_end여기까지 바꾸고 나서야 생성이 특정 브라우저 연결의 생명주기에서 빠져나왔다.
SDK 밖에서 직접 다루는 resume 스트림
기존 POST 스트림은 AI SDK의 useCompletion이 처리하고 있었다. 하지만 resume endpoint는 이미 진행 중인 스트림을 GET으로 다시 여는 구조라서 별도의 reader를 만들었다.
const reader =
response.body.getReader();
const decoder =
new TextDecoder();
let buffer = '';
let accumulated = '';직접 reader를 붙이자 SDK가 가려주던 경계 조건도 직접 다뤄야 했다. reader.read() 한 번이 SSE 이벤트 하나와 일치하지 않는다는 점이 대표적이었다.
chunk 1:
data: {...}\n\ndata: {"
chunk 2:
type":"text-delta"...}\n\n이벤트 중간에서 chunk가 끊길 수도 있고 한 chunk 안에 이벤트가 여러 개 들어올 수도 있다. 수신한 데이터를 buffer에 계속 붙인 뒤 \n\n으로 완성된 이벤트만 꺼냈다.
buffer += decoder.decode(
value,
{ stream: true }
);
let sepIndex =
buffer.indexOf('\n\n');
while (sepIndex !== -1) {
const rawEvent =
buffer.slice(0, sepIndex);
buffer =
buffer.slice(sepIndex + 2);
handleRawEvent(rawEvent);
sepIndex =
buffer.indexOf('\n\n');
}오류보다 더 애매했던 경우는 연결이 열린 채 아무 데이터도 오지 않는 상태였다. 서버가 close하지 않으면 reader.read()가 계속 기다리고, 클라이언트에서는 입력창도 계속 잠긴 상태로 남을 수 있다.
일정 시간 동안 데이터가 없으면 reader를 취소하는 watchdog을 넣었다. 스트림이 끝난 뒤에는 decoder를 flush해서 마지막 buffer도 처리했다.
buffer += decoder.decode();
if (buffer.length > 0) {
handleRawEvent(buffer);
}스트리밍은 정상 경로보다 이런 애매한 종료 상태에서 문제가 오래 남았다.
RAF 페이서와 resume 사이의 쓰기 충돌
2편에서 만든 RAF 페이서는 기존 useCompletion 값을 일정한 속도로 store에 쓰고 있었다. resume reader가 추가되면서 같은 메시지에 쓰는 주체가 둘이 됐다.
resume reader
→ Redis에서 복구한 최신 텍스트를 store에 기록
RAF pacer
→ 기존 useCompletion 값을 store에 기록resume이 더 최신 텍스트를 쓴 직후 다음 RAF tick에서 오래된 completion이 다시 들어오면, 화면의 글자가 뒤로 돌아갈 수 있었다.
resume 중에는 기존 페이서를 잠깐 막도록 했다. 처음에는 resumeActive boolean 하나로 처리했는데, 채팅방을 빠르게 이동하면 이 값도 충분하지 않았다.
A resume 시작
→ resumeActive = true
B resume 시작
→ resumeActive = true
A resume 종료
→ resumeActive = false
B는 아직 resume 중A의 비동기 작업이 늦게 끝나면서 아직 진행 중인 B의 gate까지 풀어버리는 상황이다. 그래서 resumeOwner를 추가해 현재 gate를 누가 잡고 있는지까지 같이 관리했다.
여기서는 단순한 on/off 상태보다 현재 비동기 작업의 소유자를 구분하는 게 필요했다.
배포 직후 나타난 사용자 반응
이 작업에는 LLM 비용 누수가 줄어드는 효과도 있었지만, 그 크기를 숫자로 남기지는 못했다. 생성 중 새로고침으로 다시 답변을 뽑던 행동이 얼마나 줄었는지는 서버 로그만으로 사후에 확인하기 어려웠다.
대신 배포 다음 날 커뮤니티에 이런 글이 올라왔다.
우리는 새로고침 뒤에도 답변이 이어지는 동작을 만들고 있었지만, 사용자 쪽에서는 그동안 쓰던 우회 방법이 막힌 변화로 먼저 받아들였다. 댓글을 보면 이 방식이 생각보다 널리 공유되고 있었다.
사용자가 연결을 끊는 시점에는 LLM 토큰이 상당 부분 소비된 뒤였지만, 기존 구조에서는 최종 저장과 재화 차감까지 이어지지 않을 수 있었다. 생성의 생명주기를 HTTP 연결에서 떼어낸 뒤에는 브라우저가 사라져도 서버 생성이 계속 진행되고, 정상적으로 저장과 정산까지 이어졌다. reroll이 가능했던 조건도 같이 사라졌다.
비용 절감 효과를 수치로 남기지 못한 건 아쉬운 부분이다. 당시에는 기능을 안정적으로 배포하는 쪽이 먼저였고, 배포 전 비교 지표를 따로 설계하지 않았다. 지금 다시 한다면 생성 시작 대비 정상 종료 비율이나 생성 시작 후 저장까지 완료된 비율 같은 지표를 미리 남겨뒀을 것 같다.
브라우저 연결과 분리된 스트림의 생명주기
처음 손대려던 건 새로고침 후 재연결이었다. 그런데 그 기능을 만들려면 생성 자체가 HTTP 연결보다 오래 살아야 했고, 그 뒤에는 active stream, owner, 종료 처리, SSE 알림과 프론트 resume reader까지 이어서 바꿔야 했다.
기존에는 아래 경로가 가능했다.
LLM 비용 발생
↓
클라이언트 연결 끊김
↓
최종 저장 실패
↓
재화 차감도 완료되지 않음바뀐 구조에서는 클라이언트가 사라져도 서버 쪽 생성은 계속 자기 생명주기를 가진다. 정상 종료든 예외 종료든 상태 정리도 서버를 기준으로 진행된다.
전체 경로만 놓고 보면 레이어는 꽤 많다.
LLM 생성
↓
메시지 서버
↓
Redis resumable stream
↓
active stream / owner
↓
Go API
↓
Redis Pub/Sub
↓
방 단위 SSE
↓
React 클라이언트실제로 까다로웠던 부분은 각 기술 자체보다는 이 레이어 사이에서 생겼다. 서버에 스트림이 남아 있어도 프론트가 SSE 이벤트 경계를 잘못 읽으면 데이터가 빠진다. resume으로 최신 텍스트를 복구해도 기존 RAF 페이서가 오래된 값을 다시 쓰면 화면이 뒤로 간다. 종료 처리가 한 경로라도 빠지면 끝난 스트림이 계속 살아 있는 것처럼 보인다.
이 구조를 적용한 뒤 새로고침은 더 이상 생성 종료를 의미하지 않게 됐다. 브라우저는 진행 중인 스트림을 바라보는 클라이언트 중 하나가 됐고, 생성의 수명은 서버에서 관리하게 됐다.
1편에서는 긴 대화가 렌더링 비용을 계속 키우지 않도록 구조를 바꿨고, 2편에서는 서버에서 토큰이 도착하는 속도와 화면에 보여주는 속도를 나눴다. 마지막으로 3편에서는 모델 생성이 특정 HTTP 연결과 같이 죽지 않도록 떼어냈다.
채팅방을 고치면서 손댄 영역은 렌더링, 스크롤, 스트리밍, Redis, SSE까지 꽤 넓어졌지만, 실제 문제는 대부분 경계에서 생기고 있었다. 이번 작업도 그 경계를 하나씩 분리하고, 다시 연결하는 쪽에 가까웠다.