JavaScript using 키워드 — try/finally 없이 리소스를 자동으로 정리하는 법

9 min read
JavaScriptTC39usingSymbol.dispose리소스 관리
JavaScript using 키워드 — try/finally 없이 리소스를 자동으로 정리하는 법

파일 핸들, 데이터베이스 커넥션, 락(Lock). 열었으면 닫아야 한다. 문제는 개발자가 닫는 걸 잊는다는 것이다. try/finally로 매번 감싸는 건 작동하지만, 코드가 중첩될수록 "닫는 코드"가 "여는 코드"보다 더 많아지는 기현상이 벌어진다. C#에는 using, Python에는 with, Go에는 defer가 있다. JavaScript는 지금까지 이 기본기가 빠져 있었다. TC39 Stage 4에 도달한 Explicit Resource Management 제안이 그 빈자리를 채운다.

[💡 잠깐! 이 용어는?] TC39 Stage 4: JavaScript 언어 표준(ECMAScript)에 새로운 기능이 추가되는 마지막 단계. Stage 4에 도달하면 다음 연도 사양에 공식 포함이 확정된다.


using이 뭔가

using 키워드는 블록 스코프가 끝날 때 리소스의 [Symbol.dispose]() 메서드를 자동으로 호출한다. 비유하면 호텔 체크아웃 시스템과 같다. 투숙객이 방을 나가면(스코프 종료) 프런트 데스크가 알아서 객실 정리를 트리거한다. 일일이 "청소해주세요" 전화를 걸 필요가 없다.

핵심: using으로 선언된 변수는 해당 블록이 끝나는 시점에 자동으로 정리된다. try/finally를 직접 작성할 필요가 없다.


try/finally와 뭐가 다른가

항목try/finallyusing
정리 코드 위치블록 끝에 수동 작성자동 호출
리소스 여러 개일 때중첩 try/finally선언 순서 역순으로 자동 정리
정리 누락 가능성높음 (개발자 의존)낮음 (언어 수준 보장)
비동기 정리직접 await 관리await using 키워드
가독성들여쓰기 지옥플랫한 구조

using이 유리한 이유는 단순하다. 정리 로직이 리소스 정의와 함께 캡슐화되기 때문이다. 리소스를 만드는 쪽에서 "나는 이렇게 정리돼야 한다"를 선언하면, 사용하는 쪽은 using만 붙이면 된다.


사용 방법

기본: Symbol.dispose

disposable-resource.ts
class FileHandle {
  #handle: number
 
  constructor(path: string) {
    this.#handle = openFile(path)
    console.log(`파일 열림: ${path}`)
  }
 
  read(): string {
    return readFromHandle(this.#handle)
  }
 
  [Symbol.dispose](): void {
    closeFile(this.#handle)
    console.log('파일 닫힘')
  }
}
 
function processFile() {
  using file = new FileHandle('/data/config.json')
  const content = file.read()
  // 블록이 끝나면 file[Symbol.dispose]() 자동 호출
  return JSON.parse(content)
}

usingconst처럼 동작하되, 스코프 끝에서 [Symbol.dispose]()를 호출하는 점만 다르다. let처럼 재할당은 불가능하다.

비동기: await using

async-disposable.ts
class DatabaseConnection {
  #pool: ConnectionPool
 
  constructor(pool: ConnectionPool) {
    this.#pool = pool
  }
 
  async query(sql: string): Promise<Result> {
    return this.#pool.execute(sql)
  }
 
  async [Symbol.asyncDispose](): Promise<void> {
    await this.#pool.release()
    console.log('커넥션 반환 완료')
  }
}
 
async function fetchUsers() {
  await using conn = new DatabaseConnection(pool)
  const users = await conn.query('SELECT * FROM users')
  // 블록 끝에서 await conn[Symbol.asyncDispose]() 자동 호출
  return users
}

[💡 잠깐! 이 용어는?] Symbol.asyncDispose: 비동기 정리가 필요한 리소스를 위한 심볼. 네트워크 커넥션 반환, 스트림 닫기 등 await가 필요한 정리 작업에 사용한다.

여러 리소스를 한 번에: DisposableStack

disposable-stack.ts
function processTransaction() {
  using stack = new DisposableStack()
 
  const conn = stack.use(getConnection())
  const lock = stack.use(acquireLock('users'))
  const tempFile = stack.use(createTempFile())
 
  conn.execute('UPDATE users SET active = true')
  tempFile.write(conn.execute('SELECT * FROM users'))
 
  // 블록 끝에서 역순 정리: tempFile → lock → conn
}

비유하면 DisposableStack은 식당의 설거지 바구니와 같다. 접시를 쌓은 순서의 역순으로 꺼내서 씻는다. 가장 마지막에 쌓은 접시가 가장 먼저 씻기는 LIFO(후입선출) 구조다.


using 예시를 더 현실적으로 보면

실무에서 자주 묻는 포인트는 "그래서 언제 dispose가 호출되는데?"다. 핵심은 블록을 벗어나는 모든 경로에서 호출된다는 점이다. 정상 종료, return, 예외 throw 모두 동일하게 정리된다.

early return이 있어도 정리됨

early-return-dispose.ts
class TempDir {
  constructor(private path: string) {
    fs.mkdirSync(path, { recursive: true })
  }
 
  [Symbol.dispose]() {
    fs.rmSync(this.path, { recursive: true, force: true })
    console.log('temp dir removed')
  }
}
 
function buildPreview(enabled: boolean) {
  using tmp = new TempDir('.tmp/preview')
 
  if (!enabled) {
    return null // 여기서 바로 return해도 tmp는 정리됨
  }
 
  return renderPreview(tmp)
}

try/catch와 함께 써도 중복 정리 불필요

using-with-try-catch.ts
function runTask() {
  try {
    using lock = acquireUserLock('u:42')
    using file = openAuditFile('audit.log')
 
    file.write('start task')
    riskyOperation()
    file.write('done')
  } catch (e) {
    logger.error(e)
    // lock/file 정리는 여기서 수동으로 안 해도 됨
  }
}

await using + for 루프 패턴

await-using-loop.ts
async function processJobs(jobs: Job[]) {
  for (const job of jobs) {
    await using conn = await openJobConnection(job.id)
    await conn.run(job)
    // 각 루프 이터레이션이 끝날 때마다 conn이 자동 반환됨
  }
}

이 패턴이 중요한 이유는, 루프 중간 실패가 나도 이전 iteration에서 열린 리소스가 누수되지 않기 때문이다.


런타임 지원 현황

환경지원 버전비고
Chrome134+V8 기반
Firefox132+SpiderMonkey
Node.js22+V8 기반
TypeScript5.2+타입 체크 지원
Safari미지원2026년 2월 기준
Babel플러그인 필요@babel/plugin-proposal-explicit-resource-management

Safari를 지원해야 하는 프로젝트라면 아직 폴리필이나 Babel 플러그인이 필요하다. 그 외 주요 환경에서는 바로 사용 가능한 상태다.


실전 활용 사례

fetch AbortController 자동 정리

auto-abort-fetch.ts
function createAbortable(): AbortController & Disposable {
  const controller = new AbortController()
  return Object.assign(controller, {
    [Symbol.dispose]() {
      controller.abort()
    }
  })
}
 
async function fetchWithTimeout(url: string) {
  using controller = createAbortable()
  setTimeout(() => controller.abort(), 5000)
  const response = await fetch(url, { signal: controller.signal })
  return response.json()
}

EventListener 자동 해제

auto-remove-listener.ts
function onEvent(
  target: EventTarget,
  event: string,
  handler: EventListener
): Disposable {
  target.addEventListener(event, handler)
  return {
    [Symbol.dispose]() {
      target.removeEventListener(event, handler)
    }
  }
}
 
function setupUI() {
  using clickHandler = onEvent(button, 'click', handleClick)
  using scrollHandler = onEvent(window, 'scroll', handleScroll)
  // 스코프 끝에서 두 리스너 모두 자동 해제
}

[💡 잠깐! 이 용어는?] Disposable: [Symbol.dispose]() 메서드를 가진 객체가 구현하는 인터페이스. TypeScript 5.2+에서 DisposableAsyncDisposable 타입으로 정의되어 있다.


정리

  • using은 블록 스코프 종료 시 [Symbol.dispose]()를 자동 호출하는 선언 키워드다
  • 비동기 리소스는 await using + [Symbol.asyncDispose]() 조합을 쓴다
  • DisposableStack으로 여러 리소스를 LIFO 순서로 일괄 정리할 수 있다
  • Chrome 134+, Firefox 132+, Node 22+, TypeScript 5.2+에서 사용 가능하다
  • 기존 try/finally 패턴 대비 가독성과 안정성 모두 개선된다

try/finally를 쓰면 안 되는 건 아니다. 하지만 리소스가 2개 이상 겹치기 시작하면 using의 가치가 체감된다. 새로 시작하는 코드라면 using으로 작성하고, 기존 코드는 리팩토링할 때 점진적으로 전환하는 전략이 현실적이다.


참고:

관심 있을 만한 포스트

Error.isError() — realm을 넘나드는 안전한 에러 검사 API

instanceof Error가 iframe과 worker에서 실패하는 이유, 그리고 이를 근본적으로 해결하는 Error.isError()의 동작 원리를 정리한다.

JavaScriptError.isError

Temporal API — JavaScript Date의 30년 묵은 저주가 풀린다

Chrome 144가 Temporal API를 정식 탑재하면서 JavaScript 날짜 처리의 새 시대가 열렸다.

TemporalJavaScript

Native JSON Modules — 번들러 없이 JSON을 import하는 시대

Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.

JavaScriptESM

Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1

2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.

BabelJavaScript

V8의 Sea of Nodes 탈출기 — 왜 우아한 이론이 실전에서 무너졌는가

V8 팀이 10년간 사용한 Sea of Nodes IR을 포기하고 Turboshaft로 전환한 7가지 이유와 그 교훈을 정리한다.

V8컴파일러

jQuery 4.0 — 10년 만의 메이저 릴리스, 무엇이 바뀌었나

jQuery가 20주년을 맞아 10년 만에 메이저 버전을 출시했다. IE 지원 축소, ES 모듈 전환, Trusted Types 등 핵심 변경 사항을 정리한다.

jQueryJavaScript

V8 Mutable Heap Numbers — 숫자 하나 바꿀 때마다 새 객체를 만들던 비효율을 잡다

V8 엔진이 스크립트 컨텍스트의 숫자 변수를 매번 새 HeapNumber로 할당하던 방식을 제자리 수정(mutable)으로 바꿔 최대 2.5배 성능 향상을 달성했다.

V8JavaScript

V8 Explicit Compile Hints — 주석 한 줄로 JavaScript 시작 속도를 630ms 줄이는 법

Chrome 136에 도입된 V8의 Explicit Compile Hints 기능으로 JavaScript 초기 로딩 성능을 개선하는 원리와 사용법을 분석한다.

V8성능 최적화