[TYPESCRIPT] 타입 안정성과 런타임 검증을 나누는 기준

TypeScript를 도입하고 나면 자연스럽게 드는 기대가 있습니다. 타입을 잘 잡아두면 런타임 오류도 함께 줄어들 것이라는 기대입니다.

 

하지만 운영 환경을 겪어 보면, 타입이 통과했는데도 장애가 발생하는 상황은 계속 등장합니다. 외부 입력, 레거시 데이터, 버전이 다른 클라이언트 요청은 타입 시스템이 관여할 수 없는 영역에서 들어오기 때문입니다.

 

결국 어느 지점에서는 타입과 런타임 검증의 역할을 나눠야 합니다. 어디까지 타입에 맡기고, 어디서부터 런타임 검증을 두는 편이 유지에 덜 부담이 되는지 알아보겠습니다.

 

 

타입이 보장하는 범위는 컴파일 타임까지다

TypeScript 타입은 코드가 컴파일되는 시점까지의 안전을 보장합니다. 실제 실행 환경에서 들어오는 값의 형태까지 보장하지는 않습니다.

 

예를 들어 다음 코드는 타입만 보면 안전해 보입니다.

 


type CreateUserInput = {
  email: string;
};

function createUser(input: CreateUserInput) {
  return input.email.toLowerCase();
}

 

하지만 외부에서 들어오는 JSON을 그대로 캐스팅했다면, 런타임에서는 언제든 깨질 수 있습니다.

 


const body = JSON.parse(req.body) as CreateUserInput;
createUser(body); // 타입은 통과하지만 런타임은 보장되지 않음

 

이 지점에서 타입과 검증의 역할을 분리하지 않으면, 타입이 안전망처럼 보이지만 실제로는 형식적인 장치가 됩니다.

 

어디까지 타입에 맡기는 편이 맞는가

실무 코드에서는 대략 다음 범위까지 타입이 역할을 맡습니다.

 

  • 내부 함수 간 계약
  • 리팩터링 시 영향 범위 확인
  • 컴파일 단계의 사용 오류 방지
  • 도메인 모델의 구조 표현

 

이 범위를 넘어 외부 입력까지 타입으로 해결하려고 하면, 결국 타입 단언(as)이 늘어나거나, 과도한 타입 가드가 퍼지는 쪽으로 흘러갑니다.

 

런타임 검증이 필요한 경계

다음 위치에서는 런타임 검증을 두는 편이 운영에서 안정적입니다.

 

  • HTTP 요청 바디
  • 외부 API 응답
  • 메시지 큐 소비 데이터
  • DB에서 읽어온 신뢰하기 어려운 데이터

 

이 영역은 타입 시스템이 통제할 수 없는 입력이 들어옵니다. 여기서 타입만 믿고 넘어가면, 장애는 대부분 이 경계에서 발생합니다.

 

구조를 나눌 때 자주 쓰는 흐름

여러 프로젝트에서 오래 유지된 흐름은 대체로 비슷합니다.

 

  • 외부 입력: unknown 또는 느슨한 DTO
  • 런타임 검증: 스키마 검증 또는 타입 가드
  • 검증 통과 후: 좁은 도메인 타입

 

이렇게 경계를 나눠두면, 타입 단언이 코드 전반으로 퍼지는 것을 막을 수 있습니다.

 

코드 예제: 타입과 검증 경계를 분리한 흐름


import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
});

type CreateUserCommand = {
  email: string;
  name: string;
};

export function parseCreateUser(input: unknown): CreateUserCommand {
  const result = CreateUserSchema.parse(input);

  return {
    email: result.email,
    name: result.name,
  };
}

 

외부 입력은 unknown으로 받고, 검증을 통과한 이후에만 도메인 타입으로 변환합니다. 이후 로직에서는 타입 가드를 반복할 필요가 없습니다.

 

타입 가드만으로 버티려 할 때 생기는 일

스키마 검증 없이 타입 가드만으로 처리하려는 시도도 자주 보입니다. 초기에는 가볍지만, 필드가 늘어나면 검증 코드가 여러 곳에 퍼집니다.

 


function isCreateUser(input: any): input is CreateUserCommand {
  return (
    typeof input?.email === 'string' &&
    typeof input?.name === 'string'
  );
}

 

이 방식은 단순 구조에서는 충분히 동작합니다. 다만 중첩 구조나 배열 검증이 늘어나면 검증 로직의 유지 비용이 빠르게 올라갑니다.

 

스키마 검증 도입 이후 달라지는 점

스키마 기반 검증을 도입하면 몇 가지 변화가 생깁니다.

 

  • 검증 위치가 경계로 모인다
  • 타입 단언 사용이 줄어든다
  • 에러 메시지 관리가 쉬워진다
  • API 계약 테스트가 수월해진다

 

대신 런타임 비용과 번들 크기 같은 현실적인 trade-off도 함께 고려하게 됩니다. 팀과 서비스 성격에 따라 선택은 달라질 수 있습니다.

 

운영에서 자주 보이는 문제

타입과 런타임 검증의 경계가 흐려지면, 대체로 다음 패턴이 반복됩니다.

 

  • as 캐스팅이 점점 늘어난다
  • 검증 로직이 여러 레이어에 흩어진다
  • 타입은 통과했는데 런타임 오류가 발생한다
  • 외부 입력 포맷 변경에 취약해진다

 

이 상태가 되면 타입 시스템에 대한 신뢰도도 함께 떨어집니다.

 

점검할 때 보게 되는 항목들

  • 외부 입력을 바로 도메인 타입으로 캐스팅하고 있지 않은지
  • 검증 책임이 여러 레이어에 분산되어 있지 않은지
  • 타입 단언(as)이 점점 늘어나고 있지 않은지
  • 스키마 검증 없이 깊은 객체를 신뢰하고 있지 않은지
  • 검증 실패 로그가 충분히 남고 있는지
  • API 변경 시 검증 계층에서 바로 감지되는 구조인지

 


 

 

TypeScript 타입과 런타임 검증은 같은 문제를 해결하는 도구처럼 보이지만, 역할이 분명히 나뉩니다. 타입은 컴파일 시점의 계약을, 런타임 검증은 실제 입력의 신뢰도를 다룹니다.

 

외부 경계에서 검증을 모으고, 검증 이후에는 좁은 타입으로 작업하도록 흐름을 나눠두면 대부분의 입력 관련 장애는 초기에 걸러집니다.