Published on

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

Authors
Binance registration banner

리소스 누수는 “메모리”만의 문제가 아닙니다. 파일 핸들, 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”에 가까운 코드베이스로 빠르게 이동할 수 있습니다.