TypeScript 5.0이 나온 게 2023년 초였는데, 2026년 현재 5.8까지 왔다. 그 사이에 정말 많은 기능이 추가됐지만 솔직히 대부분은 “있으면 좋고 없어도 그만”인 수준이다. 내가 지난 2년 동안 실제 프로덕션 코드에서 의미 있게 사용한 기능은 손가락에 꼽을 정도다. 이 글은 그것만 다룬다.
참고로 내 환경: Next.js 기반 SaaS, 백엔드는 Node.js + PostgreSQL, 팀은 7명. 규모가 작은 편이지만 코드베이스는 꽤 커서 타입 검사 시간 문제로 여러 번 고생했다.
using으로 리소스 관리가 달라졌다 — 진짜로
TypeScript 5.2에서 나온 using 선언은 처음 봤을 때 별로였다. Rust의 Drop 트레이트나 Python의 with 문을 흉내 낸 건데, “JS에서 굳이?” 싶었다. 우리 팀도 try-finally 패턴으로 잘 버티고 있었고.
그런데 작년 가을, 서비스 트래픽이 갑자기 두 배로 뛰면서 DB 커넥션 풀이 고갈되는 일이 생겼다. 원인을 파보니 오류 처리 경로에서 커넥션을 제대로 반환하지 않는 코드가 여기저기 흩어져 있었다. 팀원이 finally 블록을 빠뜨린 게 아니라, finally 블록 안에서 또 예외가 나는 케이스를 처리하지 않은 거였다. 이게 생각보다 찾기 어렵다.
// 기존 방식 — finally에서 오류가 나면 원래 오류가 묻힌다
async function processOrder(orderId: string) {
const conn = await pool.acquire();
try {
const order = await conn.query('SELECT * FROM orders WHERE id = $1', [orderId]);
await conn.query('UPDATE orders SET status = $1 WHERE id = $2', ['processing', orderId]);
return order;
} finally {
await conn.release(); // 여기서 예외 나면? 원래 오류 스택 사라짐
}
}
// using 방식 — Symbol.asyncDispose를 구현하면 자동으로 정리됨
class PooledConnection implements AsyncDisposable {
constructor(private conn: Connection) {}
async query(sql: string, params?: unknown[]) {
return this.conn.query(sql, params);
}
async [Symbol.asyncDispose]() {
await this.conn.release();
}
}
async function processOrder(orderId: string) {
await using conn = new PooledConnection(await pool.acquire());
// 여기서 무슨 일이 일어나든 conn은 스코프 끝에서 반드시 dispose됨
const order = await conn.query('SELECT * FROM orders WHERE id = $1', [orderId]);
await conn.query('UPDATE orders SET status = $1 WHERE id = $2', ['processing', orderId]);
return order;
}
dispose 오류와 원래 오류가 둘 다 살아남는다는 점이 핵심이다. SuppressedError라는 타입이 새로 생겼는데, dispose 중 오류가 나면 원래 오류를 suppressed 속성에 담아서 던진다. try-finally 패턴에서는 이게 불가능했다.
우리 팀은 이걸 DB 커넥션뿐만 아니라 임시 파일 처리, Redis 락 해제, 외부 API 세션 관리에도 적용했다. 3주 만에 DB 커넥션 관련 버그 리포트가 0으로 줄었다. 기존 코드를 전부 바꾼 건 아니고 새로 작성하는 코드부터 적용했는데, 이 정도 효과면 충분하다.
한 가지 처음에 헷갈렸던 것: using은 블록 스코프를 따른다. if 블록 안에서 선언하면 그 블록이 끝날 때 dispose된다. 당연한 것 같지만 처음 쓸 때 실수하기 쉽다.
NoInfer<T> — 제네릭 타입 추론의 숨겨진 함정을 막다
TypeScript 5.4에서 나온 NoInfer<T> 유틸리티 타입은 처음엔 뭔지 이해조차 못 했다. 공식 문서를 세 번 읽었는데 “추론 사이트에서 제외한다”는 설명이 감이 안 왔다. 어떤 문제를 해결하는 건지 직접 겪어보고 나서야 겨우 납득됐다.
우리 코드베이스에 이런 패턴의 함수가 있었다:
// NoInfer 없는 버전 — 의도치 않게 타입이 widening됨
function createRoute<T extends string>(
routes: T[],
defaultRoute: T // T가 routes에서 추론된 후 defaultRoute도 같은 T여야 함
): Router<T> { /* ... */ }
// 이렇게 호출하면?
const router = createRoute(['home', 'about', 'contact'], 'settings');
// ^^^^^^^^^^
// 에러가 나야 하는데 안 난다!
// TypeScript가 T를 string으로 widening해서 'settings'를 허용해버림
// NoInfer 적용 — 이제 제대로 타입 체크됨
function createRoute<T extends string>(
routes: T[],
defaultRoute: NoInfer<T> // T 추론에 영향 안 줌, 하지만 타입은 여전히 T여야 함
): Router<T> { /* ... */ }
const router = createRoute(['home', 'about', 'contact'], 'settings');
// ✗ 'settings'는 'home' | 'about' | 'contact'에 없음 — 에러!
const router2 = createRoute(['home', 'about', 'contact'], 'home');
// ✓ OK
이 패턴이 우리 코드에서 얼마나 많이 쓰이는지 Grep 돌려봤더니 비슷한 형태의 제네릭 함수가 23개나 있었다. 그중 실제로 버그가 될 수 있는 케이스가 5개였고, 전부 NoInfer로 고쳤다.
발견했을 때 약간 허탈했던 게 — 이런 타입 버그는 런타임에서 잡기 정말 어렵다. 타입이 string으로 widening되면 컴파일도 통과하고, 테스트도 통과하고, 실제로 잘못된 값이 들어와야 비로소 문제가 드러난다. NoInfer는 이 허점을 설계 단계에서 막는다.
타입 술어 자동 추론 — 배열 filter()가 드디어 제대로 작동한다
TypeScript 5.5에서 나온 inferred type predicates, 이게 생각보다 훨씬 더 큰 변화다.
기존에 이런 코드 안 써봤다고 할 사람 없을 것이다:
const items = [1, null, 2, undefined, 3].filter(x => x !== null && x !== undefined);
// items의 타입? (number | null | undefined)[]
// 우리는 number[]를 기대했지만 TypeScript는 모른다
그래서 다들 이런 타입 가드 함수를 별도로 만들었다:
function isNonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
const items = [1, null, 2, undefined, 3].filter(isNonNullable);
// 이제 items는 number[] — 하지만 보일러플레이트가...
5.5부터는 TypeScript가 함수 본문을 분석해서 타입 술어를 자동으로 추론한다. 인라인 화살표 함수에서도 그냥 작동한다:
// 5.5+에서는 이게 그냥 됨
const items = [1, null, 2, undefined, 3].filter(x => x !== null && x !== undefined);
// items: number[] ✓
처음 이걸 테스트했을 때 반신반의했다. “설마 이렇게 단순한 게 될 리가?” 싶어서 TypeScript Playground에서 직접 확인했는데 진짜로 됐다. 그 순간 우리 코드베이스에서 불필요한 타입 가드 함수가 얼마나 될지 궁금해서 검색해봤더니 value is 패턴이 41곳이나 있었고, 그중 절반 가까이는 이제 필요 없었다. 실제로 제거한 보일러플레이트가 약 200줄.
한 가지 주의할 점은 복잡한 조건에서는 추론이 안 될 수 있다. 특히 외부 함수 호출 결과를 기반으로 타입을 좁히는 경우 — isValid(x) 같은 함수를 조건으로 쓰면 TypeScript가 그 함수의 의미를 모르니까 추론을 못 한다. 이런 경우엔 여전히 명시적 타입 술어가 필요하다.
Isolated Declarations: 빌드 속도 문제의 절반쯤 되는 해결책
이건 솔직히 내가 처음에 너무 많은 걸 기대했다가 실망한 기능이다.
TypeScript 5.5에서 나온 isolatedDeclarations 옵션은 각 파일이 다른 파일의 타입 정보 없이도 타입 선언을 생성할 수 있도록 강제한다. 이렇게 하면 .d.ts 파일을 병렬로 생성할 수 있고, 대규모 프로젝트에서 빌드 속도가 크게 향상될 수 있다는 게 공식 설명이다.
// tsconfig.json
{
"compilerOptions": {
"isolatedDeclarations": true,
"declaration": true
}
}
설정은 간단하다. 근데 이 옵션을 켜는 순간 기존 코드에서 에러가 쏟아진다:
// isolatedDeclarations 위반 — 반환 타입이 추론에만 의존함
export function getUser() { // ✗ 반환 타입 명시 필요
return { id: 1, name: 'Alex' };
}
// 올바른 방식
export function getUser(): { id: number; name: string } { // ✓
return { id: 1, name: 'Alex' };
}
우리 팀 기준으로 이 옵션을 켰더니 에러가 340개 나왔다. 고치는 데 이틀 걸렸다. 그리고 실제 빌드 시간 개선은 우리 규모에서 체감이 거의 없었다. 이 옵션은 esbuild나 Rolldown 같은 도구들이 타입 스트리핑을 병렬로 처리할 때 의미가 있는데, 우리는 그런 파이프라인을 쓰지 않았다.
그런데 뜻밖에 좋은 부작용이 생겼다. 모든 공개 API 함수에 반환 타입을 명시하게 됐더니 팀원들이 함수 시그니처만 보고 의도를 파악하기 훨씬 쉬워졌다. 결국 우리는 옵션을 끄고 “공개 API 함수에는 반환 타입 명시”를 컨벤션으로만 유지하는 방향으로 절충했다. 100만 명 이상 일일 활성 사용자를 가진 서비스나 수십 개 패키지를 가진 모노레포라면 얘기가 다르겠지만, 그 이하 규모에서는 서두를 필요 없다.
기대했다가 별로였던 것들
솔직히 말하면 실망한 기능들도 있다.
TC39 데코레이터 (TypeScript 5.0 공식 지원) — 처음엔 흥분했다. 기존의 실험적 데코레이터 대신 표준을 쓸 수 있다는 게 좋았다. 근데 실제로 써보니 기존 emitDecoratorMetadata에 의존하는 라이브러리들, 특히 TypeORM이나 NestJS 일부 기능이 TC39 데코레이터와 완전히 호환되지 않는 경우가 있었다. 2026년 초 현재도 완전히 매끄럽지는 않다. 새 프로젝트를 시작한다면 TC39 데코레이터를 쓰겠지만, 기존 프로젝트 마이그레이션은 서두를 필요 없다.
const 타입 파라미터 (TypeScript 5.0) — 꽤 유용하고 알고 있는 사람이 적다. function inferArray<const T extends string[]>(arr: T): T처럼 쓰면 ['a', 'b']가 string[] 대신 ['a', 'b'] 튜플로 추론된다. 근데 이게 필요한 상황이 생각보다 자주 오지 않는다. 내 경험상 라이브러리 작성자에게 더 유용하고, 애플리케이션 코드에서는 가끔씩만 쓰게 된다.
당장 프로덕션에 가져다 써야 하는 건 세 가지다. using/await using으로 리소스 관리를 정리하고, NoInfer<T>로 제네릭 타입 추론의 허점을 막고, 5.5의 타입 술어 자동 추론으로 불필요한 보일러플레이트를 지운다. 이 순서대로 적용해라. isolatedDeclarations는 팀 규모와 빌드 파이프라인을 먼저 평가한 뒤 결정해도 늦지 않다.