개발 기술/개발 이야기

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

by GicoMomg 2025. 9. 28.

0. 들어가며

이번에 회사에서 새 레포지토리를 구성하면서 가장 먼저 고민한 것은 도메인 계층과 UI 계층을 어떻게 분리할 것인가였다.

기존에는 UI 코드 안에 모든 비즈니스 로직이 섞여 있었고, API 응답 객체를 그대로 사용하는 방식이었다. 하지만 이 방법은 다음과 같은 두 가지 문제를 만들었다.

  • 문제 1. API 응답 객체를 그대로 사용
    MER_UUID, USR_NM 같은 축약 필드명이 코드 전반에 퍼져 가독성이 떨어졌고, 백엔드 필드명이 변경되면 프론트엔드 전체를 수정해야 했다.

  • 문제 2. UI와 비즈니스 로직이 뒤섞임
    → Vue/React 컴포넌트 파일 안에 UI 코드와 비즈니스 플로우가 함께 들어있어, 코드 변경 시 UI까지 영향을 받는 경우가 많았고 책임 분리가 어려웠다.

클린 아키텍처는 “의존성이 안쪽(도메인)으로만 흐르게 하여, 비즈니스 로직을 외부(UI, DB, 프레임워크) 변화로부터 보호하는 구조”이다.

그 과정에서 자연스럽게 데이터 변환 로직이 필요해졌다. 즉, 백엔드에서 내려오는 DTO(Data Transfer Object)를

프론트엔드의 Entity(도메인 모델)로 변환해 다룰 수 있는 장치가 필요했고, 이를 위해 data → domain 변환을 담당하는 Mapper 클래스를 선언했다.


하지만 여기서 또 다른 문제가 드러났다.
Mapper만으로는 구조를 정리할 수 있지만, 타입스크립트는 컴파일 타임에만 타입을 보장한다는 것이다!

즉, 런타임에 잘못된 데이터 타입이 들어와도 검증할 수 없다는 한계가 있었다.

이를 보완하기 위해 런타임 검증을 지원하는 두 가지 방식을 참고했다.

  • Zod: 스키마 기반 Validator (런타임에 스키마를 순회하면서 검증)
  • typia: 코드 생성 기반 Validator (빌드 타임에 최적화된 검증 코드를 생성 → 런타임에서는 실행만)

물론 라이브러리를 그대로 가져다 쓸 수도 있지만, 보안 요건상 직접 구현해야 했고 동시에 학습 차원에서도 이를 시도해보기로 했다.

이번 글은 “[TS × 클린 아키텍처] 1편 — 타입스크립트 한계와 Mapper” 시리즈의 1차 글이다.


시리즈는 총 2편으로 구성된다.

  • 이번 글(1차)에서는 Zod의 스키마 방식을 차용해, Mapper와 스키마 변환 클래스를 직접 구현하며 타입스크립트의 한계를 어떻게 극복하려 했는지 다룬다.
  • 다음 글(2차)에서는 typia의 방식을 차용해, 빌드 타임에 Validator 함수를 만들어 성능까지 보완한 결과를 살펴볼 예정이다.




1. Mapper의 필요성과 타입스크립트의 한계

본격적으로 구현 얘기로 들어가기 전에,

먼저 왜 Mapper가 필요했는지, 그리고 타입스크립트만으로는 왜 부족했는지를 짚고 넘어가야 한다.

이 부분을 이해해야 뒤에서 스키마 기반 접근이 왜 등장했는지 자연스럽게 연결된다.


1) Mapper가 필요한 이유

(1) Mapper의 역할

  • 프론트엔드에서 백엔드 API를 연동하다 보면, 종종 이런 데이터를 그대로 쓰게 되는 경우가 있다:
// dto/UserDTO.ts
// 백엔드에서 내려오는 원본 데이터 (DTO)

export type UserDTO = {
  MER_UUID: string;  // 사용자 고유 UUID
  USR_NM: string;    // 사용자 이름
  CST_AGE: number;   // 고객 나이
  USR_STS?: string;  // 상태 (ACTIVE, INACTIVE 등)
};
  • 얼핏 보면 별문제 없어 보이지만, 실제 코드에서는 두 가지 불편함이 있다.
  • MER_UUID, USR_NM 같은 축약 필드가 프론트 전역에 퍼지면 코드 해석에 지연을 준다.
  • 또한, 만약 백엔드가 MER_UUID → MERCHANT_ID로 바꾼다면? 프론트 전체에서 해당 필드를 쓰는 코드를 전부 수정해야 한다.

  • 이 문제를 해결하기 위해, DTO ↔ Domain 변환을 담당하는 Mapper를 둔다.
  • 즉, 외부에서 내려오는 복잡한 데이터는 Mapper 안에서만 다루고, 도메인 계층에서는 항상 깨끗한 Entity만 쓰도록 한다.
  • 프론트엔드에서는 다음과 같은 Entity를 선언하면 된다:
// domain/User.ts
// 프론트엔드에서 실제 사용하는 도메인 모델 (Entity)

export type User = {
  id: string;     //  MER_UUID → id
  name: string;   //  USR_NM → name
  age: number;    //  CST_AGE → age
  status?: string;
};

  • 그 다음, Mapper 클래스를 이용해, DTO -> Domain으로 변환한다.
// mapper/UserMapper.ts

import type { UserDTO } from "../dto/UserDTO";
import type { User } from "../domain/User";

export class UserMapper {
  // DTO -> Domain (백엔드 → 프론트엔드)
  toDomain(dto: UserDTO): User {
    return {
      id: dto.MER_UUID,
      name: dto.USR_NM,
      age: dto.CST_AGE,
      status: dto.USR_STS,
    };
  }

  // Domain -> DTO (프론트엔드 → 백엔드)
  toDTO(entity: User): UserDTO {
    return {
      MER_UUID: entity.id,
      USR_NM: entity.name,
      CST_AGE: entity.age,
      USR_STS: entity.status,
    };
  }
}

  • 결과적으로, 이제 프론트에서는 항상 User Entity만 다루게 되어, 가독성과 더불어 외부 변경에도 안전해진다.
사용 예시
const raw: UserDTO = {
  MER_UUID: "ABC-123",
  USR_NM: "Alice",
  CST_AGE: 30,
  USR_STS: "ACTIVE",
};

const user = new UserMapper().toDomain(raw);

console.log(user);
// { id: "ABC-123", name: "Alice", age: 30, status: "ACTIVE" }

(2) Mapper와 타입스크립트 조합의 한계

  • Mapper를 도입하면 DTO ↔ Entity 변환이 깔끔해지고, 코드 가독성·유지보수성이 좋아진다.

  • 하지만 여전히 한 가지 치명적인 빈틈이 남는다.

  • 바로 타입스크립트의 타입은 컴파일 타임에만 동작한다는 점이다.

  • 타입스크립트의 타입은 어디까지나 개발자가 코드를 작성할 때만 도움을 준다.

  • 즉, 에디터 자동완성이나 컴파일러의 타입 체크 단계에서는 유효성을 보장하지만,

  • 실제로 코드가 런타임에서 실행될 때는 타입 정보가 전부 사라진다.

다시 말해, 개발 중에는 "이 값은 number여야 해요" 라고 알려주지만,
실행 중에는 그냥 자바스크립트 객체로만 존재한다.
👉 그래서 외부에서 들어오는 값(API 응답, 사용자 입력 등) 은 타입스크립트만으로는 막을 수 없다.

  • 다음과 같은 예시를 살펴보자.
  • 겉보기엔 타입이 맞는 것처럼 보이지만, 실제 실행 시점에는 전혀 다른 값이 들어와도 그대로 통과되는 상황이다.
type User = { id: string; age: number };

// ❌ 잘못된 값
const raw = { id: "123", age: "스물" };

// any로 들어오면 런타임에서는 검증 불가
function printUser(user: User) {
  console.log(`${user.id} is ${user.age} years old.`);
}

printUser(raw as any);
// 출력: "123 is 스물 years old."
  • 즉, 타입스크립트는 "약속"만 지켜줄 뿐, 실제 값이 올바른지는 보장하지 않는다는 게 문제다.
  • 이 때문에 API 응답이 잘못 내려오거나 사용자 입력이 엉뚱하게 들어와도 코드가 그대로 실행되며,
  • 결과적으로 잘못된 값이 도메인 로직 안으로 흘러 들어가게 된다.



2) 타입스크립트의 빈틈을 메우는 스키마

(1) 타입 스크립트의 단점, 런타임 데이터 문제

  • 앞서 본 것처럼 타입스크립트는 컴파일 타임에는 안전성을 보장해주지만, 실제로 프로그램이 실행되는 런타임 단계에서는 타입 정보가 사라진다.
  • 이 말은 곧, API 응답이나 사용자 입력이 잘못 들어와도 그대로 통과할 수 있다는 뜻이다.
  • 예를 들어, 서버에서 다음과 같은 데이터가 내려왔다고 하자.
// 서버에서 내려온 잘못된 데이터
const rawUser1 = { id: "123", age: "20" };   // 숫자 대신 문자열
const rawUser2 = { id: "456", age: "스물" }; // 완전히 잘못된 값

function printUser(user: { id: string; age: number }) {
  console.log(`${user.id} is ${user.age} years old.`);
}

printUser(rawUser1 as any);
// 출력: 123 is 20 years old. (논리적으로 잘못된 값)

printUser(rawUser2 as any);
// 출력: 456 is 스물 years old. (완전히 잘못된 값)
  • rawUser1의 경우 문자열 "20"이 그대로 출력되지만, 실제로는 숫자로 계산할 수 없는 값이다.
  • rawUser2의 경우 "스물" 같은 한글 문자열이 들어왔음에도 오류 없이 실행된다.
  • 이처럼 타입스크립트 타입만으로는 런타임 데이터 유효성 검증을 할 수 없다.

(2) 스키마: 데이터의 설계도

  • 이 문제를 해결하기 위해 도입되는 개념이 바로 스키마(schema)다.
  • 스키마는 한마디로 “데이터의 설계도”다.
  • 어떤 필드가 존재해야 하는지, 타입은 무엇인지, 값의 범위는 어떻게 되는지, 필수/옵션 여부는 무엇인지 등의 규칙을 코드로 선언해둔 것이다.
  • 예를 들어, 나이는 0 이상의 정수여야 한다는 스키마를 정의해보자.
const ageSchema = s.number().int().min(0);

const result = ageSchema.safeParse("스물");

if (!result.success) {
  console.log(result.error.issues);
  // 출력: "Expected number"
}
  • 입력값이 "스물" 같은 잘못된 값이면 에러가 발생한다.
  • 올바른 값일 경우에만 변환된 결과를 안전하게 얻을 수 있다.

(3) 타입 vs 스키마

구분 타입스크립트(Type) 스키마(Schema)
동작 시점 컴파일 타임 런타임
역할 코드 작성 시 오류 탐지 실행 중 실제 값 검증
보장 범위 개발자 코드 내부 외부 입력(API, 사용자 입력 등)
한계 런타임에서는 타입 정보 사라짐 성능 부담(검증 오버헤드 발생)
  • 타입스크립트의 타입 시스템만으로 런타임 안전성을 보장할 수 없는데, 잘못된 데이터가 들어와도 그대로 실행되기 때문이다.
  • 그래서 스키마를 도입하면 실제 기대한 규칙을 따르는지 런타임에서 확인 가능하다.





2. 스키마 기반 검증기 구현하기

💡 구현의 목표는 단순했다. (구현한 레포지토리 링크)
- 데이터 구조를 코드로 선언할 수 있어야 하고,
- 런타임에도 타입 검증을 수행할 수 있어야 하며,
- Mapper와 결합했을 때 안전하게 DTO ↔ Entity 변환이 가능해야 했다.

1) 설계 원칙

이를 위해 다음과 같은 원칙으로 설계했다.

  1. BaseSchema 추상 클래스
    • 모든 스키마 클래스의 공통 부모
    • _parse() 메서드를 구현해 실제 검증 로직을 정의
    • safeParse()를 통해 검증 성공/실패 결과를 반환
  2. 단일 타입별 Schema 클래스
    • StringSchema, NumberSchema, ObjectSchema
    • 각 타입에 맞는 제약 조건(길이, 범위, 정규식 등)을 체이닝 방식으로 추가
  3. 조합 가능한 구조
    • OptionalSchema, NullableSchema 등을 통해 값이 없거나 null인 경우 처리
    • TransformSchema를 통해 검증 후 변환 로직 적용 (예: 문자열 "20" → 숫자 20)
  4. 에러 관리
    • ValidationError 객체에 발생한 문제를 누적 저장
    • 어떤 경로(path)에서 어떤 에러가 났는지 명확하게 전달

2) 주요 코드

추상 클래스(BaseSchema)로 스키마의 뼈대를 정의하고, 이를 상속받은 구체 스키마들이 각자 _parse를 구현한다.
모든 결과는 safeParse로 감싸 동일한 구조로 반환되므로, 외부에서는 일관된 방식으로 검증 결과를 처리할 수 있다.

(1) BaseSchema: 추상 클래스

  • 모든 스키마 클래스의 공통 부모로 추상 클래스 BaseSchema<T>를 정의했다.
export abstract class BaseSchema<T> {
  readonly _type!: T;

  protected abstract _parse(value: unknown, path: Path): MaybePromise<T>;

  safeParse(value: unknown): SafeParseResult<T> {
    try {
      return { success: true, data: this.parse(value) };
    } catch (e) {
      return this._makeSafeResultError(e);
    }
  }
  ...
}
  • 이 클래스는 최소 두 가지를 책임진다.
대상 역할
추상 메서드 _parse 실제 검증 로직을 하위 클래스에서 구현하도록 강제한다.
검증 결과 포맷 통일 try/catch로 감싸 검증 성공/실패 여부를 객체 형태로 반환한다.
항상 { success: boolean, data?: T, error?: ValidationError } 같은 형태로 리턴하여, 모든 스키마가 동일한 결과 구조를 가진다.

(2) 구체 스키마 클래스: 상속

  • 이제 각 타입별 스키마(StringSchema, NumberSchema, ObjectSchema 등)는 BaseSchema를 상속받아 _parse 메서드를 구현한다.
  • 이 중 StringSchema 구현 예시를 살펴보자.
export class StringSchema extends BaseSchema<string> {
  constructor(private readonly config: StringConfig = {}) {
    super();
  }

  protected override _parse(input: unknown, path: Path): string {
    // [1] 타입 검증
    if (typeof input !== 'string') { 
      this._fail(path, 'invalid_type', 'Expected string');
    }

    const { min, max, re } = this.config;
    const len = input.length;

    // [2] **조건 검증**
    if (min != null && len < min) {
      this._fail(path, 'too_small', `Min length ${min}`);
    }
    if (max != null && len > max) {
      this._fail(path, 'too_big', `Max length ${max}`);
    }
    if (re && !re.test(input)) {
      this._fail(path, 'invalid_string', 'Regex mismatch');
    }

    return input;
  }

  private _fail(
    path: Path,
    code: 'invalid_type' | 'too_small' | 'too_big' | 'invalid_string',
    message: string
  ): never {
    throw new ValidationError([{ path, code, message }]);
  }
  ...
}
번호 설명
[1] 타입 검증 입력값이 string인지 먼저 확인한다. 아니라면 throw로 에러를 던져 즉시 실패 처리한다.
[2] 조건 검증 생성자에 넘겨받은 설정(min, max, re)을 기준으로 길이와 패턴을 검사한다. 모든 조건을 통과하면 입력값을 그대로 반환한다.

min: 최소 길이
max: 최대 길이
re: 정규식

export class StringSchema extends BaseSchema<string> {
   ...
  // [3] 체이닝
  min = (n: number) => new StringSchema({ ...this.config, min: n });
  max = (n: number) => new StringSchema({ ...this.config, max: n });
  regex = (r: RegExp) => new StringSchema({ ...this.config, re: r });
  email = () => this.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
}
번호 설명
[3] 체이닝 StringSchema 클래스는 min, max, regex 같은 메서드를 체이닝 방식으로 제공한다.
즉, 기존 설정을 유지하면서 새로운 조건을 추가한 새로운 StringSchema 인스턴스를 반환한다.

  • 체이닝은 다음과 같이 사용할 수 있다.
const schema = new StringSchema()
  .min(3)             // 최소 길이 3자
  .max(10)            // 최대 길이 10자
  .regex(/^[a-z]+$/); // 알파벳 소문자만 허용
  • 이 외에도 객체, enum 등 여러 타입에 대해 BaseSchema를 상속받아, 구현하는 방식으로 진행했다.

(3) Mapper와 결합

  • 스키마 클래스는 단독으로도 사용할 수 있지만, Mapper와 함께 쓰면 DTO → Entity 변환 + 런타임 검증을 동시에 처리할 수 있다.
// User 스키마 정의
const UserDTOSchema = new ObjectSchema({
  MER_UUID: new StringSchema(),
  USR_NM: new StringSchema().min(1),
  CST_AGE: new BaseSchema<number>(), // 간단 예시
});

// User 도메인 모델
type User = { id: string; name: string; age: number };

// Mapper with Schema
class UserMapper {
  static toDomain(raw: unknown): User {
    const result = UserDTOSchema.safeParse(raw);
    if (!result.success) throw result.error;

    const dto = result.data;
    return {
      id: dto.MER_UUID,
      name: dto.USR_NM,
      age: dto.CST_AGE,
    };
  }
}

  • 실제 동작 코드를 살펴보자
// 올바른 데이터
const raw1 = { MER_UUID: "ABC-123", USR_NM: "Alice", CST_AGE: 25 };
console.log(UserMapper.toDomain(raw1));

// { id: "ABC-123", name: "Alice", age: 25 }

// 잘못된 데이터 (age가 문자열)
const raw2 = { MER_UUID: "DEF-456", USR_NM: "Bob", CST_AGE: "스물" };
console.log(UserMapper.toDomain(raw2));

// → Error: Expected number at CST_AGE
  • 첫 번째 케이스(raw1)는 CST_AGE가 숫자 타입이라 스키마 검증을 통과하고, Mapper가 DTO → Domain 변환을 정상적으로 수행한다.
  • 두 번째 케이스(raw2)는 CST_AGE가 문자열 "스물"이라 NumberSchema 검증에서 실패한다. 따라서 스키마가 에러를 던지고, safeParse 결과가 실패로 처리된다.



3) 실제 적용 예시

(1) 상황 설정

  • API에서 내려오는 데이터는 종종 프론트엔드에서 바로 쓰기 어려운 형태다.
  • 예를 들어 다음처럼 내려올 수 있다:
{
  "MER_UUID": "ABC-123",
  "USR_NM": "Alice",
  "CST_AGE": "25",
  "USR_STS": "ACTIVE"
}
  • 여기서 CST_AGE는 문자열 "25"이므로, 단순 타입스크립트 타입 정의만으로는 안전성을 확보할 수 없다.

(2) User 스키마 정의

  • 런타임에서도 타입 안정성을 보장하기 위해, 스키마를 정의한다.
  • 우선, 프론트엔드에서 실제로 사용할 도메인 모델(Entity)을 타입으로 정의한다.
  • API의 축약 필드명 대신 id, name, age, status처럼 읽기 좋은 camelCase로 정의했다.
// domain/User.ts

export type User = {
  id: string;
  name: string;
  age: number;
  status?: string;
};

  • 그 다음으로, User에 대한 DTO 스키마(UserDTOSchema)를 정의했다.
// schema/UserSchema.ts

import { StringSchema } from "./StringSchema";
import { TransformSchema } from "./TransformSchema";
import { ObjectSchema } from "./ObjectSchema";

export const UserDTOSchema = new ObjectSchema({
  MER_UUID: new StringSchema(),
  USR_NM: new StringSchema().min(1),
  CST_AGE: new TransformSchema(new StringSchema(), (v) => Number(v)),
  USR_STS: new StringSchema().optional(),
});
  • 이 스키마는 API에서 내려오는 원시 데이터를 런타임에서 검증하고, 필요하다면 변환(transform)까지 수행한다.

    • ObjectSchema는 객체 구조를 검증.
    • MER_UUID: 문자열로만 허용.
    • USR_NM: 문자열 + 최소 길이 1자.
    • CST_AGE: 문자열로 들어오더라도 TransformSchema를 통해 숫자로 변환.
    • USR_STS: optional → 값이 없어도 통과.

(3) Mapper 구현

  • 앞서 정의한 UserDTOSchema를 Mapper와 결합해,
  • DTO → Domain 변환 과정에서 런타임 검증과 변환을 동시에 처리한다.
// mapper/UserMapper.ts
import type { User } from "../domain/User";
import { UserDTOSchema } from "../schema/UserSchema";

export class UserMapper {
  static toDomain(raw: unknown): User {
    const result = UserDTOSchema.safeParse(raw);
    if (!result.success) throw result.error;

    const dto = result.data;
    return {
      id: dto.MER_UUID,
      name: dto.USR_NM,
      age: dto.CST_AGE,
      status: dto.USR_STS,
    };
  }
}
  • safeParse를 통해 DTO를 검증한다.
  • 성공하면 { success: true, data } 구조를 반환하고,
  • 실패하면 { success: false, error } 객체를 반환한다.
  • 결과적으로 UI/비즈니스 로직에서는 항상 일관된 User 타입만 다루면 된다.

(4) 실제 동작

  • 먼저, 올바른 데이터가 들어온 경우를 보자.
// 올바른 데이터
const raw1 = { MER_UUID: "ABC-123", USR_NM: "Alice", CST_AGE: "25" };
const user1 = UserMapper.toDomain(raw1);

console.log(user1);
// { id: "ABC-123", name: "Alice", age: 25 }
  • CST_AGE는 문자열 "25"였지만, TransformSchema가 숫자 25로 변환했다.
  • Mapper가 변환 결과를 도메인 모델 { id, name, age, status }로 리턴한다.

  • 이번에는 잘못된 데이터가 들어온 경우를 보자.
// 잘못된 데이터 (나이가 '스물')
const raw2 = { MER_UUID: "DEF-456", USR_NM: "Bob", CST_AGE: "스물" };
try {
  UserMapper.toDomain(raw2);
} catch (e) {
  console.error(e);
  // ValidationError: Expected number at CST_AGE
}
  • CST_AGE"스물"은 숫자로 변환 불가하다.
  • 그래서 TransformSchema 내부에서 변환을 실패해, ValidationError 발생시켰다.

  • 결국 스키마 클래스를 사용하면 런타임에서 DTO를 검증하고,
  • Mapper가 안전하게 Domain 모델로 변환한다.
  • 또한, 올바른 값은 변환되어 통과, 잘못된 값은 즉시 실패시킬 수 있었다.





3. 마치며

이번 시간에는 클린 아키텍처를 도입하는 과정에서 마주한 타입스크립트의 한계와, 이를 해결하기 위해 시도한 방법을 정리했다.

Mapper는 DTO ↔ Entity 변환(필드명 매핑, 구조 변환)을 하는데, DTO 값 자체가 잘못 들어오면 Mapper만으로는 막을 수 없다.

따라서 런타임 검증은 스키마가 담당하고, Mapper는 이를 호출해 “검증 + 변환”을 한 번에 처리하도록 설계했다.

즉, Mapper와 스키마가 결합되면서 DTO → Entity 변환 과정에서 안전성과 일관성을 동시에 확보할 수 있었다.


하지만 런타임에서 매번 데이터 구조를 순회하며 검증하기에 성능 저하가 발생했다. 실제 벤치마크 결과는 다음과 같다.

검증 조건
  • 데이터 크기: SIZE = 100,000
    • 검증 시나리오
    1. 하드 케이스: nullable, optional, enum, strict, invalid 필드를 섞은 복잡한 데이터
    2. 간단 케이스: 모든 값이 정상, unknown 없음
    3. 혼합 케이스: 정상 80%, 실패 20% 비율
    • 비교 대상
    • manualToDomain: 수동 매핑 + 조건문 검증
    • UserMapper.toDomain: 스키마 클래스 검증 + 변환
  • 결과
케이스 스키마 적용 전 (수동) 스키마 적용 후 차이
하드 데이터셋 16.291ms 4.569s 280배 느림
간단 데이터셋 65.15ms 3.183s 49배 느림
혼합 데이터셋 51.514ms 5.197s 100배 느림
  • 스키마 클래스는 입력값을 받을 때마다 (1) 객체 필드 순회 → (2) 각 조건 검사(min, max, regex 등) → (3) 실패 시 ValidationError 생성 단계를 반복한다.
  • 즉, “해석(interpret)” 기반 실행이어서 데이터 크기만 커져도 비용이 급격히 증가한다.
  • 반대로 수동 매핑은 이미 “컴파일된 조건문”이므로 단순 분기만 실행해서 빠른 것이다.
  • 결국 스키마 클래스 방식이 안정성과 유지보수성은 확보했지만, 성능 면에서는 손해가 있었다.

이 벤치마크를 통해, 스키마를 런타임마다 해석하는 구조는 본질적으로 성능 한계가 있을 수밖에 없다는 점을 확인했다.



추가 리서치를 통해 알게 된 것은, 스키마를 매번 생성·해석하는 방식보다는 typia처럼

타입 정보를 AST로 분석하여 빌드 타임에 최적화된 validator 함수를 자동 생성하는 방식이 성능상 훨씬 유리하다는 점이었다.

typia는 TypeScript Compiler API를 이용해 타입 정보를 AST로 분석하고, 그 결과를 바탕으로 최적화된 validator 함수를 코드 형태로 생성한다.

따라서 런타임에서는 매번 스키마를 해석할 필요 없이, 이미 생성된 조건문 기반 함수를 실행하기만 하면 된다.

이 덕분에 “수동 매핑”과 거의 유사한 성능을 유지하면서도 타입 안정성을 동시에 보장할 수 있다.

👉 다음 글에서는 typia의 아이디어를 응용해, AST 기반 코드 생성 방식으로 개선하는 방법을 다뤄보겠다.



반응형

댓글