- Published on
ES2024 using으로 리소스 누수 0 만들기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치, CLI에서 오래 돌리는 Node.js 프로세스의 장애 원인을 파고들면 의외로 “리소스 해제가 누락된 코드”가 자주 나옵니다. 파일 디스크립터가 닫히지 않거나, 임시 디렉터리가 쌓이거나, 락이 풀리지 않거나, 타이머가 계속 살아남는 식입니다. 이런 누수는 초반에는 티가 안 나다가 트래픽이 늘거나 예외가 한 번만 터져도 급격히 증폭합니다.
ES2024는 이 문제를 언어 차원에서 줄이기 위해 using 선언과 Symbol.dispose(동기), Symbol.asyncDispose(비동기)라는 표준 메커니즘을 도입했습니다. 핵심은 간단합니다.
- 스코프를 벗어날 때 자동으로 정리(
dispose)가 호출된다 - 예외가 발생해도 정리 코드는 반드시 실행된다
- 여러 리소스를
using으로 선언하면 역순으로 정리된다
Java의 try-with-resources, C#의 using과 유사한 모델을 JS에서도 쓸 수 있게 된 셈입니다.
리소스 누수는 결국 “예외 경로에서의 정리 누락”이 대부분입니다.
using은 그 예외 경로를 언어가 대신 처리해 줍니다.
관련해서 커넥션/리소스 고갈이 실제 장애로 이어지는 케이스는 서버 사이드에서도 흔합니다. 예를 들어 DB 커넥션이 반납되지 않아 풀 고갈이 나는 패턴은 자바 생태계에서도 대표적이죠. 필요하면 아래 글도 함께 참고해 보세요.
using / Symbol.dispose 한 번에 이해하기
문법: using 선언
using은 변수 선언 키워드처럼 보이지만 “스코프 종료 시 정리 호출 예약”이라는 의미가 추가됩니다.
- 대상 객체가
Symbol.dispose메서드를 가지고 있으면 스코프 종료 시 호출 - 비동기 정리가 필요하면
await using과Symbol.asyncDispose를 사용
아래는 개념 예시입니다.
class TempResource {
constructor(name) {
this.name = name;
console.log(`acquire: ${name}`);
}
[Symbol.dispose]() {
console.log(`dispose: ${this.name}`);
}
}
function demo() {
using r1 = new TempResource('r1');
using r2 = new TempResource('r2');
console.log('work...');
}
demo();
// acquire: r1
// acquire: r2
// work...
// dispose: r2
// dispose: r1
정리 순서가 역순(LIFO)이라는 점이 중요합니다. 여러 리소스가 서로 의존 관계가 있을 때(예: “상위 핸들”이 “하위 핸들”을 감싸는 구조) 역순 정리가 훨씬 안전합니다.
예외가 나도 정리된다
try/finally로 직접 작성하면 실수하기 쉬운 부분이 “중간 return/throw 경로”입니다. using은 이 경로를 언어가 강제합니다.
class Lock {
constructor() {
this.locked = true;
console.log('lock acquired');
}
[Symbol.dispose]() {
this.locked = false;
console.log('lock released');
}
}
function doWork() {
using lock = new Lock();
// 어떤 이유로든 예외가 나도 lock은 풀린다
throw new Error('boom');
}
try {
doWork();
} catch (e) {
console.log('caught:', e.message);
}
기존 try/finally와 비교: 무엇이 달라지나
기존에는 아래처럼 작성했습니다.
function legacy() {
const r = acquire();
try {
return doSomething(r);
} finally {
release(r);
}
}
문제는 다음과 같은 상황에서 누락이 발생한다는 점입니다.
- 개발자가
finally를 빼먹음 release호출을 조건문 뒤로 밀어두다가 특정 분기에서 누락- 리팩터링 중
return위치가 바뀌며 정리 코드가 사라짐 - 여러 리소스 조합에서 정리 순서를 실수
using은 “정리 예약” 자체가 선언에 붙어 있으니, 코드 리뷰나 리팩터링에서도 훨씬 눈에 잘 띕니다. 특히 여러 리소스를 다룰 때 가독성이 크게 좋아집니다.
실전 1: 임시 디렉터리 자동 정리(동기)
빌드/압축/이미지 처리 같은 작업을 하다 보면 임시 디렉터리를 만들고 작업 후 삭제해야 합니다. 예외가 나면 삭제가 누락되어 디스크를 서서히 잡아먹습니다.
아래는 Node.js에서 임시 디렉터리를 만들고 스코프 종료 시 자동 삭제하는 패턴입니다.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
class TempDir {
constructor(prefix = 'job-') {
this.dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
[Symbol.dispose]() {
// Node 20+ 기준 rmSync는 재귀 삭제 지원
fs.rmSync(this.dir, { recursive: true, force: true });
}
}
function runJob() {
using tmp = new TempDir('export-');
const file = path.join(tmp.dir, 'out.txt');
fs.writeFileSync(file, 'hello');
// 중간에 예외가 나도 tmp.dir은 삭제된다
return file;
}
runJob();
포인트는 “리소스의 생명주기”가 TempDir 객체로 캡슐화된다는 점입니다. 호출자는 rmSync를 기억할 필요가 없습니다.
실전 2: 비동기 리소스 정리 await using
실무에서는 정리 자체가 비동기인 경우가 많습니다.
- 네트워크 커넥션 종료
- 트랜잭션 롤백/커밋
- 비동기 락 해제
- 스트림 종료 및 flush
이때는 Symbol.asyncDispose와 await using을 조합합니다.
class AsyncResource {
constructor(name) {
this.name = name;
console.log(`open: ${name}`);
}
async [Symbol.asyncDispose]() {
await new Promise((r) => setTimeout(r, 50));
console.log(`close: ${this.name}`);
}
}
async function main() {
await using r = new AsyncResource('conn');
console.log('do async work');
}
await main();
await using을 쓸 때는 해당 스코프를 감싸는 함수가 async여야 합니다. 또한 스코프 종료 시점에 정리 작업을 await하므로 “정리 완료 전 다음 단계로 넘어가는” 레이스 컨디션을 줄일 수 있습니다.
실전 3: AbortController + 타임아웃 타이머 누수 방지
Node.js/브라우저 모두에서 타이머는 대표적인 “은근한 누수” 포인트입니다. 특히 setTimeout으로 타임아웃을 걸어두고 성공 시 clearTimeout을 안 하면, 타이머가 살아남아 불필요한 콜백이 실행되거나 메모리가 쌓입니다.
using으로 타이머의 생명주기를 묶어두면 안전합니다.
class Timeout {
constructor(ms, onTimeout) {
this.id = setTimeout(onTimeout, ms);
}
[Symbol.dispose]() {
clearTimeout(this.id);
}
}
async function fetchWithTimeout(url, ms) {
const ac = new AbortController();
using t = new Timeout(ms, () => ac.abort());
const res = await fetch(url, { signal: ac.signal });
return res.text();
}
이 패턴의 장점은 다음과 같습니다.
- 성공/실패/예외 어떤 경로든 타이머가 정리됨
- “타임아웃 로직”이 함수 중간에 흩어지지 않음
실전 4: 여러 리소스 조합(역순 정리의 가치)
예를 들어 “파일을 열고”, “gzip 스트림을 만들고”, “출력 스트림에 파이프한다” 같은 작업은 정리 순서가 꼬이면 오류가 납니다.
using은 선언 역순으로 정리되므로, 의존성이 있는 리소스를 선언 순서대로 쌓기만 하면 됩니다.
import fs from 'node:fs';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
class DisposableStream {
constructor(stream) {
this.stream = stream;
}
[Symbol.dispose]() {
this.stream.destroy();
}
}
async function gzipFile(src, dst) {
using inS = new DisposableStream(fs.createReadStream(src));
using outS = new DisposableStream(fs.createWriteStream(dst));
using gz = new DisposableStream(zlib.createGzip());
await pipeline(inS.stream, gz.stream, outS.stream);
}
여기서 정리 순서는 gz → outS → inS가 됩니다. 파이프라인이 끝나든 실패하든 스트림이 남아 프로세스를 붙잡는 상황을 줄일 수 있습니다.
도입 시 주의사항: 런타임/트랜스파일링/린팅
1) 실행 환경 지원
using은 ES2024 기능입니다. 따라서 다음 중 하나가 필요합니다.
- 최신 Node.js가 해당 문법을 지원
- 또는 Babel/TypeScript 등으로 트랜스파일
팀에서 LTS를 쓰는 경우, CI/프로덕션 Node 버전과 로컬 버전 차이로 “로컬에서는 되는데 서버에서 문법 에러”가 날 수 있으니 engines 필드, 컨테이너 베이스 이미지, CI 런너 버전을 함께 고정하는 게 안전합니다.
2) dispose는 “절대 실패하면 안 된다”는 원칙
정리 코드에서 예외가 나면 원래 예외를 가리거나(또는 정리 단계에서 또 다른 장애를 만들거나) 디버깅이 어려워집니다. Symbol.dispose/Symbol.asyncDispose 구현에서는 다음을 권장합니다.
- 가능하면 내부에서 예외를 삼키고 로그만 남기기
- 멱등성(idempotent) 보장: 두 번 호출돼도 안전
- 부분적으로만 생성된 리소스도 정리 가능하도록 방어 코드 작성
3) 리소스 “소유권”을 명확히
가장 흔한 버그는 “누가 닫아야 하는가”입니다.
- 함수가 리소스를 생성했다면 그 함수가
using으로 소유하고 반환은 데이터만 - 호출자에게 리소스 자체를 넘겨야 한다면, 문서/타입으로 “호출자가 dispose 책임”을 명시
이 원칙이 무너지면 using을 도입해도 누수는 다른 형태로 계속 발생합니다.
운영 관점: 누수는 결국 장애로 연결된다
리소스 누수는 단순 메모리 증가에만 그치지 않습니다.
- 파일 디스크립터 고갈로 I/O 실패
- 임시 파일 누적으로 디스크 압박, 결국
Evicted나 프로세스 종료 - 커넥션/락 누적으로 병목 및 타임아웃
이런 장애는 종종 컨테이너 환경에서 CrashLoopBackOff로 관측됩니다. 애플리케이션 레벨에서 누수를 줄이는 것과 더불어, 장애 징후를 빠르게 진단하는 운영 지식도 같이 있으면 좋습니다.
마이그레이션 전략: 어디부터 using을 적용할까
한 번에 전부 바꾸기보다 “누수 비용이 큰 곳”부터 적용하는 게 효율적입니다.
- 임시 디렉터리/파일 생성 코드
- 타이머/인터벌/이벤트 리스너 등록 코드
- 스트림/소켓/세션 등 장수 리소스
- 락/세마포어 같은 동시성 제어
기존 try/finally를 무조건 제거하기보다, 리소스 소유권이 명확한 경계(함수 단위, 모듈 단위)를 정해 점진적으로 바꾸면 리스크가 낮습니다.
결론
ES2024의 using은 “개발자가 매번 finally를 올바르게 작성해야만 안전한 코드”에서 “언어가 정리 실행을 보장하는 코드”로 관점을 바꿉니다. 특히 예외가 잦거나 리소스 종류가 다양한 서버/배치/CLI에서 효과가 큽니다.
Symbol.dispose로 동기 리소스 정리 표준화Symbol.asyncDispose+await using으로 비동기 정리까지 안전하게- 여러 리소스를 선언만으로 역순 정리
리소스 누수를 0에 가깝게 만들려면, 결국 “정리 책임을 사람이 기억하는 구조”를 버리고 “정리 책임이 코드 구조에 박혀 있는 패턴”으로 옮겨가야 합니다. using은 그 전환을 가장 단순한 문법으로 제공하는 도구입니다.