0. 들어가며…
최근에 클린 아키텍처를 적용하면서, data → domain 변환을 담당하는 Mapper 클래스를 만들었다.
이 과정에서 타입스크립트(TypeScript)를 적극적으로 활용해 타입 안정성을 확보했지만, 하나의 큰 벽에 부딪혔다.
타입스크립트의 타입은 런타임에 존재하지 않는다는 것!
즉, 코드 상에서는 안전해 보이지만, 실제 실행 환경(JS 런타임)에서는 그 모든 타입 정보가 사라진다.
결국 API나 외부 모듈에서 잘못된 타입의 데이터가 들어오더라도 이를 검증할 방법이 없었다.
1편에서는 검증을 위해, Zod 처럼 스키마 기반의 타입 검증기를 만들었으나 다음과 같은 3가지 문제가 있었다.
| 문제 | 설명 |
|---|---|
| 성능 | 매번 스키마를 해석하며 검증함 → 반복 비용 발생 |
| 안전성 | 타입 정의와 스키마가 불일치할 수 있음 |
| DX(개발 경험) | 타입이 중복 선언됨 (interface + z.object 둘 다 작성해야 함) |
그래서 이번 시간에는 타입 정보를 직접 AST(Abstract Syntax Tree)로 분석해서, 빌드 시점에 자동으로 최적화된 검증 함수를 만들어보았다.
이번 글은 「TS × 클린 아키텍처」 시리즈의 마지막 편으로, AST를 이용해 런타임 타입 검증을 자동화하는 방법을 다룬다.
1. 타입을 AST로 분석해서 검증 함수를 만들자!
1) 타입스크립트와 AST의 구조
(1) 런타임 타입 검증의 어려움
- 타입스크립트를 처음 배울 때 흔히 이런 착각을 한다. “타입스크립트를 쓰면 타입 검증은 끝난 거 아니야?
- 하지만 진짜 현실은 다르다. 타입스크립트는 어디까지나 정적 분석기(static analyzer) 일 뿐이다.
- 코드가 실행되는 런타임에는 타입 정보가 남아있지 않다.
(2) 타입 정보는 컴파일 이후 완전히 사라진다
- 예를 들어 이런 코드가 있다고 해보자.
interface User {
id: number;
name: string;
}
const user: User = { id: 1, name: "Lux" };
- 이 코드를 컴파일하면 다음처럼 변한다.
const user = { id: 1, name: "Lux" };
User라는 타입 선언은 자취도 없이 사라진다.- 타입스크립트의 타입 시스템은 “코드를 실행하기 전까지”만 존재한다.
- 빌드가 끝나면, 모든 타입 정보는 삭제되고 순수한 자바스크립트 객체만 남는다.
- 즉, 우리가 흔히 믿는 “타입 안정성”은 빌드 타임에서 유효하다.
- 물론 약속된 인터페이스대로 개발된다면, 빌드 타임 검증으로 충분할 수 있다.
- 하지만 외부 연계 작업을 할 경우 이 약속을 보장할 수 없기에, 디음과 같은 현상이 발생할 수 있다.
// 외부 API에서 응답이 이렇게 바뀜
{ id: "1", name: "Lux" } // id가 string!
- 하지만 프론트엔드는 여전히 이렇게 믿고 있다.
interface User {
id: number;
name: string;
}
const user: User = await fetchUser();
console.log(user.id.toFixed(2)); // 💥 런타임 에러
- 타입스크립트는 “id가 number여야 한다”고 알려줬지만, 실제로 들어온 건 string이었고
- 결국 런타임에서
toFixed가 undefined를 호출하며 서비스에 문제를 야기한다. 💥💥
(3) 그래서 런타임 검증이 필요하다
- 런타임에서 실제 값이 타입과 일치하는지를 확인하려면 다음과 같은 타입 가드(type guard) 함수가 필요하다.
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as any).id === "number" &&
typeof (value as any).name === "string"
);
}
- 그럼 외부 데이터를 받아올 때 이렇게 쓸 수 있다.
const data = await fetch("/api/user").then(res => res.json());
if (!isUser(data)) {
throw new Error("Invalid user data");
}
- 이렇게 하면 런타임에서도 타입 안전성을 확보할 수 있다.
- 하지만 문제가 있다 — 바로, 이 함수를 모든 타입마다 수동으로 작성할 수는 없다는 것…!
(4) 런타임 타입 검증을 자동화하자
- 결국 원하는 건 단 하나다.
- 타입 정의 한 번으로, 검증 함수까지 자동으로 생성되는 시스템.
- 즉,
interface User를 정의하면, 그 구조를 기반으로 자동으로 타입 검증 함수가 만들어지는 것이다. - 이를 위해선 “코드에서 타입 정보를 읽는 방법”이 필요하고, 그 해답이 바로 AST(Abstract Syntax Tree) 다.
| 항목 | 내용 |
|---|---|
| 문제 | TypeScript 타입은 런타임에서 사라진다. |
| 결과 | 외부 데이터의 타입 불일치가 발생해도 에러가 안 잡힌다. |
| 해결 방향 | 타입 정보를 AST로 분석해, 런타임 타입 검증 코드를 자동 생성한다. |
2) AST가 무엇인가
- 이제 본격적으로 타입 정보를 “코드로부터” 읽어와야 한다.
- 그 시작점이 바로 AST(Abstract Syntax Tree, 추상 구문 트리) 다.
(1) 코드가 트리로 변환된다
- TypeScript 컴파일러는 우리가 작성한 코드를 트리 구조로 해석한다.
- 이 트리를 AST(Abstract Syntax Tree)라고 부른다.
- 예를 들어 다음 코드가 있다고 해보자.
interface User {
id: number;
name: string;
}
- 이 코드는 컴파일러 내부에서 다음과 같은 트리로 해석된다.
SourceFile
└─ InterfaceDeclaration (name: "User")
├─ PropertySignature (name: "id")
│ └─ TypeReference (number)
└─ PropertySignature (name: "name")
└─ TypeReference (string)- 즉, 코드는 더 이상 “문자열”이 아니라, “의미 단위로 쪼개진 노드들의 트리”로 변환된다.
- 이 트리를 이용하면 “이 타입이 어떤 구조인지”를 프로그램적으로 분석할 수 있게 된다.
(2) 실제로 AST를 출력해보자
- TypeScript는 내부 API로
createSourceFile을 제공한다. - 이걸 이용하면 문자열을 바로 AST로 바꿀 수 있다.
import ts from "typescript";
const code = `
interface User {
id: number;
name: string;
}
`;
const source = ts.createSourceFile(
"virtual.ts",
code,
ts.ScriptTarget.ESNext,
true
);
console.log(source);
- 이 코드를 실행하면
SoureFileObject객체가 출력된다.
- 이 객체는
kind,name,type,members등 여러 속성으로 이루어져있다.

- 즉,
User라는 타입이 직접 탐색할 수 있는 데이터 구조로 바뀐다.
(3) AST를 탐색하는 방법
SoureFileObject를forEachChild(node, callback)로 순회할 수 있다.
ts.forEachChild(source, (node) => {
if (ts.isInterfaceDeclaration(node)) {
console.log("Interface name:", node.name.text);
node.members.forEach((m) => {
if (ts.isPropertySignature(m)) {
console.log("-", m.name.getText(), ":", m.type?.getText());
}
});
}
});
- 그럼 다음과 같이 인터페이스 이름, 속성값에 접근할 수 있다.
// 출력 결과:
Interface name: User
- id : number
- name : string- 우리가 만드는 런타임 타입 가드는 결국 AST를 해석해서 Type을 얻고,
- 그 Type을 기반으로 검증 함수를 생성할 것이다.
(4) 결국 AST가 핵심이다.
- TypeScript 타입은 런타임에 없지만, AST로 타입 구조를 읽어 코드(검증 함수)로 생성할 수 있다.
| 항목 | 내용 |
|---|---|
| AST란? | 코드의 문법 구조를 표현한 트리 |
| 왜 필요한가 | 런타임에는 타입이 사라지므로, AST로 타입 구조를 추출해야 함 |
| 결국 목표는 | AST → Type → Validation Function |
3) AST로 타입을 분석해보자
핵심 개념을 이해했으니, 이제 AST를 이용해 타입 검증 함수를 만드는 과정을 살펴보자.
이번에 소개하는 전체 코드는 이 레포지토리에 정리되어 있다.
(1) 설계
핵심 아이디어는 매우 단순하다.
빌드 타임에
makeValidate<T>()호출을 찾고
-> 제네릭 타입 T를 TypeScript AST로 해석한 뒤
-> 그 타입을 만족하는지 검사하는 검증 함수를 생성한다.
즉, makeValidate<T>()에 타입을 넘겨 호출하면, 런타임에는 이미 생성된 검증 함수가 실행되어야 한다.
interface User {
id: number;
name: string;
}
const validate = makeValidate<User>(); // true | false
이 동작은 크게 3단계로 이루어진다.
| 단계 | 동작 |
|---|---|
| TypeChecker 단계 | TS 컴파일러의 TypeChecker가 T의 구조를 읽는다. |
| 코드 생성 단계 (core 폴더) | 여러 emitXXX() 핸들러가 타입 → 조건식 문자열로 변환하고, GenContext가 이를 조립해 검증 함수 소스 문자열을 만든다. |
| 치환 단계 (transformer 폴더) | 소스의 makeValidate<T>() 호출을 찾아 input => /* 조건식 */ 같은 실제 함수 리터럴로 AST 치환한다. 최종 번들에는 “검증 함수”만 남는다. |
이제 실제 구현 코드를 보면서 각 단계를 자세히 살펴보자.
(2) core/emitXXX.ts — 타입을 검증하는 문자열 생성기
핸들러는 “타입 → 조건식 문자열”로 변환하는 역할을 한다.
즉, 타입 정보를 받아 해당 값을 검사하는 JavaScript 조건식 문자열을 만들어낸다.
여러 핸들러 중 여기서는 대표적으로 emitLiteralOrEnum.ts와 emitUnionOrIntersection.ts를 살펴본다.
🧩 emitLiteralOrEnum.ts — 리터럴 및 Enum 타입 처리기
- 이 함수는 TypeScript의 타입 객체(
ts.Type)를 분석하여 - 주어진 값이 특정 리터럴(literal) 또는 열거형(enum) 값과 일치하는지 검사하는
- 조건식 문자열을 생성한다.
export function emitLiteralOrEnum(
_ctx: GenContext,
expr: string,
t: ts.Type
): string | null {
// ① 리터럴 타입이면
if (t.isLiteral()) {
const value = (t as ts.LiteralType).value;
const isString = typeof value === "string";
const newValue = isString ? JSON.stringify(value) : String(value);
return `${expr}===${newValue}`;
}
// ② Enum 타입이면
const isEnum = t.flags & ts.TypeFlags.EnumLike;
if (isEnum) {
const enumValues = _extractEnumValues(t); // enum 값 배열 추출
if (enumValues.length) {
return `(${enumValues.map(v => `${expr}===${v}`).join("||")})`;
}
}
// ③ 두 경우 모두 아니라면 처리하지 않음
return null;
}
- 만약, 다음과 같은 타입이 들어온다고 해보자.
type T = "hello";
- 이 타입이
emitLiteralOrEnum()에 전달되면, - 함수는 타입 정보를 분석해 다음과 같은 조건식 문자열을 생성한다.
'x==="hello"'
- 이 문자열은 이후 core/index.ts의
emitGuardFromType()를 거쳐 - 다음과 같은 실행 가능한 검증 함수가 된다.
// 최종 결과 (빌드 시점에 생성)
(input) => input === "hello"
🧩 emitUnionOrIntersection.ts — 유니온 / 인터섹션 타입 처리기
- 이 함수는 타입이 유니온(Union) (A | B)인지, 혹은 인터섹션(Intersection) (A & B)인지 판별하여
- 그에 맞는 조합 조건식 문자열을 생성한다.
import ts from "typescript";
import type { GenContext } from "./index";
export function emitUnionOrIntersection(
ctx: GenContext,
expr: string,
t: ts.Type
): string | null {
// ① Union 타입인 경우 (A | B)
if (t.isUnion()) {
// 각 구성 타입(tt)에 대해 ctx.emit()으로 조건식을 생성하고, ||로 연결
return `(${t.types.map(tt => ctx.emit(expr, tt)).join("||")})`;
}
// ② Intersection 타입인 경우 (A & B)
if (t.isIntersection()) {
// 각 구성 타입(tt)에 대해 ctx.emit()으로 조건식을 생성하고, &&로 연결
return `(${t.types.map(tt => ctx.emit(expr, tt)).join("&&")})`;
}
// ③ 둘 다 아니라면 처리하지 않음
return null;
}
- 만약 다음과 같은 타입이 들어오면
type U = "yes" | "no";
emitUnionOrIntersection()은 각 구성 타입("yes", "no")을 재귀적으로 검사하여 다음과 같은 문자열을 만든다.
'(input==="yes"||input==="no")'
- 이 역시
emitGuardFromType()을 거치면 다음과 같은 최종 검증 함수로 바뀐다.
(input) => (input === "yes" || input === "no")
(3) transformer/ — “빌드 시점”에 검증 함수로 치환하기
- AST 기반 검증기는 빌드 시점에 타입 정보를 분석하여, 그 타입을 검사하는 실제 검증 함수 코드를 자동으로 생성한다.
- 이를 위해 헬퍼 함수
makeValidate<T>()를 구현했다. - 이 함수는 코드상에서는 단순히 “검증 함수를 만드는 호출”처럼 보이지만,
- 실제로는 빌드 시점에 AST에서 탐색되어 진짜 함수로 치환되는 마커 역할을 한다.
interface User {
id: number;
name: string;
}
const validate = makeValidate<User>();
- 빌드 과정에서 AST 분석기가
makeValidate<T>()를 찾아내고, - 그 제네릭 타입 T의 구조를 분석하여 타입에 맞는 검증 함수 코드로 자동 변환한다.
- 이 치환 작업은 Vite 같은 빌드 플러그인(transformer) 내부에서 수행된다.
⚙️ 작동 단계
- 아래는
vite-plugin-runtypex플러그인이 빌드 중 수행하는 절차이다.
| 단계 | 설명 |
|---|---|
| 1️⃣ | vite-plugin-runtypex 플러그인이 실행된다. |
| 2️⃣ | 코드에서 makeValidate<...>() 패턴을 탐색한다. |
| 3️⃣ | TypeScript Compiler API(checker)를 이용해 타입 구조를 분석한다. |
| 4️⃣ | core/emitGuardFromType()을 호출해 “타입 → 검증식 문자열”을 생성한다. |
| 5️⃣ | 기존 소스 코드의 makeValidate<T>() 부분을 생성된 검증 함수로 치환한다. |
| 6️⃣ | 빌드가 완료되면, 런타임에는 이미 완성된 검증 함수만 남는다. |
🧩 Vite 플러그인 예시 코드
- 다음은 실제 vite-plugin-runtypex의 주요 구현 부분이다.
- 이 플러그인은
makeValidate<T>()를 AST에서 찾아, 빌드 시점에 검증 함수로 교체한다.
export default function vitePluginRuntypex({ removeInProd } = {}): Plugin {
return {
// (1)
name: "vite-plugin-runtypex",
enforce: "pre",
//(2)
transform(code, id) {
if (!/\.tsx?$/.test(id)) return; // TS/TSX 파일만 처리
if (!/make(?:Validate)</.test(code)) return; // makeValidate 코드만 탐색
// (3) TypeScript Program / Checker 생성
const { program, checker } = createProgramFor(id);
const sf = program.getSourceFile(id);
if (!sf) return;
let out = code;
// (4) makeValidate<T>() 구문 탐색 및 치환
out = out.replace(/makeValidate<([^>]+)>\(\)/g, (_m, typeName) => {
// (5) 타입 분석 및 검증 함수 생성
const type = resolveTypeByName(program, sf, checker, typeName.trim());
return type ? emitGuardFromType(checker, type) : _m;
});
// (6) 코드가 변경된 경우에만 결과 반환
return out === code ? null : { code: out, map: null };
},
};
}
| 구분 | 설명 |
|---|---|
| (1) 플러그인 설정 | name과 enforce: "pre"로 Vite 내 실행 순서를 지정한다. "pre"는 다른 변환 전에 이 플러그인이 실행되도록 설정한다. |
| (2) transform 훅 | 실제 코드 변환이 일어나는 부분. Vite는 모든 파일을 transform() 훅에 전달한다. |
| (3) TypeScript Program / Checker 생성 | createProgramFor(id)는 타입 해석을 위해 TS Compiler API의 Program과 TypeChecker를 생성한다. |
| (4) makeValidate 탐색 | 정규식을 이용해 makeValidate<...>() 구문을 찾는다. |
| (5) 타입 분석 및 함수 생성 | resolveTypeByName()으로 타입 정보를 얻고, core의 emitGuardFromType()을 호출해 해당 타입의 검증 함수를 문자열 형태로 생성한다. |
| (6) 최종 반환 | 코드가 수정된 경우 { code, map: null } 형태로 반환하여 Vite가 변환 결과를 반영한다. |
🧪 예시 변환 결과
- 플러그인이 실행되면, 다음 코드가
interface User {
id: number;
name: string;
}
const validate = makeValidate<User>();
- 빌드 후에는 자동으로 검증 함수로 변환된다.
const validate = (input) => (
typeof input === "object" &&
input !== null &&
typeof input.id === "number" &&
typeof input.name === "string"
);
- 런타임에서 검증 함수를 실행하면, 타입 일치 여부를 boolean으로 리턴해준다.
validate({ id: 1, name: "Lux" }); // ✅ true
validate({ id: "nope" }); // ❌ false
✅ 정리
- 런타임에서 타입을 해석하지 않는다.
- 빌드 시점에 타입 정보를 읽고 검증 함수를 자동 생성한다.
- 결과적으로 런타임에는 순수한 자바스크립트 함수만 남는다.
- 타입이 변경되면 빌드 시점에 검증 로직도 자동 갱신된다.
4) 핵심을 정리해보자
앞서 우리는 AST의 개념과, 어떻게 타입 정보를 기반으로 검증 함수를 만들어내는지 살펴보았다.
이제 마지막으로, 그 핵심 원리를 간단히 정리해보자.
(1) 검증기의 동작 원리
- 빌드 타임에
makeValidate<T>()같은 헬퍼 함수를 AST에서 탐색한다. - TypeScript Compiler API(
TypeChecker)로 제네릭 타입T의 구조를 정적으로 분석한다. core/emitGuardFromType()을 호출해 타입 구조를 조건식 문자열로 변환한다.- 변환된 문자열을
(input) => ...형태의 검증 함수 코드로 치환한다. - 결과적으로 런타임에는 완성된 자바스크립트 함수만 남는다.
즉, 타입 검증 로직이 “실행 시점”이 아니라 “빌드 시점”에 미리 만들어진다.
(2) 핵심 개념
AST 기반 변환기
→ 타입 정보를 런타임에서 파싱하지 않고, 컴파일러의 AST 분석으로 해결한다.
자동 코드 생성기
→
makeValidate<T>()호출을 실제 타입 검증 함수 코드로 자동 치환한다.Zero-runtime Reflection
→ 런타임에는 타입 정보가 전혀 남지 않으며, 순수 자바스크립트 함수만 실행된다.
변경 자동 반영
→ 타입 정의(
interface,type,enum)가 수정되면, 빌드 시 검증 로직이 자동 갱신된다.
(3) 성능상 이점
- 지난 1편에 구현했던 스키마 방식과 비교해보면, AST 방식이 성능이 더 좋은 걸 알 수 있다.
- 그 이유는 AST 검증기는 스키마처럼 런타임에 타입을 해석하지 않는다. 대신 빌드 시점에 타입 정보를 분석해 검증 함수를 생성하기에 속도면에서 더 빨랐다.
| 케이스 | 스키마 방식 | AST 검증 방식 (Runtypex) | 차이 |
|---|---|---|---|
| 하드 데이터셋) | 4.569 s | 0.472 s | 약 9.7× 빠름 |
| 간단 데이터셋 | 3.183 s | 0.329 s | 약 9.6× 빠름 |
| 혼합 데이터셋 | 5.197 s | 0.511 s | 약 10.1× 빠름 |
- 실제 동작 관점에서 두 방식을 비교하면 다음과 같다.
| 비교 항목 | 스키마 검증기 | AST 검증기( runtypex) |
|---|---|---|
| 타입 해석 시점 | 런타임 | 빌드 타임 |
| 검증 로직 생성 | 매번 스키마 해석 | 사전 생성된 함수 실행 |
| 오버헤드 | 높음 (Reflection, 객체 순회) | 거의 없음 |
| 빌드 후 코드 크기 | 스키마 객체 포함 | 순수 함수만 남음 |
| 성능 | 느림 (동적 파싱) | 매우 빠름 (단순 조건문 실행) |
- 결국 차이는 ‘언제 타입을 검증하느냐’에 있다.
- runtypex는 스키마 방식에 비해 런타임이 아닌 빌드 타임으로 끌어올리기에, 타입 안정성과 실행 성능 모두 확보했다.
2. 마치며…
- 처음 이 프로젝트를 시작했을 때의 목표는 단순했다. “런타임에서도 타입을 지키고 싶다.”
- 하지만 막상 구현을 시작하니, 생각보다 복잡했다.
- 가장 먼저 시도한 방법은 Zod였다.
- 스키마 기반으로 타입을 정의하고, 런타임에 그 스키마로 값을 검증하는 전형적인 구조였다.
- 그러나 곧 한계가 드러났다. 스키마 방식은 런타임에서 타입 정보를 다시 정의해야 한다.
- 즉, 타입스크립트 타입을 한 번, 스키마를 또 한 번 — 두 번 선언해야 했다.
- 이중 선언은 유지보수를 어렵게 만들고, 매번 스키마를 해석해야 하니 성능상 오버헤드도 피할 수 없었다.
- 물론 Zod의 체이닝 API는 좋았다.
z.object({...}).array().optional()처럼 선언적이고 읽기 쉬웠다. - 하지만 “개발 경험(DX)”이 아무리 좋아도, 런타임 성능을 대가로 삼는 구조라면 결국 한계가 있었다.
- 그래서 두 번째 방식을 선택했다. 바로 AST(Abstract Syntax Tree) 를 직접 탐색해, 빌드 시점에 검증 함수를 생성하는 방식이다.
- 빌드 시점에 타입 정보를 분석해 검증 함수를 코드 형태로 생성해두면, 런타임에는 더 이상 타입스크립트도, 스키마도 필요 없다. 오직 순수한 자바스크립트 함수만 남는다.
- 타입 안정성은 유지하면서도, 검증 속도는 지난 번 구현에 비해 10배 이상 빨라졌다.
- 현재는 간단한 구조로 구현했지만 추가 테스트를 통해, 구조 개선과 더불어 검증용 헬퍼 함수를 추가로 구현해보고자 한다.
'개발 기술 > 개발 이야기' 카테고리의 다른 글
| [TS × 클린 아키텍처] 1편 — 타입스크립트 한계와 Mapper: 스키마로 런타임 검증하기 (0) | 2025.09.28 |
|---|---|
| Tailwind 없이, PostCSS+PurgeCSS로 유틸리티 클래스 구축하기 (0) | 2025.05.26 |
| [CSS] SPA에서 Global과 Split CSS, 두 장점을 모두 살리는 방법 (0) | 2025.04.27 |
| Vue 3 <script setup> 선언 순서를 자동 정렬하는 ESLint 룰 개발기 (0) | 2025.02.28 |
| 테스트 코드를 관리하는 법 2: 커버리지 감소 검사하기 (0) | 2025.01.24 |
댓글