TypeScript `using`과 `Symbol.asyncDispose`로 DB 커넥션 누수를 언어 레벨에서 차단하기
PostgreSQL 커넥션 풀이 꽉 차서 새 요청이 블로킹되는 상황, 원인은 대부분 try/finally 블록에서 finally를 빠뜨린 함수입니다. TC39의 Explicit Resource Management 프로포절이 ES2026(ECMA-262 17th Edition)에 공식 수록되면서, 이 문제를 언어 레벨에서 방어할 수 있게 됐습니다. using과 await using이라는 두 선언 키워드가 스코프를 벗어날 때 리소스 정리를 자동으로 보장합니다. 에러가 발생하든, early return이 터지든, 예외가 던져지든 상관없이 dispose가 실행됩니다.
Node.js 22+, Bun v1.3.12+, Deno 1.38+는 모두 이 구문을 런타임 레벨에서 네이티브로 지원합니다. TypeScript 설정 두 줄만 바꾸면 트랜스파일 없이 지금 당장 쓸 수 있습니다.
이 글에서는 using/await using 구문의 동작 원리, Symbol.dispose/Symbol.asyncDispose 프로토콜 구현법, 그리고 PostgreSQL 커넥션 풀·MongoDB 트랜잭션·멀티 리소스 조합에 이르는 실제 서버 코드 패턴을 정리합니다.
핵심 개념
Explicit Resource Management란
기존의 try/finally 패턴은 개발자가 직접 finally 블록을 작성해야 합니다. 실수할 여지가 있고, 중첩될수록 코드가 깊어집니다. Explicit Resource Management는 이 책임을 언어 레벨로 끌어올립니다.
// 기존 방식 — finally를 빠뜨리면 커넥션이 풀로 돌아오지 않음
const client = await pool.connect();
try {
await client.query('BEGIN');
// ... 비즈니스 로직 ...
await client.query('COMMIT');
} finally {
client.release();
}
// Explicit Resource Management 방식
await using client = await acquireClient(pool);
await client.query('BEGIN');
// ... 비즈니스 로직 ...
await client.query('COMMIT');
// 블록 종료 시 client[Symbol.asyncDispose]() 자동 호출
// 에러가 발생해도 반드시 실행됨핵심은 using/await using으로 선언된 변수는 스코프를 벗어나는 순간 반드시 dispose 메서드가 호출된다는 점입니다.
flowchart TD
A[함수 진입] --> B[await using res 선언]
B --> C[비즈니스 로직 실행]
C --> D{에러 발생?}
D -->|아니오| E[함수 정상 종료]
D -->|예| F[에러 캐치]
E --> G[Symbol.asyncDispose 자동 호출]
F --> G
G --> H[리소스 정리 완료]
H --> I{에러 존재?}
I -->|예| J[에러 전파]
I -->|아니오| K[정상 반환]두 선언 키워드와 프로토콜
| 키워드 | 대상 리소스 | 호출되는 메서드 |
|---|---|---|
using |
동기 리소스 | [Symbol.dispose](): void |
await using |
비동기 리소스 | [Symbol.asyncDispose](): Promise<void> |
리소스 클래스는 각각 Disposable 또는 AsyncDisposable 인터페이스를 구현하면 됩니다.
// 동기 Disposable 예시
class ManagedFileHandle implements Disposable {
constructor(private fd: number) {}
[Symbol.dispose]() {
fs.closeSync(this.fd);
}
}
// 비동기 AsyncDisposable 예시
class ManagedDbClient implements AsyncDisposable {
constructor(private client: PoolClient) {}
async [Symbol.asyncDispose]() {
this.client.release();
}
}TypeScript 설정 — 두 줄이면 됩니다
lib에 "esnext.disposable"을 명시적으로 추가해야 Symbol.dispose/Symbol.asyncDispose 타입이 제대로 인식됩니다. "lib": ["esnext"]처럼 esnext를 베이스로 쓴다면 esnext.disposable이 이미 포함되어 있어서 별도 추가가 필요 없습니다. 명시적 추가가 필요한 이유는 권장 설정인 "es2022" 베이스가 esnext.disposable을 포함하지 않기 때문입니다.
target은 "es2022" 이상이어야 TypeScript가 구문을 다운트랜스파일하지 않고 그대로 통과시킵니다.
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable"],
"module": "NodeNext"
}
}target: "es2022" 설정 시 tsc는 타입 정보만 제거하고 using/await using 구문을 그대로 출력 파일에 남깁니다. 런타임이 직접 해석하는 구조입니다.
flowchart LR
A[TypeScript 소스] --> B{tsc target 버전}
B -->|es2022 이상| C[using 구문 그대로 출력]
B -->|es2021 이하| D[try/finally로 다운트랜스파일]
C --> E{런타임}
E -->|Node.js 22 이상| F[네이티브 파싱]
E -->|Bun v1.3.12 이상| F
E -->|Deno 1.38 이상| F
F --> G[Symbol.asyncDispose 실행]
D --> H[구형 런타임에서도 동작]런타임별 지원 현황은 다음과 같습니다.
| 런타임 | using/await using 구문 |
Symbol.dispose/asyncDispose |
|---|---|---|
| Node.js | 22+ | 18.18.0+ |
| Bun | v1.3.12+ | v1.3.12+ |
| Deno | 1.38+ | 1.37.0+ |
| Chrome | 127+ | 127+ |
| Firefox | 132+ | 132+ |
| Safari | 18+ | 18.3+ |
DisposableStack / AsyncDisposableStack
여러 리소스를 하나의 단위로 묶고 싶을 때는 AsyncDisposableStack이 유용합니다. LIFO(후입선출) 순서로 dispose를 실행하므로, 의존 관계가 있는 리소스도 안전하게 정리됩니다.
async function importData(srcUrl: string) {
await using stack = new AsyncDisposableStack();
const conn = stack.use(await acquireDbConnection()); // AsyncDisposable 구현체 등록
const file = stack.use(await openRemoteStream(srcUrl)); // 동일하게 등록
stack.defer(async () => await sendAuditLog('import.done')); // 임의 클린업 함수
stack.adopt(rawHandle, (h) => h.close()); // 비-Disposable에 dispose 부여
for await (const chunk of file.readable) {
await conn.query(/* ... */);
}
// 스코프 종료 시 sendAuditLog → file → conn 순서로 자동 정리
}실전 적용
1. PostgreSQL 커넥션 풀 자동 반환
node-postgres의 PoolClient는 아직 AsyncDisposable을 네이티브로 구현하지 않았습니다(이슈 #3515에서 논의 중). 래퍼 클래스 하나를 만들어두면 이후 모든 함수에서 재사용할 수 있습니다.
import { Pool, PoolClient } from 'pg';
class ManagedClient implements AsyncDisposable {
constructor(private client: PoolClient) {}
async query(sql: string, params?: unknown[]) {
return this.client.query(sql, params);
}
async [Symbol.asyncDispose]() {
this.client.release();
}
}
async function acquireClient(pool: Pool): Promise<ManagedClient> {
const client = await pool.connect();
return new ManagedClient(client);
}
// 사용 측 — try/finally 없이 누수 방지
async function getUser(pool: Pool, id: string) {
await using client = await acquireClient(pool);
const { rows } = await client.query('SELECT * FROM users WHERE id = $1', [id]);
return rows[0];
} // 여기서 client[Symbol.asyncDispose]() → client.release() 자동 호출에러가 발생하더라도 release()가 보장됩니다. 커넥션 풀 고갈 문제를 코드 리뷰 없이도 언어 레벨에서 방어할 수 있습니다.
2. 트랜잭션 자동 롤백 패턴
트랜잭션은 조금 다르게 설계해야 합니다. [Symbol.asyncDispose]는 현재 함수가 에러로 종료됐는지를 직접 알 수 없기 때문에, 커밋 여부를 플래그로 관리하고 dispose 내부에서 분기하는 패턴을 씁니다.
세션 생성과 트랜잭션 시작은 팩토리 함수에서 한 번에 처리해, 래퍼의 진입 조건을 명확히 합니다.
import { MongoClient, ClientSession } from 'mongodb';
class ManagedTransaction implements AsyncDisposable {
private committed = false;
constructor(public session: ClientSession) {}
commit() {
this.committed = true;
}
async [Symbol.asyncDispose]() {
if (!this.committed) {
await this.session.abortTransaction();
}
await this.session.endSession();
}
}
function beginTransaction(client: MongoClient): ManagedTransaction {
const session = client.startSession();
session.startTransaction();
return new ManagedTransaction(session);
}
async function transfer(client: MongoClient, from: string, to: string, amount: number) {
const db = client.db();
await using tx = beginTransaction(client);
await db.collection('accounts').updateOne(
{ _id: from },
{ $inc: { balance: -amount } },
{ session: tx.session }
);
await db.collection('accounts').updateOne(
{ _id: to },
{ $inc: { balance: amount } },
{ session: tx.session }
);
await tx.session.commitTransaction();
tx.commit(); // 이 줄 전에 에러 발생하면 → dispose에서 자동 롤백
}flowchart TD
A[transfer 함수 진입] --> B[beginTransaction으로 세션 시작]
B --> C[계좌 잔액 업데이트]
C --> D{에러 발생?}
D -->|예| E[commit 미호출]
D -->|아니오| F[commitTransaction 호출]
F --> G[tx.commit 호출로 플래그 활성화]
G --> H[스코프 종료]
E --> H
H --> I[Symbol.asyncDispose 호출]
I --> J{committed?}
J -->|아니오| K[abortTransaction 자동 롤백]
J -->|예| L[세션 종료만]
K --> M[endSession]
L --> M3. Bun 내장 SQL — 래퍼 없이 바로 사용
Bun v1.3+ 내장 SQL 클라이언트의 ReservedSQL은 AsyncDisposable을 네이티브로 구현하고 있어서, 별도 래퍼 클래스를 작성하지 않고 await using을 바로 쓸 수 있는 현재 유일한 사례입니다.
const sql = new Bun.SQL(process.env.DATABASE_URL!);
async function runInTransaction() {
await using reserved = await sql.reserve(); // ReservedSQL은 AsyncDisposable 구현체
await reserved`BEGIN`;
await reserved`INSERT INTO logs VALUES (${Date.now()})`;
await reserved`COMMIT`;
} // 스코프 종료 시 커넥션 자동 반환4. AsyncDisposableStack으로 멀티 리소스 조합
여러 리소스가 얽혀있는 함수에서는 AsyncDisposableStack이 특히 유용합니다. 개별 try/finally를 중첩하면 코드 깊이가 늘어나는데, 스택으로 평탄하게 묶을 수 있습니다.
async function importData(srcUrl: string) {
await using stack = new AsyncDisposableStack();
const conn = stack.use(await acquireDbConnection());
const file = stack.use(await openRemoteStream(srcUrl));
stack.defer(async () => await sendAuditLog('import.done'));
// 에러 발생 여부와 무관하게 LIFO 순서로 정리
// sendAuditLog → file → conn 순서
for await (const chunk of file.readable) {
await conn.query(/* ... */);
}
}장단점 분석
장점
| 항목 | 설명 |
|---|---|
| 누수 방지 보장 | 에러·early return·예외 모든 경로에서 dispose 실행 |
| 보일러플레이트 감소 | 중첩 try/finally 제거로 비즈니스 로직 가독성 향상 |
| 언어 레벨 지원 | 디버거·스택 트레이스에서 dispose 흐름이 의미 있게 노출 |
| 조합 가능성 | AsyncDisposableStack으로 여러 리소스를 단일 단위로 관리 |
| 트랜스파일 없이 동작 | Node.js 22+, Bun, Deno 모두 네이티브 지원 |
고려사항
| 항목 | 내용 |
|---|---|
| tsconfig 필수 설정 | lib에 "esnext.disposable" 추가, target: "es2022" 이상 필요 |
| ORM 지원 미성숙 | Drizzle, Prisma, node-postgres 등 주요 라이브러리는 아직 래퍼 직접 작성 필요 |
| commit/rollback 설계 | dispose에서 에러 여부를 알 수 없으므로 플래그 패턴 필수 |
SuppressedError 처리 |
본문과 dispose 양쪽에서 에러 발생 시 두 에러가 하나로 묶여 throw됨 |
| Node.js 버전 의존성 | 22+ 미만에서는 tsc가 try/finally로 다운컴파일 — 구형 환경 확인 필요 |
| 스코프 설계 주의 | 의도치 않게 좁은 스코프에 선언하면 너무 이른 dispose 발생 가능 |
실무에서 흔히 빠지는 함정
SuppressedError를 모르고 지나치면 에러 추적이 어려워집니다. using 블록 본문에서 에러가 발생하고, dispose에서도 에러가 발생하면 두 에러는 SuppressedError 하나로 묶입니다.
스펙 기준으로 필드 방향은 다음과 같습니다. SuppressedError.error는 dispose 중 발생한 에러(억압한 쪽), SuppressedError.suppressed는 본문에서 먼저 발생한 에러(억압당한 쪽)입니다. 기존 에러 로거가 .message만 찍는다면 suppressed 쪽 에러가 로그에서 완전히 사라질 수 있으니, 에러 핸들러를 미리 점검해두는 게 좋습니다.
try {
await using client = await acquireClient(pool);
throw new Error('비즈니스 로직 에러');
// dispose에서도 에러가 발생하는 상황 가정
} catch (err) {
if (err instanceof SuppressedError) {
console.error('dispose 에러:', err.error); // dispose 중 발생한 에러 (억압한 쪽)
console.error('본문 에러:', err.suppressed); // 본문에서 먼저 발생한 에러 (억압당한 쪽)
} else {
console.error(err);
}
}스코프를 너무 좁게 잡으면 의도한 것보다 일찍 dispose됩니다. if 블록이나 for 블록 안에 await using을 선언하면 해당 블록이 끝나는 시점에 dispose가 호출됩니다. 리소스를 여러 블록에서 공유해야 한다면 함수 레벨 스코프에 선언하는 편이 안전합니다.
마치며
using/await using은 단순한 편의 문법이 아니라, DB 커넥션 누수라는 고질적인 서버 버그를 언어 레벨에서 방어하는 메커니즘입니다. ES2026에 공식 수록되었고, Node.js 22+, Bun v1.3.12+, Deno 1.38+에서 지금 당장 트랜스파일 없이 쓸 수 있습니다. ORM 생태계의 네이티브 지원이 아직 미성숙하다는 점은 아쉽지만, 래퍼 클래스 패턴으로 충분히 커버됩니다.
지금 시작하려면 이 순서가 가장 빠릅니다.
- tsconfig 먼저 —
"lib": ["es2022", "esnext.disposable"],"target": "es2022"두 줄을 추가합니다. - 커넥션 래퍼 하나 작성 — 기존 DB 클라이언트에
[Symbol.asyncDispose]를 구현한 래퍼 클래스를 만들어두면 프로젝트 전체에서 재사용할 수 있습니다. - 트랜잭션에 commit 플래그 도입 — dispose 내부에서 롤백/종료를 분기할 수 있도록
committed플래그 패턴을 먼저 적용합니다.
중첩 try/finally가 사라지면 함수 본문의 인덴트 깊이가 눈에 띄게 줄어들고, 비즈니스 로직이 전면에 드러납니다.
참고 자료
- TC39 Proposal: Explicit Resource Management (GitHub)
- JavaScript's New Superpower: Explicit Resource Management — V8 Blog
- Symbol.asyncDispose — MDN Web Docs
- TypeScript 5.2 Release Notes — Microsoft
- Using Explicit Resource Management with TypeScript and Postgres — r0b blog
- Resource management in TypeScript with the using keyword — LogRocket Blog
- Summary of the May 2025 TC39 Plenary — Igalia
- ECMA-262 16th Edition (June 2025)
- ECMAScript Committee Advances 3 Proposals to Stage 4 — The New Stack
- Symbol.asyncDispose support · node-postgres Issue #3515
- Drizzle ORM Transactions: TC39 explicit resource management · Issue #4546
- Deno PR #28119: Enable explicit resource management for JavaScript
- @nick/dispose — JSR (폴리필)
- MongoDB Transaction with TypeScript using keyword — GitHub Gist
- Bun.ReservedSQL API Reference