사용자가 탭을 닫아도 Node 서버는 LLM 호출 비용을 계속 지불합니다
요약
사용자가 브라우저 탭을 닫아도 Node 서버의 비동기 핸들러는 LLM 호출 비용을 계속 발생시킬 수 있습니다. 이는 모델 스트림 유지, 도구 호출 실행, 다음 대화 턴 재전송 등에서 발생합니다. 따라서 사용자의 연결 종료 시점에 `AbortSignal`을 사용하여 모든 돈이 드는 API 호출을 명확하게 취소해야 합니다.
핵심 포인트
- 사용자 이탈 시에도 서버가 LLM 비용을 계속 지불할 수 있음.
- 비동기 핸들러는 자동으로 작업을 중단하지 않으므로 주의 필요.
- 모델 스트림, 도구 호출, 다음 턴 재전송 등에서 비용 발생 가능.
- `AbortSignal`을 사용하여 모든 돈이 드는 API 호출을 취소해야 함.
- 책: AI That Ships
- 시리즈: AI in TypeScript — 첫 번째 LLM 호출부터 프로덕션의 에이전트까지 다루는 5권의 책 — 다섯 권 모두 보기
- 제 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구로 배포하는 개발자를 위한 IDE
- 저: xgabriel.com | GitHub
사용자가 지원 봇에게 전체 마이그레이션 계획을 요청합니다. 3초 후, 사용자는 답변이 잘못된 방향으로 진행되는 것을 보고 탭을 닫습니다. 브라우저가 연결을 끊습니다. 하지만 당신의 Node 핸들러는 이를 알아차리지 못합니다. 계속해서 모델 스트림에서 토큰을 가져오고, 아무도 읽지 않는 소켓에 계속 작성하며, 모델이 도구 호출(tool call)을 요청하면 해당 도구를 실행하고 전체 대화 내용을 다음 턴을 위해 다시 전송합니다.
아무것도 충돌하지 않았기 때문에 경고하는 것도 없습니다. 유일한 흔적은 청구서에 남아 있습니다.
Node는 이러한 모든 것을 자동으로 취소해주지 않습니다. 핸들러는 단지 비동기 함수(async function)이며, 반환될 때까지 실행됩니다. 사용자가 떠날 때 작업을 멈추게 하려면, 소켓에서 돈이 드는 모든 호출로 그 사실을 전달해야 합니다. 이를 위한 도구가 바로 AbortSignal 하나이며, 이 신호를 끝까지 전달해야 합니다.
실제로 계속 실행되는 것들
아무것도 하지 않으면 세 가지가 사용자를 초과하여 계속 실행됩니다.
모델 스트림(The model stream). 당신의 서버는 제공업체와 열린 HTTP 연결을 유지하며 이를 계속 읽습니다. 읽는 한, 모델은 완료되거나 max_tokens에 도달할 때까지 생성을 계속합니다.
도구 호출(The tool calls). 만약 모델이 검색이나 데이터베이스 조회를 요청했다면, 당신의 코드가 이를 실행합니다. 그 결과물들은 아무 쓸모 있는 곳으로 가지 않습니다.
다음 턴(The next turns). 에이전트 루프에서 매 턴은 지금까지의 대화 전체를 입력으로 다시 전송합니다. 시작하는 한 번의 턴은 입력과 출력이 모두 비용을 발생시킵니다. 시작하지 않는 턴은 아무 비용도 들지 않습니다. 이 마지막 점이야말로 제공업체에 관계없이 의지할 수 있는 부분입니다.
업스트림 연결을 닫은 후 출력 토큰에 무슨 일이 일어나는지는 더 불분명합니다. OpenAI 스트리밍 가이드와 Anthropic 스트리밍 가이드 모두 이 부분을 명확하게 설명하지 않습니다.
AI 게이트웨이 벤더 중 하나인 TrueFoundry는 제공자별 취소 정책을 게시했습니다. 여기에는 Vertex AI와 Azure OpenAI가 '취소된 스트림에 대해 청구하지 않음'으로 명시되어 있으며, OpenAI는 '읽기를 계속하든 안 하든 전체 완료분에 대해 청구함'이라고 언급합니다. 이 문서는 자체 측정치에 기반한다고 설명하지만, 그 측정치를 게시하거나 출처를 인용하지는 않습니다. OpenAI 커뮤니티 포럼의 개발자 한 명은 정반대의 말을 했지만, 같은 스레드에서는 입력 컨텍스트(input context)는 여전히 청구된다고 언급합니다. 출력 측면은 제공자별로 다루어야 하므로 자체 사용 로그를 확인하세요. 어쨌든 취소하는 것이 좋습니다. 건너뛰는 턴과 도구 호출은 누가 옳든 절약입니다.

탭은 사라졌습니다. 서버 측 계량기는 코드가 멈추라고 지시할 때까지 계속 작동합니다.
Node에서 연결 끊김 감지하기
대부분의 핸들러가 잘못되는 지점입니다. 명백한 리스너는 req.on("close")인데, 이 이벤트는 이미 몇 년 전부터 "소켓이 닫혔음"을 의미하지 않습니다. Node.js HTTP 문서에는 v16.0.0에서 변경 사항이 기록되어 있습니다: "close 이벤트는 이제 요청이 완료되었을 때 발생하며, 기본 소켓이 닫힐 때 발생하는 것이 아닙니다." POST의 경우, 이는 본문 읽기를 마치는 즉시 req가 close를 발생시킨다는 의미입니다. 여기에 의존하면 모든 요청을 취소하게 됩니다. 이전의 'aborted' 이벤트는 "'close' 이벤트를 수신하세요"라는 참고 사항과 함께 사용 중단(deprecated)되었습니다.
응답(response)에서 리스닝해야 합니다. ServerResponse의 close 이벤트는 "응답이 완료되었거나, 기본 연결이 조기에 종료되었음"을 나타냅니다. 이 둘을 구분하려면, 모든 데이터가 플러시된 후에만 true인 writableFinished를 확인해야 합니다:
// disconnect.ts
import type { ServerResponse } from "node:http";
...
이것은 Express에서도 작동합니다. 왜냐하면 Express의 res가 Node 자체 응답 객체의 향상된 버전이기 때문입니다. 여기에 AbortSignal.any (Node v20.3.0 및 v18.17.0에 추가됨)와 AbortSignal.timeout를 사용하여 하드 데드라인을 결합하면, 어느 쪽에서든 발생하는 하나의 시그널을 얻게 됩니다.
만약 nginx 뒤에서 실행된다면, 클라이언트가 떠날 때 이미 업스트림 연결을 닫습니다: proxy_ignore_client_abort는 기본값이 off입니다. 만약 누군가 이를 on으로 설정했다면, Node 서버는 연결 끊김을 절대 감지하지 못하고 이 모든 것이 발생하지 않습니다.
SDK에 시그널 전달하기
두 공식 SDK 모두 요청당 옵션에 signal을 받습니다. Anthropic TypeScript SDK는 이를 "요청을 취소하는 데 사용될 수 있는 AbortSignal"로 문서화하고 있으며, OpenAI Node SDK README도 동일하게 언급합니다. 여기서는 Anthropic SDK를 사용한 스트리밍 라우트를 보여주는데, node:http만 사용하여 내부 로직이 숨겨지지 않도록 했습니다:
// server.ts (Node 20+)
import http from "node:http";
import type {...
핸들러는 연결 끊김과 2분 기한을 결합하여 하나의 signal을 생성한 다음, 이를 SDK에 전달합니다:
// server.ts (계속)
async function chat(req: IncomingMessage, res: ServerResponse) {
const signal = AbortSignal.any([
...
취소된 브랜치에서의 res.end는 타임아웃 경로에 중요합니다. 클라이언트가 여전히 연결되어 있을 수 있으며, 이것이 없으면 SSE 응답이 열린 상태로 유지됩니다. 소켓이 닫힌 경우에는 아무 작업도 하지 않습니다. 마지막으로 서버 자체:
// server.ts (계속)
http
.createServer((req, res) => {
...
chat 함수 내에서 for await 루프 이후의 signal.aborted 확인은 의도적입니다. SDK의 스트림 반복자에서는 주석 "If the user calls stream.controller.abort(), we should exit without throwing."와 함께 abort 오류가 포착됩니다. 따라서 signal이 스트림 중간에 발생하면, 모델이 완료된 것처럼 조용히 for await 루프를 종료할 수 있습니다. catch에서만 abort를 처리하는 코드는 이를 절대 로깅하지 않습니다. 더 나쁜 것은 루프 후에 답변을 저장하는 코드는 마치 완전한 것처럼 잘린 응답을 저장한다는 것입니다. 항상 루프 이후에 signal.aborted를 확인하세요.
같은 파일은 반대 방향도 보여줍니다: 만약 루프에서 break하면, finally 블록이 요청을 대신 취소합니다. 따라서 이 SDK에서는 "읽기 중지(stop reading)"와 "취소(cancel)"가 동일한 작업입니다.
message_start에는 스트리밍 문서(streaming docs)에 따라 입력 토큰 수가 포함됩니다. 최종 출력 수는 스트림의 끝부분에서 message_delta로 도착하며, 이는 중단된 스트림에서는 절대 도달하지 못합니다. 이것이 바로 라우트가 문자 수를 세는 이유입니다.
툴 호출 및 에이전트 루프
싱글 스트림은 쉬운 경우입니다. 비용이 많이 드는 경우는 루프(loop)이며, 모든 턴마다 모든 것을 재전송하기 때문입니다. 모델 호출에 동일한 시그널을 전달하고, 매 턴 전에 이를 확인해야 합니다. 이 코드는 같은 파일에 작성되며 client, MODEL, MAX_TOKENS를 재사용합니다. 먼저 툴 사용 부분입니다:
// server.ts (계속)
const MAX_TURNS = 8;
...
다음은 루프 부분입니다:
// server.ts (계속)
async function runAgent(
messages: Anthropic.MessageParam[],
...
fetch는 시그널을 네이티브하게 받아들이므로, 진행 중인 툴 요청은 사용자와 함께 종료됩니다. 시그널을 받지 않는 툴(CPU 루프, 레거시 클라이언트)의 경우, 단계 사이에 signal.throwIfAborted()를 호출해야 합니다. 이는 Node globals reference에 문서화되어 있습니다. 각 턴 시작 부분의 if (signal.aborted)가 파일에서 가장 저렴한 라인입니다. 이 코드는 전체 컨텍스트 턴이 시작되는 것을 막아줍니다. 턴 중간에는 SDK 호출과 fetch가 대신 취소 오류(abort error)로 거부되므로, 스트림 라우트와 마찬가지로 호출자를 감싸야 합니다: catch를 사용하고 signal.aborted를 확인하며, stats.turns를 기록합니다. 툴 루프의 형태는 Anthropic의 tool use guide를 따릅니다.

돈이 드는 모든 호출에 걸쳐 전달되는 하나의 시그널입니다. 한 번 당겨주면 다운스트림의 모든 것이 멈춥니다.
비용을 지출하지 않은 것 기록하기
정확히 '절약된 토큰'을 기록할 수는 없습니다. 생성되지 않은 토큰은 카운트가 없기 때문입니다. 대신, 알고 있는 부분과 그에 대한 정직한 상한선(upper bound)을 기록할 수 있습니다. 이것이 chat 함수가 호출하는 logAborted입니다:
// server.ts (계속)
function logAborted(route: string, s: Stats, reason: unknown) {
const outEst = Math.ceil(s.outChars / 4);
...
여기서 네로 나눈 값(divide-by-four)은 OpenAI가 영어 텍스트 기준으로 토큰당 약 4문자라는 자체적인 경험적 규칙입니다. 이는 추정치이며, 다른 토크나이저(tokenizer)는 다를 수 있습니다. output_budget_unused는 사용하지 않은 부분의 상한선이며, 연결이 닫힐 때 제공업체(provider)가 생성을 중단할 경우에만 해당됩니다. 에이전트 경로(agent route)의 경우, 중단 시점의 AgentStats에서 turns를 기록하고, 이를 완료된 실행의 평균 트ิร์น 수와 비교하십시오. 그 차이를 평균 트ิร์น당 입력값으로 곱한 것이 비용 검토 과정에서 방어할 수 있는 절감액입니다.
그런 다음 집계합니다: 경로별 일일 중단 요청 건수와 전체 요청에서 차지하는 비중을 말입니다. 만약 채팅 요청의 상당 부분이 client_disconnected로 끝난다면, 이는 비용 문제뿐만 아니라 제품 신호(product signal)이기도 합니다. 즉, 답변이 나오기 전에 사람들이 이탈하고 있다는 뜻입니다.
배포 전에 확인할 두 가지 측면
브라우저 측에서 실제로 연결을 끊어야 합니다. 만약 UI에 '중지' 버튼이 있다면, 해당 버튼을 fetch 호출의 AbortController에 연결하여, 중단하는 것이 단순히 텍스트를 숨기는 것이 아니라 실제 연결 끊김(real disconnect)이 되도록 해야 합니다:
const ac = new AbortController();
stopButton.onclick = () => ac.abort();
await fetch("/chat", {
...
그리고 백그라운드 작업은 다른 계약입니다. OpenAI의 Responses API에는 백그라운드 모드 (background mode)가 내장되어 있어 연결이 끊겨도 응답을 유지할 수 있습니다. sequence_number 커서를 사용하여 스트림을 재개할 수 있습니다. 소켓을 닫는 것은 취소(cancel)가 아닙니다. 취소 엔드포인트 (cancel endpoint)가 취소이며, 'background 매개변수가 true로 설정된 응답만 취소할 수 있습니다.' 백그라운드 모드를 사용한다면, 중단 리스너(abort listener)는 연결 끊김에 의존하기보다 client.responses.cancel(id)를 호출해야 합니다.
로컬에서 이 경로를 테스트하려면, 서버를 시작하고 -N을 사용하여 긴 프롬프트를 보내는 curl을 사용한 다음, 2초 후에 Ctrl-C를 누르세요. llm.aborted 라인 하나만 받고 event: done은 받지 못해야 합니다.
유용했다면
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기