콘텐츠로 건너뛰기
Codex

비동기 응답에서 오래된 결과가 최신 것을 덮어쓸 때 — 세대 번호

TECH NOTE · AUTOMATION OPS

취소한 요청의 응답이 나중에 도착해 화면을 덮을 때, 요청 ID만으로는 부족합니다. 세대 번호로 무효화하는 방법을 정리했습니다.

취소한 답이 나중에 화면에 뜬다

로컬 LLM에 말을 걸면 응답까지 몇 초가 걸린다. 그동안 사용자가 기다려 주지 않는다. 다시 묻거나, 대화를 지우거나, 창을 닫는다.

그때마다 같은 증상이 났다. 이미 지운 질문의 답이 몇 초 뒤에 툭 튀어나왔다.

대화를 초기화하고 새 질문을 했는데 이전 질문의 답이 먼저 오고, 그 다음에 새 답이 오면서 화면이 두 번 바뀐다. 사용자 입장에서는 앱이 제멋대로 구는 것으로 보인다.

요청 ID만으로는 부족하다

가장 먼저 떠오르는 방법은 요청마다 ID를 붙이고, 응답의 ID가 지금 기다리는 것과 같을 때만 반영하는 것이다.

if (response.requestId !== current.requestId) return;   // 오래된 응답, 버림

이걸로 “다시 물어보기”는 막힌다. 새 요청은 새 ID를 받으니 옛 응답이 걸러진다.

막히지 않는 게 하나 있다. 초기화다.

대화를 지우고 나면 기다리는 요청이 없다. currentnull이다. 그 상태로 옛 응답이 도착하면 비교할 대상이 없어서, 코드에 따라 그냥 통과하거나 예외가 난다.

더 고약한 건 초기화 후 새 요청이 우연히 같은 상황을 만들 때다. 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) 짝을 붙여 짝이 안 맞으면 둘 다 버린다
  • 클라이언트와 백엔드가 각자 세대를 갖는다. 한쪽만으로는 상대의 무효화를 모른다
  • 세대는 무시하는 장치다. 자원을 아끼려면 취소를 따로 건다

무엇을 버릴지 정하는 일이라, 조건이 늘어나는 건 정상이다. 줄이려다 경로 하나를 빼면 그 경로에서만 재현되는 버그가 남는다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다