외부 입력은 any가 아니라 unknown에서 시작하기

TypeScript를 도입했는데도 운영 장애가 계속 나는 지점은 대개 외부 입력입니다. API 요청 본문, 쿠키, 로컬 스토리지, 메시지 큐, 결제 웹훅, 서드파티 API 응답은 컴파일러가 실제 형태를 보장해 주지 않습니다. 이 값을 any로 받으면 이후 코드 전체가 타입 검사를 우회하게 되고, 테스트에서 지나간 데이터만 정상이라는 착시가 생깁니다. 실무에서는 외부 경계에서 unknown으로 받고, 검증 함수를 통과한 값만 내부 도메인 타입으로 승격시키는 흐름이 더 안전합니다.

핵심은 타입 선언과 런타임 검증을 분리해서 생각하는 것입니다. interface UserCreateInput을 선언했다고 해서 실제 요청 본문이 그 모양이 되는 것은 아닙니다. TypeScript 타입은 빌드 후 사라집니다. 따라서 컨트롤러, 라우트 핸들러, 작업 큐 소비자처럼 외부와 만나는 첫 지점에서 값의 존재 여부, 문자열 길이, 숫자 범위, 배열 원소 타입, 허용 가능한 enum 값을 확인해야 합니다. 이 과정을 지나면 서비스 계층은 방어 코드가 줄고, 비즈니스 로직에 집중할 수 있습니다.

타입 가드로 검증 경계를 만들기

작은 프로젝트에서는 별도 스키마 라이브러리 없이도 타입 가드와 assertion 함수만으로 충분한 안정성을 얻을 수 있습니다. 타입 가드는 반환 타입에 value is SomeType을 사용해 조건문 안에서 타입을 좁힙니다. assertion 함수는 실패 시 예외를 던지고, 성공 이후의 코드에서 타입이 보장되도록 만듭니다. 라우트 핸들러에서는 assertion 방식이 읽기 쉽고, 배열 필터링이나 분기 처리에서는 타입 가드 방식이 자연스럽습니다.

type Role = 'admin' | 'member';

type UserCreateInput = {
  email: string;
  name: string;
  role: Role;
  marketingAgreed: boolean;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isRole(value: unknown): value is Role {
  return value === 'admin' || value === 'member';
}

function assertUserCreateInput(value: unknown): asserts value is UserCreateInput {
  if (!isRecord(value)) {
    throw new Error('요청 본문은 객체여야 합니다.');
  }

  if (typeof value.email !== 'string' || !value.email.includes('@')) {
    throw new Error('email 형식이 올바르지 않습니다.');
  }

  if (typeof value.name !== 'string' || value.name.trim().length < 2) {
    throw new Error('name은 두 글자 이상이어야 합니다.');
  }

  if (!isRole(value.role)) {
    throw new Error('role은 admin 또는 member만 허용됩니다.');
  }

  if (typeof value.marketingAgreed !== 'boolean') {
    throw new Error('marketingAgreed는 boolean이어야 합니다.');
  }
}

async function handleCreateUser(rawBody: unknown) {
  assertUserCreateInput(rawBody);

  const input = {
    email: rawBody.email.trim().toLowerCase(),
    name: rawBody.name.trim(),
    role: rawBody.role,
    marketingAgreed: rawBody.marketingAgreed,
  };

  return createUser(input);
}

위 예제에서 중요한 점은 rawBody를 처음부터 UserCreateInput으로 단언하지 않는다는 것입니다. 검증 전에는 unknown이고, assertUserCreateInput을 통과한 뒤에만 UserCreateInput처럼 사용할 수 있습니다. 이렇게 하면 누락된 필드나 잘못된 타입이 서비스 계층까지 흘러 들어가지 않습니다. 또한 trim, 소문자 변환 같은 정규화 작업을 검증 직후에 수행하면 이후 레이어에서 같은 처리를 반복하지 않아도 됩니다.

검증 함수가 확인해야 할 것

  • 필수 필드가 실제로 존재하는지 확인합니다. in 연산자만 믿지 말고 값의 타입까지 함께 확인해야 합니다.
  • 문자열은 빈 문자열, 앞뒤 공백, 최대 길이, 허용 문자 범위를 점검합니다. 데이터베이스 컬럼 길이보다 긴 값은 저장 직전이 아니라 요청 경계에서 거르는 편이 좋습니다.
  • 숫자는 NaN, Infinity, 음수 허용 여부, 최소값과 최대값을 확인합니다. JSON에서 숫자로 들어왔다고 해서 업무적으로 유효한 숫자는 아닙니다.
  • 배열은 Array.isArray로 먼저 확인하고, every로 각 원소의 타입과 범위를 검사합니다. 빈 배열 허용 여부도 명시해야 합니다.
  • 날짜는 문자열인지와 파싱 가능 여부를 나누어 확인합니다. new Date(value)가 Invalid Date를 만들 수 있다는 점을 잊지 말아야 합니다.
  • enum 성격의 값은 문자열 전체를 허용하지 말고 허용 목록을 코드로 고정합니다. 운영 중 오타 데이터가 들어오면 통계, 권한, 상태 전환 로직이 쉽게 깨집니다.

에러 응답과 내부 로그를 분리하기

검증 실패 메시지는 사용자에게 보여 줄 메시지와 운영자가 볼 로그를 구분해야 합니다. 사용자에게는 어떤 필드를 고쳐야 하는지 알려 주되, 내부 스택, SQL, 토큰, 서드파티 응답 원문은 노출하지 않습니다. 반대로 서버 로그에는 요청 ID, 실패 필드, 입력 출처, 사용자 식별자 같은 진단 정보를 남겨야 합니다. 이때 전체 요청 본문을 그대로 기록하면 개인정보가 로그에 쌓일 수 있으므로 필드명과 실패 이유 중심으로 남기는 것이 안전합니다.

여러 필드를 한 번에 검증할 때는 첫 번째 오류에서 바로 중단할지, 모든 오류를 모아 반환할지 정해야 합니다. 관리자 화면이나 내부 도구는 모든 오류를 모아 보여 주는 편이 편리합니다. 공개 API는 응답 형식을 단순하게 유지하기 위해 첫 오류만 반환해도 됩니다. 중요한 것은 프로젝트 안에서 규칙을 통일하는 것입니다. 같은 API에서 어떤 곳은 400, 어떤 곳은 422, 어떤 곳은 500으로 실패하면 프론트엔드와 운영 모니터링이 모두 어려워집니다.

서비스 계층에서는 이미 검증된 타입만 받기

검증 코드를 작성해도 서비스 함수가 다시 unknown이나 any를 받으면 효과가 줄어듭니다. 라우트 핸들러에서 검증한 뒤에는 createUser(input: UserCreateInput)처럼 명확한 타입을 넘기고, 서비스 내부에서는 타입 오류를 대비한 방어 분기를 최소화합니다. 그래야 컴파일러가 누락된 필드를 잡아 주고, 테스트도 정상 흐름과 업무 규칙에 집중할 수 있습니다.

반대로 저장소 계층이나 외부 API 클라이언트에서 돌아오는 값은 다시 외부 입력으로 취급하는 것이 좋습니다. 데이터베이스 스키마가 바뀌었거나, 오래된 마이그레이션 데이터가 남아 있거나, 외부 API가 문서와 다른 응답을 줄 수 있기 때문입니다. 특히 결제, 정산, 권한, 고객 정보처럼 실패 비용이 큰 영역은 입력과 출력 양쪽에 검증 경계를 두는 편이 운영 리스크를 줄입니다.

운영 체크리스트

  • 외부 입력 타입은 any 대신 unknown으로 받습니다.
  • 검증 함수 이름은 assertUserCreateInput처럼 도메인과 목적이 드러나게 작성합니다.
  • 검증 직후 정규화한 값을 새 객체로 만들어 서비스 계층에 전달합니다.
  • 검증 실패 응답 형식과 HTTP 상태 코드를 프로젝트 전체에서 통일합니다.
  • 로그에는 실패 필드와 요청 ID를 남기되, 민감한 원문 데이터는 남기지 않습니다.
  • 복잡한 중첩 구조가 많아지면 Zod, Valibot, TypeBox 같은 스키마 기반 도구 도입을 검토합니다.

정리하면 TypeScript의 타입 안정성은 외부 경계에서 시작됩니다. unknown으로 받고, 런타임 검증으로 좁히고, 정규화된 도메인 객체만 내부로 넘기는 규칙을 세우면 입력 오류가 비즈니스 로직과 데이터베이스까지 번지는 일을 줄일 수 있습니다. 작은 타입 가드라도 API 경계에 꾸준히 배치하면 장애 원인 추적이 쉬워지고, 서비스 코드의 가독성도 함께 좋아집니다.