리소스 누수 방지: Playwright 테스트에서 AbortSignal 사용 방법

발행: (2026년 2월 16일 오후 08:19 GMT+9)
11 분 소요
원문: Dev.to

Source: Dev.to

위 링크에 있는 전체 글 내용을 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. (코드 블록, URL 및 기술 용어는 그대로 유지됩니다.)

문제

test('should fetch user profile', async () => {
  const response = await fetch('https://api.example.com/users/123');
  const user = await response.json();
  expect(user.name).toBe('Alice');
});
  • 테스트가 타임아웃될 경우(느린 API, 서버 부하 등), Playwright는 테스트를 중단하지만 fetch 호출은 **완료되거나 자체 타임아웃(보통 30 초 이상)**에 도달할 때까지 계속 실행됩니다.

규모가 큰 경우 왜 중요한가

  • 연결 풀 고갈 – 워커가 소켓을 다 사용하게 됩니다.
  • 서버 과부하 – 실제로 기다리는 사람이 없는 요청까지 스테이징 환경에서 처리됩니다.
  • 오해를 일으키는 로그 – 고아가 된 요청이 서버 로그에 오류로 나타납니다.
  • 연쇄적인 실패 – 하나의 서비스가 고갈되면 전체 테스트 스위트가 중단될 수 있습니다.

afterEach가 도움이 안 되는 이유

afterEach가 실행될 때는 이미 진행 중인 요청에 대한 참조가 사라져 있습니다. 타임아웃된 테스트 함수 안에 프로미스가 잡혀 있기 때문에, 접근하지 못하는 요청을 취소할 수 없습니다.

해결책: 모든 비동기 작업에 취소 토큰(AbortSignal)을 미리 전달하고, 테스트가 끝날 때 토큰을 트리거합니다.

AbortController & AbortSignal

AbortController (브라우저와 Node.js에서 표준으로 제공됨) 은 비동기 작업을 취소하는 데 사용할 수 있는 시그널을 생성합니다:

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

// Pass the signal to fetch
await fetch('/api/data', { signal });

// Cancel later
controller.abort(); // fetch rejects with an AbortError
  • Pass the signal to fetchfetch에 시그널을 전달합니다
  • Cancel later나중에 취소
  • fetch rejects with an AbortErrorfetch가 AbortError와 함께 거부됩니다

@playwright-labs/fixture-abort 패키지

설치

npm install @playwright-labs/fixture-abort

패키지가 제공하는 내용

픽스처설명
abortController각 테스트마다 새로운 AbortController 인스턴스.
signal연관된 AbortSignal.
useAbortController(options?)컨트롤러를 반환합니다; onAbort 콜백을 제공할 수 있습니다.
useSignalWithTimeout(ms)ms 밀리초 후 자동으로 중단되는 신호를 반환합니다.

Note: @playwright/test 대신 패키지에서 testexpect를 가져와야 픽스처가 자동으로 적용됩니다.

import { test, expect } from '@playwright-labs/fixture-abort';

사용 예시

1️⃣ 자동 취소가 포함된 간단한 fetch

import { test, expect } from '@playwright-labs/fixture-abort';

test('should fetch user profile', async ({ signal }) => {
  const response = await fetch('https://api.example.com/users/123', { signal });
  const user = await response.json();
  expect(user.name).toBe('Alice');
});

테스트가 시간 초과되면 signal이 발생하고 요청이 즉시 취소됩니다.

2️⃣ 조건이 만족될 때까지 폴링

import { test, expect } from '@playwright-labs/fixture-abort';

test('should wait for order processing', async ({ signal }) => {
  const orderId = await createOrder();

  while (!signal.aborted) {
    const response = await fetch(`/api/orders/${orderId}`, { signal });
    const order = await response.json();

    if (order.status === 'completed') {
      expect(order.total).toBeGreaterThan(0);
      return;
    }

    // 다음 폴링 전 2 초 대기
    await new Promise(resolve => setTimeout(resolve, 2000));
  }
});

while (!signal.aborted) 가드가 테스트가 중단될 때 루프가 깔끔하게 종료되도록 보장합니다.

3️⃣ 병렬 요청 – 하나의 signal, 여러 호출

import { test, expect } from '@playwright-labs/fixture-abort';

test('should fetch dashboard data', async ({ signal }) => {
  const [users, orders, metrics] = await Promise.all([
    fetch('/api/users',   { signal }),
    fetch('/api/orders',  { signal }),
    fetch('/api/metrics', { signal })
  ]);

  expect(users.ok).toBe(true);
  expect(orders.ok).toBe(true);
  expect(metrics.ok).toBe(true);
});

테스트가 중단되면 세 요청이 모두 동시에 취소됩니다.

4️⃣ 테스트 로직에 기반한 수동 abort

import { test, expect } from '@playwright-labs/fixture-abort';

test('should stop on first error', async ({ signal, abortController }) => {
  const items = await getItemsToProcess();

  for (const item of items) {
    if (signal.aborted) break;

    const response = await fetch(`/api/process/${item.id}`, {
      method: 'POST',
      signal
    });

    if (!response.ok) {
      abortController.abort(); // 남은 작업 취소
      break;
    }
  }
});

테스트 본문 내부에서 전체 테스트를 중단할 수 있습니다.

5️⃣ useAbortController – abort 시 콜백 등록

import { test, expect } from '@playwright-labs/fixture-abort';

test('should handle abort with cleanup', async ({ useAbortController, signal }) => {
  const controller = useAbortController({
    onAbort: () => console.log('Operation cancelled, cleaning up'),
    abortTest: true               // 선택 사항 – 테스트 자체를 중단
  });

  const response = await fetch('/api/long-operation', { signal });
  const data = await response.json();
  expect(data).toBeDefined();
});

onAbort 훅은 signal이 트리거될 때 자동으로 실행됩니다.

6️⃣ useSignalWithTimeout – 고정 기간 후 자동 abort

import { test, expect } from '@playwright-labs/fixture-abort';

test('should auto‑abort after 10 seconds', async ({ useSignalWithTimeout }) => {
  const signal = useSignalWithTimeout(10_000); // 10 s

  const response = await fetch('/api/slow-endpoint', { signal });
  const data = await response.json();
  expect(data).toBeDefined();
});

signal은 지정된 타임아웃 후 자동으로 abort되어, 테스트 자체가 시간 초과되지 않더라도 장기 요청으로부터 보호합니다.

TL;DR

  • 문제: Playwright 테스트에서 오래 실행되는 비동기 작업이 테스트 타임아웃 후에도 계속 실행되어 리소스가 누수됩니다.
  • 해결책: 모든 비동기 작업에 AbortSignal을 전달하고 테스트가 끝날 때 이를 중단(abort)합니다.
  • 도구: @playwright-labs/fixture-abort는 준비된 픽스처(signal, abortController, useAbortController, useSignalWithTimeout)를 제공합니다.
  • 결과: 고아 HTTP 요청이 없고, 연결 풀 고갈도 없으며, CI 실행이 더 깔끔해집니다.

행복한 테스트 되세요! 🚀

# Abort Fixtures for Playwright

```ts
import { test, expect } from '@playwright-labs/fixture-abort';

test('should complete within 5 seconds', async ({ useSignalWithTimeout }) => {
  const timeoutSignal = useSignalWithTimeout(5000);

  const response = await fetch('/api/slow-endpoint', {
    signal: timeoutSignal,
  });
  expect(response.ok).toBe(true);
});

많은 최신 라이브러리가 AbortSignal을 지원합니다. 해당 신호를 지원하는 어디에든 전달할 수 있습니다:

import { test, expect } from '@playwright-labs/fixture-abort';

test('should query database', async ({ signal }) => {
  // 많은 DB 클라이언트가 abort signal을 받습니다
  const result = await db.query('SELECT * FROM users', {
    signal,
  });
  expect(result.rows.length).toBeGreaterThan(0);
});

다음과 함께 사용할 수 있습니다:

  • Axios (signal 옵션을 통해)
  • Node.js fetch 구현
  • 다양한 데이터베이스 드라이버
  • gRPC 클라이언트
  • …그 외 여러 경우

이 패키지는 abort 상태를 테스트하기 위한 커스텀 expect 매처도 제공합니다:

import { test, expect } from '@playwright-labs/fixture-abort';

test('should verify abort state', async ({ signal, abortController }) => {
  expect(signal).toBeActive();

  abortController.abort('test reason');

  expect(signal).toBeAborted();
  expect(signal).toBeAbortedWithReason('test reason');
  expect(abortController).toHaveAbortedSignal();
});

test('should verify timeout signal aborts', async ({ useSignalWithTimeout }) => {
  const timeoutSignal = useSignalWithTimeout(100);
  await expect(timeoutSignal).toAbortWithin(150);
});

How It Works

  1. 각 테스트 실행 전 – Playwright 픽스처 시스템을 통해 새로운 AbortController가 생성됩니다.
  2. 컨트롤러와 그 signal은 각각 abortControllersignal 픽스처로 노출됩니다.
  3. 테스트가 타임아웃될 때, 컨트롤러가 자동으로 abort됩니다.
  4. 신호를 듣고 있던 모든 작업은 AbortError를 받아 중단됩니다.
  5. 각 테스트가 끝난 뒤, 컨트롤러가 정리됩니다.

이는 각 테스트마다 독립된 취소 범위가 제공된다는 의미이며, 한 테스트가 타임아웃돼도 다른 테스트에 영향을 주지 않습니다.

Best Practices

  • 픽스처가 이미 AbortController를 제공하고 있다면 직접 AbortController를 만들지 마세요. 픽스처가 제공하는 컨트롤러는 테스트 라이프사이클에 연결되어 있어 타임아웃 시 자동으로 abort됩니다.
  • 항상 신호를 비동기 작업에 전달하세요 (fetch, DB 쿼리 등). 신호 없이 호출하면 좀비 요청 문제가 발생할 수 있습니다.
  • AbortError를 조용히 잡아먹지 마세요. 신호가 발생하면 작업은 AbortError로 거부됩니다. 오류를 숨기지 말고 Playwright가 타임아웃을 보고하도록 두세요.

Installation

npm install @playwright-labs/fixture-abort

전체 소스 코드와 문서:

이 패키지는 @playwright-labs 모노레포의 일부입니다. @playwright/test 대신 @playwright-labs/fixture-abort에서 testexpect를 임포트하면 abort 픽스처를 모든 테스트에서 바로 사용할 수 있습니다.

한 번 사용해 보세요—스테이징 서버가 고마워할 겁니다!

0 조회
Back to Blog

관련 글

더 보기 »

JavaScript의 가장 이상한 비교

왜 `== false`가 `true`인지 `javascript` `== false // true` `==` 연산자는 타입 강제 변환을 수행합니다: 1. `false`는 `0`으로 변환됩니다. 2.는 먼저 빈 문자열 `''`으로 변환됩니다.