타입스크립트는 결국 당신을 당신 자신으로부터 지켜주지 못합니다.
제네릭, 조건부 타입, 매핑 타입 같은 고급 기능을 다루기 위해 많은 시간을 투자한 사람이라면 이 말이 다소 가혹하게 들릴 수 있습니다. 하지만 타입스크립트 컴파일러가 보여주는 초록색 체크표시는 당신의 코드가 스스로 모순 없이 일관된다는 것일 뿐, 그 코드가 정말로 올바르다는 보증은 아닙니다.
이 글은 타입스크립트를 비난하려는 것이 아닙니다. 오히려 “타입이 곧 타입 안전성이다”라는 오해를 깨기 위한 이야기입니다.
문제는 도구가 아니라 사고방식입니다. 저는 이 장면을 현업에서 끊임없이 보게 됩니다. "타입이 잡아주겠지"라며 엣지 케이스를 더 이상 고민하지 않는 개발자, "이미 타입이 있으니까"라는 이유로 검증을 생략하는 코드, 그리고 컴파일러를 지나치게 신뢰하는 습관이 문제입니다.
타입스크립트는 안전하다는 느낌은 줄 수 있지만, 실제 안전을 보장해 주지는 않습니다.
그리고 그 간극은 느낌과 현실 사이, 컴파일타임과 런타임 사이, 코드와 외부 세계 사이에 존재합니다. 바로 그 지점에서 프로덕션 버그가 발생합니다.
안전하다는 착각
타입스크립트는 훌륭한 도구입니다. 수많은 버그를 사전에 잡아주고, 리팩터링을 훨씬 안전하게 만들어주며, 개발 경험을 눈에 띄게 개선해 줍니다. 저 역시 매일 사용하고 있고, 중요한 프로덕션 코드라면 다시는 순수 자바스크립트로 돌아가고 싶지 않습니다.
하지만 타입스크립트는 외부 세계로부터 당신을 보호해주지 않습니다. 그리고 더 큰 문제는, 이 언어 자체가 곳곳에 우회 경로(escape hatch)를 마련해 두어 상황을 오히려 악화시킨다는 점에 있습니다.
우회 경로의 문제
무슨 말인지 예시로 보여드리겠습니다. 아래 코드는 타입스크립트 관점에서는 전혀 문제가 없습니다.
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return await response.json();
}
const firstUser = await getUser(1);
// 여기서 무슨 일이 벌어지나요?
console.log(`Greetings, ${firstUser.name}!`);
컴파일러는 아무 문제 없다는 듯 만족합니다. 코드 편집기 역시 오류를 보여주지 않습니다. (심지어 console.log 안에서 User의 프로퍼티를 자동 완성까지 해줍니다!) 하지만 사실 우리는 이미 타입 시스템에게 명백한 거짓말을 한 셈입니다.
as User나, 더 나쁜 형태인 as unknown as User 같은 단언을 쓰지도 않았는데, 우리는 타입스크립트에게 이 함수가 실제로 무엇을 반환하든(아무것도 반환하지 않는 경우조차도) 항상 User를 반환한다고 믿게 만들어 버린 것입니다. API는 null을 반환할 수도 있고, 에러 객체를 보낼 수도 있고, 전혀 다른 구조의 데이터를 보낼 수도 있습니다. 타입스크립트는 이를 절대 알 수 없습니다.
반환 타입을 통해 암묵적으로 캐스팅되는 상황은 우회 경로 중 하나에 불과합니다. 타입스크립트는 더 많은 우회 경로를 제공합니다.
any(사실상 핵무기)@ts-ignore(문제를 그냥 덮어버리는 방식)as unknown as T(항상 동작하는 이중 거짓말)- 검증할 수 없는 타입 단언
규모가 큰 코드베이스에서 누군가 이런 우회 경로를 쓰지 않았다는 걸 어떻게 확신할 수 있을까요? 방법이 없습니다. 결국 코드의 안전성은 가장 취약한 부분(any) 만큼만 안전할 뿐입니다.
Elm과 비교해 보면 차이는 더 분명합니다. Elm에서는 이런 속임수가 아예 불가능합니다. 우회 경로가 없습니다. 컴파일러가 안전하다고 말하면, 그건 실제로 안전합니다.
경계 문제
조금 더 깊이 들어가 보면, 핵심 문제는 여기에 있습니다. 타입스크립트는 당신의 코드 내부만 알고 있으며, 외부 세계에 대해서는 아무것도 모른다는 점입니다.
API 응답, 사용자 입력, 로컬스토리지 값, URL 파라미터 등 시스템으로 들어오는 모든 데이터는 본질적으로 믿을 수 없는 상태입니다. 타입스크립트는 이를 검증할 수 없습니다. 당신이 부여한 타입은, 실제 검증을 하기 전까지는 그냥 희망 사항에 불과합니다.
문제는 이것뿐만이 아닙니다. 현대 프론트엔드 개발 대부분이 프레임워크 로직과 인프라스트럭처 로직을 강하게 결합해 놓는다는 점에서 상
황은 더 나빠집니다.
아래는 전형적인 리액트 컴포넌트의 예입니다.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data as User)); // ??
}, [userId]);
if (!user) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
이런 구조는 너무도 흔합니다. UI 로직, 상태 관리, 사이드 이펙트, 데이터 패칭이 한 컴포넌트 안에서 전부 뒤섞여 있습니다. 이 컴포넌트는 동시에 다음 역할을 수행하고 있습니다.
- 리액트 고유의 상태와 라이프사이클 관리
- 네트워크에서 데이터 가져오기
- 가져온 데이터를 변환하기 (타입 단언까지 곁들여서)
- UI 렌더링
안전한 데이터와 안전하지 않은 데이터의 경계는 존재하지 않습니다. 인프라스트럭처 로직(fetching)은 프레임워크 로직(hooks, effects)에 결합되고, 이는 다시 프레젠테이션 로직과 섞여버립니다.
이 문제는 타입스크립트만의 문제가 아니라, 아키텍처적인 문제입니다. 하지만 타입스크립트는 data as User 같은 단언이 마치 안전한 것처럼 보이게 만들어 오히려 상황을 더 악화시킵니다.
제대로 설계된 시스템에서는 도메인 레이어와 애플리케이션 레이어가 검증된 안전한 데이터만을 다룹니다. 오직 인프라스트럭처 레이어만이 외부 세계의 지저분하고, 구조가 보장되지 않은 데이터를 처리해야 합니다.
Elm: 기본적으로 안전한 언어
Elm은 이런 아키텍처를 강제로 따르게 합니다. API 데이터를 다루는 방식만 봐도 확연히 드러납니다.
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
Decode.map3 User
(Decode.field "id" Decode.int)
(Decode.field "name" Decode.string)
(Decode.field "email" Decode.string)
-- Result Error User를 반환한다
-- 컴파일러가 성공/실패 두 경우 모두를 처리하도록 강제한다
decodeUser : String -> Result Error User
decodeUser json =
Decode.decodeString userDecoder json
```
도메인 레이어에 User가 도달한 순간, 그 값은 무조건 유효합니다. 타입 시스템이 유효하지 않은 데이터가 비즈니스 로직에 침투하는 것을 원천적으로 막아주기 때문입니다. 내부 레이어는 오직 안전한 데이터만을 다루게 됩니다.
타입스크립트: 경계가 없는 세계
반면 타입스크립트에는 이런 강제력이 없습니다. 검증되지 않은 데이터를 어디로든 그대로 흘려보낼 수 있습니다.
// 인프라스트럭처 레이어 - raw 데이터를 얻음
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return await response.json(); // ?? 실제로 User이기를 희망함
}
// 도메인 레이어 - user 데이터가 안전하다고 가정함
function sendWelcomeEmail(user: User) {
// user가 null, 숫자 등 User가 아니라면 깨짐
emailService.send(user.email, "Welcome!");
}
타입스크립트는 fetchUser가 실제 User를 반환하지 않을 수도 있다는 사실을 알려주지 못합니다. 또한 도메인 레이어가 잠재적으로 잘못된 데이터를 다루고 있다는 점도 감지하지 못합니다.
물론 타입스크립트에서도 경계를 올바르게 구축할 수는 있습니다. 바로 Zod나 io-ts 같은 라이브러리를 사용해 시스템의 가장자리에서 데이터를 검증하는 방식입니다.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserSchema.parse(data); // 실제로 검증됨!
}
그런데 중요한 점이 있습니다. 이 검증을 직접 기억해서 해야 한다는 것입니다. 타입스크립트는 잊어버린다고 해서 경고해주지도 않고, 컴파일을 막지도 않습니다. 그리고 수십 명이 함께 작업하는 큰 코드베이스에서는, 누군가는 반드시 빠뜨립니다.
(조금 더 전체적인 접근을 원한다면 Effect 같은 도구를 고려해볼 수도 있습니다. 하지만 이것은 또 다른 글의 주제입니다.)
런타임 vs 컴파일타임
이 지점에서 핵심적인 차이가 드러납니다. 타입스크립트는 런타임에 존재하지 않습니다. 그리고 컴파일타임에도 많은 것을 모른 채로 지나갑니다.
프로덕션 환경에서 코드가 실제로 실행될 때, 그 멋진 타입들은 모두 사라집니다. 남는 것은 동적이고, 타입이 없고, undefined가 앱을 터뜨려도 아무렇지 않은 언어인 자바스크립트입니다.
타입스크립트는 컴파일타임 도구일 뿐입니다. 코드가 스스로에게 모순되지 않는지만 확인할 뿐, 현실과 맞는지는 전혀 확인하지 못합니다. 그리고 당신이 알려주지 않는 이상, 아키텍처 레이어나 도메인과 외부 세계의 경계에는 관심도 없습니다.
반면 Elm의 타입은 아키텍처 전반에 걸쳐 일관되게 강제됩니다. 디코더는 단순히 타입을 주석처럼 붙이는 것이 아니라 실제로 데이터를 검증합니다. Maybe 타입은 “값이 없을 수도 있다”는 힌트를 주는 데서 끝나지 않고, 그 경우를 반드시 처리하지 않으면 컴파일 자체가 되지 않습니다.
더 근본적인 문제: 마인드셋
타입스크립트는 개발자에게 잘못된 안전감을 제공합니다.
저는 다음과 같은 개발자들의 모습을 자주 봅니다.
- "타입이 있으니까"라며 검증을 건너뛰는 경우
- "컴파일러가 확인했으니까"라며 엣지 케이스 테스트를 생략하는 경우
- 급하다는 이유로
as단언에 의존하는 경우 - 에러를 없애려고
any를 집어넣는 경우 - 컴파일되면 동작한다는 잘못된 믿음을 갖는 경우
진짜 위험한 건 여기입니다. 타입스크립트가 나쁘다는 뜻은 아닙니다. 실제로 매우 훌륭한 도구입니다. 문제는 우리가 타입스크립트를 본래 역할 이상으로 과신한다는 데 있습니다.
타입스크립트는 고급 린터입니다. 코드 내부의 오타, 잘못된 API 사용, 리팩터링 오류를 잡는 데 뛰어나죠. 하지만 이것은 안전성에 대한 보증이 아닙니다. 생각을 대신해주는 도구도 아니고, 진정한 의미의 완전한 타입 안전성과는 거리가 멉니다.
진짜로 당신을 지켜주는 것은 무엇인가
그렇다면 타입스크립트가 당신을 지켜주지 못한다면, 무엇이 필요할까요?
경계를 이해하기
타입스크립트나 Elm 또는 어떤 언어로 작성된 시스템이든 상관없이 어디까지가 안전하지 않은 데이터이고, 어디서부터 안전해지는지를 명확하게 구분해야 합니다. 외부 세계에서 들어오는 데이터는 인프라스트럭처 레이어에서 검증되고, 도메인 레이어는 오직 검증된 값만을 다뤄야 합니다.
Elm은 이 구조를 언어 차원에서 강제로 따르게 합니다. 경계에서는 디코더가 데이터를 검증하고, 핵심 로직은 순수 함수로 유지되며, 부수효과는 바깥으로 밀려납니다. 속임수는 허용되지 않습니다.
하지만 타입스크립트에서는 이 규칙을 직접 만들어야 합니다. 다음과 같은 방식으로요.
- 경계에서 데이터를 검증하거나 파싱하기 - Zod, io-ts, Effect 같은 도구를 사용하고, 외부 데이터를 그대로 믿지 않기.
- 안전한 타입 만들기 - 검증된 값으로만 생성 가능한 브랜드 타입이나 클래스를 사용하기.
- 우회 경로를 차단하기 -
any,as,@ts-ignore를 경고나 에러로 처리하도록 설정해 사실상 쓰기 어렵게 만들기. - 관심사 분리하기 - 인프라스트럭처(fetching, parsing)와 도메인 로직을 분리하고,
useEffect안에 비즈니스 로직을 섞지 않기. - 실패 경로 테스트하기 - 타입은 잘못된 데이터를 막아주지 못하지만, 테스트는 막아줄 수 있습니다.
타입 안전성의 공예(Craft)
여기서 자연스럽게 한 가지 생각으로 돌아가게 됩니다. 바로 코딩을 공예로 바라보는 관점입니다. (이에 대해서는 Coding as Craft: Going Back to the Old Gym에서도 이야기한 바 있습니다.)
좋은 장인은 자신이 사용하는 도구의 장점과 한계를 모두 이해합니다. 망치는 못을 박을 때 훌륭한 도구지만, 손에 쥐고 있다고 해서 나사에
사용하는 것은 올바른 선택이 아니죠.
타입스크립트도 마찬가지입니다. 그 한계를 이해할 때 비로소 훌륭한 도구가 됩니다.
• 코드베이스 내부의 버그를 잡아주고
• 리팩터링을 훨씬 안전하게 만들어주며
• 코드의 의도를 문서화하고
• 개발 경험을 전반적으로 향상시킵니다
하지만 타입스크립트가 못 하는 일도 명확합니다.
• 외부 데이터를 검증하지 못합니다
• 런타임 오류를 막지 못합니다
• 완전한 타입 안전성을 보장하지 못합니다
• 잘못된 데이터로부터 코드를 보호하지 못합니다
타입스크립트든 Elm이든, 어떤 언어를 사용하든 중요한 것은 당신이 실제로 무엇을 얻고 있는지 이해하는 것입니다. 도구는 훌륭하지만, 사고를 대신해주지는 않습니다. 그리고 (이 이야기는 다른 글에서 더 자세히 다루겠지만) 프런트엔드에는 단순히 “타입 선언”이 아니라 탄탄한 엔지니어링과 아키텍처가 필요합니다.
진짜 타입 안전성을 배우기
“컴파일되면 동작한다”가 밈이 아니라 현실이 되는 타입 안전성을 직접 느껴보고 싶다면, Elm을 시도해보세요. 물론 Elm만큼 안전한(그리고 함수형인) 언어들이 다른 곳에도 있지만, 프런트엔드 도메인(특히 리액트에 익숙하다면) Elm은 가장 짧고 직접적인 경로를 제공합니다.
꼭 프로덕션에 사용하라는 말은 아닙니다. (저는 실제로 쓰고 있고 매우 좋아하지만요) 대신, 언어가 타입 안전성을 ‘진지하게’ 받아들일 때 어떤 모습이 되는지 배우기 위한 도구로서 큰 의미가 있습니다. 우회 경로가 없고, 컴파일러가 진짜로 당신 편이며, 검증되지 않은 데이터가 시스템 내부까지 도달할 수 없는 환경입니다. 이런 진짜 타입 안전성을 경험하고 나면, 어떤 언어를 사용하든 자연스럽게 더 나은 경계를 설계하게 됩니다.
(이 주제는 An Elm Primer for React Developers에서 더 깊이 다뤘습니다. Elm의 보장이 어떻게 사고방식과 아키텍처를 바꾸는지, 그리고 그 경험이 타입스크립트로 돌아왔을 때 어떤 영향을 주는지에 대해 이야기합니다.)
결론
타입스크립트가 당신을 구해주지는 않습니다. 하지만 그 한계를 이해하는 것이 당신을 구할지도 모릅니다.
타입스크립트를 사용하세요. 즐기세요. 하지만 맹신하지는 마세요. 경계에서 데이터를 검증하고, 실패 시나리오를 테스트하며, 제대로 된 아키텍처를 구축해야 합니다. 그리고 기억해야 합니다. 타입스크립트의 초록색 체크표시는 코드가 자기 자신과 일관적이라는 뜻이지, 정확하다는 의미는 아니라는 것을.
좋은 코드는 생각하는 개발자, 자신의 공예를 갈고닦는 엔지니어와 아키텍트에게서 나옵니다. 매끈한 타입과 단단하게 결합된 코드에 취한 프레임워크나 하이프 추종자들에게서 나오는 것이 아닙니다.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article을 구독해주세요!
'개발 > 번역' 카테고리의 다른 글
| [번역] HTTP 범위 요청(Range Requests)을 통한 동영상 제공하기 (1) | 2025.12.31 |
|---|---|
| [번역] 상태 기반 렌더링 vs 시그널 기반 렌더링 (2) | 2025.11.06 |
| [번역] CSS 길이 단위 이해하기 (1) | 2025.09.02 |
| [번역] React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가 (10) | 2025.08.11 |
| [번역] 리액트의 개방-폐쇄 원칙: 확장 가능한 컴포넌트 만들기 (3) | 2025.06.29 |