YKSS
자바스크립트의 명시적 리소스 관리
TC39 Explicit Resource Management 제안이 도입한 using / await using 키워드로 파일·DB·소켓 리소스를 스코프 종료 시 자동 해제하는 패턴을 정리한다.
핵심 요약
Explicit Resource Management 는 TC39 Stage 3+ 제안으로, using 과 await using 두 가지 선언을 추가한다. using x = resource 는 스코프를 벗어날 때 x[Symbol.dispose]() 를 자동 호출하고, await using 은 Symbol.asyncDispose 를 await 한다. 사용자 정의 객체도 이 두 Symbol 메서드만 구현하면 프로토콜에 참여한다. 여러 리소스를 묶거나 동적으로 등록해야 할 땐 DisposableStack / AsyncDisposableStack 으로 LIFO 순 해제를 관리한다. 해제는 역순(LIFO) 으로 일어나고, finally 흐름과 호환되며 예외가 나도 반드시 실행된다.
try-finally 를 언어 문법으로 승격한 기능이라고 읽으면 된다. 블록 스코프 = 리소스 수명이고, using 선언은 Symbol.dispose() 훅을 예약하는 문법 설탕이다. C#의 using, Python의 with, Rust의 Drop 과 같은 계열의 결정론적 해제(Deterministic Disposal) 패턴을 JS 에 이식한 것이다.
리소스 누수(파일 디스크립터, DB 커넥션, 이벤트 리스너, 타이머)는 프론트·백엔드 모두에서 실사용에서 가장 조용히 터지는 버그다. 기존 해법의 한계는 명확하다.
try-finally는 보일러플레이트가 많고, 리소스가 2개 이상이면 중첩이 기하급수적으로 늘어난다.- 해제 코드를 한 번이라도 누락하면 런타임/타입 시스템이 못 잡는다.
- 비동기 리소스는
finally안에서await를 또 써야 해서 에러 흐름이 뒤엉킨다.
using / await using 은 이걸 선언만으로 강제하고, 면접에서도 "리소스 관리 어떻게 하냐" 질문에 한 단계 깊은 답을 만들어 준다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
`using` = 스코프 기반 자동 해제
using 선언된 변수는 블록 스코프 종료 시점에 [Symbol.dispose]() 가 자동 호출된다. 예외가 터져도, return 으로 빠져나가도 반드시 실행되며 이는 try-finally 를 언어가 대신 깔아주는 구조다.
{
using file = openFile("./a.txt");
file.read();
} // 여기서 file[Symbol.dispose]() 자동 호출
블록 안에 선언이 여러 개면 역순(LIFO) 으로 해제된다.
using 을 let/const 처럼 재할당이나 호이스팅 가능한 바인딩으로 오해하는 것. using 은 블록 스코프 전용이고 재할당 불가이며, 함수 최상위에서 해제 시점을 제어하려면 DisposableStack 을 써야 한다.
`await using` = 비동기 해제
DB 커넥션 close, 파일 flush, 스트림 end 처럼 해제 자체가 비동기인 리소스는 await using 으로 선언하고 Symbol.asyncDispose 를 구현한다. 스코프 종료 시 엔진이 await 로 해제를 기다린다.
async function query() {
await using conn = await pool.connect();
return await conn.query("...");
} // 여기서 await conn[Symbol.asyncDispose]()
await using 은 async 함수나 최상위 await 가 허용되는 모듈 안에서만 쓸 수 있다.
비동기 리소스에 동기 using 을 쓰면 해제가 fire-and-forget 되어 커넥션 누수로 이어진다. 반대로 동기 리소스에 불필요하게 await using 을 쓰면 마이크로태스크를 한 번 더 만들어 성능·스택 트레이스가 지저분해진다.
사용자 정의 리소스 = 프로토콜 구현
특정 라이브러리가 지원하지 않아도 두 Symbol 중 하나만 구현하면 using 에 태울 수 있다.
class Timer {
private id = setInterval(() => {}, 1000);
[Symbol.dispose]() { clearInterval(this.id); }
}
{
using t = new Timer(); // 블록 끝나면 clearInterval 자동
}
기존 코드베이스의 close() / destroy() 메서드를 Symbol.dispose 로 alias 하면 점진적 마이그레이션이 된다.
Symbol.dispose 안에서 예외를 던지면 이미 발생한 예외를 덮어쓰거나 SuppressedError 로 감싸진다. 해제 로직은 idempotent 하게, 실패해도 로깅/무시로 끝나게 설계해야 다른 리소스 해제를 막지 않는다.
여러 리소스는 `DisposableStack`
동적으로 개수가 변하거나 조건부로 획득하는 리소스는 DisposableStack / AsyncDisposableStack 에 use() 로 등록한다. 스택이 dispose 되면 등록 역순으로 전부 해제된다.
using stack = new DisposableStack();
const a = stack.use(openFile("a"));
if (needB) {
const b = stack.use(openFile("b"));
}
// stack.dispose() 시 b → a 순으로 해제
stack.move() 로 리소스 소유권을 호출자에게 이관하는 패턴도 표준이다.
try-finally 로 여러 리소스를 중첩해서 관리하다가 중간에 실패하면 뒷 리소스는 획득되지 않아 finally 에서 null 체크가 난무한다. DisposableStack 은 획득한 만큼만 해제해 주므로 이 문제를 구조적으로 없앤다.
`try-finally` 와의 관계
using 은 try-finally 의 대체가 아니라 더 엄격한 서브셋이다. 둘을 표로 정리하면:
| 항목 | try-finally | using / await using |
|---|---|---|
| 해제 누락 가능성 | 있음 (수동) | 없음 (선언 강제) |
| 비동기 해제 | await 수기 작성 | await using 이 자동 |
| 다중 리소스 | 중첩 필요 | DisposableStack 로 평탄화 |
| 예외 발생 시 동작 | catch/finally 분기 | 원 예외 + SuppressedError |
| 런타임 지원 | 전 버전 | ES2026 / Node 20.4+ / TS 5.2 |
구형 타겟(레거시 브라우저, Node 18 이하)을 지원하는 프로젝트에서 using 을 무심코 쓰는 것. TS 5.2+ 와 @esfx/disposable 같은 폴리필 / downlevel 설정 없이는 런타임 에러로 끝난다.
읽는 순서
- 1이론
TC39
explicit-resource-management제안 문서와 MDN 의using/Symbol.dispose페이지를 훑고,using,await using,Symbol.dispose,Symbol.asyncDispose,DisposableStack다섯 키워드의 역할을 한 문장씩 적어본다. - 2구현
TS 5.2+ 프로젝트에서
class Timer { [Symbol.dispose]() { ... } }를 직접 구현하고,using블록과try-finally버전을 나란히 작성해 바이트·가독성·예외 전파를 비교한다. 그다음DisposableStack으로 파일 2개를 동시에 관리하는 예제를 작성한다. - 3실무
현재 프로젝트에서
try { ... } finally { conn.close() }또는useEffect의 cleanup 이 누락되기 쉬운 지점을 찾아,using/Symbol.asyncDispose로 치환했을 때 런타임 요구사항(ES2026, Node 20.4+, TS 5.2+)이 맞는지 확인하고 마이그레이션 PR 초안을 만든다. - 4설명
동료에게 "
using과try-finally의 차이, 비동기 리소스 처리, 여러 리소스 관리" 세 축으로 5분 안에 설명해본다.SuppressedError와 LIFO 해제를 언급하지 못했다면 해당 부분을 다시 학습한다.
면접 연결 질문
[감점 답변] "자동으로 해제해준다" 수준으로만 답하고 누락 방지 메커니즘을 언급하지 못하면 감점. [좋은 답변] (1) 블록 스코프 종료 = 결정론적 Symbol.dispose 호출, (2) 예외·return·정상 종료 모두에서 강제, (3) 다중 리소스는 DisposableStack 으로 평탄화, (4) SuppressedError 로 해제 중 예외도 관찰 가능. 트레이드오프로 런타임 타겟 제약(ES2026, Node 20.4+) 과 함수 최상위 지속 리소스는 여전히 수동 관리 필요를 언급.
[감점 답변] "비동기면 await 붙이면 된다" 식으로만 답하는 것. [좋은 답변] using 은 Symbol.dispose(동기), await using 은 Symbol.asyncDispose(Promise 반환)을 호출. 기준은 해제 동작이 I/O 를 수반하는가. 예: fs.FileHandle close, pg.Pool release, ReadableStream.cancel() 은 await using. setInterval clear, 메모리 캐시 해제는 using. await using 은 async 컨텍스트에서만 사용 가능함도 언급.
[감점 답변] [Symbol.dispose]() { ... } 만 추가한다고 답하고 끝. [좋은 답변] (1) idempotent 하게 구현해 중복 호출에도 안전, (2) 해제 중 예외를 그대로 던지면 원 예외와 함께 SuppressedError.error / .suppressed 에 래핑됨을 설명, (3) 기존 close() / destroy() 를 Symbol.dispose 로 re-export 하는 점진 마이그레이션, (4) 비동기 해제가 필요하면 Symbol.asyncDispose 추가 구현. 보너스로 DisposableStack.adopt(value, onDispose) 로 프로토콜 미지원 객체도 끌어들이는 패턴 언급.
자기 점검
선언 순서대로 해제된다고 착각하는 것. 실제로는 LIFO(후입선출) 로 b → a 순으로 [Symbol.dispose]() 가 호출된다. try-finally 를 중첩한 것과 동일한 의미다.
"어차피 dispose 가 불리니 괜찮다"고 생각하는 것. Symbol.dispose 만 호출되므로 비동기 close 가 기다려지지 않고 버려져 커넥션 풀 누수, 트랜잭션 미종료, 테스트 race condition 으로 이어진다.