- Published on
TS 5.5 await using·Disposable로 리소스 누수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치 코드를 오래 운영하다 보면, 가장 골치 아픈 버그 중 하나가 리소스 누수(resource leak) 입니다. 파일 핸들이 닫히지 않거나, 임시 디렉터리가 남거나, 락이 해제되지 않거나, DB 커넥션이 풀로 돌아가지 않아 서서히 시스템이 질식합니다. 문제는 이런 누수가 대부분 예외(exception), 조기 반환(early return), 중간 실패 같은 "비정상 흐름"에서 발생한다는 점입니다.
TypeScript 5.5는 이런 패턴을 언어 차원에서 더 안전하게 만들 수 있는 도구를 제공합니다. 바로 using / await using과 Disposable(정확히는 Symbol.dispose, Symbol.asyncDispose) 프로토콜입니다. 이 글에서는 TS 5.5에서 이 기능이 왜 유용한지, 어떻게 설계하고 적용해야 누수를 체계적으로 줄일 수 있는지, 그리고 실무에서 자주 부딪히는 함정까지 정리합니다.
> 참고로 TS 5.x의 타입 안전 기능을 더 확장해 쓰고 싶다면, 객체 검증/스키마에 satisfies를 결합하는 글도 같이 보면 좋습니다: TS 5.x satisfies로 타입 안전 유지하며 객체 검증
왜 try/finally만으로는 부족했나
전통적으로 JS/TS에서 리소스 정리는 try/finally가 정석이었습니다.
import { open } from "node:fs/promises";
async function readFirstLine(path: string) {
const file = await open(path, "r");
try {
const text = await file.readFile({ encoding: "utf8" });
return text.split("\n")[0] ?? "";
} finally {
await file.close();
}
}
이 방식은 옳지만, 실무에서는 다음 문제가 반복됩니다.
- 중첩 리소스가 많아질수록
try/finally가 계단식으로 늘어나 가독성이 급락 return/throw경로가 많아질수록 실수로finally밖에서 반환하거나, 정리 순서가 꼬임- 비동기 정리(
await close())가 섞이면 더 장황해짐
TS 5.5의 await using은 이 패턴을 언어 구문으로 표준화해, 실수를 줄이고 코드 의도를 명확히 합니다.
핵심 개념: Disposable 프로토콜
TS 5.5의 using 계열은 “이 값은 스코프를 벗어날 때 정리해야 한다”는 의도를 담습니다. 이를 위해 객체가 아래 중 하나를 구현하면 됩니다.
- 동기 정리:
obj[Symbol.dispose](): void - 비동기 정리:
obj[Symbol.asyncDispose](): Promise<void>
그리고 변수 선언을 이렇게 합니다.
using resource = ...(동기 dispose)await using resource = ...(async dispose)
스코프(블록)를 벗어나면 자동으로 dispose가 호출됩니다. 예외/조기 반환 여부와 무관하게 실행되며, 여러 using이 있으면 역순(LIFO) 으로 정리됩니다.
준비: tsconfig와 런타임 주의점
TypeScript 설정
TS 5.5 이상에서 동작합니다. 보통 아래처럼 설정합니다.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"strict": true
}
}
런타임(중요)
TypeScript는 타입/구문을 제공하지만, 실제로 Symbol.dispose/Symbol.asyncDispose가 런타임에 필요합니다.
- 최신 Node.js에서는 점진적으로 지원이 들어오고 있습니다.
- 환경에 따라 폴리필이 필요할 수 있습니다.
실무 팁: 서버 런타임 버전이 제각각이라면, 이 기능을 도입하기 전에 배포 대상(Node 버전/번들러 타깃)을 먼저 확정하세요.
await using으로 파일 핸들 누수 막기
Node의 fs/promises.open()은 FileHandle을 반환하고 close()는 비동기입니다. 즉 await using이 딱 맞습니다.
아래는 FileHandle을 감싸서 Symbol.asyncDispose를 구현하는 예시입니다.
import { open, FileHandle } from "node:fs/promises";
type AsyncDisposable = {
[Symbol.asyncDispose](): Promise<void>;
};
class DisposableFile implements AsyncDisposable {
constructor(private readonly fh: FileHandle) {}
static async open(path: string, flags: string) {
const fh = await open(path, flags);
return new DisposableFile(fh);
}
async readAll() {
return this.fh.readFile({ encoding: "utf8" });
}
async [Symbol.asyncDispose]() {
await this.fh.close();
}
}
export async function readFirstLine(path: string) {
await using file = await DisposableFile.open(path, "r");
const text = await file.readAll();
return text.split("\n")[0] ?? "";
}
장점은 단순히 짧아지는 게 아닙니다.
- 정리 로직이 반드시 스코프 종료와 결합됨
- “이 값은 반드시 닫아야 한다”는 의도가 선언문에서 드러남
- 리소스가 늘어나도
try/finally중첩이 줄어듦
여러 리소스의 정리 순서: LIFO가 왜 중요한가
실무에서 흔한 패턴은 “락을 잡고 → 파일을 쓰고 → 네트워크 호출” 같은 계층 구조입니다. 정리도 역순이어야 안전합니다.
class Lock {
constructor(private readonly key: string) {}
async acquire() {
// ...
}
async release() {
// ...
}
async [Symbol.asyncDispose]() {
await this.release();
}
}
class TempDir {
constructor(public readonly path: string) {}
async cleanup() {
// rm -rf
}
async [Symbol.asyncDispose]() {
await this.cleanup();
}
}
export async function doJob() {
await using lock = new Lock("job:123");
await lock.acquire();
await using dir = new TempDir("/tmp/job-123");
// 작업 수행
// throw / return 어디서든 안전하게 정리
}
위 코드에서 스코프가 끝나면 dir이 먼저 정리되고, 그 다음 lock이 해제됩니다. (자원 의존성이 있을 때 매우 중요)
실전 패턴: DB 트랜잭션/커넥션을 Disposable로 감싸기
DB 라이브러리마다 API는 다르지만, 공통적으로 “반드시 반환해야 하는 핸들”이 있습니다.
- 커넥션 풀에서 빌린 커넥션 →
release() - 트랜잭션 →
commit()또는rollback()
여기서 중요한 건 예외가 나면 rollback이 되어야 한다는 점입니다. await using은 “스코프 종료 시 자동 정리”만 보장하므로, 트랜잭션의 성공/실패에 따른 분기까지 설계해야 합니다.
아래는 개념 예시입니다.
type AsyncDisposable = { [Symbol.asyncDispose](): Promise<void> };
type DbConn = {
query(sql: string, params?: unknown[]): Promise<unknown>;
release(): Promise<void>;
};
type DbPool = {
connect(): Promise<DbConn>;
};
class DisposableConn implements AsyncDisposable {
constructor(public readonly conn: DbConn) {}
static async from(pool: DbPool) {
return new DisposableConn(await pool.connect());
}
async [Symbol.asyncDispose]() {
await this.conn.release();
}
}
class Tx implements AsyncDisposable {
private committed = false;
constructor(private readonly conn: DbConn) {}
static async begin(conn: DbConn) {
await conn.query("BEGIN");
return new Tx(conn);
}
async commit() {
await this.conn.query("COMMIT");
this.committed = true;
}
async [Symbol.asyncDispose]() {
if (!this.committed) {
await this.conn.query("ROLLBACK");
}
}
}
export async function transfer(pool: DbPool) {
await using dc = await DisposableConn.from(pool);
await using tx = await Tx.begin(dc.conn);
await dc.conn.query("UPDATE accounts SET balance = balance - 100 WHERE id = $1", [1]);
await dc.conn.query("UPDATE accounts SET balance = balance + 100 WHERE id = $1", [2]);
await tx.commit();
}
이 패턴의 핵심은:
- 커넥션은 스코프 종료 시 무조건 반환
- 트랜잭션은
commit()이 호출되지 않으면 자동 rollback
이렇게 만들면 “중간에 throw가 나서 커넥션이 안 돌아감” 같은 장애를 구조적으로 줄일 수 있습니다.
함정 1: await using은 스코프(블록) 기반이다
await using은 함수 전체가 아니라 현재 블록을 기준으로 정리됩니다. 즉, 아래는 의도대로 동작합니다.
export async function example() {
{
await using res = await makeResource();
// 여기서만 사용
}
// 여기서는 이미 dispose됨
}
반대로, 리소스를 함수 밖으로 반환하려는 설계와는 충돌합니다.
// 안티패턴: 반환 시점에 이미 dispose될 수 있음
export async function getClient() {
await using client = await makeClient();
return client; // 스코프 종료 → dispose
}
리소스를 반환해야 한다면 using이 아니라 “생성/정리 책임”을 호출자에게 넘기거나, 팩토리에서 dispose 가능한 래퍼를 명시적으로 반환하는 식으로 설계를 바꾸는 게 안전합니다.
함정 2: dispose에서 던진 에러 처리
정리 과정도 실패할 수 있습니다(네트워크 세션 종료 실패, 파일 close 실패 등). 이때 dispose 에러는 원래의 에러를 가릴 수 있습니다.
실무 권장:
- dispose에서는 가능하면 throw를 피하고 로깅
- 정말 치명적일 때만 throw
- 여러 리소스를 정리할 때 어떤 것이 실패했는지 식별 가능하게 로그에 컨텍스트 포함
예를 들어:
class SafeCloser {
constructor(private readonly close: () => Promise<void>, private readonly name: string) {}
async [Symbol.asyncDispose]() {
try {
await this.close();
} catch (e) {
console.error(`[dispose failed] ${this.name}`, e);
}
}
}
함정 3: “누수”는 결국 재시도/타임아웃과 같이 온다
리소스 누수는 단독으로 오기보다, 재시도 로직이나 타임아웃 미설정과 결합되어 더 크게 터집니다. 예를 들어 외부 API 호출이 hang → 커넥션 점유 시간이 길어짐 → 풀 고갈 → 장애.
따라서 await using으로 정리 안정성을 올리면서, 동시에 호출부에 재시도/백오프/타임아웃을 설계하는 게 좋습니다. 관련해서는 레이트리밋 재시도 설계 글을 함께 참고할 만합니다.
팀에 도입하는 현실적인 가이드
1) “반드시 닫아야 하는 것”부터 목록화
- 파일 핸들
- 임시 디렉터리/파일
- 락(분산락 포함)
- 풀에서 빌린 커넥션/클라이언트
- 구독(subscription) 해제(이벤트 리스너, 스트림 등)
그리고 각각에 대해 “정리 책임이 어디에 있는가”를 명확히 합니다.
2) 래퍼를 만들어 Symbol.asyncDispose를 구현
라이브러리 객체를 직접 확장하기 어렵다면, 이 글의 DisposableFile, DisposableConn처럼 얇은 래퍼를 두는 방식이 가장 안전합니다.
3) 코드리뷰 체크리스트에 추가
open/connect/lock같은 호출이 보이면await using또는try/finally로 감싸졌는지 확인- dispose에서 예외를 던지는지(그리고 그게 의도인지) 확인
- 리소스를 반환하는 함수에
using이 들어가 있지 않은지 확인
정리
TS 5.5의 await using/Disposable은 단순한 문법 설탕이 아니라, 리소스 생명주기를 스코프에 묶어 누수를 구조적으로 줄이는 도구입니다.
try/finally중첩을 줄이고 의도를 선언적으로 표현- 예외/조기 반환에서도 자동 정리
- 여러 리소스의 역순 정리(LIFO)로 의존성 안전성 향상
실무에서는 “무조건 await using으로 바꿔라”가 아니라, 반드시 닫아야 하는 경계(파일/락/커넥션) 부터 래퍼를 만들어 점진적으로 적용하는 전략이 가장 효율적입니다.