프론트엔드가 하는 일의 절반은 서버에서 받아온 데이터를 화면에 뿌리는 것이다. 그런데 fetch로 받아온 응답은 타입스크립트가 보기엔 정체불명이다. 여기에 타입을 제대로 씌우지 않으면, 타입스크립트를 쓰는 의미의 절반이 날아간다. 서버가 준 데이터가 내가 기대한 모양인지 아닌지를 타입이 지켜주지 못하기 때문이다.
res.json()은 any를 뱉는다. 이게 함정이다. fetch로 받은 응답에서 res.json()을 부르면 그 결과 타입은 any다. any는 타입 검사를 통째로 꺼버린다는 뜻이라, 없는 속성을 찍어도 아무 경고가 없다.
async function load() {
const res = await fetch("/api/user");
const data = await res.json();
return data.usernaem.toUpperCase();
}
// 에러 없음. data 가 any 라 오타도 그냥 통과한다
여기서 무서운 건 에러가 뜨는 게 아니라 안 뜨는 것이다. usernaem이라고 오타를 냈는데도 컴파일러가 조용하다. 실행하면 그제야 undefined의 toUpperCase를 읽을 수 없다며 런타임에 죽는다. 타입스크립트를 켜놓고도 자바스크립트 시절과 똑같이 당하는 것이다. 그러니 응답에는 반드시 우리가 아는 모양을 명시해줘야 한다.
인터페이스로 응답의 모양을 먼저 그린다. 서버가 준다고 약속한 데이터의 생김새를 interface로 적어두고, 그걸 응답에 붙인다. 그러면 그 뒤로는 타입스크립트가 그 모양대로 검사해준다.
interface User {
id: number;
name: string;
email: string;
}
async function getUser(): Promise<User> {
const res = await fetch("/api/user");
const data: User = await res.json();
return data;
}
const data: User라고 적는 순간, 이 뒤로 data는 User로 취급된다. 이제 data.usernaem 같은 오타는 TS2339로 즉시 잡히고, 편집기 자동완성도 id, name, email만 얌전히 제안한다. 응답에 타입을 붙이는 건 이 한 줄이 거의 전부다.
타입 단언(as)은 검사가 아니라 약속일 뿐이다. 위에서 const data: User 대신 await res.json() as User라고 쓰는 사람도 많다. 결과는 비슷해 보이지만 as에는 함정이 있다. as는 타입스크립트에게 "내가 책임질 테니 이 값을 이 타입으로 봐라"라고 우기는 것이지, 실제 값이 그런지 확인하는 게 전혀 아니다.
const data = await res.json() as User;
// 서버가 실제로 { id, name } 만 보내도
// TS 는 email 이 있다고 철석같이 믿는다
console.log(data.email.trim()); // 런타임에서 터짐
서버가 email을 빼먹고 보내도 타입스크립트는 모른다. as로 있다고 약속했으니까. 그래서 as는 컴파일 에러를 지워줄 뿐, 런타임의 진실은 바꾸지 못한다. 나는 급하게 as로 빨간 줄만 지웠다가, 정작 서버 응답 형태가 바뀐 걸 배포 후에야 안 적이 있다. 편할 때 쓰되, 이게 확인이 아니라 떠넘기기라는 걸 잊으면 안 된다.
매번 같은 코드를 쓰기 싫으면 제네릭 헬퍼로 묶는다. API를 부를 때마다 fetch, res.json(), 타입 붙이기를 반복하게 되는데, 이걸 함수 하나로 묶어두면 편하다. 어떤 타입으로 받을지를 <T>로 바깥에서 정하게 하는 게 요령이다.
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`요청 실패: ${res.status}`);
}
return res.json();
}
// 부를 때 원하는 타입을 넣어준다
const user = await getJson<User>("/api/user");
user.name; // string 으로 잡힘
getJson<User>(...)라고 부르면 반환값이 Promise<User>가 되고, await로 풀면 user가 곧장 User다. T는 정해진 타입이 아니라 부르는 쪽이 그때그때 끼워 넣는 자리다. 응답이 비정상일 때 res.ok로 걸러 던지는 습관도 같이 들여두면, 404나 500이 와도 엉뚱한 데서 터지지 않고 부른 자리에서 잡힌다. 이 헬퍼 하나면 프로젝트 전체의 API 호출이 짧고 안전해진다.
배열과 중첩 응답도 그대로 그려주면 된다. 목록을 받으면 User[], 응답이 { data: ... }처럼 한 겹 감싸여 있으면 그 껍데기까지 타입으로 적는다.
interface ListResponse {
page: number;
items: User[];
}
const res = await fetch("/api/users");
const body: ListResponse = await res.json();
body.items.forEach(u => console.log(u.name));
이렇게 껍데기를 정확히 적어두면 body.items가 User[]로 잡혀서, forEach 안의 u도 자동으로 User가 된다. 응답 구조를 한 번 성실하게 옮겨 적어두면 그 아래로는 전부 공짜로 검사가 딸려 온다. 목록 응답에서 껍데기를 빼먹고 User[]라고만 적었다가, 실제로는 { page, items }로 감싸여 와서 forEach가 없다며 터지는 건 아주 흔한 실수다. 서버가 주는 그대로, 한 겹도 빼지 말고 적어야 한다.
없을 수 있는 필드는 옵셔널이나 유니온으로 정직하게 적는다. 서버가 상황에 따라 안 줄 수도 있는 값을 필수인 척 적어두면, 그게 곧 거짓말이 되어 나중에 undefined로 되돌아온다. 있을 수도 없을 수도 있는 값은 ?를 붙이거나 | null을 명시한다.
interface Profile {
id: number;
nickname: string;
bio?: string; // 없을 수 있음
avatar: string | null; // 값이 없으면 null 로 옴
}
이렇게 적어두면 profile.bio.length처럼 바로 쓰려 할 때 possibly undefined로 막아주니, 없는 값을 실수로 건드리는 사고를 컴파일 단계에서 미리 잡는다. 귀찮아도 응답의 진짜 모양을 있는 그대로 적는 게 결국 나를 구한다.
정말 안전하게 가려면 런타임 검증까지 붙인다. 지금까지의 타입은 전부 컴파일 시점의 약속이고, 실제로 서버가 그 모양을 지켰는지는 실행해봐야 안다. 여기서 한 발 더 나가면 zod 같은 런타임 검증 라이브러리로 응답을 실제로 검사한 뒤 타입을 얻는 방식이 있다. 이건 다음 단계의 이야기이고, 지금은 응답마다 인터페이스를 붙이는 습관만 확실히 들여도 사고의 대부분이 사라진다. res.json()의 any를 그냥 흘려보내지 않는 것, 그게 시작이자 핵심이다.