Published on

TS 5.6+ using 선언으로 자동 dispose 누수 막기

Authors

서버든 프론트든 “리소스 누수”는 대부분 정리 코드가 실행되지 않는 경로에서 시작합니다. 예외가 터졌거나, return이 중간에 발생했거나, 여러 단계로 나뉜 로직 중 한 곳에서만 finally를 빼먹는 식입니다. TypeScript 5.6+에서 도입된 using 선언은 이런 문제를 구조적으로 줄이는 도구입니다. 스코프를 빠져나갈 때 자동으로 dispose를 호출해 주기 때문에, “정리 코드”를 인간의 기억력에 맡기지 않아도 됩니다.

이 글에서는 using이 해결하는 문제, 기존 try/finally와의 차이, 동기·비동기 자원 정리 패턴, 그리고 팀 코드베이스에 안전하게 도입하는 방법을 실전 중심으로 다룹니다.

관련해서 “리소스가 닫히지 않아 터지는” 류의 장애 패턴은 언어를 가리지 않습니다. 예를 들어 파이썬에서도 세션을 제대로 닫지 않으면 런타임에 문제가 재현되는데, 이런 관점은 아래 글과도 맞닿아 있습니다.

using 선언이란

using블록 스코프 기반 자동 정리 문법입니다. 블록을 벗어나는 순간(정상 종료, 예외, 조기 return 포함) 해당 변수에 연결된 “정리 함수”가 호출됩니다.

핵심은 “무엇을 호출하느냐”인데, 타입스크립트/자바스크립트 표준화 흐름에서는 Symbol.dispose(동기)와 Symbol.asyncDispose(비동기) 심볼을 통해 자원을 정리합니다. 즉, 객체가 이 심볼 메서드를 구현하면 using이 스코프 종료 시 호출해 줍니다.

정리하면 다음과 같습니다.

  • 동기 자원: Symbol.dispose 구현 후 using으로 선언
  • 비동기 자원: Symbol.asyncDispose 구현 후 await using으로 선언

왜 필요한가: try/finally의 한계

기존에도 안전한 정리는 try/finally로 가능했습니다.

  • 문제는 “가능”과 “항상 그렇게 작성된다”는 다릅니다.
  • 코드가 길어질수록 finally는 빠지기 쉽고, 여러 자원을 다루면 정리 순서도 실수하기 쉽습니다.

특히 다음 상황에서 누수가 자주 발생합니다.

  1. 중간 return 또는 예외 경로에서 정리 누락
  2. 여러 자원을 획득한 뒤 일부만 정리
  3. 비동기 흐름에서 정리 타이밍이 꼬임
  4. 이벤트 리스너/구독 해제 누락으로 메모리 누수

using은 “정리 코드를 반드시 작성하게 만드는” 강제력이 생깁니다. 자원을 래핑해 Symbol.dispose를 구현해 두면, 호출부는 using 한 줄로 안전하게 정리됩니다.

기본 예제: 커스텀 disposable 만들기

Node.js의 파일 핸들처럼 “닫아야 하는 자원”을 예로 들어보겠습니다. 여기서는 표준 라이브러리 대신, 이해를 위해 최소 구현을 만듭니다.

class FileHandle {
  constructor(private readonly path: string) {
    // open
  }

  read(): string {
    return "data";
  }

  close(): void {
    // close
  }
}

class DisposableFile {
  constructor(private readonly fh: FileHandle) {}

  read(): string {
    return this.fh.read();
  }

  [Symbol.dispose](): void {
    this.fh.close();
  }
}

function readConfig(path: string): string {
  using file = new DisposableFile(new FileHandle(path));
  return file.read();
}

포인트는 2가지입니다.

  • 호출부는 close()를 직접 호출하지 않습니다.
  • return이 어디서 발생하든, 블록을 벗어나면 Symbol.dispose가 호출됩니다.

예외가 터져도 정리되는지 확인

using의 가치는 “예외 경로에서도 정리된다”는 점입니다.

class DisposableLock {
  constructor(private readonly name: string) {
    // acquire
  }

  [Symbol.dispose](): void {
    // release
  }
}

function criticalSection(): void {
  using lock = new DisposableLock("order:123");

  // 중간에 예외가 나도 lock은 해제돼야 함
  throw new Error("boom");
}

이 패턴은 락, 세마포어, 임시 디렉터리, 성능 측정 타이머 같은 “반드시 정리해야 하는” 리소스에 특히 유용합니다.

여러 자원 정리 순서: LIFO로 안전하게

대부분의 스코프 기반 정리는 역순(LIFO) 으로 정리하는 것이 안전합니다. 예를 들어 연결을 열고, 트랜잭션을 시작하고, 임시 리소스를 만들었다면 정리는 반대로 해야 합니다.

using은 자연스럽게 선언 역순으로 정리되도록 설계되어 있습니다.

class DisposableA {
  [Symbol.dispose](): void {
    console.log("dispose A");
  }
}
class DisposableB {
  [Symbol.dispose](): void {
    console.log("dispose B");
  }
}

function demo(): void {
  using a = new DisposableA();
  using b = new DisposableB();

  // 스코프 종료 시 b가 먼저, 그 다음 a가 정리되는 형태가 안전
}

자원 간 의존성이 있는 경우(예: transactionconnection에 의존) LIFO는 실수를 줄입니다.

비동기 자원 정리: await using

네트워크 연결 종료, 비동기 flush, 원격 세션 종료처럼 정리 자체가 비동기인 경우가 있습니다. 이때는 Symbol.asyncDisposeawait using 조합을 씁니다.

class AsyncResource {
  async doWork(): Promise<void> {
    // ...
  }

  async [Symbol.asyncDispose](): Promise<void> {
    // 비동기 정리 로직
    // 예: await client.close()
  }
}

async function handler(): Promise<void> {
  await using r = new AsyncResource();
  await r.doWork();
}

주의할 점은 await using을 쓸 수 있는 곳이 async 함수/컨텍스트라는 것입니다. 또한 정리 단계에서 발생하는 예외 처리 전략도 팀 규칙으로 정해두는 것이 좋습니다(아래 “운영 관점” 참고).

실전 패턴 1: 이벤트 리스너/구독 자동 해제

프론트엔드나 Node 이벤트 기반 코드에서 누수의 단골은 “리스너 제거 누락”입니다. using은 이 문제를 아주 깔끔하게 해결합니다.

type Unsubscribe = () => void;

class DisposableSubscription {
  constructor(private readonly unsubscribe: Unsubscribe) {}

  [Symbol.dispose](): void {
    this.unsubscribe();
  }
}

function onEvent(target: EventTarget, name: string, fn: EventListener): DisposableSubscription {
  target.addEventListener(name, fn);
  return new DisposableSubscription(() => target.removeEventListener(name, fn));
}

function setupOnce(target: EventTarget): void {
  using sub = onEvent(target, "click", () => {
    console.log("clicked");
  });

  // 이 블록을 나가면 자동으로 removeEventListener가 호출됨
}

이 패턴은 RxJS의 Subscription.unsubscribe() 같은 것에도 그대로 적용할 수 있습니다.

실전 패턴 2: 임시 파일/디렉터리 정리

빌드/변환/압축 파이프라인에서 임시 디렉터리를 만들고 지우는 코드는 예외가 나면 쉽게 누락됩니다.

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

class TempDir {
  readonly path: string;

  constructor(prefix: string) {
    this.path = mkdtempSync(join(tmpdir(), prefix));
  }

  [Symbol.dispose](): void {
    rmSync(this.path, { recursive: true, force: true });
  }
}

function buildArtifact(): void {
  using dir = new TempDir("artifact-");

  // dir.path 아래에서 작업
  // 예외가 나도 디렉터리 정리
}

운영 환경에서는 임시 파일 누수가 디스크 고갈로 이어질 수 있으니, 이런 자동 정리 패턴은 효과가 큽니다.

실전 패턴 3: “부분 성공”이 있는 흐름에서 누수 방지

여러 단계를 거치며 자원을 획득하는 로직에서, 중간 단계에서 실패하면 앞에서 만든 자원을 반드시 정리해야 합니다.

class DisposableTimer {
  private readonly start = Date.now();
  constructor(private readonly name: string) {}

  [Symbol.dispose](): void {
    const ms = Date.now() - this.start;
    console.log(`${this.name} took ${ms}ms`);
  }
}

function pipeline(): void {
  using t = new DisposableTimer("pipeline");

  // step1
  // step2
  // step3
  // 어디서 return/throw가 나도 타이머 정리는 보장
}

타이머는 누수라기보다 관측(Observability) 예시지만, “스코프 종료 시 반드시 실행돼야 하는 코드”를 using으로 묶을 수 있다는 점을 보여줍니다.

도입 체크리스트: 설정, 트랜스파일, 린트

using은 런타임/트랜스파일 환경에 따라 동작 방식이 달라질 수 있습니다. 팀에 도입할 때는 아래를 확인하세요.

1) TypeScript 버전

프로젝트가 TypeScript 5.6+인지 확인합니다.

  • 모노레포라면 패키지별로 TS 버전이 갈릴 수 있어 주의합니다.

2) 타깃 런타임과 트랜스파일

최신 Node.js에서 네이티브 지원 여부, 혹은 번들러/트랜스파일러가 using을 어떻게 내리는지 확인해야 합니다.

  • tsc가 헬퍼를 삽입하는 방식인지
  • Babel/SWC가 해당 문법을 처리하는지
  • Jest/ts-jest 같은 테스트 런너가 파싱 가능한지

프로덕션 배포 파이프라인에서 파서 에러가 나지 않도록, CI에서 “빌드 + 테스트”를 먼저 통과시키는 것이 안전합니다.

3) ESLint 규칙과 코드리뷰 가이드

using을 도입하면 “정리 코드”가 호출부에서 사라지므로, 리뷰 포인트가 바뀝니다.

  • 자원을 생성하는 팩토리/클래스가 Symbol.dispose 또는 Symbol.asyncDispose를 구현했는지
  • 정리 함수가 멱등(idempotent) 인지(두 번 호출돼도 안전한지)
  • 정리 중 예외가 발생하면 어떻게 처리할지(로그만 남기고 삼킬지, 상위로 전파할지)

특히 멱등성은 재시도/중복 실행이 있는 시스템 전반에서 중요한데, 결제/요청 재시도에서 중복을 막는 설계와도 결이 같습니다.

운영 관점: dispose에서 예외를 던질 것인가

정리 단계에서 예외가 나면, 원래의 예외를 가릴 수도 있고(디버깅 악화), 반대로 중요한 장애 신호를 놓칠 수도 있습니다.

권장 전략은 보통 다음 중 하나입니다.

  • 정리 실패는 로깅 후 삼키기: 서비스 연속성이 중요하고, 정리 실패가 치명적이지 않은 경우
  • 정리 실패를 전파: 데이터 손상/락 미해제/트랜잭션 미종료처럼 치명적인 경우

팀 규칙을 정하고, Symbol.dispose 구현부에서 일관되게 적용하세요.

using을 잘 쓰기 위한 설계 팁

얇은 래퍼를 만들어 호출부를 단순화하기

호출부에서 매번 new SomethingDisposable(...)을 만들게 하면 도입 장점이 줄어듭니다. 팩토리 함수를 만들어 패턴을 고정하는 편이 좋습니다.

class Disposable {
  constructor(private readonly fn: () => void) {}
  [Symbol.dispose](): void {
    this.fn();
  }
}

function usingCleanup(fn: () => void): Disposable {
  return new Disposable(fn);
}

function example(): void {
  // 어떤 정리든 스코프에 묶을 수 있음
  using _ = usingCleanup(() => console.log("cleanup"));
}

dispose는 빠르고 안전하게

정리 로직은 가능하면 짧고, 실패 가능성이 낮게 구성합니다.

  • 네트워크 호출 같은 무거운 정리는 await using으로 분리
  • 동기 dispose에서 블로킹 작업을 과도하게 하지 않기

“자원 획득”과 “정리 책임”을 같은 레이어에 두기

자원을 만든 곳과 정리하는 곳이 멀어질수록 누수가 생깁니다.

  • 자원을 만드는 함수가 disposable을 반환하도록 설계
  • 호출부는 using만 쓰도록 강제

마무리

TypeScript 5.6+의 using 선언은 단순한 문법 설탕이 아니라, 리소스 정리의 책임을 스코프에 묶어 누수를 구조적으로 줄이는 장치입니다. 파일/락/임시 디렉터리 같은 전통적인 리소스뿐 아니라, 이벤트 리스너/구독 해제처럼 자바스크립트에서 흔한 메모리 누수 원인에도 직접적으로 효과가 있습니다.

도입할 때는 Symbol.disposeSymbol.asyncDispose 구현을 표준화하고, 정리 실패 시 예외 처리 정책(전파 vs 로깅)을 팀 규칙으로 고정하세요. 그 위에 using을 얹으면, “정리 코드를 빼먹어서 터지는” 류의 버그는 눈에 띄게 줄어듭니다.