Published on

TS 5.5+ using으로 Disposable 스코프 안전하게

Authors

서버/CLI/배치에서 파일 핸들, DB 커넥션, 락, 임시 디렉터리 같은 리소스는 “반드시 정리되어야” 합니다. 하지만 현실의 코드는 return, throw, 여러 단계의 에러 처리, 중간 await 등으로 흐름이 복잡해지면서 finally 누락이나 정리 순서 꼬임이 자주 발생합니다.

TypeScript 5.5+의 using 선언은 이런 문제를 언어 차원에서 줄여줍니다. 스코프를 빠져나갈 때(정상 종료/예외/조기 반환 모두) 자동으로 dispose 로직을 호출해, 리소스 해제를 “기본값”으로 만들어 줍니다.

이 글에서는 using의 동작 모델, Symbol.dispose/Symbol.asyncDispose 구현 방법, 실전 패턴, 그리고 TS 설정/트랜스파일 시 주의점을 정리합니다.

using이 해결하는 문제: try/finally의 인간적 한계

전통적으로는 아래처럼 try/finally로 정리를 보장합니다.

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

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

문제는 이런 패턴이 코드 전체에 퍼지면 다음이 자주 발생한다는 점입니다.

  • 중간 return 추가하면서 finally를 빼먹음
  • 여러 리소스가 얽혀 정리 순서가 불명확해짐
  • 에러 처리 분기별로 close() 호출이 중복/누락됨

using은 “스코프 기반 정리”를 언어가 자동으로 해주도록 바꿉니다.

핵심 개념: Symbol.disposeSymbol.asyncDispose

using은 임의의 객체를 마법처럼 닫아주지 않습니다. 객체가 아래 중 하나를 구현해야 합니다.

  • 동기 정리: obj[Symbol.dispose](): void
  • 비동기 정리: obj[Symbol.asyncDispose](): PromiseLike<void>

TypeScript는 using 선언된 값이 스코프를 벗어날 때 해당 심볼 메서드를 호출하도록 코드를 변환합니다.

동기 리소스 예시

class Stopwatch {
  private start = Date.now();

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

function run() {
  using sw = new Stopwatch();

  // ...작업 수행
  for (let i = 0; i < 1e6; i++) {}

  // 여기서 함수가 끝나면 자동으로 sw[Symbol.dispose]() 호출
}

비동기 리소스 예시

비동기 정리에는 await using을 사용합니다.

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

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

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

  // 스코프 종료 시 file[Symbol.asyncDispose]()가 호출되어 close가 보장됨
}

Node의 FileHandle은 최신 런타임에서 dispose 심볼을 제공하거나(또는 제공될 수 있고), 그렇지 않다면 래퍼를 만들어 적용할 수 있습니다. 중요한 건 “정리 계약을 타입/심볼로 명시”하는 것입니다.

실전 패턴 1: dispose 래퍼로 기존 API를 using에 연결하기

현실의 라이브러리들은 close(), destroy(), release() 등 제각각입니다. using을 쓰려면 심볼 메서드로 어댑트하면 됩니다.

close()Symbol.asyncDispose로 연결

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

function asAsyncDisposable<T extends { close(): Promise<void> }>(resource: T): T & AsyncDisposable {
  return Object.assign(resource, {
    async [Symbol.asyncDispose]() {
      await resource.close();
    },
  });
}

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

async function readAll(path: string) {
  await using file = asAsyncDisposable(await open(path, "r"));
  return await file.readFile({ encoding: "utf8" });
}

이 패턴의 장점은 다음과 같습니다.

  • 기존 타입/객체를 거의 그대로 유지
  • 호출부에서 try/finally를 제거해 가독성 개선
  • 정리 누락을 구조적으로 방지

실전 패턴 2: 여러 리소스의 정리 순서 보장(스택처럼 LIFO)

using을 여러 번 선언하면, 스코프 종료 시 역순(LIFO) 으로 dispose 됩니다. 이는 락/트랜잭션/임시 파일처럼 “획득의 역순으로 해제”가 중요한 경우에 특히 유용합니다.

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

function criticalSection() {
  using a = new Lock("A");
  a.acquire();
  using b = new Lock("B");
  b.acquire();

  // ...작업

  // 종료 시 unlock B -> unlock A 순서로 호출
}

try/finally로도 가능하지만, 중간에 리소스가 추가될 때마다 finally 블록을 재구성해야 하는 부담이 줄어듭니다.

실전 패턴 3: “부분 성공”이 있는 초기화에서 누수 막기

초기화 과정에서 중간 단계가 실패하면 이미 확보한 리소스를 정리해야 합니다. using은 이 상황에 특히 강합니다.

async function initPipeline() {
  await using conn = await connectDb();
  await using tempDir = await createTempDir();
  await using lock = await acquireJobLock();

  // 여기서 어떤 단계에서 throw가 나도
  // 확보된 리소스들은 자동으로 역순 정리됨

  return { conn, tempDir, lock };
}

단, 위처럼 반환값으로 리소스를 밖으로 “탈출”시키고 싶다면 스코프 종료 시 dispose가 일어나므로 설계가 달라져야 합니다. 이 경우에는 반환하지 말고, 콜백 기반으로 스코프를 캡슐화하는 편이 안전합니다.

실전 패턴 4: 스코프를 콜백으로 캡슐화해 누수 불가능하게 만들기

리소스를 외부로 넘기지 않고, 사용 범위를 강제로 제한하는 패턴입니다.

async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
  await using db = await connectDb();
  return await fn(db);
}

// 호출부
const user = await withDb(async (db) => {
  return await db.users.findById("u_123");
});

이렇게 하면 호출자는 db.close()를 호출할 기회 자체가 없어져(=실수 여지 제거) 운영 안정성이 올라갑니다.

컴파일/런타임 주의점

1) TS 버전과 타깃

using은 TypeScript 5.2 이후 도입되고, 5.5+에서 생태계/설정 조합에서 더 많이 쓰이기 시작했습니다. 중요한 건 “문법 지원” 뿐 아니라 “트랜스파일 결과를 실행할 런타임”입니다.

  • 최신 Node/Bun/Deno는 Symbol.dispose 관련 지원이 점차 확대되고 있습니다.
  • 구형 런타임을 대상으로 한다면, TypeScript가 생성하는 헬퍼/폴리필 전략을 확인해야 합니다.

프로젝트가 isolatedDeclarations를 켠 상태라면 선언 파일 생성 규칙이 더 엄격해져, using과 직접 관련은 없더라도 타입 노출 방식에서 오류가 날 수 있습니다. 이 경우 설정/타입 내보내기 전략은 아래 글이 도움이 됩니다.

2) await using은 반드시 async 함수 안에서

await using은 비동기 dispose를 보장하므로, 문맥상 await가 가능한 곳에서만 사용할 수 있습니다.

async function ok() {
  await using r = await createAsyncResource();
}

function notOk() {
  // await using은 사용할 수 없음
}

3) dispose 구현은 “절대 throw하지 않게” 설계하는 게 안전

정리 단계에서 예외가 터지면 원래의 예외를 가리거나(마스킹), 디버깅을 더 어렵게 만들 수 있습니다. 가능하면 dispose 내부에서 예외를 삼키고 로깅만 하거나, 여러 예외를 합치는 전략을 택하세요.

class SafeTmpFile {
  constructor(private path: string) {}

  async remove() {
    // unlink...
  }

  async [Symbol.asyncDispose]() {
    try {
      await this.remove();
    } catch (e) {
      console.warn("cleanup failed", e);
    }
  }
}

using을 도입하기 좋은 지점 체크리스트

아래 항목 중 2개 이상 해당하면 using 도입 효과가 큽니다.

  • try/finally가 반복되는 모듈이 있다
  • 커넥션/파일/락/세마포어 등 “반드시 반납”해야 하는 리소스가 많다
  • 초기화 단계가 길고, 중간 실패 시 누수 가능성이 있다
  • 코드 리뷰에서 close()/release() 누락이 반복된다
  • 테스트에서 간헐적으로 핸들 누수(파일 디스크립터 고갈 등)가 난다

예제: 간단한 리소스 풀을 using 친화적으로 만들기

마지막으로, 리소스 풀에서 “빌려온 리소스는 반드시 반납”을 강제하는 예시입니다.

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

class Pool<T> {
  private items: T[];
  constructor(items: T[]) {
    this.items = items;
  }

  acquire(): { value: T } & Disposable {
    const value = this.items.pop();
    if (!value) throw new Error("pool exhausted");

    let released = false;
    return {
      value,
      [Symbol.dispose]: () => {
        if (released) return;
        released = true;
        this.items.push(value);
      },
    };
  }
}

function work(pool: Pool<number>) {
  using lease = pool.acquire();
  const n = lease.value;
  // ...
  return n * 2;
  // 스코프 종료 시 자동 반납
}

이 패턴은 DB 커넥션 풀, 브라우저 자동화 세션 풀, GPU 컨텍스트 풀 등에도 그대로 확장할 수 있습니다.

마무리

TypeScript의 using은 단순한 문법 설탕을 넘어, 리소스 관리의 기본값을 “정리 보장”으로 바꾸는 도구입니다. 특히 예외/조기 반환/다단계 초기화가 흔한 서버 코드에서 누수와 정리 누락을 구조적으로 줄일 수 있습니다.

도입 순서는 보통 다음이 가장 안전합니다.

  1. 누수가 치명적인 리소스(파일/락/임시 디렉터리)부터 Symbol.dispose 또는 Symbol.asyncDispose 어댑터를 만든다
  2. 반복되는 try/finallyusing으로 치환한다
  3. 리소스를 외부로 반환하는 API는 withXxx 같은 콜백 스코프 API로 재설계한다

이렇게 하면 코드 양은 줄고, 운영 안정성은 올라가는 “좋은 종류의 추상화”를 얻을 수 있습니다.