TECH NOTE · AUTOMATION OPS
취소한 요청의 응답이 나중에 도착해 화면을 덮을 때, 요청 ID만으로는 부족합니다. 세대 번호로 무효화하는 방법을 정리했습니다.
취소한 답이 나중에 화면에 뜬다
로컬 LLM에 말을 걸면 응답까지 몇 초가 걸린다. 그동안 사용자가 기다려 주지 않는다. 다시 묻거나, 대화를 지우거나, 창을 닫는다.
그때마다 같은 증상이 났다. 이미 지운 질문의 답이 몇 초 뒤에 툭 튀어나왔다.
대화를 초기화하고 새 질문을 했는데 이전 질문의 답이 먼저 오고, 그 다음에 새 답이 오면서 화면이 두 번 바뀐다. 사용자 입장에서는 앱이 제멋대로 구는 것으로 보인다.
요청 ID만으로는 부족하다
가장 먼저 떠오르는 방법은 요청마다 ID를 붙이고, 응답의 ID가 지금 기다리는 것과 같을 때만 반영하는 것이다.
if (response.requestId !== current.requestId) return; // 오래된 응답, 버림
이걸로 “다시 물어보기”는 막힌다. 새 요청은 새 ID를 받으니 옛 응답이 걸러진다.
막히지 않는 게 하나 있다. 초기화다.
대화를 지우고 나면 기다리는 요청이 없다. current가 null이다. 그 상태로 옛 응답이 도착하면 비교할 대상이 없어서, 코드에 따라 그냥 통과하거나 예외가 난다.
더 고약한 건 초기화 후 새 요청이 우연히 같은 상황을 만들 때다. ID를 카운터로 만들었다면 초기화 후 다시 1번부터 시작하고, 옛 1번 응답이 새 1번으로 받아들여진다.
세대를 하나 더 둔다
요청 ID 위에 세대(generation) 를 얹는다. 상태가 무효화될 때마다 올라가는 번호다.
type ChatState = {
clientGeneration: number; // 초기화·리셋 때마다 +1
current: {
requestId: string;
backendGeneration: number; // 백엔드가 부여
effectToken: { generation: number; requestId: string };
} | null;
};
세대가 다르면 요청 ID가 뭐든 버린다. 초기화 한 번으로 그 이전의 모든 응답이 한꺼번에 무효가 된다.
실제 수락 검사는 이렇게 생겼다.
if (
state.status !== "sending" ||
current === null ||
action.clientGeneration !== current.clientGeneration ||
accepted.requestId !== current.requestId ||
accepted.accepted !== true ||
accepted.status !== "accepted" ||
accepted.effectToken.requestId !== accepted.requestId ||
accepted.effectToken.generation !== accepted.generation
) {
return state; // 하나라도 안 맞으면 상태를 그대로 돌려준다
}
조건이 여덟 개인 게 과해 보이지만, 각각이 실제로 겪은 경로다. 마지막 두 줄은 응답 안에서 토큰과 본체의 짝이 맞는지 보는 것이다.
왜 부수효과에 별도 토큰이 필요한가
응답은 말풍선만 띄우고 끝나지 않는다. 호감도가 오르고, 캐릭터 표정이 바뀌고, 기억에 기록이 남는다.
이 부수효과들은 백엔드에서 일어난다. 그래서 화면은 버렸는데 부수효과는 이미 적용된 상태가 생길 수 있다.
function sameEffectToken(
first: ConversationEffectToken | null,
second: ConversationEffectToken,
): boolean {
return (
first !== null &&
first.generation === second.generation &&
first.requestId === second.requestId
);
}
부수효과에도 같은 (세대, 요청 ID) 짝을 붙여서, 화면과 효과가 항상 같은 요청에서 왔는지 확인한다. 짝이 안 맞으면 둘 다 버린다.
세대를 클라이언트와 백엔드 양쪽에 두는 이유도 여기 있다. 클라이언트만 세면 백엔드가 자체적으로 상태를 버린 경우(재시작, 프로바이더 교체)를 모른다. 백엔드도 자기 세대를 응답에 실어 보내고, 클라이언트는 수락 시점에 그 값을 받아 적는다.
const waiting = {
...state,
status: "waiting",
current: {
...current,
backendGeneration: accepted.generation,
effectToken: { ...accepted.effectToken },
deadlineAtMs: accepted.deadlineAtMs,
},
};
세대를 올리는 지점
여기가 실제 설계다. 언제 무효화할 것인가.
| 사건 | 세대를 올리나 |
|---|---|
| 새 질문 전송 | 올린다 |
| 대화 초기화 | 올린다 |
| 프로바이더 설정 변경 | 올린다 |
| 캐릭터 팩 교체 | 올린다 |
| 창 최소화 | 안 올린다 |
| 응답 도착 | 안 올린다 |
기준은 “이전 요청의 답이 지금도 의미가 있는가” 다. 창을 내렸다 올리는 건 대화 맥락을 안 바꾸니 그대로 두고, 프로바이더를 바꾸면 다른 모델의 답이 오는 셈이니 버린다.
취소가 아니라 무시다
여기서 오해하기 쉬운 게 있다. 세대를 올린다고 진행 중인 작업이 멈추지는 않는다.
백엔드는 여전히 계산하고 있고, 응답도 온다. 다만 클라이언트가 안 받을 뿐이다.
진짜로 멈추려면 별도의 취소 신호가 필요하다. 로컬 LLM이면 추론을 중단시키고, HTTP면 AbortController를 쓴다.
const controller = new AbortController();
fetch(url, { signal: controller.signal });
// ...
controller.abort();
둘 다 필요하다. 취소는 자원을 아끼고, 세대는 취소가 늦게 도착하거나 실패해도 화면이 안 망가지게 한다. 취소만 믿으면 경합이 남는다.
어디서 또 나오나
같은 구조가 웹 프론트엔드에 널려 있다.
- 검색어를 빠르게 칠 때 자동완성 결과가 뒤섞임
- 탭을 전환하며 각 탭의 데이터를 불러올 때
- 무한 스크롤에서 필터를 바꿨는데 옛 페이지가 붙음
- 라우팅 후 이전 화면의 데이터가 도착
React라면 useEffect 정리 함수에서 플래그를 내리는 관용구가 같은 일을 한다.
useEffect(() => {
let alive = true;
fetchData().then((data) => { if (alive) setData(data); });
return () => { alive = false; };
}, [query]);
alive 하나로 되는 건 컴포넌트 수명과 요청 수명이 같기 때문이다. 그보다 오래 사는 상태(전역 스토어, 백엔드 세션)를 다루면 세대 번호가 필요해진다.
정리
- 비동기 응답은 순서를 보장하지 않는다. 늦게 보낸 게 먼저 오고, 취소한 게 나중에 온다
- 요청 ID만으로는 초기화 이후를 못 막는다. ID를 재사용하면 더 위험하다
- 상태가 무효화될 때마다 올라가는 세대 번호를 둔다. 세대가 다르면 전부 버린다
- 화면과 부수효과에 같은 (세대, 요청 ID) 짝을 붙여 짝이 안 맞으면 둘 다 버린다
- 클라이언트와 백엔드가 각자 세대를 갖는다. 한쪽만으로는 상대의 무효화를 모른다
- 세대는 무시하는 장치다. 자원을 아끼려면 취소를 따로 건다
무엇을 버릴지 정하는 일이라, 조건이 늘어나는 건 정상이다. 줄이려다 경로 하나를 빼면 그 경로에서만 재현되는 버그가 남는다.