- Published on
TS 5.6 `using`으로 리소스 누수 0 만드는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치/CLI에서 진짜 무서운 버그는 기능 오작동보다 리소스 누수입니다. 파일 핸들이 닫히지 않거나, 뮤텍스가 풀리지 않거나, 이벤트 리스너가 해제되지 않거나, 스트림이 종료되지 않으면 증상은 뒤늦게 터집니다. 메모리 증가, 핸들 고갈, 처리량 급락, 결국 OOM이나 장애로 이어지죠.
TypeScript 5.6에서 본격적으로 다루기 쉬워진 키워드가 using입니다. C#의 using과 비슷하게 스코프를 벗어날 때 자동으로 정리(dispose) 하도록 강제하는 문법입니다. 한 번 패턴을 잡아두면 “까먹어서 누수”를 구조적으로 막을 수 있습니다.
이 글에서는 using/await using이 무엇을 해결하는지, 실무에서 어떤 리소스에 적용하면 좋은지, 그리고 함정(트랜스파일/런타임/예외 처리)을 어떻게 피하는지 정리합니다.
타입 좁힘/
satisfies같은 TS 5.x 변화는 이전 글(TS 5.5 타입 좁힘이 안 될 때 - is·satisfies)도 함께 보면 문법 적응이 더 빠릅니다.
using이 해결하는 문제: “반드시 해제”를 언어가 보장
기존 JS/TS에서 리소스 해제는 보통 다음 중 하나였습니다.
try/finally로 수동 해제- 콜백 스타일의 “with” 유틸
- 린트 규칙/코드리뷰로 강제
문제는 사람이 깜빡한다는 점입니다. 특히 early return, 예외, 여러 단계의 비동기 흐름이 섞이면 finally가 빠지기 쉽습니다.
using은 “이 변수는 disposable이다”를 선언하고, 스코프 종료 시점에 TS가 자동으로 dispose 호출을 삽입하는 형태로 동작합니다.
핵심은 두 가지입니다.
- 동기 해제:
using - 비동기 해제:
await using
그리고 disposable 판정은 표준 심볼을 사용합니다.
Symbol.disposeSymbol.asyncDispose
동작 원리(개념): 컴파일러가 try/finally로 내린다
아래 코드를 보겠습니다.
using lock = acquireLock();
// 임계 구역
criticalSection();
개념적으로는 다음과 같이 내려갑니다.
const lock = acquireLock();
try {
criticalSection();
} finally {
lock[Symbol.dispose]();
}
즉, 예외가 터져도 dispose가 호출됩니다. 이게 “누수 0”의 핵심입니다.
준비물: disposable 객체 만들기
1) 동기 disposable: Symbol.dispose
예를 들어 “락”을 가볍게 구현해보겠습니다.
class Mutex {
private locked = false;
lock() {
if (this.locked) throw new Error("already locked");
this.locked = true;
return {
[Symbol.dispose]: () => {
this.locked = false;
},
};
}
}
const mutex = new Mutex();
function doWork() {
using _guard = mutex.lock();
// 여기서 예외가 나도 자동으로 unlock
}
이 패턴이 강력한 이유는 “unlock을 호출해야 한다”가 아니라 “락을 잡는 순간 자동으로 해제되게 만들어버린다”로 사고방식이 바뀌기 때문입니다.
2) 비동기 disposable: Symbol.asyncDispose
DB 커넥션, 트랜잭션, 원격 리소스 정리처럼 await가 필요한 해제는 await using을 씁니다.
type Conn = {
query: (sql: string) => Promise<unknown>;
[Symbol.asyncDispose]: () => Promise<void>;
};
async function connect(): Promise<Conn> {
const client = {
async query(sql: string) {
return { ok: true, sql };
},
async close() {
// 실제로는 소켓 close
},
};
return {
query: client.query,
[Symbol.asyncDispose]: async () => {
await client.close();
},
};
}
async function handler() {
await using conn = await connect();
await conn.query("select 1");
} // 여기서 자동 close
실무 적용 1: 파일/스트림/리더 안전 종료
Node의 파일 핸들, 스트림은 “닫아야 하는데 종종 잊는” 대표 케이스입니다.
아래는 파일 핸들을 감싼 예시입니다.
import { open } from "node:fs/promises";
async function openFile(path: string) {
const handle = await open(path, "r");
return {
handle,
[Symbol.asyncDispose]: async () => {
await handle.close();
},
};
}
async function readFirstBytes(path: string) {
await using file = await openFile(path);
const buf = Buffer.alloc(16);
await file.handle.read(buf, 0, 16, 0);
return buf;
}
이제 early return/예외/중간 실패가 있어도 close는 보장됩니다.
실무 적용 2: 이벤트 리스너/구독 누수 방지
프론트/백엔드 모두에서 이벤트 구독 누수는 흔합니다. 특히 “함수에서 리스너 등록하고 return” 같은 패턴에서요.
function on(emitter: { on: Function; off: Function }, event: string, fn: (...args: any[]) => void) {
emitter.on(event, fn);
return {
[Symbol.dispose]: () => {
emitter.off(event, fn);
},
};
}
function useSomething(emitter: any) {
using _sub = on(emitter, "data", (x: unknown) => {
// ...
});
// 여기서 어떤 이유로든 빠져나가면 자동 off
}
이 패턴은 React의 useEffect cleanup, RxJS subscription.unsubscribe() 같은 개념을 언어 레벨 스코프로 끌어온 것입니다.
실무 적용 3: 트랜잭션/락/세마포어 “반납” 강제
리소스 누수는 메모리만의 문제가 아닙니다.
- DB 트랜잭션 미종료로 커넥션 풀이 고갈
- 분산 락 미반납으로 전체 처리 정지
- 세마포어 미반납으로 동시성 제한이 영구적으로 줄어듦
이런 문제는 종종 OOM과 함께 나타나고, 운영에서 원인 찾기가 어렵습니다. OOM 자체를 다루는 관점은 Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계 같은 글이 도움이 되지만, 애초에 누수/미해제를 구조적으로 차단하는 게 더 싸게 먹힙니다.
세마포어 예시를 보겠습니다.
class Semaphore {
private permits: number;
private queue: Array<() => void> = [];
constructor(permits: number) {
this.permits = permits;
}
async acquire() {
if (this.permits > 0) {
this.permits -= 1;
return this.guard();
}
await new Promise<void>((resolve) => this.queue.push(resolve));
this.permits -= 1;
return this.guard();
}
private guard() {
return {
[Symbol.dispose]: () => {
this.permits += 1;
const next = this.queue.shift();
if (next) next();
},
};
}
}
const sem = new Semaphore(5);
async function limitedJob() {
using _permit = await sem.acquire();
// 동시 실행 최대 5개 보장 + 예외 시에도 자동 반납
}
using을 “누수 0”로 쓰는 팀 규칙
using은 문법이지만, 효과는 팀의 코딩 규칙과 결합될 때 최대로 나옵니다.
1) “리소스를 얻는 함수는 disposable을 반환”
예:
openX()는{ [Symbol.dispose]: ... }또는{ [Symbol.asyncDispose]: ... }를 반환acquireX()도 동일
이렇게 API를 디자인하면 호출자는 자연스럽게 using을 쓰게 됩니다.
2) “해제 함수는 외부에 노출하지 않는다”
close()/release()를 공개해두면 결국 누군가는 직접 호출하고, 누군가는 빼먹습니다.
가능하면 “해제는 dispose 심볼로만” 제공하고, 사용자는 using만 쓰게 만드는 편이 일관성이 좋습니다.
3) 린트/리뷰 포인트를 단순화
- “이 함수에서 acquire/open 했는데
using이 없네?” - “이 객체는 disposable인데 일반 변수로 받았네?”
처럼 체크가 쉬워집니다.
주의점 1: 런타임/타깃 환경과 심볼 지원
Symbol.dispose/Symbol.asyncDispose는 비교적 최신 런타임 기능과 맞물립니다.
- TS는 컴파일로 어느 정도 내려주지만, 환경에 따라 심볼이 없으면 문제가 될 수 있습니다.
- Node 버전/번들러/트랜스파일 설정에 따라 동작이 달라질 수 있으니, 프로덕션 런타임에서 실제로 dispose가 호출되는지를 테스트로 보장하세요.
권장 체크:
- CI에서 “예외가 발생해도 해제되는지” 단위 테스트 추가
- E2E에서 핸들 수/커넥션 수가 장시간 안정적인지 관측
주의점 2: 스코프 경계를 의식해야 한다
using은 “블록 스코프를 벗어날 때” 해제됩니다.
- 너무 큰 스코프에서 잡으면 오래 붙들립니다.
- 너무 작은 스코프에서 잡으면 필요한 동안 유지되지 않습니다.
따라서 리소스는 가장 좁은 스코프에서 획득하는 게 좋습니다.
async function processAll(items: string[]) {
for (const item of items) {
await using conn = await connect();
await conn.query("/* per item */");
}
}
위처럼 루프 안에서 잡으면 반복마다 정리됩니다. 반대로 루프 밖에서 잡으면 커넥션을 오래 점유합니다(그게 의도라면 괜찮지만요).
주의점 3: dispose에서 예외가 나면?
dispose 자체가 실패할 수 있습니다(네트워크 close 실패, flush 실패 등). 이때 정책을 정해야 합니다.
- dispose 예외를 로깅만 하고 삼킬지
- 원래 예외를 덮어쓸지
- 둘 다 보존할지
실무에서는 “원래 예외가 있으면 원래 예외를 우선”하는 편이 디버깅에 유리합니다. dispose는 보통 best-effort 정리이기 때문입니다.
간단한 패턴:
function safeDispose(dispose: () => void) {
try {
dispose();
} catch (e) {
console.error("dispose failed", e);
}
}
function makeDisposable(close: () => void) {
return {
[Symbol.dispose]: () => safeDispose(close),
};
}
try/finally는 이제 끝인가?
완전히 끝은 아닙니다.
- 여러 리소스를 조건부로 잡는 복잡한 흐름
- dispose 순서를 명시적으로 제어해야 하는 케이스
- 아직
using을 적용하기 어려운 레거시 코드
에서는 여전히 try/finally가 필요합니다.
다만 신규 코드/핵심 경로에서는 using이 “기본값”이 되도록 만드는 게 유지보수 비용을 크게 줄입니다.
마무리: 누수는 버그가 아니라 “설계 부채”다
리소스 누수는 대개 특정 개발자의 실수라기보다, 해제를 잊어도 컴파일이 통과되는 API 설계에서 시작됩니다. TS 5.6의 using은 이 문제를 언어 차원에서 줄여줍니다.
정리하면 다음 3가지만 해도 효과가 큽니다.
- 리소스를 얻는 함수는 disposable(또는 async disposable)을 반환한다
- 호출부는
using/await using으로만 사용한다 - 예외 상황에서도 해제가 되는지 테스트로 고정한다
이렇게 하면 “잘 닫자/잘 해제하자” 같은 구호가 아니라, 누수가 구조적으로 불가능한 코드에 가까워집니다.