Tistory
왜 타입스크립트는 당신을 구해주지 못하는가
TypeScript 의 초록색 체크는 *코드가 자기 자신과 일관됨* 을 의미할 뿐, *외부 데이터가 정말 그 모양인지* 는 보장하지 않는다. 안전성은 경계(boundary) 에서 검증되어야 한다.
핵심 요약
안전한 프런트엔드 아키텍처는 (1) 인프라 레이어에서만 외부 데이터(API, localStorage, URL, 사용자 입력)를 받아 (2) Zod/io-ts/Effect 로 파싱하여 검증된 타입을 만든 뒤 (3) 도메인 레이어는 그 검증된 타입만 다루도록 분리한다. as 단언과 any 는 벤치마크 안전망 이 없는 한 PR 에서 차단한다. 컴파일된다 ≠ 동작한다 는 사고를 팀의 디폴트로 만들고, 실패 경로 테스트 를 항상 함께 작성한다.
TypeScript 를 '런타임을 막아주는 보안 장치' 로 보는 시각에서 '내부 일관성을 보장하는 컴파일타임 린터' 로 시각을 바꾼다. 진짜 안전성은 어디까지가 외부 데이터인가 를 명확히 그어 인프라 레이어에서 파싱·검증 하고, 도메인 레이어로는 검증된 값만 흘려보내는 아키텍처에서 나온다.
프로덕션 버그의 큰 비중은 타입은 맞지만 값은 틀린 데이터에서 시작된다.
response.json()은 시그니처상Promise<any>인데, 반환 타입에User만 적으면 TS 는 거짓말을 그대로 믿는다as,as unknown as T,@ts-ignore,any같은 escape hatch 가 코드베이스 어딘가에 하나만 있어도 그 지점부터는 안전하지 않다useEffect안에서 fetch +setState(data as User)처럼 인프라/도메인이 한 컴포넌트에 뭉치면 검증할 위치 자체가 사라진다
면접에서 '타입스크립트로 어떻게 안전성을 보장하나' 라는 질문에 Zod 같은 런타임 파서로 경계에서 검증 하고 brand 타입 으로 검증된 값만 도메인에 들어오게 강제한다는 답을 할 수 있어야 한다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
반환 타입은 *암묵적 캐스팅* 이다
함수의 반환 타입을 적는 순간, 본문에서 어떤 값이 나오든 TS 는 그 타입 으로 믿는다. response.json() 처럼 실제로는 any 를 반환하는 경우 특히 위험하다.
// ❌ 거짓말 — fetch 응답이 어떤 모양이든 User 라고 단언
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json(); // any → User 로 암묵 캐스팅
}
// ✅ 경계에서 파싱
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
async function getUser(id: number): Promise<z.infer<typeof UserSchema>> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json()); // 런타임 검증
}
Promise<User> 만 적고 안심하는 것. TS 는 fetch 응답이 진짜 User 인지 검사할 능력이 없다.
Escape hatch 는 *팀의 가장 약한 부분* 만큼 안전하다
any, @ts-ignore, as unknown as T 는 한 곳에서만 쓰여도 그 지점 이후 모든 안전성 가정을 무너뜨린다.
| 우회 경로 | 위험도 | 대응 |
|---|---|---|
any | 매우 높음 | noImplicitAny + ESLint no-explicit-any 로 차단 |
as User | 높음 | 검증 함수(타입 가드) 로 대체 |
as unknown as T | 매우 높음 | PR 차단 + 코드리뷰 항목 |
@ts-ignore | 매우 높음 | @ts-expect-error + 만료 일자 코멘트 |
ESLint 규칙으로 자동 차단하지 않으면 대규모 코드베이스에서 누군가는 반드시 빠뜨린다.
'테스트 코드에서만 any 쓰자' 같은 예외를 만드는 것. 한 번 허용하면 곧 도메인 코드로 번진다.
Brand 타입으로 *검증된 값만* 도메인에 들어오게
런타임 검증으로 만든 값에 컴파일타임 브랜드 를 붙이면 검증을 거치지 않은 raw 값을 도메인 함수에 못 넘긴다.
type Email = string & { readonly __brand: 'Email' };
function parseEmail(input: string): Email {
if (!/^[^@]+@[^@]+$/.test(input)) throw new Error('invalid');
return input as Email;
}
function sendWelcome(email: Email) { /* ... */ }
sendWelcome('plain string'); // ❌ 컴파일 에러
sendWelcome(parseEmail(form.email)); // ✅ 검증된 값만
Zod 로 검증만 해두고 결과를 평범한 string 으로 흘려보내는 것. 검증되지 않은 값과 구분되지 않으면 결국 검증을 잊는 곳이 생긴다.
읽는 순서
- 1이론
Zod 의
.parse()vs.safeParse()와z.infer<>동작을 정독하고, brand 타입 패턴 한 페이지로 정리한다. - 2구현
기존 API 클라이언트의 fetch 함수 3개를 Zod 스키마로 감싸 본다. 응답 형태가 다른 케이스를 일부러 만들어
safeParse가 어떻게 실패하는지 관찰한다. - 3실무
ESLint 에
no-explicit-any,no-non-null-assertion,consistent-type-assertions를 켜고 한 PR 에서 위반을 모두 잡는다. - 4설명
팀에 '타입스크립트와 타입 안전성은 다르다' 라는 주제로 10분 발표를 준비. 데모로
as User가 통과하는 코드 vs Zod 로 잡히는 코드를 비교.
면접 연결 질문
[감점 답변] 'as 는 캐스팅, parse 는 검증' 만 말하기. [좋은 답변] as 는 컴파일타임에 타입을 강제 할 뿐 런타임에 아무 일도 일어나지 않는다 — 거짓말이 가능하다. 타입 가드는 런타임에 형태를 검사하고 반환값으로 narrowing 까지 제공한다. 외부 데이터에는 항상 가드/파서, 내부에서 이미 검증된 값 의 타입을 좁힐 때만 as.
[감점 답변] '안전이 최우선'. [좋은 답변] (1) 대부분의 페이로드는 KB 수준이라 파싱 비용은 ms 미만 — fetch 지연에 묻힌다. (2) 핫패스(거대 리스트) 만 strict 대신 passthrough + 필드 단위 검증으로 완화. (3) 백엔드 OpenAPI 에서 스키마를 생성하면 타입과 검증을 동시 에 얻어 유지보수 비용도 작다.
[감점 답변] 'any 를 다 unknown 으로 바꾼다'. [좋은 답변] (1) noImplicitAny 켜고 신규 코드부터 차단. (2) eslint-plugin-import + no-explicit-any 를 경고 로 두고 기존 위반 카운트를 측정. (3) 가장 위험한 경계(API 클라이언트, localStorage, URL parser) 부터 Zod 로 교체. (4) @ts-expect-error 에 만료 일자를 코멘트로 강제하고, 만료 시 CI 에서 차단.
자기 점검
'백엔드가 검증했으니 프런트는 신뢰해도 된다'. 백엔드 변경/스키마 마이그레이션/캐시된 stale 응답에서 깨진다.
'타입이 있으니 그대로 믿어도 된다'. 실제로는 검증 없는 타입 어노테이션은 주석에 가깝다.