개발 기술/개발 이야기

[TS × 클린 아키텍처] 2편 — 타입스크립트 한계와 Mapper: AST로 타입 검증하기

by GicoMomg 2025. 10. 12.

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를 탐색하는 방법

  • SoureFileObjectforEachChild(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 컴파일러의 TypeCheckerT의 구조를 읽는다.
코드 생성 단계 (core 폴더) 여러 emitXXX() 핸들러가 타입 → 조건식 문자열로 변환하고,
GenContext가 이를 조립해 검증 함수 소스 문자열을 만든다.
치환 단계 (transformer 폴더) 소스의 makeValidate<T>() 호출을 찾아
input => /* 조건식 */ 같은 실제 함수 리터럴로 AST 치환한다.
최종 번들에는 “검증 함수”만 남는다.

이제 실제 구현 코드를 보면서 각 단계를 자세히 살펴보자.


(2) core/emitXXX.ts — 타입을 검증하는 문자열 생성기

핸들러는 “타입 → 조건식 문자열”로 변환하는 역할을 한다.
즉, 타입 정보를 받아 해당 값을 검사하는 JavaScript 조건식 문자열을 만들어낸다.
여러 핸들러 중 여기서는 대표적으로 emitLiteralOrEnum.tsemitUnionOrIntersection.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.tsemitGuardFromType() 를 거쳐
  • 다음과 같은 실행 가능한 검증 함수가 된다.
// 최종 결과 (빌드 시점에 생성)
(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) 플러그인 설정 nameenforce: "pre"로 Vite 내 실행 순서를 지정한다.
"pre"는 다른 변환 전에 이 플러그인이 실행되도록 설정한다.
(2) transform 훅 실제 코드 변환이 일어나는 부분.
Vite는 모든 파일을 transform() 훅에 전달한다.
(3) TypeScript Program / Checker 생성 createProgramFor(id)는 타입 해석을 위해 TS Compiler API의 ProgramTypeChecker를 생성한다.
(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) 검증기의 동작 원리

  1. 빌드 타임에 makeValidate<T>() 같은 헬퍼 함수를 AST에서 탐색한다.
  2. TypeScript Compiler API(TypeChecker)로 제네릭 타입 T의 구조를 정적으로 분석한다.
  3. core/emitGuardFromType()을 호출해 타입 구조를 조건식 문자열로 변환한다.
  4. 변환된 문자열을 (input) => ... 형태의 검증 함수 코드로 치환한다.
  5. 결과적으로 런타임에는 완성된 자바스크립트 함수만 남는다.

즉, 타입 검증 로직이 “실행 시점”이 아니라 “빌드 시점”에 미리 만들어진다.


(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배 이상 빨라졌다.
  • 현재는 간단한 구조로 구현했지만 추가 테스트를 통해, 구조 개선과 더불어 검증용 헬퍼 함수를 추가로 구현해보고자 한다.



반응형

댓글