TypeScript 5.2에서 도입된 using 선언과 Symbol.dispose를 활용한 명시적 리소스 관리 패턴을 실전 예제와 함께 심층 분석합니다.
3장에서 TC39 표준 데코레이터를 살펴봤습니다. 이 장에서는 TypeScript 5.2에서 도입된 또 다른 TC39 표준 기능인 명시적 리소스 관리(Explicit Resource Management) 를 다룹니다. using 선언은 파일 핸들, 데이터베이스 연결, 락(lock) 등 생명주기를 가진 리소스를 안전하게 관리하는 새로운 패러다임입니다.
프로그래밍에서 가장 흔한 버그 유형 중 하나는 리소스 누수(Resource Leak) 입니다. 파일을 열고 닫지 않거나, 데이터베이스 연결을 반환하지 않거나, 이벤트 리스너를 해제하지 않는 경우입니다.
async function processData() {
const connection = await db.connect();
const file = await fs.open("output.txt", "w");
try {
const data = await connection.query("SELECT * FROM users");
await file.write(JSON.stringify(data));
} finally {
// 문제 1: 하나가 실패하면 다른 것도 정리되지 않을 수 있음
await file.close();
await connection.release();
}
}try/finally 패턴은 동작하지만, 리소스가 늘어날수록 중첩이 깊어지고, 정리 순서를 실수하기 쉽습니다. C#의 using, Python의 with, Java의 try-with-resources가 해결한 문제를 JavaScript/TypeScript에서도 해결하려는 것이 이 제안의 목적입니다.
using 선언을 사용하려면, 리소스 객체가 Symbol.dispose 메서드를 구현해야 합니다.
interface Disposable {
[Symbol.dispose](): void;
}
interface AsyncDisposable {
[Symbol.asyncDispose](): Promise<void>;
}using 키워드로 선언된 변수는 현재 스코프가 끝날 때 자동으로 [Symbol.dispose]()가 호출됩니다.
class FileHandle implements Disposable {
private closed = false;
constructor(private path: string) {
console.log(`Opening ${path}`);
}
write(data: string) {
if (this.closed) throw new Error("File already closed");
console.log(`Writing to ${this.path}: ${data}`);
}
[Symbol.dispose]() {
if (!this.closed) {
this.closed = true;
console.log(`Closing ${this.path}`);
}
}
}
function processFile() {
using file = new FileHandle("data.txt");
file.write("Hello, World!");
// 스코프 종료 시 자동으로 file[Symbol.dispose]() 호출
// 출력:
// Opening data.txt
// Writing to data.txt: Hello, World!
// Closing data.txt
}비동기 정리가 필요한 리소스는 Symbol.asyncDispose를 구현하고, await using으로 선언합니다.
class DatabaseConnection implements AsyncDisposable {
private released = false;
static async connect(url: string): Promise<DatabaseConnection> {
console.log(`Connecting to ${url}`);
// 실제 연결 로직...
return new DatabaseConnection();
}
async query(sql: string) {
if (this.released) throw new Error("Connection released");
console.log(`Executing: ${sql}`);
return [{ id: 1, name: "Alice" }];
}
async [Symbol.asyncDispose]() {
if (!this.released) {
this.released = true;
console.log("Releasing connection to pool");
// 실제 연결 반환 로직...
}
}
}
async function getUsers() {
await using conn = await DatabaseConnection.connect("postgres://localhost/mydb");
const users = await conn.query("SELECT * FROM users");
return users;
// 스코프 종료 시 await conn[Symbol.asyncDispose]() 자동 호출
}여러 using 선언이 있을 때, 리소스는 선언의 역순으로 정리됩니다. 이는 스택(LIFO) 방식으로, 나중에 열린 리소스가 먼저 닫힙니다.
function multiResource() {
using a = createResource("A");
using b = createResource("B");
using c = createResource("C");
// 작업 수행...
// 스코프 종료 시 정리 순서: C → B → A
}이 동작은 리소스 간 의존성이 있을 때 중요합니다. 예를 들어, 데이터베이스 트랜잭션은 커넥션보다 먼저 종료되어야 합니다.
async function transferMoney(fromId: string, toId: string, amount: number) {
await using conn = await pool.getConnection();
await using tx = await conn.beginTransaction();
try {
await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [amount, fromId]);
await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [amount, toId]);
await tx.commit();
} catch (error) {
await tx.rollback();
throw error;
}
// 정리 순서: tx[Symbol.asyncDispose]() → conn[Symbol.asyncDispose]()
}개별 using 선언 외에도, 여러 리소스를 그룹으로 관리할 수 있는 DisposableStack이 제공됩니다.
function setupServerResources() {
using stack = new DisposableStack();
const logger = stack.use(new FileLogger("server.log"));
const cache = stack.use(new RedisConnection("localhost:6379"));
const db = stack.use(new PostgresPool("postgres://localhost/app"));
// 세 리소스를 모두 사용하여 작업...
return { logger, cache, db };
// 스코프 종료 시 stack이 모든 리소스를 역순으로 정리
}DisposableStack은 다음 메서드를 제공합니다.
class DisposableStack implements Disposable {
// 리소스 추가 (Disposable 구현체)
use<T extends Disposable>(resource: T): T;
// 정리 콜백 추가
adopt<T>(value: T, onDispose: (value: T) => void): T;
// 일반 콜백 추가
defer(fn: () => void): void;
// 소유권 이전 — 이 스택은 더 이상 리소스를 정리하지 않음
move(): DisposableStack;
// 수동 정리
[Symbol.dispose](): void;
}adopt는 Disposable을 구현하지 않는 기존 리소스를 관리할 때 유용합니다.
function processImage() {
using stack = new DisposableStack();
// Disposable을 구현하지 않는 기존 API
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
// adopt로 정리 로직 연결
stack.adopt(canvas, (c) => c.remove());
// defer로 콜백만 등록
stack.defer(() => console.log("Image processing complete"));
// 작업 수행...
}move()를 사용하면 리소스 소유권을 다른 스코프로 이전할 수 있습니다.
function createManagedResources() {
using stack = new DisposableStack();
const db = stack.use(new DatabasePool());
const cache = stack.use(new CacheClient());
// 소유권을 새 스택으로 이전
// 기존 stack은 이 리소스들을 정리하지 않음
return stack.move();
}
function main() {
// 반환된 스택이 리소스 소유권을 가짐
using resources = createManagedResources();
// main 스코프 종료 시 resources가 모든 리소스 정리
}class AsyncLock implements AsyncDisposable {
private locked = false;
private waitQueue: (() => void)[] = [];
async acquire(): Promise<AsyncDisposable> {
while (this.locked) {
await new Promise<void>((resolve) => this.waitQueue.push(resolve));
}
this.locked = true;
return {
[Symbol.asyncDispose]: async () => {
this.locked = false;
const next = this.waitQueue.shift();
if (next) next();
},
};
}
}
const lock = new AsyncLock();
async function criticalSection() {
await using guard = await lock.acquire();
// 이 영역은 동시에 하나의 실행만 허용
await doSomethingExclusive();
// 스코프 종료 시 자동으로 락 해제
}function createInterval(callback: () => void, ms: number): Disposable {
const id = setInterval(callback, ms);
return {
[Symbol.dispose]() {
clearInterval(id);
},
};
}
function createTimeout(callback: () => void, ms: number): Disposable {
const id = setTimeout(callback, ms);
return {
[Symbol.dispose]() {
clearTimeout(id);
},
};
}
function monitorSystem() {
using heartbeat = createInterval(() => sendPing(), 5000);
using timeout = createTimeout(() => shutdown(), 60000);
// 모니터링 로직...
// 스코프 종료 시 인터벌과 타임아웃 자동 정리
}function createEventBinding(
target: EventTarget,
event: string,
handler: EventListener,
options?: AddEventListenerOptions
): Disposable {
target.addEventListener(event, handler, options);
return {
[Symbol.dispose]() {
target.removeEventListener(event, handler, options);
},
};
}
function setupUI() {
using stack = new DisposableStack();
stack.use(createEventBinding(window, "resize", handleResize));
stack.use(createEventBinding(document, "keydown", handleKeydown));
stack.use(createEventBinding(button, "click", handleClick));
// UI 활성화 기간 동안 이벤트 처리...
// 스코프 종료 시 모든 이벤트 리스너 자동 해제
}using 선언에서 리소스 사용 중 에러가 발생하더라도, [Symbol.dispose]는 반드시 호출됩니다. 이는 try/finally와 동일한 보장입니다.
function riskyOperation() {
using resource = createResource();
// 에러가 발생해도 resource[Symbol.dispose]()는 호출됨
throw new Error("Something went wrong");
}정리 과정에서도 에러가 발생할 수 있습니다. 본문과 정리 모두에서 에러가 발생하면, SuppressedError로 감싸져 두 에러 모두 보존됩니다.
function doubleError() {
try {
using resource = {
[Symbol.dispose]() {
throw new Error("Cleanup error");
},
};
throw new Error("Operation error");
} catch (e) {
// e는 SuppressedError
if (e instanceof SuppressedError) {
console.log(e.error); // Error: Cleanup error
console.log(e.suppressed); // Error: Operation error
}
}
}using 선언을 사용하려면 다음 설정이 필요합니다.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "ESNext.Disposable"]
}
}Symbol.dispose와 Symbol.asyncDispose는 아직 모든 JavaScript 런타임에서 네이티브로 지원되지 않을 수 있습니다. TypeScript는 폴리필 없이도 다운레벨 컴파일을 지원하지만, 런타임 환경에 따라 disposablestack 폴리필이 필요할 수 있습니다.
using 선언은 JavaScript/TypeScript에서 리소스 관리를 혁신적으로 개선합니다. Symbol.dispose를 구현하는 것만으로 스코프 기반 자동 정리가 가능해지며, DisposableStack으로 여러 리소스를 그룹으로 관리할 수 있습니다. 파일 핸들, 데이터베이스 연결, 이벤트 리스너, 타이머 등 생명주기를 가진 모든 리소스에 적용할 수 있는 범용적인 패턴입니다.
다음 장에서는 TypeScript 타입 시스템의 핵심인 조건부 타입을 심층 분석합니다. extends 키워드를 활용한 타입 분기, 분배적 조건부 타입, 그리고 infer와의 조합 패턴을 다룹니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 조건부 타입의 원리, 분배적 조건부 타입, infer 키워드와의 조합, 그리고 실전 활용 패턴을 깊이 있게 다룹니다.
TypeScript 5.0에서 도입된 TC39 Stage 3 데코레이터의 원리, API 구조, 실전 패턴을 다루고, 기존 실험적 데코레이터와의 차이를 분석합니다.
TypeScript 매핑 타입의 원리, 수정자 조작, 키 재매핑(as 절), 그리고 조건부 타입과의 결합 패턴을 실전 예제와 함께 다룹니다.