파일 읽기는 단순해 보이지만 웹 서버나 CLI에서는 생각보다 자주 장애 지점이 됩니다. 설정 파일, 업로드 임시 파일, 큰 JSON 문서, 리포트 템플릿을 읽는 동안 요청이 이미 끊겼거나 전체 작업 시간이 초과됐다면 더 진행할 이유가 없습니다. 그런데 취소 경로가 없으면 프로세스는 필요 없는 I/O를 계속 붙잡고, 뒤따르는 파싱이나 응답 처리까지 불필요하게 실행할 수 있습니다.

Node.js의 fs.readFile()fsPromises.readFile()signal 옵션을 받을 수 있습니다. AbortController 또는 AbortSignal.timeout()과 연결하면 파일 읽기 작업을 시간 제한, 사용자 취소, HTTP 요청 종료 같은 상위 작업 수명주기에 맞춰 중단할 수 있습니다. 이 글에서는 Node.js fs.readFile AbortSignal 기본 사용법, 타임아웃 적용 패턴, AbortError 처리, 큰 파일에서 스트림으로 전환해야 하는 기준을 정리합니다. 취소 설계 전체를 먼저 보고 싶다면 Node.js AbortSignal.any와 timeout 가이드: 여러 취소 조건을 하나로 묶는 법도 함께 보면 좋습니다.

fs.readFile에 AbortSignal이 필요한 이유

H3. 요청이 끝난 뒤에도 파일 읽기를 계속하지 않는다

서버에서 파일을 읽어 응답으로 내려주는 코드는 흔합니다. 하지만 클라이언트가 중간에 연결을 끊었는데도 백엔드가 파일 읽기와 후처리를 계속하면, 트래픽이 몰릴 때 불필요한 작업이 쌓입니다. 이때 요청 수명주기와 AbortSignal을 연결하면 이미 의미가 사라진 작업을 일찍 정리할 수 있습니다.

import { readFile } from 'node:fs/promises';

export async function loadTemplate(signal) {
  return readFile('./templates/report.html', {
    encoding: 'utf8',
    signal,
  });
}

핵심은 readFile() 호출부가 직접 타이머나 플래그를 확인하지 않아도 된다는 점입니다. 상위 계층이 controller.abort()를 호출하면 readFile()은 중단 신호를 받고 거절됩니다. 이 패턴은 파일 읽기뿐 아니라 fetch(), 타이머, 일부 Node.js API와도 비슷하게 이어집니다.

H3. 시간 제한을 명시해 느린 I/O를 방치하지 않는다

파일 시스템이 항상 빠른 것은 아닙니다. 네트워크 볼륨, 컨테이너의 느린 디스크, 과도한 동시 읽기, 바이러스 스캐너 개입처럼 환경에 따라 작은 파일도 예상보다 늦게 읽힐 수 있습니다. 업무 로직에 허용 가능한 최대 시간이 있다면 AbortSignal.timeout()으로 시간 제한을 코드에 명확히 남기는 편이 안전합니다.

import { readFile } from 'node:fs/promises';

const configText = await readFile('./config/app.json', {
  encoding: 'utf8',
  signal: AbortSignal.timeout(800),
});

const config = JSON.parse(configText);

이 예시는 설정 파일 읽기가 800ms 안에 끝나야 한다는 운영 기준을 드러냅니다. 타임아웃은 무조건 짧게 잡는 것이 아니라, 파일 크기와 배포 환경을 고려해 정해야 합니다. 외부 API 호출처럼 네트워크 변수가 큰 작업과 함께 다룬다면 Node.js AbortController timeout 가이드: 요청 취소와 시간 제한 패턴도 같이 참고할 만합니다.

기본 사용법

H3. AbortController로 수동 취소를 연결한다

가장 기본적인 형태는 AbortController를 만들고 controller.signalreadFile() 옵션으로 넘기는 것입니다. 취소가 필요해지는 순간 controller.abort()를 호출하면 됩니다. 아래 예시는 CLI에서 사용자가 작업을 취소하는 상황을 단순화한 코드입니다.

import { readFile } from 'node:fs/promises';

const controller = new AbortController();

process.once('SIGINT', () => {
  controller.abort(new Error('User cancelled the file read'));
});

try {
  const text = await readFile('./large-report.json', {
    encoding: 'utf8',
    signal: controller.signal,
  });

  console.log(JSON.parse(text));
} catch (error) {
  if (error.name === 'AbortError') {
    console.error('파일 읽기가 취소되었습니다.');
  } else {
    throw error;
  }
}

AbortError는 실패 원인이 파일 없음이나 권한 문제가 아니라 취소였음을 구분하게 해 줍니다. 실무에서는 취소를 장애로 기록할지, 정상적인 사용자 행동으로 낮은 레벨에 기록할지를 분리해야 합니다. 취소는 보통 재시도 대상이 아니고, 사용자에게도 “작업이 취소됨” 정도로 안내하는 것이 충분합니다.

H3. AbortSignal.timeout으로 짧은 시간 제한을 만든다

수동 컨트롤러가 필요 없고 단순히 최대 실행 시간만 제한하고 싶다면 AbortSignal.timeout(ms)가 간단합니다. 별도 타이머를 만들고 clearTimeout()을 호출하는 코드를 줄일 수 있습니다.

import { readFile } from 'node:fs/promises';

async function readJsonWithTimeout(filePath, timeoutMs = 1000) {
  const text = await readFile(filePath, {
    encoding: 'utf8',
    signal: AbortSignal.timeout(timeoutMs),
  });

  return JSON.parse(text);
}

const settings = await readJsonWithTimeout('./settings.json', 1200);
console.log(settings.name);

이 함수의 장점은 호출자가 시간 제한을 숫자로 조정할 수 있다는 점입니다. 단, 파일을 읽은 뒤 JSON.parse()에 오래 걸리는 아주 큰 파일이라면 readFile() 취소만으로 전체 시간이 통제되지 않습니다. 파싱까지 포함한 전체 작업 제한이 필요하다면 더 작은 단위의 스트리밍 처리나 워커 분리를 검토해야 합니다.

여러 취소 조건을 함께 다루기

H3. 요청 종료와 타임아웃을 하나의 signal로 묶는다

웹 서버에서는 취소 조건이 하나가 아닙니다. 클라이언트 연결이 끊길 수도 있고, 서버가 정한 응답 시간 제한을 넘길 수도 있습니다. Node.js에서는 AbortSignal.any()로 여러 신호 중 하나라도 중단되면 전체 작업을 취소하도록 묶을 수 있습니다.

import http from 'node:http';
import { readFile } from 'node:fs/promises';

const server = http.createServer(async (req, res) => {
  const requestController = new AbortController();

  req.on('close', () => {
    requestController.abort(new Error('Client connection closed'));
  });

  const signal = AbortSignal.any([
    requestController.signal,
    AbortSignal.timeout(1500),
  ]);

  try {
    const html = await readFile('./templates/home.html', {
      encoding: 'utf8',
      signal,
    });

    if (!res.writableEnded) {
      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
      res.end(html);
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      if (!res.writableEnded) {
        res.writeHead(499);
        res.end('request cancelled');
      }
      return;
    }

    res.writeHead(500);
    res.end('internal server error');
  }
});

server.listen(3000);

이 구조는 파일 읽기 작업이 요청 수명주기를 벗어나지 않도록 묶어 줍니다. 응답 객체가 이미 닫혔을 수 있으므로 취소 후 응답을 쓰기 전에 res.writableEnded 같은 상태도 확인합니다. 운영 서버에서는 499 같은 비표준 상태 코드를 외부에 그대로 노출할지, 내부 로그에서만 구분할지도 팀 규칙에 맞춰 정하세요.

H3. 상위 작업의 signal을 함수 인자로 전달한다

취소 가능한 함수를 만들 때는 내부에서 임의로 컨트롤러를 새로 만드는 것보다 signal을 인자로 받는 편이 좋습니다. 그래야 호출자가 요청 종료, 배치 취소, 타임아웃, 테스트 제어를 한곳에서 조합할 수 있습니다.

import { readFile } from 'node:fs/promises';

export async function loadUserFixture(userId, { signal } = {}) {
  const filePath = `./fixtures/users/${userId}.json`;
  const text = await readFile(filePath, {
    encoding: 'utf8',
    signal,
  });

  return JSON.parse(text);
}

이 함수는 취소 정책을 강제하지 않습니다. 대신 호출자에게 정책을 위임합니다. 테스트에서는 짧은 타임아웃을 넣고, 서버에서는 요청 수명주기와 묶고, 배치에서는 전체 작업 취소 컨트롤러를 넣을 수 있습니다.

에러 처리와 로깅 기준

H3. AbortError는 일반 실패와 분리한다

파일 읽기 실패는 원인이 다양합니다. ENOENT는 파일이 없다는 뜻이고, EACCES는 권한 문제이며, AbortError는 의도적으로 작업이 중단됐다는 뜻입니다. 이 셋을 같은 에러 레벨로 기록하면 운영 중 원인 파악이 어려워집니다.

import { readFile } from 'node:fs/promises';

async function safeReadText(filePath, signal) {
  try {
    return await readFile(filePath, {
      encoding: 'utf8',
      signal,
    });
  } catch (error) {
    if (error.name === 'AbortError') {
      return null;
    }

    if (error.code === 'ENOENT') {
      throw new Error(`Required file not found: ${filePath}`);
    }

    throw error;
  }
}

취소를 null로 바꿀지, 별도 결과 객체로 반환할지, 그대로 던질지는 함수 책임에 따라 달라집니다. 중요한 것은 취소와 장애를 구분하는 것입니다. 관측성 이벤트와 함께 남긴다면 Node.js diagnostics_channel 가이드: 라이브러리 관측 이벤트를 안전하게 발행하는 법의 패턴을 붙일 수 있습니다.

H3. signal.aborted로 이미 취소된 작업을 빠르게 거른다

함수에 들어온 시점에 이미 취소된 signal일 수도 있습니다. 이때는 파일 시스템 호출까지 가지 않고 초기에 빠져나오는 것이 더 명확합니다. Node.js의 AbortSignalaborted 상태를 확인할 수 있고, 일부 환경에서는 throwIfAborted() 패턴도 함께 사용할 수 있습니다.

import { readFile } from 'node:fs/promises';

export async function readOptionalMarkdown(filePath, { signal } = {}) {
  if (signal?.aborted) {
    return null;
  }

  try {
    return await readFile(filePath, {
      encoding: 'utf8',
      signal,
    });
  } catch (error) {
    if (error.name === 'AbortError') {
      return null;
    }
    throw error;
  }
}

이미 취소된 상태를 초기에 확인하면 코드 의도가 분명해집니다. 다만 signal.aborted를 확인한 직후에도 취소가 발생할 수 있으므로 readFile()AbortError 처리는 여전히 필요합니다. 취소 체크포인트를 여러 단계에 두는 방식은 Node.js AbortSignal.throwIfAborted 가이드: 취소 체크포인트를 명확히 두는 법와도 연결됩니다.

readFile 대신 스트림이 필요한 경우

H3. 큰 파일은 취소보다 메모리 사용량이 먼저 문제다

readFile()은 파일 내용을 메모리에 한 번에 올립니다. AbortSignal을 연결해도 파일이 큰 경우에는 취소 가능성보다 메모리 사용량 자체가 더 큰 문제가 될 수 있습니다. 로그, CSV, 대용량 JSON Lines처럼 크기가 큰 파일은 처음부터 스트림이나 라인 단위 처리로 설계하는 것이 낫습니다.

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline/promises';

export async function countErrorLines(filePath, { signal } = {}) {
  const stream = createReadStream(filePath, { encoding: 'utf8', signal });
  const lines = createInterface({ input: stream, crlfDelay: Infinity });
  let count = 0;

  for await (const line of lines) {
    if (signal?.aborted) {
      break;
    }

    if (line.includes('ERROR')) {
      count += 1;
    }
  }

  return count;
}

스트림은 부분 처리, 백프레셔, 조기 종료에 더 유리합니다. 대용량 로그 처리라면 Node.js filehandle.readLines 가이드: 큰 로그 파일을 메모리 폭증 없이 읽는 법을 함께 보는 편이 실용적입니다.

H3. 작은 설정 파일과 템플릿은 readFile이 충분하다

반대로 모든 파일 읽기를 스트림으로 바꿀 필요는 없습니다. 작은 설정 파일, HTML 템플릿, 테스트 fixture처럼 크기가 작고 전체 내용이 필요하다면 readFile()이 더 단순합니다. 이 경우 AbortSignal은 “메모리 최적화”보다 “작업 수명주기 정리”에 가깝습니다.

실무 기준은 단순합니다. 파일 크기가 작고 전체 문자열이 필요하면 readFile()signal을 붙입니다. 파일이 커질 수 있고 일부만 처리해도 되거나 라인 단위 처리가 자연스럽다면 스트림을 선택합니다. 취소 기능은 두 방식 모두에 붙이되, 데이터 처리 방식은 파일 크기와 사용 패턴에 맞춰 고르는 것이 좋습니다.

실무 체크리스트

H3. 적용 전에 확인할 항목

  • 파일 읽기가 상위 요청, 배치, CLI 작업의 수명주기와 연결돼 있는가?
  • 최대 허용 시간을 코드에 드러낼 필요가 있는가?
  • 취소를 장애 로그와 분리해서 기록하는가?
  • ENOENT, EACCES, AbortError를 구분해 처리하는가?
  • 파일이 커질 가능성이 있다면 readFile() 대신 스트림을 검토했는가?
  • 취소 후 응답이나 후처리를 계속하지 않도록 상태를 확인하는가?

이 체크리스트를 통과하면 fs.readFile() 취소 처리는 대부분 충분히 안전해집니다. 중요한 것은 AbortSignal을 단순 옵션으로만 보지 않고, 작업 전체의 생명주기를 표현하는 인터페이스로 쓰는 것입니다.

FAQ

H3. AbortSignal을 쓰면 디스크 I/O가 즉시 중단되나요?

항상 물리적인 I/O가 즉시 멈춘다고 가정하면 안 됩니다. Node.js 문서 기준으로 fs.readFile()의 취소는 내부 버퍼링 작업을 중단하는 방식에 가깝고, 운영체제 수준의 모든 읽기 요청이 즉시 사라진다고 보장하는 모델은 아닙니다. 그래도 불필요한 후속 처리와 결과 전달을 막을 수 있어 서버 코드에서는 충분히 의미가 있습니다.

H3. 모든 readFile 호출에 timeout을 넣어야 하나요?

아닙니다. 애플리케이션 시작 시 한 번 읽는 작은 설정 파일처럼 실패하면 바로 종료해도 되는 경로에는 과한 타임아웃이 오히려 디버깅을 어렵게 만들 수 있습니다. 요청 처리 중 반복적으로 호출되거나, 사용자 대기 시간에 직접 영향을 주거나, 느린 스토리지에 접근하는 경로부터 적용하는 것이 좋습니다.

H3. 취소된 작업은 재시도해야 하나요?

대부분은 재시도하지 않는 편이 맞습니다. 취소는 보통 사용자가 중단했거나, 요청이 닫혔거나, 상위 시간 예산을 넘겼다는 뜻입니다. 자동 재시도가 필요하다면 취소가 아니라 일시적인 I/O 실패인지 먼저 구분해야 합니다. 재시도 전략은 Node.js exponential backoff와 jitter 가이드: 재시도 폭주를 막는 법처럼 별도 정책으로 설계하세요.

마무리

fs.readFile()AbortSignal을 붙이는 것은 작은 변경이지만, 서버와 CLI 코드의 수명주기를 훨씬 명확하게 만들어 줍니다. 요청이 끝났거나 시간 예산을 넘긴 작업을 계속 붙잡지 않고, 취소와 장애를 구분해 기록하며, 큰 파일은 스트림으로 넘기는 기준까지 함께 세울 수 있습니다.

새 코드를 작성할 때는 readFile(filePath)에서 멈추지 말고 “이 파일 읽기는 언제까지 유효한가?”를 같이 묻는 습관을 들이세요. 그 질문에 답이 있다면 signal 옵션은 단순한 부가 기능이 아니라 운영 안정성을 높이는 기본 인터페이스가 됩니다.