TECH NOTE · AUTOMATION OPS
완료 이벤트가 접수 응답보다 먼저 도착하면 버려도 반영해도 문제가 됩니다. 보관했다가 식별자 확정 후 꺼내는 방법입니다.
"완료" 이벤트가 "접수" 응답보다 먼저 왔다
요청을 보내는 코드는 대개 이렇게 생겼다. 명령을 부르고, 접수됐다는 응답을 받고, 결과는 이벤트로 따로 받는다.
const accepted = await invoke("submit_conversation_turn", { text });
// 여기서 요청 ID와 세대를 알게 된다
// 결과는 나중에 "conversation-completed" 이벤트로 온다
로컬 LLM이 아주 짧은 답을 내놓을 때 이상한 일이 생겼다. await가 아직 안 끝났는데 완료 이벤트가 먼저 도착했다.
이벤트 핸들러가 도는 시점에 앱은 아직 “보내는 중”이다. 어떤 요청의 결과인지 확인할 방법이 없다. 요청 ID는 접수 응답에 실려 오는데 그게 아직 안 왔기 때문이다.
두 채널은 서로를 모른다
원인은 단순하다. 명령 응답과 이벤트가 다른 경로로 온다.
프론트엔드 ──invoke────▶ 백엔드
◀──반환값── (경로 1: 명령 응답)
◀──이벤트── (경로 2: 브로드캐스트)
백엔드가 접수 응답을 보내고 곧바로 작업을 끝내면, 두 메시지가 거의 동시에 출발한다. 어느 쪽이 먼저 프론트엔드의 처리 큐에 들어갈지는 보장되지 않는다.
Tauri의 invoke는 Promise 해결까지 이벤트 루프를 한 번 더 거치는 반면, listen 콜백은 이벤트가 도착하자마자 큐에 들어간다. 작업이 충분히 빠르면 역전이 난다.
이건 Tauri만의 문제가 아니다. WebSocket으로 실시간 갱신을 받으면서 REST로 명령을 보내는 구조면 똑같이 난다. 서버는 두 연결이 어떤 순서로 클라이언트에 닿을지 모른다.
버리면 영영 안 온다
처음에 한 처리는 무시였다. 아직 sending 상태니까 완료 이벤트는 내 것이 아니라고 보고 버렸다.
그러면 그 요청은 영원히 끝나지 않는다. 완료는 한 번만 오고, 이미 버렸다.
사용자 화면에는 “생각 중…”이 계속 떠 있고, 타임아웃이 걸릴 때까지 기다린다. 답은 이미 백엔드에서 나왔는데 화면에만 안 뜬다.
반대로 그냥 반영하는 것도 안 된다. 이 시점에는 이 완료가 지금 보낸 요청의 것인지 확인이 안 된다. 직전에 취소한 요청의 결과일 수도 있다.
보관했다가 확인되면 꺼낸다
답은 셋 중 하나다. 버리지도 말고 반영하지도 말고, 보관한다.
상태에 자리를 하나 만든다.
type ChatState = Readonly<{
status: "idle" | "sending" | "waiting" | "failed";
current: CurrentChatRequest | null;
earlyCompletion: Readonly<{
completion: ConversationTurnCompletion;
nowMs: number;
}> | null;
}>;
완료 이벤트가 sending 중에 오면 여기 넣는다.
if (
state.status === "sending" &&
current !== null &&
completion.requestId === current.requestId
) {
return {
...state,
earlyCompletion: {
completion: sanitizeCompletion(completion),
nowMs: action.nowMs,
},
};
}
그리고 접수 응답이 도착해 waiting으로 넘어가는 자리에서, 보관해 둔 게 있으면 곧바로 꺼내 처리한다.
const early = state.earlyCompletion;
return early !== null && early.completion.generation === accepted.generation
? reduceCompletion(waiting, {
type: "completion",
completion: early.completion,
nowMs: early.nowMs,
})
: waiting;
이 시점에는 접수 응답이 왔으니 세대를 안다. 보관해 둔 완료의 세대와 맞는지 확인하고, 맞을 때만 진행한다.
보관할 때 정제하는 이유
sanitizeCompletion을 거쳐서 넣는 게 중요하다.
보관하는 시점에는 이 데이터가 내 것인지 아직 모른다. 아직 검증 안 된 값을 그대로 상태에 넣으면, 어딘가에서 그걸 읽어 화면에 쓰는 경로가 생길 수 있다.
그래서 넣기 전에 필요한 필드만 골라 담는다.
function sanitizeConversationError(
error: ConversationCommandError,
): ConversationCommandError {
return CONVERSATION_ERROR_CODES.has(error.code)
? { code: error.code }
: { code: "unavailable" };
}
오류 코드도 아는 것만 통과시키고 나머지는 뭉뚱그린다. 백엔드가 새 코드를 추가해도 프론트엔드가 모르는 값을 화면에 흘리지 않는다.
nowMs를 같이 보관하는 것도 같은 이유다. 나중에 꺼낼 때 현재 시각을 쓰면 응답 시간이 실제보다 길게 기록된다. 도착한 시점을 같이 넣어 둔다.
비우는 자리를 정한다
보관함은 반드시 비우는 규칙이 있어야 한다. 안 그러면 옛 완료가 다음 요청에 붙는다.
비우는 자리는 셋이다.
| 시점 | 왜 |
|---|---|
| 새 요청 전송 | 이전 것의 결과는 이제 의미 없다 |
| 초기화·리셋 | 세대가 올라간다 |
| 완료 처리 직후 | 이미 썼다 |
코드에서는 상태를 바꾸는 거의 모든 자리에 earlyCompletion: null이 붙어 있다. 빠뜨리기 쉬운 곳이라 기본값을 null로 두고 넣는 자리만 예외로 만드는 편이 안전하다.
애초에 이벤트를 안 쓰면 안 되나
명령 응답에 결과까지 담으면 이 문제가 없다. 실제로 짧은 작업은 그게 낫다.
이벤트로 나눈 이유는 두 가지다.
작업이 길다. 로컬 LLM은 몇 초에서 수십 초가 걸린다. 그동안 명령이 안 끝나면 타임아웃 처리가 어렵고, 진행 상황도 못 알린다.
결과가 여러 곳으로 간다. 완료 이벤트는 대화 화면만 받는 게 아니라 캐릭터 표정, 기억 갱신도 같이 받는다. 반환값이면 한 곳이 받아서 다시 나눠 줘야 한다.
즉 분리 자체는 맞고, 순서 가정이 틀린 것이다. “명령이 먼저 끝난다”는 가정만 빼면 된다.
어디서 또 나오나
- WebSocket 알림 + REST 명령을 같이 쓰는 화면
- 서버 전송 이벤트(SSE)로 진행률을 받으며 폼을 제출할 때
- 낙관적 업데이트를 걸어 두고 서버 확인을 기다릴 때
- 워커 스레드에 작업을 던지고
postMessage로 결과를 받을 때
공통 신호는 “이 응답이 오기 전에는 식별자를 모른다” 는 구조다. 식별자를 클라이언트가 먼저 만들어 보내면 이 문제가 상당히 줄어든다.
const requestId = crypto.randomUUID();
await invoke("submit", { requestId, text });
이러면 이벤트가 먼저 와도 요청 ID로 바로 짝을 지을 수 있다. 다만 백엔드가 부여하는 세대까지는 여전히 모르므로, 보관 자체가 없어지지는 않는다.
정리
- 명령 응답과 이벤트는 다른 경로다. 완료가 접수보다 먼저 도착할 수 있다
- 이때 버리면 요청이 영영 안 끝나고, 그냥 반영하면 남의 결과를 쓸 수 있다
- 보관했다가 식별자가 확정되는 자리에서 꺼내 처리한다
- 보관 전에 정제한다. 아직 내 것인지 모르는 데이터다
- 도착 시각도 같이 보관한다. 나중에 꺼내며 현재 시각을 쓰면 안 된다
- 비우는 자리를 명시한다. 기본값을
null로 두고 넣는 자리만 예외로 - 클라이언트가 요청 ID를 만들어 보내면 짝짓기가 쉬워진다
“먼저 부른 게 먼저 끝난다”는 가정이 어디에 박혀 있는지 찾는 게 이 종류 버그의 절반이다.