Published on

TS 5.5+ using으로 리소스 누수 0 만들기

Authors

리소스 누수는 “메모리”만의 문제가 아닙니다. 파일 핸들, DB 커넥션, 락, 타이머, 이벤트 리스너, Observable 구독, 임시 디렉터리 같은 것들이 finally에서 빠지면 서비스는 조용히 망가집니다. 특히 Node.js/웹 서버에서는 누수가 누적되다가 어느 순간 EMFILE(파일 디스크립터 고갈), 커넥션 풀 고갈, 이벤트 리스너 폭증 경고로 터집니다.

TypeScript 5.5+는 ECMAScript의 Explicit Resource Management 제안을 따라 using 문을 지원합니다. 핵심은 간단합니다.

  • 리소스를 생성할 때 “정리 함수”를 함께 등록
  • 스코프를 벗어날 때(정상 종료, 예외, return/throw) 자동으로 정리

이 글에서는 using을 실제 코드에 적용해 “리소스 누수 0”에 가깝게 만드는 방법을 다룹니다.

관련해서 타입 시스템 변화에 민감하다면 TS 5.5+ satisfies로 타입추론 깨짐 7가지 해결도 같이 읽어두면 TS 5.5대 업그레이드 시 시행착오를 줄일 수 있습니다.

using이 해결하는 문제: try/finally는 항상 새기 어렵다

전통적으로는 이런 코드가 많았습니다.

import { open } from "node:fs/promises";

export async function readFirstLine(path: string) {
  const f = await open(path, "r");
  try {
    const content = await f.readFile({ encoding: "utf8" });
    return content.split("\n")[0] ?? "";
  } finally {
    await f.close();
  }
}

문제는 다음과 같습니다.

  • 함수가 커질수록 finally를 빼먹기 쉽다
  • 중간에 리턴/예외가 늘어날수록 정리 코드가 흩어진다
  • 여러 리소스를 동시에 다루면 try/finally 중첩이 지저분해진다

using은 “리소스 생명주기”를 스코프에 붙여서 이 문제를 구조적으로 제거합니다.

TS 5.5+ using의 핵심 개념

using은 “Disposable” 프로토콜을 따르는 객체를 스코프 종료 시 자동으로 정리합니다.

  • 동기 정리: Symbol.dispose
  • 비동기 정리: Symbol.asyncDispose

즉, 객체가 위 심볼 메서드를 구현하면 using이 알아서 호출합니다.

컴파일러/런타임 전제

  • TypeScript: 5.5 이상
  • tsconfig.json에서 target은 보통 ES2022 이상 권장
  • 런타임(Node.js 등)이 Symbol.dispose를 “기본 제공”할 필요는 없습니다. 심볼 자체는 최신 런타임에 존재하지만, 실제 정리는 여러분이 구현합니다.
  • 폴리필/헬퍼가 필요한 경우 TS가 tslib 헬퍼로 변환하기도 합니다(설정에 따라 다름)

정확한 변환 결과는 프로젝트 설정에 따라 달라질 수 있으니, 실제 적용 전 tsc --pretty false로 출력 JS를 확인하는 습관을 권합니다.

1) 가장 작은 예제: “정리 함수”를 가진 리소스 만들기

다음은 “스코프를 벗어나면 자동으로 로그를 남기는” 단순 리소스입니다.

class ScopeLog {
  constructor(private name: string) {}

  [Symbol.dispose]() {
    console.log(`disposed: ${this.name}`);
  }
}

export function demo() {
  using _log = new ScopeLog("demo");

  console.log("work...");
  // 여기서 throw/return이 발생해도 dispose 호출
}

이 패턴이 중요한 이유는, 이제부터 파일/락/타이머/리스너/구독/임시 파일 등 “모든 것”을 동일한 방식으로 다룰 수 있기 때문입니다.

2) 실전: Node.js 파일 핸들 누수 없애기

fs/promisesFileHandleclose()를 호출해야 합니다. using을 위해 어댑터를 하나 만들면 깔끔해집니다.

import { open, type FileHandle } from "node:fs/promises";

type AsyncDisposable = {
  [Symbol.asyncDispose](): Promise<void>;
};

class DisposableFile implements AsyncDisposable {
  constructor(public handle: FileHandle) {}

  async [Symbol.asyncDispose]() {
    await this.handle.close();
  }
}

export async function readFirstLine(path: string) {
  await using file = new DisposableFile(await open(path, "r"));

  const content = await file.handle.readFile({ encoding: "utf8" });
  return content.split("\n")[0] ?? "";
}

포인트는 두 가지입니다.

  • await using을 사용하면 비동기 정리(Symbol.asyncDispose)가 보장됩니다.
  • 중간에 예외가 나도, return을 여러 번 해도, close() 누락이 구조적으로 불가능해집니다.

3) 여러 리소스를 동시에 다룰 때: 역순 정리(LIFO)

현실 코드는 “파일 하나”가 아니라 “파일 + 락 + 타이머 + 구독” 같은 조합입니다. using은 일반적으로 생성 역순으로 정리됩니다(스택처럼).

class Lock {
  constructor(private name: string) {
    console.log(`lock acquired: ${name}`);
  }
  [Symbol.dispose]() {
    console.log(`lock released: ${this.name}`);
  }
}

class Timer {
  private id: ReturnType<typeof setInterval>;
  constructor() {
    this.id = setInterval(() => {}, 1000);
  }
  [Symbol.dispose]() {
    clearInterval(this.id);
  }
}

export function criticalSection() {
  using lock = new Lock("A");
  using timer = new Timer();

  // 작업 도중 예외가 나도 timer -> lock 순서로 안전하게 정리
}

이 “역순 정리”는 의존성이 있는 리소스(예: 트랜잭션이 끝나야 커넥션 반납)가 있을 때 특히 중요합니다.

4) try/catch와 함께 쓰는 패턴: 에러 처리와 정리 분리

using은 정리를 담당하고, try/catch는 에러 정책만 담당하게 분리하는 게 좋습니다.

export async function handler(path: string) {
  try {
    return await readFirstLine(path);
  } catch (e) {
    // 로깅/매핑/재시도 같은 정책만 여기서 처리
    throw e;
  }
}

이 구조가 좋은 이유는 “정리”가 에러 처리 로직에 섞이지 않아 유지보수성이 크게 올라가기 때문입니다.

5) 이벤트 리스너/DOM 구독 누수 방지

프론트엔드에서 흔한 누수는 이벤트 리스너입니다. addEventListenerremoveEventListener를 빼먹는 케이스가 많습니다.

type Disposable = {
  [Symbol.dispose](): void;
};

function on<K extends keyof WindowEventMap>(
  target: Window,
  type: K,
  listener: (ev: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
): Disposable {
  target.addEventListener(type, listener as EventListener, options);
  return {
    [Symbol.dispose]() {
      target.removeEventListener(type, listener as EventListener, options);
    },
  };
}

export function mount() {
  using _resize = on(window, "resize", () => {
    console.log("resized");
  });

  // 컴포넌트 언마운트/스코프 종료 시 자동 remove
}

React/Vue/Svelte에서 “컴포넌트 생명주기”에 맞춰 스코프를 잡는 방식으로 응용할 수 있습니다. 예를 들어 특정 훅 내부에서 using을 쓰려면, 해당 스코프가 실제로 언제 종료되는지(클린업 타이밍)를 명확히 설계해야 합니다.

프론트 성능 이슈로 Long Task가 늘어 UI가 굳는 상황을 추적 중이라면, 리스너/타이머 누수도 함께 의심해야 합니다. 관련해서는 Chrome INP 폭증? Long Task를 50ms로 쪼개는 법도 참고할 만합니다.

6) Node.js 서버에서 자주 새는 것들: 타이머, AbortController, 스트림

타이머

타이머는 “정리 안 해도 프로세스 종료되면 끝”이라고 생각하기 쉽지만, 서버는 종료되지 않습니다. 타이머 누수는 곧 CPU/메모리 누수입니다.

class Interval implements Disposable {
  private id: ReturnType<typeof setInterval>;
  constructor(fn: () => void, ms: number) {
    this.id = setInterval(fn, ms);
  }
  [Symbol.dispose]() {
    clearInterval(this.id);
  }
}

export function startPolling() {
  using _poll = new Interval(() => {
    // poll work
  }, 1000);

  // 특정 스코프에서만 폴링해야 할 때 안전
}

AbortController

fetch나 각종 API에서 AbortController를 쓰는 경우, “스코프 종료 시 abort”를 걸어두면 예외/조기 반환에서도 네트워크가 질질 끌리지 않습니다.

class AbortOnDispose implements Disposable {
  constructor(public controller: AbortController) {}
  [Symbol.dispose]() {
    this.controller.abort();
  }
}

export async function fetchWithScope(url: string) {
  const controller = new AbortController();
  using _abort = new AbortOnDispose(controller);

  const res = await fetch(url, { signal: controller.signal });
  return await res.text();
}

스트림

Node 스트림은 에러/조기 종료 시 destroy() 호출이 빠지면 리소스가 남을 수 있습니다. 스트림도 동일한 방식으로 감싸면 됩니다.

import type { Readable } from "node:stream";

type StreamLike = Readable & { destroy: (error?: unknown) => void };

class DestroyOnDispose implements Disposable {
  constructor(private s: StreamLike) {}
  [Symbol.dispose]() {
    this.s.destroy();
  }
}

7) “어댑터/팩토리”로 팀 규칙 만들기

using을 프로젝트에 도입할 때 가장 효과적인 방법은 “리소스 생성은 반드시 팩토리를 통해서만” 하게 만드는 것입니다.

예를 들어 DB 트랜잭션을 직접 열지 못하게 하고, 다음처럼 스코프 기반으로 사용하게 강제합니다.

type AsyncDisposable = { [Symbol.asyncDispose](): Promise<void> };

class Tx implements AsyncDisposable {
  constructor(private db: { commit: () => Promise<void>; rollback: () => Promise<void> }) {}
  private committed = false;

  async commit() {
    await this.db.commit();
    this.committed = true;
  }

  async [Symbol.asyncDispose]() {
    if (!this.committed) await this.db.rollback();
  }
}

export async function withTx<T>(
  begin: () => Promise<{ commit: () => Promise<void>; rollback: () => Promise<void> }>,
  fn: (tx: Tx) => Promise<T>
) {
  await using tx = new Tx(await begin());
  const result = await fn(tx);
  await tx.commit();
  return result;
}

이 패턴은 다음을 보장합니다.

  • 성공 시 commit
  • 예외/조기 반환 시 자동 rollback
  • 개발자가 finally를 직접 작성할 필요가 거의 없음

8) 도입 시 자주 겪는 함정과 체크리스트

1) await using을 빼먹기

비동기 정리가 필요한데 using만 쓰면 기대한 타이밍에 정리가 되지 않거나(혹은 타입 레벨에서 막히거나), 정리가 누락된 것처럼 보일 수 있습니다. 규칙은 단순합니다.

  • Symbol.disposeusing
  • Symbol.asyncDisposeawait using

2) 스코프가 생각보다 길어지는 문제

using은 “스코프 종료”에 묶입니다. 즉, 함수 전체 스코프에서 선언하면 함수가 끝날 때까지 리소스를 잡고 있습니다.

  • 큰 함수에서 리소스를 늦게 해제하고 싶다면 블록 스코프를 추가하세요.
export async function work() {
  {
    await using file = new DisposableFile(await open("/tmp/a", "r"));
    // 파일이 필요한 작업만 여기서
  }

  // 여기서는 이미 close 완료
}

3) 정리 코드에서의 예외

정리(dispose) 자체가 실패할 수도 있습니다(예: close() 실패). 이때 정책을 정해야 합니다.

  • 정리 실패를 로깅만 하고 무시할지
  • 원래의 예외를 덮어쓸지
  • 여러 리소스 정리 중 일부 실패를 어떻게 합칠지

팀 표준이 없다면, 정리 단계에서는 “로깅 후 삼키기”가 운영 안정성 측면에서 더 낫습니다(원인 예외를 보존).

4) 린트/리뷰 규칙

도입 효과를 극대화하려면 다음 규칙을 추천합니다.

  • addEventListener 직접 호출 금지, 반드시 on() 같은 어댑터 사용
  • 파일/스트림/락/구독 생성은 createDisposableX() 팩토리만 사용
  • 코드 리뷰에서 try/finally로 정리하는 코드가 보이면 using으로 교체 검토

9) Next.js/서버리스에서의 감각: “요청 스코프”에 붙이기

Next.js API Route나 App Router의 서버 핸들러처럼 “요청 단위”로 스코프가 명확한 곳은 using이 특히 잘 맞습니다. 요청 처리 함수 내부에서 생성한 리소스는 함수 종료 시 정리되므로, 누수 방어선이 강해집니다.

캐시/요청 경계가 복잡한 환경이라면, 리소스 생명주기 또한 “요청/렌더 스코프”에 맞춰 설계해야 합니다. Next.js 운영 팁은 Next.js App Router 캐시 무효화 7가지 정리도 참고해 보세요.

결론: using은 “정리 책임”을 코드 스타일이 아니라 언어 기능으로 만든다

TS 5.5+의 using은 리소스 정리를 “개발자의 성실함”에 맡기지 않고, 스코프 규칙으로 강제합니다. 결과적으로 다음 효과가 큽니다.

  • 예외/조기 반환/복잡한 분기에서도 누수 방지
  • 여러 리소스를 다루는 코드가 단순해짐
  • 팀 차원에서 어댑터/팩토리로 표준화하기 쉬움

실무 적용은 작은 것부터 시작하는 게 좋습니다.

  1. 이벤트 리스너/타이머를 using으로 전환
  2. 파일/스트림/락을 어댑터로 감싸기
  3. 트랜잭션/구독 같은 “반드시 정리되어야 하는 것”을 팩토리로 강제

이 3단계만 지켜도, “리소스 누수 0”에 가까운 코드베이스로 빠르게 이동할 수 있습니다.