- Published on
TS 5.5+ using으로 리소스 누수 0 만들기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
리소스 누수는 “메모리”만의 문제가 아닙니다. 파일 핸들, DB 커넥션, 락, 타이머, 이벤트 리스너, Observable 구독, 임시 디렉터리 같은 것들이 finally에서 빠지면 서비스는 조용히 망가집니다. 특히 Node.js/웹 서버에서는 누수가 누적되다가 어느 순간 EMFILE(파일 디스크립터 고갈), 커넥션 풀 고갈, 이벤트 리스너 폭증 경고로 터집니다.
TypeScript 5.5+는 ECMAScript의 Explicit Resource Management 제안을 따라 using 문을 지원합니다. 핵심은 간단합니다.
- 리소스를 생성할 때 “정리 함수”를 함께 등록
- 스코프를 벗어날 때(정상 종료, 예외,
return/throw) 자동으로 정리
이 글에서는 using을 실제 코드에 적용해 “리소스 누수 0”에 가깝게 만드는 방법을 다룹니다.
관련해서 타입 시스템 변화에 민감하다면 TS 5.5+ satisfies로 타입추론 깨짐 7가지 해결도 같이 읽어두면 TS 5.5대 업그레이드 시 시행착오를 줄일 수 있습니다.
using이 해결하는 문제: try/finally는 항상 새기 어렵다
전통적으로는 이런 코드가 많았습니다.
import { open } from "node:fs/promises";
export async function readFirstLine(path: string) {
const f = await open(path, "r");
try {
const content = await f.readFile({ encoding: "utf8" });
return content.split("\n")[0] ?? "";
} finally {
await f.close();
}
}
문제는 다음과 같습니다.
- 함수가 커질수록
finally를 빼먹기 쉽다 - 중간에 리턴/예외가 늘어날수록 정리 코드가 흩어진다
- 여러 리소스를 동시에 다루면
try/finally중첩이 지저분해진다
using은 “리소스 생명주기”를 스코프에 붙여서 이 문제를 구조적으로 제거합니다.
TS 5.5+ using의 핵심 개념
using은 “Disposable” 프로토콜을 따르는 객체를 스코프 종료 시 자동으로 정리합니다.
- 동기 정리:
Symbol.dispose - 비동기 정리:
Symbol.asyncDispose
즉, 객체가 위 심볼 메서드를 구현하면 using이 알아서 호출합니다.
컴파일러/런타임 전제
- TypeScript: 5.5 이상
tsconfig.json에서target은 보통ES2022이상 권장- 런타임(Node.js 등)이
Symbol.dispose를 “기본 제공”할 필요는 없습니다. 심볼 자체는 최신 런타임에 존재하지만, 실제 정리는 여러분이 구현합니다. - 폴리필/헬퍼가 필요한 경우 TS가
tslib헬퍼로 변환하기도 합니다(설정에 따라 다름)
정확한 변환 결과는 프로젝트 설정에 따라 달라질 수 있으니, 실제 적용 전 tsc --pretty false로 출력 JS를 확인하는 습관을 권합니다.
1) 가장 작은 예제: “정리 함수”를 가진 리소스 만들기
다음은 “스코프를 벗어나면 자동으로 로그를 남기는” 단순 리소스입니다.
class ScopeLog {
constructor(private name: string) {}
[Symbol.dispose]() {
console.log(`disposed: ${this.name}`);
}
}
export function demo() {
using _log = new ScopeLog("demo");
console.log("work...");
// 여기서 throw/return이 발생해도 dispose 호출
}
이 패턴이 중요한 이유는, 이제부터 파일/락/타이머/리스너/구독/임시 파일 등 “모든 것”을 동일한 방식으로 다룰 수 있기 때문입니다.
2) 실전: Node.js 파일 핸들 누수 없애기
fs/promises의 FileHandle은 close()를 호출해야 합니다. using을 위해 어댑터를 하나 만들면 깔끔해집니다.
import { open, type FileHandle } from "node:fs/promises";
type AsyncDisposable = {
[Symbol.asyncDispose](): Promise<void>;
};
class DisposableFile implements AsyncDisposable {
constructor(public handle: FileHandle) {}
async [Symbol.asyncDispose]() {
await this.handle.close();
}
}
export async function readFirstLine(path: string) {
await using file = new DisposableFile(await open(path, "r"));
const content = await file.handle.readFile({ encoding: "utf8" });
return content.split("\n")[0] ?? "";
}
포인트는 두 가지입니다.
await using을 사용하면 비동기 정리(Symbol.asyncDispose)가 보장됩니다.- 중간에 예외가 나도,
return을 여러 번 해도,close()누락이 구조적으로 불가능해집니다.
3) 여러 리소스를 동시에 다룰 때: 역순 정리(LIFO)
현실 코드는 “파일 하나”가 아니라 “파일 + 락 + 타이머 + 구독” 같은 조합입니다. using은 일반적으로 생성 역순으로 정리됩니다(스택처럼).
class Lock {
constructor(private name: string) {
console.log(`lock acquired: ${name}`);
}
[Symbol.dispose]() {
console.log(`lock released: ${this.name}`);
}
}
class Timer {
private id: ReturnType<typeof setInterval>;
constructor() {
this.id = setInterval(() => {}, 1000);
}
[Symbol.dispose]() {
clearInterval(this.id);
}
}
export function criticalSection() {
using lock = new Lock("A");
using timer = new Timer();
// 작업 도중 예외가 나도 timer -> lock 순서로 안전하게 정리
}
이 “역순 정리”는 의존성이 있는 리소스(예: 트랜잭션이 끝나야 커넥션 반납)가 있을 때 특히 중요합니다.
4) try/catch와 함께 쓰는 패턴: 에러 처리와 정리 분리
using은 정리를 담당하고, try/catch는 에러 정책만 담당하게 분리하는 게 좋습니다.
export async function handler(path: string) {
try {
return await readFirstLine(path);
} catch (e) {
// 로깅/매핑/재시도 같은 정책만 여기서 처리
throw e;
}
}
이 구조가 좋은 이유는 “정리”가 에러 처리 로직에 섞이지 않아 유지보수성이 크게 올라가기 때문입니다.
5) 이벤트 리스너/DOM 구독 누수 방지
프론트엔드에서 흔한 누수는 이벤트 리스너입니다. addEventListener 후 removeEventListener를 빼먹는 케이스가 많습니다.
type Disposable = {
[Symbol.dispose](): void;
};
function on<K extends keyof WindowEventMap>(
target: Window,
type: K,
listener: (ev: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
): Disposable {
target.addEventListener(type, listener as EventListener, options);
return {
[Symbol.dispose]() {
target.removeEventListener(type, listener as EventListener, options);
},
};
}
export function mount() {
using _resize = on(window, "resize", () => {
console.log("resized");
});
// 컴포넌트 언마운트/스코프 종료 시 자동 remove
}
React/Vue/Svelte에서 “컴포넌트 생명주기”에 맞춰 스코프를 잡는 방식으로 응용할 수 있습니다. 예를 들어 특정 훅 내부에서 using을 쓰려면, 해당 스코프가 실제로 언제 종료되는지(클린업 타이밍)를 명확히 설계해야 합니다.
프론트 성능 이슈로 Long Task가 늘어 UI가 굳는 상황을 추적 중이라면, 리스너/타이머 누수도 함께 의심해야 합니다. 관련해서는 Chrome INP 폭증? Long Task를 50ms로 쪼개는 법도 참고할 만합니다.
6) Node.js 서버에서 자주 새는 것들: 타이머, AbortController, 스트림
타이머
타이머는 “정리 안 해도 프로세스 종료되면 끝”이라고 생각하기 쉽지만, 서버는 종료되지 않습니다. 타이머 누수는 곧 CPU/메모리 누수입니다.
class Interval implements Disposable {
private id: ReturnType<typeof setInterval>;
constructor(fn: () => void, ms: number) {
this.id = setInterval(fn, ms);
}
[Symbol.dispose]() {
clearInterval(this.id);
}
}
export function startPolling() {
using _poll = new Interval(() => {
// poll work
}, 1000);
// 특정 스코프에서만 폴링해야 할 때 안전
}
AbortController
fetch나 각종 API에서 AbortController를 쓰는 경우, “스코프 종료 시 abort”를 걸어두면 예외/조기 반환에서도 네트워크가 질질 끌리지 않습니다.
class AbortOnDispose implements Disposable {
constructor(public controller: AbortController) {}
[Symbol.dispose]() {
this.controller.abort();
}
}
export async function fetchWithScope(url: string) {
const controller = new AbortController();
using _abort = new AbortOnDispose(controller);
const res = await fetch(url, { signal: controller.signal });
return await res.text();
}
스트림
Node 스트림은 에러/조기 종료 시 destroy() 호출이 빠지면 리소스가 남을 수 있습니다. 스트림도 동일한 방식으로 감싸면 됩니다.
import type { Readable } from "node:stream";
type StreamLike = Readable & { destroy: (error?: unknown) => void };
class DestroyOnDispose implements Disposable {
constructor(private s: StreamLike) {}
[Symbol.dispose]() {
this.s.destroy();
}
}
7) “어댑터/팩토리”로 팀 규칙 만들기
using을 프로젝트에 도입할 때 가장 효과적인 방법은 “리소스 생성은 반드시 팩토리를 통해서만” 하게 만드는 것입니다.
예를 들어 DB 트랜잭션을 직접 열지 못하게 하고, 다음처럼 스코프 기반으로 사용하게 강제합니다.
type AsyncDisposable = { [Symbol.asyncDispose](): Promise<void> };
class Tx implements AsyncDisposable {
constructor(private db: { commit: () => Promise<void>; rollback: () => Promise<void> }) {}
private committed = false;
async commit() {
await this.db.commit();
this.committed = true;
}
async [Symbol.asyncDispose]() {
if (!this.committed) await this.db.rollback();
}
}
export async function withTx<T>(
begin: () => Promise<{ commit: () => Promise<void>; rollback: () => Promise<void> }>,
fn: (tx: Tx) => Promise<T>
) {
await using tx = new Tx(await begin());
const result = await fn(tx);
await tx.commit();
return result;
}
이 패턴은 다음을 보장합니다.
- 성공 시
commit - 예외/조기 반환 시 자동
rollback - 개발자가
finally를 직접 작성할 필요가 거의 없음
8) 도입 시 자주 겪는 함정과 체크리스트
1) await using을 빼먹기
비동기 정리가 필요한데 using만 쓰면 기대한 타이밍에 정리가 되지 않거나(혹은 타입 레벨에서 막히거나), 정리가 누락된 것처럼 보일 수 있습니다. 규칙은 단순합니다.
Symbol.dispose면usingSymbol.asyncDispose면await using
2) 스코프가 생각보다 길어지는 문제
using은 “스코프 종료”에 묶입니다. 즉, 함수 전체 스코프에서 선언하면 함수가 끝날 때까지 리소스를 잡고 있습니다.
- 큰 함수에서 리소스를 늦게 해제하고 싶다면 블록 스코프를 추가하세요.
export async function work() {
{
await using file = new DisposableFile(await open("/tmp/a", "r"));
// 파일이 필요한 작업만 여기서
}
// 여기서는 이미 close 완료
}
3) 정리 코드에서의 예외
정리(dispose) 자체가 실패할 수도 있습니다(예: close() 실패). 이때 정책을 정해야 합니다.
- 정리 실패를 로깅만 하고 무시할지
- 원래의 예외를 덮어쓸지
- 여러 리소스 정리 중 일부 실패를 어떻게 합칠지
팀 표준이 없다면, 정리 단계에서는 “로깅 후 삼키기”가 운영 안정성 측면에서 더 낫습니다(원인 예외를 보존).
4) 린트/리뷰 규칙
도입 효과를 극대화하려면 다음 규칙을 추천합니다.
addEventListener직접 호출 금지, 반드시on()같은 어댑터 사용- 파일/스트림/락/구독 생성은
createDisposableX()팩토리만 사용 - 코드 리뷰에서
try/finally로 정리하는 코드가 보이면using으로 교체 검토
9) Next.js/서버리스에서의 감각: “요청 스코프”에 붙이기
Next.js API Route나 App Router의 서버 핸들러처럼 “요청 단위”로 스코프가 명확한 곳은 using이 특히 잘 맞습니다. 요청 처리 함수 내부에서 생성한 리소스는 함수 종료 시 정리되므로, 누수 방어선이 강해집니다.
캐시/요청 경계가 복잡한 환경이라면, 리소스 생명주기 또한 “요청/렌더 스코프”에 맞춰 설계해야 합니다. Next.js 운영 팁은 Next.js App Router 캐시 무효화 7가지 정리도 참고해 보세요.
결론: using은 “정리 책임”을 코드 스타일이 아니라 언어 기능으로 만든다
TS 5.5+의 using은 리소스 정리를 “개발자의 성실함”에 맡기지 않고, 스코프 규칙으로 강제합니다. 결과적으로 다음 효과가 큽니다.
- 예외/조기 반환/복잡한 분기에서도 누수 방지
- 여러 리소스를 다루는 코드가 단순해짐
- 팀 차원에서 어댑터/팩토리로 표준화하기 쉬움
실무 적용은 작은 것부터 시작하는 게 좋습니다.
- 이벤트 리스너/타이머를
using으로 전환 - 파일/스트림/락을 어댑터로 감싸기
- 트랜잭션/구독 같은 “반드시 정리되어야 하는 것”을 팩토리로 강제
이 3단계만 지켜도, “리소스 누수 0”에 가까운 코드베이스로 빠르게 이동할 수 있습니다.