using은 왜 8년이 걸렸나 — GC가 닫아 주지 않는 것들
using은 왜 8년이 걸렸나 — GC가 닫아 주지 않는 것들
JavaScript가 20년 넘게 리소스 해제를 프로그래머의 규율에 맡겨 온 것은 설계의 태만이었는가, 아니면 가비지 컬렉션 언어가 치를 수밖에 없었던 대가인가.
도입
2026년 5월 19일 TC39 회의에서 Explicit Resource Management 제안이 마지막 조건을 통과했다. 회의록의 결론란은 두 줄이 전부다. “All conditions met for Stage 4.” 제안자인 Ron Buckton은 그 자리에서 이렇게 말했다. “이 제안은 한동안 일종의 조건부 Stage 4 상태에 있었습니다. 이제 모든 조건을 충족했다고 생각하며, 곧 승격될 것입니다.” 의장은 다시 Stage 4를 정식으로 요청할 필요가 있느냐는 질문에 “형식적인 절차일 뿐”이라며 선을 그었다. 조건부 승격은 조건이 충족되는 순간 발효된다는 것이 전례라는 이유였다.
TC39의 완료 제안 목록을 열어 보면 이 제안에 붙은 회의 이력의 가장 오래된 항목은 2018년 7월이다. 8년이 걸렸다. 그 사이 JavaScript는 optional chaining을 얻었고, 최상위 await를 얻었고, Temporal이 먼저 Stage 4에 올랐다. 그런데 “파일을 열었으면 닫아라”는, 언어 설계에서 가장 오래된 축에 속하는 요구는 2026년에야 문법이 됐다.
이번에 언어에 들어오는 것은 네 덩어리다. using과 await using 선언, Symbol.dispose와 Symbol.asyncDispose 프로토콜, DisposableStack과 AsyncDisposableStack 컨테이너, 그리고 해제 도중 발생한 예외를 다루는 SuppressedError다. 이를 소개한 Qiita 기사의 제목은 노골적이다. “using이 Stage 4가 되었으니 드디어 리소스 해제를 잊는 일에서 해방된다.” 정말 해방인가. 그리고 애초에 왜 이렇게 오래 붙잡혀 있었는가.
무엇이 들어왔는가 — 스코프가 끝나면 닫힌다
문법 자체는 한 줄로 끝난다.
function hoge(){
using handle = exampleResource();
// 무언가 긴 처리
}
// 스코프를 빠져나온 시점에 자동으로 리소스가 해제된다
using으로 선언한 변수는 그 변수가 속한 블록이 끝나는 순간 [Symbol.dispose]() 메서드가 동기적으로 호출된다. 블록이든 함수 본문이든 모듈 최상위든 마찬가지다. 값이 null이나 undefined이면 조용히 무시되고, 객체인데 [Symbol.dispose]가 함수가 아니면 TypeError가 던져진다.
진짜 이득은 리소스가 둘 이상일 때 드러난다. 제안서가 직접 제시한 대비는 잔인할 만큼 명료하다. 지금까지 “올바른” 코드는 이랬다.
{ // 블록으로 감싸 a, b 가 바깥 스코프로 새지 않게 한다
const a = ...;
try {
const b = ...;
try {
...
}
finally {
b.close(); // b 가 a 에 의존할 수 있으므로 b 를 먼저 닫는다
}
}
finally {
a.close(); // b.close() 가 던져도 a 는 반드시 닫는다
}
}
같은 보장을 using은 한 줄로 표현한다.
using a = ..., b = ...;
...
여러 리소스는 선언의 역순으로 해제된다. y가 x에 의존해 만들어졌다면 y가 먼저 닫히고 x가 나중에 닫힌다. 내부적으로는 스택이고, 해제는 pop 순서다. Node.js의 파일 핸들을 두 개 여는 전형적인 예에서 이 순서는 우연이 아니라 계약이다.
{
using f1 = await fs.promises.open(s1, constants.O_RDONLY),
f2 = await fs.promises.open(s2, constants.O_WRONLY);
const buffer = Buffer.alloc(4092);
const { bytesRead } = await f1.read(buffer);
await f2.write(buffer, 0, bytesRead);
} // f2 가 해제된 뒤 f1 이 해제된다
비동기 해제는 await using이 맡는다. 데이터베이스 트랜잭션처럼 닫는 동작 자체가 비동기인 리소스를 위한 것으로, [Symbol.asyncDispose]를 호출하고 없으면 [Symbol.dispose]로 물러난다.
컨테이너 두 개는 문법만으로 표현되지 않는 경우를 위해 있다. DisposableStack은 use(value)로 이미 처분 가능한 객체를 담고, adopt(value, onDispose)로 아직 Symbol.dispose를 구현하지 않은 기존 API를 감싸며, defer(onDispose)로 Go의 defer 같은 정리 콜백만 등록하고, move()로 스택에 든 것 전부를 새 스택으로 옮긴다. 제안서는 이 설계가 Python의 contextlib.ExitStack에서 왔다고 명시한다. Buckton은 회의에서 이 컨테이너들의 존재 이유를 두 가지로 요약했다. “리소스 집약, 그리고 현재 dispose 문법을 지원하지 않는 기존 API와의 상호운용.”
해제 도중 예외가 나면 어떻게 되는가. 이것이 SuppressedError다. 본문에서 던진 예외를 해제 코드의 예외가 덮어써 버리는 사고를 막기 위해, 새 예외는 억눌린 예외를 suppressed 필드에 물고 올라온다. 예외가 연달아 발생하면 SuppressedError가 중첩된다. 제안서는 AggregateError를 쓰지 않은 이유까지 적어 두었다. AggregateError는 상관관계 없는 여러 오류의 평평한 목록을 담는 반면, 억제 관계는 두 오류 사이의 직접적인 대응이며 들쭉날쭉하게 중첩되기 때문이다.
문법에는 금지 구역도 있다. 스크립트 최상위에서는 쓸 수 없고(모듈 최상위는 가능하다), for-in 루프의 초기화부에도, switch의 case나 default 절 바로 아래에도 쓸 수 없다. 브라우저 지원은 아직 고르지 않다. caniuse 기준으로 Chrome과 Edge는 134 버전부터, Firefox는 141 버전부터 지원하며 Safari는 정식 릴리스가 아니라 Technology Preview에만 들어와 있다. 전역 지원율은 73.39퍼센트다. MDN은 이 기능에 “일부 널리 쓰이는 브라우저에서 동작하지 않으므로 Baseline이 아니다”라는 딱지를 붙여 두었다. TC39 완료 목록이 기재한 예상 게재 연도는 2027년이다.
GC는 메모리를 회수하지만 파일 핸들을 닫지 않는다
여기서부터가 본론이다. JavaScript는 가비지 컬렉션 언어다. 아무도 free()를 부르지 않는다. 그런데 왜 close()는 손으로 불러야 하는가.
가비지 컬렉터가 회수하는 것은 메모리다. 정확히는 힙에 잡힌 객체의 저장 공간이다. 도달 불가능해진 객체를 찾아 그 자리를 되돌려 주는 일에는 GC가 능하다. 그러나 파일 디스크립터는 힙에 없다. 커널의 프로세스별 테이블에 있다. TCP 소켓은 커널의 연결 상태에 있다. 뮤텍스는 다른 스레드가 기다리고 있는 잠금 상태에 있고, 데이터베이스 트랜잭션은 서버 쪽 세션에 열려 있으며, 이벤트 구독은 발행자 쪽 리스너 배열에 남아 있다. 자바스크립트 객체를 GC가 치워도 커널의 fd 테이블은 그대로다. 제안서의 용어 정의는 이 경계를 정확히 긋는다. 리소스란 “수명이 정해진 객체로, 그 수명이 끝날 때 수명에 민감한 연산이 수행되거나, 파일 핸들이나 소켓처럼 가비지 컬렉션되지 않는 참조가 닫히거나 해제되어야 하는” 것이다.
문제는 하나 더 있다. GC는 “언제” 도는지 약속하지 않는다. 설령 언어가 모든 객체에 소멸자 훅을 제공한다 해도, 그 훅이 언제 불릴지는 컬렉터의 스케줄에 달려 있다. 락을 GC에 맡기면 다른 코드가 락을 얻는 시점이 컬렉터의 기분에 좌우된다. 결정적이지 않은 해제는 상당수 리소스에서 해제하지 않은 것과 실질적으로 같다. Qiita 기사의 저자가 대조로 든 PHP는 참조 카운팅을 쓰기 때문에 참조가 사라지는 순간 리소스가 정리된다. 대가는 참조 카운팅 자체의 비용과 순환 참조 처리이며, 그것은 엔진 설계의 근본 선택이라 문법 하나로 되돌릴 수 있는 종류가 아니다.
그래서 JavaScript에 남은 유일한 답이 try/finally였다. 그리고 제안서가 나열한 실패 목록은 이 답이 왜 부족했는지를 보여 준다. 첫째, 생태계의 해제 방식 자체가 제각각이다. 이터레이터는 iterator.return(), WHATWG 스트림 리더는 reader.releaseLock(), Node의 파일 핸들은 handle.close(), Emscripten의 C++ 객체 핸들은 Module._free(ptr)이나 obj.delete()다. 이름이 통일되지 않으면 자동화도 습관화도 되지 않는다. 둘째, try 없이 쓴 해제 호출은 예외 한 번에 건너뛰어진다. 셋째, 리소스가 둘일 때 손으로 짠 finally 블록은 순서를 틀리거나(“b.close()가 a에 의존하면 문제”), 앞의 해제가 던지면 뒤의 해제에 도달하지 못한다(“a.close()가 던지면 b에 영원히 도달하지 못한다”). 넷째, try/finally는 리소스를 블록 밖으로 새어 나가게 한다. finally에서 닫은 핸들 변수는 그 뒤로도 스코프에 살아 있고, 이미 닫힌 객체를 쓰는 코드를 언어가 막아 주지 않는다.
다른 언어들은 이 문제를 저마다 다른 값을 치르고 풀었다. C++의 RAII는 스코프를 벗어날 때 소멸자를 반드시, 결정적으로 호출한다. 가장 강한 보장이지만 대가도 크다. GC가 없으니 소유권을 프로그래머가 설계해야 하고, 이동 의미론이 필요하며, 소멸자에서 예외를 던지면 프로그램이 끝난다. Rust의 Drop은 같은 결정적 해제에 소유권과 빌림 검사를 얹어 이중 해제와 해제 후 사용을 컴파일 시점에 막는다. 보장은 가장 세지만 그 대가로 프로그래머는 컴파일러와 논쟁하는 법을 배워야 한다. C#은 GC 언어이면서 IDisposable과 using을 도입해 결정적 해제 문법을 얹었고, C# 8부터는 블록을 새로 만들지 않는 using 선언까지 갖췄다. 다만 IDisposable을 구현할지, using으로 감쌀지는 규율의 영역으로 남는다. Python의 with는 __enter__/__exit__ 프로토콜과 블록 문법을 함께 제공하고, 개수가 유동적인 리소스를 위해 ExitStack을 표준 라이브러리에 두었다. Java의 try-with-resources도 같은 계열이며, 제안서는 이 셋을 선행 사례로 명시한다.
JavaScript의 using은 이 스펙트럼에서 C#의 위치에 앉는다. 해제 시점은 결정적이고 문법에 박혀 있지만, 어떤 객체가 처분 가능한지는 라이브러리의 선택이고 using을 쓸지는 호출자의 선택이다. 컴파일러가 “이 핸들을 using으로 받지 않았다”고 거절해 주지 않는다. Buckton이 회의에서 쓴 표현은 정확히 그 절반의 보장을 가리킨다. “using과 await using 선언, 즉 리소스의 RAII 스타일 초기화입니다. 리소스는 선언된 지점에서 할당되고, 그 선언이 스코프를 벗어날 때까지 추적되다가 해제됩니다.”
그렇다면 왜 8년인가. 첫째는 문법을 나중에 끼워 넣는 비용이다. using은 기존 코드에서 얼마든지 변수 이름일 수 있으므로 문맥에 따라 해석돼야 했고, 그 조정이 언어의 구석구석과 충돌했다. 마지막 회의에서 Buckton이 보고한 정리 작업이 그 흔적이다. “지난 3월 회의에서 우리는 case와 default 절, 그리고 스크립트 최상위에서 using 선언을 금지하는 방식이 모호하게 서술돼 있던 것을 피하기 위해 ContainsUsing이라는 추상 연산을 도입했습니다.” 더 흥미로운 것은 그 다음이다. 새로 만든 이 연산이 꼬리 호출 가능 여부를 판정하는 데 쓰이던 기존 연산과 “다르게 쓰였을 뿐 정확히 같은 의미”임이 밝혀져 둘을 합쳤다는 것이다. 리소스 해제가 꼬리 호출 최적화까지 건드린다. 스코프를 벗어나며 해제할 것이 남아 있으면 그 호출은 꼬리 호출이 아니기 때문이다. 나중에 들어오는 기능은 언어의 예상하지 못한 곳에서 대가를 청구한다.
둘째는 프로토콜을 소급 적용하는 비용이다. Symbol.dispose는 새 심볼이므로 기존 객체에는 없다. 스트림 리더도, 파일 핸들도, 서드파티 커넥션 풀도 각자 이 심볼을 구현해 넣기 전에는 using의 대상이 되지 못한다. DisposableStack.adopt가 존재하는 이유가 바로 그 공백이다. 셋째는 비동기다. await using은 단순히 해제를 await하는 것이 아니라, 그 선언을 품은 블록·함수 본문·모듈에서 제어 흐름이 빠져나갈 때마다 암묵적인 비동기 끼어들기 지점을 만든다. 제안서는 이 결과를 숨기지 않고 코드로 보여 준다.
async function f() {
{
await using x = ...;
a();
} // 블록 종료, 암묵적 await
b(); // a() 호출과는 다른 마이크로태스크
}
await using 한 줄을 추가하는 것만으로 같은 마이크로태스크에서 실행되던 두 문장이 서로 다른 마이크로태스크로 갈라진다. 제안서는 “이런 암묵적 끼어들기 지점은 문법에 충분히 드러나야 한다”고 적으며, await using의 존재 자체가 그 표식 역할을 한다고 판단했다. 8년 중 상당 부분은 이런 판단을 하나씩 확정하는 데 쓰였다.
새 문법이 만드는 새 실수
모든 새 기능은 새 실수 유형을 데려온다. using도 예외가 아니다.
첫 번째 함정은 스코프를 벗어나 살아남아야 하는 리소스다. 커넥션을 만들어 반환하는 팩토리 함수 안에서는 using을 쓸 수 없다. 함수가 끝나는 순간 방금 만든 커넥션이 닫혀 버리기 때문이다. 그런데 생성 도중 실패하면 이미 만든 것들은 정리해야 하고, 성공하면 소유권을 호출자에게 넘겨야 한다. 이 조건부 소유권 이전이 move()가 존재하는 이유다. 제안서의 예제는 생성자 안에서 using stack = new DisposableStack()을 만들어 부품을 차례로 담고, 마지막 줄에서 this.#disposables = stack.move()로 전부 인스턴스 필드로 옮긴다. 주석이 설계 의도를 그대로 말한다. “생성이 실패했다면 위 줄에 도달하기 전에 stack이 해제된다.” move()를 잊으면 생성자가 끝나는 순간 방금 조립한 객체가 스스로를 해체한다. try/finally 시대에는 존재하지 않던 실수다.
두 번째는 해제 중 예외의 처리다. SuppressedError는 정보를 보존해 주지만, 그 구조를 아는 코드에만 그렇다. 기존 catch 블록이 error.message만 찍고 있다면 진짜 원인은 error.suppressed 안에, 경우에 따라서는 error.suppressed.suppressed 안에 들어가 조용히 사라진다. 로깅과 오류 리포팅 코드가 이 중첩을 풀도록 갱신되지 않으면, 새 문법은 관측 가능성을 떨어뜨리는 쪽으로 작동한다.
세 번째는 조용한 무시다. null과 undefined는 옵셔널 리소스를 지원하려고 의도적으로 무시된다. 편리하지만, 리소스를 만들지 못해 undefined가 들어온 경우와 잘 만들어져 해제까지 마친 경우가 코드상 구분되지 않는다. 실패가 시끄럽게 드러나지 않고 정상 경로를 흉내 낸다.
네 번째는 생태계의 공백기다. Qiita 기사의 저자도 이 점을 낙관하지 않는다. 우선순위가 높은 쪽부터 점진적으로 대응되고는 있으나, 지금 당장 모든 리소스에 쓸 수 있는 것은 결코 아니라는 것이다. 문법은 이미 표준이고 런타임 일부는 이미 지원하지만, 여러분이 실제로 닫아야 하는 그 커넥션 풀이 Symbol.dispose를 구현했는지는 별개의 문제다. 그 공백기 동안 코드베이스에는 using을 쓰는 자리와 try/finally를 쓰는 자리가 섞이고, 섞인 코드는 어느 쪽 규율도 일관되게 유지하기 어렵다.
실무자의 판단은 두 단계로 갈린다. 도입 시점의 문제는 비교적 단순하다. Node 계열 서버 코드라면 런타임 지원만 확인하면 되고, 브라우저로 직접 배포하는 코드라면 Safari 정식 릴리스가 아직 없으므로 트랜스파일이 전제다. 자신이 쓰는 트랜스파일러와 린터가 이 문법을 처리하는지는 직접 확인해야 한다.
더 중요한 것은 기존 코드를 옮길 값어치가 있는가다. 리소스가 하나이고 finally가 한 줄인 자리는 옮겨도 얻는 것이 거의 없다. 옮길 값어치가 분명한 자리는 셋이다. 리소스가 둘 이상이라 중첩 try/finally를 요구하는 곳, 해제 순서가 의존 관계 때문에 중요한 곳, 그리고 생성 도중 실패 시의 부분 정리를 손으로 짜고 있던 생성자다. 이 세 자리는 정확히 사람이 반복적으로 틀려 온 자리이며, 문법이 실제로 보장을 대신 져 주는 자리다. 낙관적으로 보면 5년 안에 표준 라이브러리와 주요 프레임워크가 Symbol.dispose를 기본으로 달고, using은 const처럼 눈에 띄지 않는 기본값이 된다. 비관적으로 보면 Symbol.dispose 구현이 산발적으로 남아 using이 “쓸 수 있으면 쓰는” 선택지에 머물고, 두 방식이 뒤섞인 코드가 오히려 리뷰 부담을 키운다. 현실적인 시나리오는 그 사이다. 데이터베이스 드라이버, 파일 시스템, 락 라이브러리처럼 해제 실수가 곧바로 장애로 이어지는 영역이 먼저 옮겨 가고, 나머지는 오래 남는다.
결론
처음 질문으로 돌아가자. JavaScript가 20년 넘게 리소스 해제를 규율에 맡긴 것은 태만이었는가, 필연이었는가. 답은 둘 다 아니다. 그것은 선택이었고, 오래 유예된 청구서였다. 가비지 컬렉션은 메모리라는 한 종류의 자원에 대해서만 프로그래머를 해방시켰고, 언어는 나머지 자원에 대해 아무 장치도 주지 않은 채 “잘 짜라”고 말해 왔다. 그 사이 iterator.return()과 reader.releaseLock()과 handle.close()가 각자의 이름으로 흩어졌고, 올바른 중첩 try/finally를 손으로 쓰는 사람은 드물었다. 필연이 아니었다는 증거는 C#이 이미 오래전에 같은 문제를 GC 언어 안에서 풀었다는 사실이다.
동시에 using이 해방은 아니다. 언어는 이제 “해제하겠다고 선언하면 반드시 해제한다”를 보장할 뿐, “해제하겠다고 선언하라”를 강제하지는 않는다. 그 절반의 보장은 C#이 20년 가까이 살아온 자리이고, 그 자리에서도 리소스 누수는 사라지지 않았다. 바뀌는 것은 실수의 종류다. finally를 빠뜨리는 실수 대신 move()를 빠뜨리는 실수가, 해제 순서를 틀리는 실수 대신 suppressed를 들여다보지 않는 실수가 온다.
그렇다면 남는 질문은 이것이다. 언어가 규율을 문법으로 승격시킬 때, 우리는 무엇을 얻고 무엇을 잃는가. 그리고 여러분의 코드베이스에서 using으로 옮길 값어치가 있는 자리는, 정말로 지금까지 사고가 났던 그 자리인가.
출처:
- https://qiita.com/rana_kualu/items/83783c6dd3b4b51fa77f
- https://github.com/tc39/proposal-explicit-resource-management
- https://github.com/tc39/notes/blob/HEAD/meetings/2026-05/may-19.md#explicit-resource-management-stage-4-progress-update
- https://github.com/tc39/proposals/blob/main/finished-proposals.md
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using
- https://caniuse.com/mdn-javascript_statements_using