취소 버튼을 눌렀는데 요청이 계속 돈다? AbortController로 진짜 취소하기

네트워크 요청에 진행 알림을 붙였습니다. 오래 걸릴 수 있으니 취소 버튼도 달았습니다.

사용자가 취소를 누르면 알림을 닫고 함수에서 return합니다. 끝났을까요?

아닙니다. 이미 시작된 fetch()는 여전히 응답이나 타임아웃을 기다릴 수 있습니다.

화면에서 사라진 것은 진행 표시이지 요청이 아닙니다. API 사용량도, 네트워크 연결도, 서버 작업도 계속될 수 있습니다.

이런 취소는 사실 안 보기에 가깝습니다. 진짜 취소를 만들려면 UI 이벤트가 네트워크 계층까지 전달되어야 합니다.

Commit Quill은 VS Code 진행 알림의 취소 토큰을 AbortController에 연결하고, 같은 AbortSignal을 HTTP 클라이언트의 fetch()에 전달합니다.

취소 버튼
  -> VS Code CancellationToken
  -> AbortController.abort()
  -> AbortSignal
  -> fetch(..., { signal })
  -> 요청 중단

버튼 하나가 계층 다섯 개를 건넙니다. 버튼은 작지만 책임은 장거리 선수네요.

취소 가능한 UI만으로는 부족합니다

VS Code의 withProgress()cancellable: true로 취소 버튼을 노출할 수 있습니다.

return vscode.window.withProgress(
  {
    location: vscode.ProgressLocation.Notification,
    title: "Generating commit message…",
    cancellable: true,
  },
  async (_progress, token) => {
    // 작업 실행
  }
);

여기까지 하면 사용자에게 버튼은 보입니다. 하지만 버튼을 눌렀을 때 어떤 작업을 멈출지는 애플리케이션이 연결해야 합니다.

현재 구현은 취소 이벤트에서 AbortController.abort()를 호출합니다.

const subscription = token.onCancellationRequested(() => {
  controller.abort();
});

try {
  return await run();
} finally {
  subscription.dispose();
}

finally에서 구독을 해제하는 것도 중요합니다. 요청이 성공하거나 실패해도 취소 이벤트 리스너가 남지 않게 합니다.

UI 취소 토큰과 웹 표준 AbortSignal은 타입도 생태계도 다릅니다. AbortController가 둘 사이의 어댑터 역할을 합니다.

fetch()까지 같은 신호를 전달하기

컨트롤러는 요청 흐름을 시작할 때 만듭니다.

const controller = new AbortController();
const client = new FetchHttpClient(controller.signal);

HTTP 클라이언트는 이 신호를 보관했다가 fetch()에 넘깁니다.

const response = await fetch(request.url, {
  method: "POST",
  headers: request.headers,
  body: request.body,
  signal,
});

이제 사용자가 취소하면 fetch()의 대기 상태가 중단됩니다. 진행 알림만 사라지고 요청은 계속 도는 유령 작업이 남지 않습니다.

중요한 점은 AbortController를 각 작업 흐름에 맞는 수명으로 만드는 것입니다. 한 번 abort()된 signal은 다시 살아나지 않습니다.

전역 컨트롤러 하나를 모든 요청에 재사용하면 첫 취소 이후의 요청까지 즉시 실패할 수 있습니다. 현재 구조처럼 한 명령 실행에 컨트롤러 하나를 만들고, 그 실행 안의 provider 호출에 공유하는 편이 수명을 이해하기 쉽습니다.

사용자 취소와 타임아웃을 동시에 다루기

요청에는 취소 버튼 외에도 자체 타임아웃이 필요합니다. 사용자가 가만히 있어도 네트워크가 영원히 답하지 않을 수 있으니까요.

현재 FetchHttpClient는 30초 timeout signal을 만들고, 사용자 취소 signal이 있으면 AbortSignal.any()로 합칩니다.

const timeout = AbortSignal.timeout(30_000);
const signal = cancellation === undefined ? timeout : AbortSignal.any([timeout, cancellation]);

두 신호 중 하나라도 먼저 중단되면 fetch()가 멈춥니다.

사용자가 취소함 --------┐
                       ├-> fetch 중단
30초 타임아웃 발생 ----┘

여기서 “둘 다 abort니까 같은 에러로 처리하면 되지 않나?”라는 생각이 들 수 있습니다. 하지만 사용자 경험에서는 둘이 다릅니다.

  • 사용자가 취소한 것은 의도한 종료입니다.
  • 타임아웃은 요청 실패입니다.

둘을 같은 빨간 오류 토스트로 보여주면 사용자가 직접 누른 취소 버튼 때문에 오류 메시지를 받습니다. 버튼이 뒤끝이 있군요.

취소를 실패로 꾸미지 않기

HTTP 클라이언트의 catch는 외부 cancellation signal이 실제로 중단되었는지 확인합니다.

} catch (error: unknown) {
  if (this.cancellation?.aborted === true) {
    throw new LanguageModelCancelledError();
  }

  throw new LanguageModelError(
    "The provider request failed before receiving a response.",
    error,
  );
}

사용자 취소라면 전용 LanguageModelCancelledError를 던집니다. 상위 워크플로우는 이 오류를 조용한 취소 결과로 바꿀 수 있습니다.

그 외 중단은 전송 실패로 처리합니다. 타임아웃, 연결 오류, DNS 문제처럼 사용자가 요청하지 않은 종료까지 “취소됨”으로 숨기지 않습니다.

이 분류 덕분에 로그와 UI가 더 정직해집니다.

user abort -> cancelled
timeout    -> failed
HTTP 4xx  -> provider error
HTTP 5xx  -> provider error
bad JSON  -> response error

네트워크 예외는 모두 비슷해 보이지만 복구 방법과 사용자 메시지가 다릅니다. 타입을 나누는 이유는 예쁘게 보이려고가 아니라 다음 행동이 다르기 때문입니다.

HTTP 클라이언트를 주입 가능하게 만들기

여기서 테스트 문제가 생깁니다.

provider adapter가 내부에서 직접 fetch()를 만들면 다음을 검증하기 어렵습니다.

  • 올바른 URL을 사용했는지
  • API 키가 헤더에 들어갔는지
  • 요청 body의 모델과 입력이 정확한지
  • 오류 응답을 어떻게 해석하는지
  • 취소가 어떤 오류 타입으로 변환되는지

실제 API를 호출하는 테스트는 느리고, 키가 필요하고, 네트워크 상태에 영향을 받습니다. 그리고 테스트를 돌릴 때마다 돈이 들 수 있습니다. 테스트가 열심히 일해서 카드 명세서로 성과를 보고하는 건 원하지 않습니다.

그래서 provider 계층은 작은 HttpClient 인터페이스에 의존합니다.

export interface HttpClient {
  post(request: HttpRequest): Promise<HttpResponse>;
}

운영에서는 FetchHttpClient를 넣고, 테스트에서는 요청을 기록하는 fake client를 넣습니다.

const client = new RecordingHttpClient({
  status: 200,
  body: JSON.stringify({
    output: [
      {
        type: "message",
        content: [{ type: "output_text", text: "feat: add commit command" }],
      },
    ],
  }),
});

const model = createLanguageModel(settings, client);
await model.generateMessage(context);

assert.strictEqual(client.request?.headers.Authorization, "Bearer test-key");

provider adapter는 네트워크가 없어도 요청 모양을 테스트할 수 있습니다. 실제 fetch()의 취소 동작은 FetchHttpClient의 좁은 테스트로 따로 확인합니다.

이것이 주입 가능한 HTTP 경계의 장점입니다. 요청을 만드는 책임과 요청을 보내는 책임을 분리합니다.

이미 취소된 signal로 네트워크 없이 테스트하기

취소 테스트는 컨트롤러를 만든 뒤 요청 전에 바로 중단합니다.

test("reports a cancelled request rather than a transport failure", async () => {
  const controller = new AbortController();
  controller.abort();

  await assert.rejects(
    new FetchHttpClient(controller.signal).post({
      url: "https://example.invalid/",
      headers: {},
      body: "{}",
    }),
    (error: Error) => error.name === "LanguageModelCancelledError"
  );
});

이미 중단된 signal을 fetch()에 전달하면 네트워크에 닿기 전에 거절됩니다. 그래서 실제 provider도, API 키도, 응답 대기 시간도 필요 없습니다.

이 테스트는 두 가지를 확인합니다.

  1. signal이 HTTP 클라이언트까지 전달됩니다.
  2. 중단이 일반 전송 오류가 아니라 취소 오류로 변환됩니다.

UI 버튼 클릭까지 포함한 통합 테스트는 별도 문제입니다. 하지만 가장 중요한 전송 경계는 이 작은 테스트로 빠르게 고정할 수 있습니다.

여러 요청이 이어지는 워크플로우라면

AI 기반 커밋 분할처럼 한 실행 안에서 요청이 여러 번 이어질 수 있습니다. 이 저장소는 그렇게 하지 않습니다. 분할 경로는 proposeGroups한 번만 호출하고, 각 그룹은 그때 받은 제목을 그대로 씁니다. 하지만 그룹마다 다시 모델을 부르는 설계도 충분히 있을 수 있습니다.

그런 설계라면 같은 실행 범위에 하나의 controller를 공유해서, 사용자가 취소한 순간 이후 호출도 시작되지 않게 만들 수 있습니다. 다만 이미 일부 커밋이 만들어졌다면 취소 결과는 “아무 일도 없었다”가 아닙니다.

그래서 워크플로우 결과는 취소 여부뿐 아니라 이미 만들어진 커밋 수를 함께 기록합니다.

type WorkflowOutcome =
  | { kind: "cancelled"; count: number }
  | { kind: "committed"; count: number }
  | { kind: "drafted" }
  | { kind: "nothing-to-commit" };

네트워크 취소와 상태 변경 취소는 연결되어 있지만 같은 문제가 아닙니다.

  • 전송 계층은 요청을 중단합니다.
  • 워크플로우 계층은 어디까지 작업됐는지 보고합니다.
  • UI 계층은 사용자에게 정확한 결과를 보여줍니다.

취소를 잘 구현하려면 “멈춰”뿐 아니라 “어디까지 했어?”에도 답해야 합니다.

흔히 놓치는 취소 안티패턴

알림만 닫기

요청은 계속 실행됩니다. UI 상태와 작업 상태가 어긋납니다.

Boolean 플래그만 확인하기

응답이 온 뒤 결과를 무시할 수는 있지만 네트워크와 서버 작업은 이미 끝까지 실행됩니다. 필요하다면 결과 무시와 transport abort를 함께 써야 합니다.

하나의 controller를 전역 재사용하기

한 번 중단한 signal은 영구적으로 중단 상태입니다. 작업 수명에 맞춰 새 controller를 만들어야 합니다.

모든 AbortError를 사용자 취소로 처리하기

타임아웃과 사용자 취소를 구분하지 못합니다. 어떤 signal이 중단됐는지 확인하거나 원인 타입을 분리해야 합니다.

취소를 오류 토스트로 보여주기

사용자가 요청한 동작을 실패처럼 표현합니다. 취소는 대개 조용한 정상 종료에 가깝습니다.

정리

진짜 취소는 UI 기능이 아니라 끝까지 이어지는 제어 흐름입니다.

  1. 진행 UI가 취소 이벤트를 제공합니다.
  2. 이벤트가 AbortController.abort()를 호출합니다.
  3. 같은 signal이 HTTP 클라이언트와 fetch()에 전달됩니다.
  4. 사용자 취소와 타임아웃을 다른 결과로 분류합니다.
  5. provider는 주입 가능한 HTTP 인터페이스에 의존합니다.
  6. 이미 취소된 signal과 fake client로 경계를 테스트합니다.
  7. 부분 상태 변경이 있었다면 워크플로우가 그 사실을 보고합니다.

취소 버튼이 보인다고 취소가 구현된 것은 아닙니다.

버튼에서 시작한 신호가 가장 아래의 비동기 작업까지 도착해야 합니다. 그래야 사용자가 “그만”이라고 했을 때 컴퓨터도 정말 그만합니다.

네, 이번에는 진짜로요. ㅎㅎ