본문으로 건너뛰기
Tech Blog

받아온 데이터에 타입을 입히는 법

글 복사 완료!

fetch 응답에 as 를 붙이면 거짓말이 돼요. 경계에서 검증해 타입을 얻어내요.

·11분·

const user = await res.json() 한 줄을 쓰고 나면 에디터가 user. 까지 쳤을 때 name, email 을 친절하게 추천해줘요. 그래서 다들 그 자동완성을 믿고 넘어가죠. 근데 실제 응답에서 name 이 빠져 있거나 id 가 문자열로 와도 컴파일러는 아무 말도 안 해요. 받아온 데이터의 타입은 우리가 손으로 적어서 생기는 게 아니라, 경계에서 한 번 검증해서 얻어내는 거예요.

타입이 경계에서 사라지는 순간

fetch 로 받은 응답을 res.json() 으로 풀면 뭐가 나올까요. MDN 은 그냥 body 텍스트를 JSON 으로 파싱한 결과로 resolve 되는 Promise 라고만 설명해요. 파싱 결과가 객체인지 배열인지 숫자인지는 런타임에 가봐야 알 수 있죠. 그래서 TypeScript 가 들고 있는 Response.json() 의 시그니처는 json(): Promise<any> 예요.

"It returns a promise which resolves with the result of parsing the body text as JSON." - MDN

파싱 결과의 타입이 뭔지 fetch 는 알 수가 없어요. 그래서 가장 헐겁게 열어둔 게 any 예요.

any 가 끼는 순간 그 뒤로는 타입 검사가 통째로 꺼져요. user.naem 처럼 오타를 쳐도, user.profile.avatar 처럼 있지도 않은 중첩 속성을 파고들어도 컴파일러가 다 통과시켜요. 네트워크를 건너온 데이터는 우리 코드가 한 번도 본 적 없는 값인데, any 라는 한 단어가 믿고 써도 된다는 거짓 신호를 주는 셈이죠.

as User는 컴파일러를 설득할 뿐

가장 먼저 떠오르는 처방은 단언이에요. await res.json() as User 라고 적으면 빨간 줄이 사라지고 user.namestring 으로 잡혀요. 문제가 풀린 것처럼 보이죠. 근데 as 는 값을 바꾸지도, 검사하지도 않아요. 그냥 컴파일러한테 이건 User 니까 그렇게 알아두라고 말해주는 것뿐이에요.

"Because type assertions are removed at compile-time, there is no runtime checking associated with a type assertion." - TypeScript Handbook

단언은 빌드할 때 사라져요. 응답이 User 모양이 아니어도 예외 하나 안 나고, 그 거짓말은 한참 뒤 user.name.toUpperCase() 가 터질 때야 들통나요.

as 가 왜 검증이 아니라 설득에 가까운지는 전에 다룬 글에서 더 풀어놨어요. 핵심만 옮기면, 단언은 컴파일러의 시야를 좁히거나 넓힐 뿐 런타임의 실제 값에는 손을 못 대요.

그래서 첫걸음은 응답을 any 가 아니라 unknown 으로 받는 거예요. unknown 은 모든 값을 담지만, 좁히기 전엔 .name 접근조차 막아서 any 보다 안전하거든요. 단언으로 컴파일러 입을 틀어막는 대신, unknown 이 검증부터 하라고 강제하게 두는 거죠.

경계에서 검증하면 타입이 따라온다

unknown 으로 받았으면 이제 진짜 검증을 해야 해요. 손으로 if (typeof data.id === "number") 를 줄줄이 쓸 수도 있지만, 응답 스키마가 조금만 커져도 금방 지쳐요. 여기서 Zod 같은 런타임 스키마 검증 도구가 들어와요.

스키마를 한 번 정의하면 두 가지가 동시에 따라와요. 하나는 런타임에서 실제 값이 그 모양인지 확인하는 검사기예요. 다른 하나는 그 스키마에서 뽑아낸 정적 타입이고요. z.infer<typeof userSchema> 한 줄이면 인터페이스를 따로 손으로 적지 않아도 User 타입이 나와요. 스키마 하나가 검증과 타입의 단일 출처가 되는 거죠.

import { z } from "zod";
 
const userSchema = z.object({
  id: z.number(),
  name: z.string(),
});
 
// 타입을 따로 적지 않아요. 스키마에서 뽑아내요.
type User = z.infer<typeof userSchema>;
 
async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();
  return userSchema.parse(data); // 모양이 안 맞으면 여기서 throw
}

"Zod infers a static type from your schema definitions." - Zod

타입을 먼저 적고 검증을 거기 맞추는 게 아니라, 검증을 적으면 타입이 따라 나와요. 둘이 어긋날 일이 없어진다는 게 진짜 이득이에요.

parse 는 응답이 스키마와 안 맞으면 ZodError 를 던져요. 던지는 게 부담스러운 자리라면 safeParse 가 있어요. 얘는 예외 대신 { success: true, data }{ success: false, error } 모양의 결과를 돌려줘서 분기로 다룰 수 있어요. 아래에서 서버 응답을 바꿔가며 직접 눌러보세요. 실제 zod 를 설치해서 돌아가는 데모예요.

id 가 문자열로 오면 safeParse 가 막아주고, 성공으로 좁혀지면 그 뒤 result.dataUser 로 확정돼요. 이건 타입 가드가 제어 흐름을 좁히는 거랑 같은 원리예요. 그 좁히기가 안에서 어떻게 도는지는 타입 가드 글에서 다뤘어요.

TanStack Query는 queryFn을 따라간다

TanStack Query 를 쓰면 이 검증을 어디다 넣어야 할지 헷갈릴 수 있어요. 답은 queryFn 이에요. useQueryqueryFn 이 돌려주는 타입을 보고 data 타입을 정하거든요. queryFnPromise<User>dataUser | undefined 가 돼요. 로딩이나 에러일 땐 아직 값이 없으니까 undefined 가 붙는 거고요.

그래서 아까 만든 getUser 를 그대로 queryFn 에 꽂으면 끝이에요. getUser 의 반환 타입이 z.infer 로 잡힌 User 라서, data 도 알아서 User 로 추론돼요.

const { data } = useQuery({
  queryKey: ["user", id],
  queryFn: () => getUser(id), // Promise<User> 를 돌려줘요
});
// data 는 User | undefined. 검증을 통과한 값만 여기 도착해요.

"Keep in mind that most data fetching libraries return any per default, so make sure to extract it to a properly typed function." - TanStack Query

useQuery<User>(...) 처럼 꺾쇠로 타입을 박는 대신, 제대로 타이핑된 함수로 빼라는 얘기예요. 그 제대로 타이핑된 함수 자리가 바로 우리가 검증을 끼운 getUser 고요.

왜 제네릭으로 박는 걸 말리냐면, useQuery<User> 라고 한 칸을 직접 채우는 순간 나머지 제네릭의 추론이 같이 깨져요. 게다가 그 Useras 랑 똑같이 그렇게 알아두라는 약속 수준이라 런타임 보장이 없어요. 검증을 통과한 함수에서 타입이 흘러나오게 두는 편이 훨씬 단단하죠.

변환이 필요하면 select 가 타입까지 같이 옮겨줘요. dataUser | undefined 일 때 select: (u) => u.name 을 주면 datastring | undefined 로 바뀌어요. 변환 함수의 반환 타입을 그대로 따라가니까, 여기서도 우리가 타입을 적을 일이 없어요. 참고로 SWR 은 또 다른 방식으로 같은 문제를 푸는데, 두 도구의 철학 차이는 SWR 과 TanStack Query 글에서 비교해놨어요.

검증할 곳을 한 군데로 모으기

시니어가 타입을 다룰 때 던지는 질문은 어떻게 타입을 적을까가 아니라 어디서 한 번 검증할까예요. 경계 한 곳을 정해서 거기서만 막으면, 그 안쪽 수백 줄은 값을 의심하지 않아도 되거든요. 저도 예전엔 응답마다 as 를 흩뿌려놓고 런타임에서 undefined 가 터질 때마다 그 자리를 땜질했어요. 검증을 함수 하나로 모으고 나서야 같은 버그를 두 번 안 쫓게 됐죠.

정리하면, 받아온 데이터에 타입을 입히는 일은 선언이 아니라 검증이에요. as 로 컴파일러를 설득하면 거짓말이 코드 깊숙이 박히고, 경계에서 한 번 검증하면 그 뒤 코드는 값을 믿고 쓸 수 있어요. TanStack Query 든 맨 fetch 든, 검증을 통과한 함수 하나만 잘 만들어두면 타입은 알아서 따라와요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

런타임 경계를 건너오는 데이터는 스키마에서 뽑는 쪽이 안전해요. 검증과 타입이 한 출처에서 나오니까 어긋날 일이 없거든요. 반대로 네트워크를 안 거치는 내부 전용 타입까지 전부 스키마로 만들 필요는 없어요. 그건 그냥 interface 로 충분해요.
검증은 경계에서 한 번만 돌아요. 작은 객체를 safeParse 하는 비용보다, 잘못된 데이터가 코드 안쪽 깊이까지 퍼진 뒤 디버깅하는 비용이 훨씬 큽니다. 응답이 아주 크고 자주 온다면 그때 부분 검증을 고민해도 늦지 않아요.
한 번에 다 걷어낼 필요는 없어요. queryFn 을 타이핑된 함수로 추출하는 것부터 시작하면, 그 쿼리의 제네릭은 자연스럽게 지울 수 있어요. 나머지는 손이 닿을 때 점진적으로 옮기면 돼요.

참고 자료

관련 글