[TYPESCRIPT] 타입 가드를 쓰다 보면 결국 마주치게 되는 문제들

TypeScript를 어느 정도 쓰다 보면, 런타임에서 들어오는 값 때문에 타입이 흐려지는 순간을 계속 마주하게 됩니다. HTTP 요청 바디, 외부 API 응답, 큐 메시지, 캐시에서 꺼낸 값 같은 것들입니다.

 

컴파일 단계에서는 문제가 없어 보이는데, 운영 환경에서는 전혀 다른 형태의 값이 들어오는 경우도 적지 않습니다. 이 시점부터 타입 정의만으로는 부족해지고, 직접 값을 확인하는 코드가 하나씩 늘어나기 시작합니다.

 

is, in, instanceof 같은 타입 가드는 이 구간에서 자연스럽게 등장합니다. 다만 쓰다 보면, 어떤 방식은 오래 버티고 어떤 방식은 자주 깨진다는 차이가 보이기 시작합니다.

 

타입 가드는 타입을 증명하지 않는다

타입 가드를 처음 접했을 때 가장 착각하기 쉬운 부분은, 이 코드가 타입을 “증명해 준다”고 느끼는 것입니다. 실제로는 그렇지 않습니다.

 

타입 가드는 런타임에서 몇 가지 조건을 확인하고, 그 이후 코드를 해당 타입으로 취급하겠다고 컴파일러에게 알려주는 역할에 가깝습니다. 조건이 부족하면, 그만큼 위험한 가정을 하게 됩니다.

 

is 타입 가드를 만들 때 자주 생기는 일

is 타입 가드는 문법 자체는 단순합니다. 문제는 내부에서 무엇을 확인하느냐입니다.

 


type User = {
  id: string;
  email: string;
};

function isUser(value: unknown): value is User {
  const v = value as any;
  return typeof v?.id === 'string' && typeof v?.email === 'string';
}

 

이 정도면 최소한의 확인은 하고 있다고 볼 수 있습니다. 반대로, 아래 같은 코드도 종종 보게 됩니다.

 


function isUser(value: unknown): value is User {
  return !!value;
}

 

이 가드는 컴파일러만 안심시킵니다. 이후 로직에서 user.email을 바로 쓰기 시작하면, 문제는 런타임에서 터집니다.

 

타입 가드가 얕을수록, 그 다음 코드가 더 위험해지는 경우를 여러 번 보게 됩니다.

 

in 연산자를 쓰기 시작하면 생기는 착각

in은 객체에 특정 프로퍼티가 있는지를 빠르게 확인할 수 있어서 편합니다. 특히 유니온 타입을 나눌 때 자주 쓰입니다.

 


type CardPayment = { type: 'CARD'; cardNumber: string };
type BankPayment = { type: 'BANK'; bankCode: string };
type Payment = CardPayment | BankPayment;

function handlePayment(p: Payment) {
  if ('cardNumber' in p) {
    p.cardNumber.toUpperCase();
    return;
  }
  p.bankCode.toUpperCase();
}

 

내부에서 생성한 Payment라면 큰 문제가 없습니다. 하지만 외부 입력을 그대로 Payment로 캐스팅한 뒤 in만으로 분기하기 시작하면 이야기가 달라집니다.

 

cardNumber가 존재는 하지만, string이 아닌 값으로 들어오는 경우도 실제로는 종종 있습니다. 이 시점부터는 존재 여부만으로는 부족해집니다.

 


function isCardPayment(value: unknown): value is CardPayment {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as any;
  return v.type === 'CARD' && typeof v.cardNumber === 'string';
}

 

in은 출발점으로는 괜찮지만, 그 자체로 판단을 끝내기에는 근거가 약한 경우가 많습니다.

 

instanceof가 잘 맞지 않는 순간들

instanceof는 클래스 기반 코드에서는 꽤 직관적입니다. 같은 런타임, 같은 클래스라면 문제없이 동작합니다.

 


class AppError extends Error {
  constructor(public code: string, message: string) {
    super(message);
  }
}

 

문제는 에러가 항상 이 형태로만 오지 않는다는 점입니다. JSON으로 직렬화되거나, 다른 프로세스에서 전달되거나, 라이브러리 내부에서 변형된 에러가 섞이기 시작하면 instanceof는 더 이상 좁혀주지 못합니다.

 

그래서 운영 코드에서는 instanceof 하나에만 의존하기보다는, 형태를 한 번 더 확인하는 쪽으로 흐르게 됩니다.

 


type ErrorLike = {
  message: string;
  code?: string;
};

function isErrorLike(value: unknown): value is ErrorLike {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as any;
  return typeof v.message === 'string';
}

 

외부 입력을 다룰 때 자주 선택하게 되는 구조

HTTP 요청 바디처럼 외부에서 그대로 들어오는 값은 처음부터 타입을 믿지 않는 쪽으로 정리되는 경우가 많습니다.

 


type LoginBody = {
  email: string;
  password: string;
};

function isLoginBody(value: unknown): value is LoginBody {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as any;
  return typeof v.email === 'string' && typeof v.password === 'string';
}

 

번거롭기는 하지만, 한 번 장애를 겪고 나면 이 구조에서 쉽게 벗어나기 어렵습니다. 타입 가드가 귀찮은 게 아니라, 문제가 생겼을 때 원인을 찾기 쉬워지기 때문입니다.

 


 

is, in, instanceof는 모두 쓸 만한 도구입니다. 다만 어디까지 믿을 수 있는지는 상황마다 다릅니다.

 

운영 환경에서는 값이 어디서 왔는지, 어떤 경계를 넘어왔는지가 가드 방식 선택에 더 큰 영향을 미치는 경우가 많았습니다. 타입 가드를 많이 만드는 것보다, 깨지는 지점을 줄이는 쪽이 결과적으로는 편했습니다.