리액트(React)로 컴포넌트를 분리하며 프로젝트를 개발하다 보면 누구나 한 번쯤 마주치는 악몽이 있습니다. 바로 '프롭스 드릴링(Props Drilling)'입니다.

로그인한 유저 정보나 다크모드 설정값처럼 앱 전체에서 쓰이는 데이터를 단 하나의 하위 컴포넌트에 전달하기 위해, 부모 ➔ 자식 ➔ 손자 ➔ 증손자 컴포넌트를 거쳐 불필요하게 props를 계속 넘겨주다 보면 코드가 지저분해지고 유지보수가 불가능해집니다.

처음에는 Redux(리덕스)를 도입해 보려 했지만, 단순한 상태 하나 만드는데 Action, Reducer, Store 등 보일러플레이트 코드가 너무 많아 배보다 배꼽이 더 큰 느낌을 받았습니다.

오늘은 보일러플레이트 없이 가볍고 직관적인 Zustand(주스탠드)를 도입하여 프롭스 드릴링을 단 10분 만에 해결했던 경험과 실무 활용 꿀팁을 공유해 보려고 합니다.

1. 내가 마주친 문제: "증손자 컴포넌트 하나 때문에 부모들이 고통받다"

헤더에 있는 장바구니 카운트와, 메인 상품 목록 페이지에 있는 '장바구니 담기' 버튼을 동기화해야 하는 상황이었습니다.

TypeScript
 
// ❌ 프롭스 드릴링으로 고통받던 과거 컴포넌트 구조
<App>
  <Header cartCount={cartCount} /> {/* 헤더에 전달 */}
  <MainPage>
    <ProductList>
      <ProductCard onAddToCart={handleAddToCart} /> {/* 3단계를 거쳐 함수 전달 */}
    </ProductList>
  </MainPage>
</App>

중간에 있는 MainPage나 ProductList는 onAddToCart 함수를 직접 쓰지도 않으면서 단순히 자식에게 넘겨주기 위해 props를 억지로 받아야 했습니다. 이 구조는 나중에 컴포넌트 위치가 바뀌거나 기능이 추가될 때마다 치명적인 리팩토링 비용을 발생시켰습니다.

2. 왜 Redux 대신 Zustand를 선택했는가?

전역 상태 라이브러리를 고민할 때 가장 먼저 떠오른 것은 Redux였지만, 작은 규모의 기능 하나를 추가하기 위해 작성해야 할 보일러플레이트 코드가 너무 많았습니다. 반면 Zustand는 다음과 같은 압도적인 장점이 있었습니다.

  1. 단순한 훅(Hook) 기반: 별도의 Context Provider로 앱 전체를 감쌀 필요가 없음.
  2. 극도로 가벼운 번들 크기: 수십 KB에 달하는 다른 라이브러리에 비해 약 1KB 수준으로 초경량.
  3. 타입스크립트(TypeScript) 완벽 지원: 복잡한 제네릭 설정 없이 직관적인 타입 추론 가능.

3. 실무 적용: 10줄로 만드는 Zustand 전역 스토어

Zustand의 핵심은 create 함수를 사용해 스토어(Store)를 정의하고, 필요한 컴포넌트에서 일반 커스텀 훅처럼 가져다 쓰는 것입니다.

TypeScript
 
// store/useCartStore.ts (장바구니 전역 스토어)
import { create } from 'zustand';

interface CartState {
  cartCount: number;
  addToCart: () => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>((set) => ({
  cartCount: 0,
  // set 함수를 사용해 상태를 불변성 유지하며 업데이트
  addToCart: () => set((state) => ({ cartCount: state.cartCount + 1 })),
  clearCart: () => set({ cartCount: 0 }),
}));

Provider로 최상단 App 컴포넌트를 감싸는 과정조차 필요 없이, 이 파일 하나로 전역 상태 세팅이 끝납니다.

인터페이스 정의와 Zustand create 스토어 구조


4. 컴포넌트에서 사용하기: "필요한 곳에서 바로 꺼내 쓴다"

이제 프롭스 드릴링 없이, 상태가 필

요한 컴포넌트에서 훅을 호출하기만 하면 됩니다.

TypeScript
 
// 1. 헤더 컴포넌트: cartCount 값만 구독
import { useCartStore } from '../store/useCartStore';

export function Header() {
  const cartCount = useCartStore((state) => state.cartCount);
  return <header>장바구니: 🛒 {cartCount}개</header>;
}

// 2. 상품 카드 컴포넌트: addToCart 함수만 호출
export function ProductCard() {
  const addToCart = useCartStore((state) => state.addToCart);
  return <button onClick={addToCart}>장바구니 담기</button>;
}

중간 컴포넌트들을 거칠 필요 없이 각자 필요한 데이터와 함수만 직접 구독하므로, 불필요한 리렌더링도 방지되고 코드가 엄청나게 깔끔해졌습니다.

담은 상품 수에 따라 달라지는 금액

5. 결론 및 느낀 점

Zustand를 도입하고 나서 리액트 상태 관리의 피로도가 절반 이하로 줄어들었습니다.

  • 상태 하나 만드는데 1분도 채 걸리지 않는 압도적인 생산성
  • 불필요한 props 전달이 사라져 깔끔해진 컴포넌트 트리 구조

컴포넌트 깊이가 깊어지면서 props를 넘겨주는 데 스트레스를 받고 계신다면, 무거운 라이브러리 대신 가볍고 직관적인 Zustand로 전역 상태 관리를 시작해 보시길 강력히 추천합니다!

자바스크립트(JavaScript)로만 프로젝트를 진행하다가 안정성을 높이기 위해 타입스크립트(TypeScript)를 처음 도입했을 때, 기쁨보다 당혹감이 훨씬 컸습니다.

분명 자바스크립트에서는 아무 문제 없이 잘 돌아가던 코드인데, 타입스크립트를 적용하자마자 VS Code 에디터 온 동네에 빨간 줄(Type Error)이 그어지며 화면이 온통 경고 메시지로 뒤덮였기 때문입니다.

당시에는 "개발 속도만 늦어지는 것 같은데 왜 쓸까?" 싶었지만, 타입 에러를 하나씩 해결해 나가면서 런타임(실행 시점)에서 터질 위험한 버그를 코드 작성 단계에서 미리 잡아주는 강력함을 체감하게 되었습니다.

오늘은 제가 타입스크립트 초보 시절 가장 자주 마주치며 식은땀을 흘렸던 대표적인 타입 에러 3가지와 완벽한 해결법을 정리해 보려고 합니다.

1. 에러 1: Object is possibly 'null' or 'undefined'

API 데이터를 받아오거나 DOM 요소를 참조할 때 가장 흔하게 만나는 에러입니다.

TypeScript
 
// ❌ 빨간 줄이 뜨는 과거의 코드
const nameLength = response.user.name.length;
// 🚨 Error: 'response.user' is possibly 'undefined'.

💡 원인 및 해결책

타입스크립트는 response.user 데이터가 서버 응답 지연이나 에러로 인해 undefined 상태일 가능성을 미리 경고해 주는 것입니다. 만약 이 상태로 자바스크립트에서 실행되었다면 런타임에 그 악명 높은 Cannot read properties of undefined 에러가 터졌을 것입니다.

해결을 위해 옵셔널 체이닝(?.)이나 타입 가드(Type Guard)를 활용하면 깔끔하게 해결됩니다.

TypeScript
 
// ⭕ 옵셔널 체이닝으로 안전하게 접근
const nameLength = response.user?.name?.length;

// ⭕ 또는 타입 가드(if문)로 안전성 보장
if (response.user && response.user.name) {
  const nameLength = response.user.name.length;
}

2. 에러 2: Property 'X' does not exist on type 'Y' (이벤트 객체 / 객체 키 접근 에러)

리액트에서 onChange 이벤트를 처리하거나, 객체의 키(Key)로 동적 접근을 시도할 때 자주 발생하는 에러입니다.

TypeScript
 
// ❌ e.target.value에서 빨간 줄이 뜨는 코드
const handleChange = (e) => {
  console.log(e.target.value);
  // 🚨 Error: Parameter 'e' implicitly has an 'any' type.
};

 

타입을 잘 지정해 에러가 안나는 모습

💡 원인 및 해결책

타입스크립트는 매개변수 e가 어떤 이벤트 객체인지 알지 못하므로 암묵적인 any 타입 경고를 보냅니다. 리액트에서 제공하는 이벤트 타입(React.ChangeEvent 등)을 명시해 주어야 합니다.

TypeScript
 
// ⭕ React의 HTMLInputElement 타입을 정확히 명시
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value); // 이제 에러 없이 자동완성까지 지원됨!
};

3. 에러 3: Type 'X' is not assignable to type 'Y' (상태 초기값 타입 불일치)

useState를 사용할 때 초기값 타입과 나중에 넣어줄 API 데이터의 타입이 달라서 발생하는 에러입니다.

TypeScript
 
// ❌ null로 초기화해 두었더니 나중에 객체를 넣을 때 에러 발생
interface UserProfile {
  id: number;
  name: string;
}

const [user, setUser] = useState(null);

// 나중에 API 응답 후 setUser 호출 시 에러!
setUser({ id: 1, name: '김개발' }); 
// 🚨 Error: Argument of type '{ id: number; name: string; }' is not assignable to parameter of type 'null'.

💡 원인 및 해결책

useState(null)로 작성하면 타입스크립트는 user 변수의 타입을 오직 null로만 추론해 버립니다. 제네릭(<T>)을 사용해 "초기엔 null이지만 나중에 UserProfile 객체가 들어올 수 있다"라고 명시해 주어야 합니다.

TypeScript
 
// ⭕ 제네릭과 유니온 타입을 활용해 두 가지 가능성을 모두 열어둠
const [user, setUser] = useState<UserProfile | null>(null);

// 이제 객체를 넣어도 빨간 줄 없이 정상 동작!
setUser({ id: 1, name: '김개발' });

 

useState에 타입추가

4. 회고 및 느낀 점

타입스크립트를 처음 시작했을 때는 일일이 타입을 적어주는 행위가 개발 속도를 갉아먹는 걸림돌처럼 느껴졌습니다.

하지만 수많은 빨간 줄과 싸우고 해결해 나가는 과정에서 "내가 작성하는 코드의 데이터 흐름을 훨씬 객관적이고 안전하게 파악하는 습관"을 가질 수 있게 되었습니다.

지금 타입스크립트의 빨간 줄 때문에 스트레스를 받고 계신 초보 개발자분들이 계시다면, 에러 메시지를 무서워하지 마시고 "타입스크립트가 미래의 런타임 에러를 대신 막아주고 있구나!" 하고 긍정적으로 바라보시면 될 것 같습니다!

타입스크립트(TypeScript)를 사용하면서 우리는 종종 착각에 빠지곤 합니다. "타입을 정의했으니, 런타임에서도 이 데이터는 안전할 거야"라고 말이죠. 하지만 타입스크립트의 타입 검사는 **빌드 시점(Compile Time)**에만 작동하며, 자바스크립트로 변환되는 순간 모두 사라집니다.

특히 외부 API에서 받아오는 데이터는 타입스크립트가 보호해 주지 못하는 '치외법권' 영역입니다. 백엔드에서 예고 없이 필드명을 바꾸거나 null을 내려주면, 우리 앱은 undefined 에러를 뿜으며 멈춰버립니다. 오늘은 이러한 '런타임 불확실성'을 제거해 주는 강력한 스키마 검증 라이브러리, Zod를 소개합니다.


1. 타입스크립트의 한계: 정적 타입 vs 동적 데이터

타입스크립트는 '기대하는 데이터의 형태'를 정의할 뿐, 실제 들어온 데이터가 그 형태인지 확인하지는 않습니다.

TypeScript
 
interface User {
  id: number;
  name: string;
}

// API 응답이 { id: "1", name: null }로 와도 TS는 에러를 잡지 못함
const user: User = await fetch('/api/user').then(res => res.json());
console.log(user.name.toUpperCase()); // 런타임 에러 발생!

이처럼 외부 데이터에 의존하는 프런트엔드 환경에서는 **런타임에서의 데이터 검증(Runtime Validation)**이 필수적입니다.


2. Zod란 무엇인가?

Zod는 'TypeScript-first' 스키마 선언 및 유효성 검사 라이브러리입니다. 단순히 데이터가 맞는지 확인하는 것을 넘어, 검증된 데이터로부터 타입스크립트 타입을 자동으로 추출해 주는 기능을 제공합니다.

2.1 Zod 스키마 정의하기

TypeScript
 
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(), // 이메일 형식까지 검증 가능
  role: z.enum(['admin', 'user']),
});

// 스키마로부터 타입 추출 (중복 정의 필요 없음!)
type User = z.infer<typeof UserSchema>;

3. 실전 활용: API 응답 안전하게 받기

Zod의 parse 메서드를 사용하면 데이터가 스키마와 일치하지 않을 때 즉시 에러를 발생시켜, 잘못된 데이터가 앱 내부로 흘러 들어오는 것을 원천 차단합니다.

3.1 안전한 패칭 로직

TypeScript
 
async function fetchUser(id: number) {
  const response = await fetch(`/api/user/${id}`);
  const rawData = await response.json();

  // 데이터 검증 시작
  const result = UserSchema.safeParse(rawData);

  if (!result.success) {
    // 유효성 검사 실패 시 처리 (로깅, 에러 바운더리 등)
    console.error("데이터 형식이 올바르지 않습니다:", result.error.format());
    throw new Error("Invalid Data");
  }

  return result.data; // 여기서 반환되는 데이터는 완벽하게 타입이 보장됨
}

4. Zod의 강력한 기능들

4.1 기본값 및 변환 (Transform)

데이터를 검증함과 동시에 원하는 형태로 가공할 수도 있습니다.

  • default: 데이터가 없을 때 기본값 부여.
  • transform: 문자열로 온 날짜 데이터를 Date 객체로 자동 변환.
TypeScript
 
const ProductSchema = z.object({
  price: z.number(),
  createdAt: z.string().transform((str) => new Date(str)),
});

4.2 조건부 검증

"나이가 19세 이상일 때만 성인 인증 여부가 필수" 같은 복잡한 로직도. refine()을 통해 구현할 수 있습니다.


5. React Hook Form과의 찰떡궁합

프론트엔드에서 Zod가 가장 많이 쓰이는 곳 중 하나가 바로 폼(Form) 검증입니다. react-hook-form의 zodResolver를 사용하면 복잡한 유효성 검사 로직을 선언적으로 관리할 수 있습니다.

TypeScript
 
const { register, handleSubmit } = useForm({
  resolver: zodResolver(SignUpSchema),
});

사용자가 입력을 마치는 순간 Zod 스키마에 따라 즉시 검증이 이뤄지며, 타입 안전성까지 챙길 수 있어 실무에서 매우 선호되는 조합입니다.

프런트엔드 개발을 하다 보면 "로직은 똑같은데 다루는 데이터 타입만 다른" 경우를 자주 만납니다. 예를 들어, API 응답 데이터를 받아서 리스트를 그려주는 컴포넌트가 있다고 합시다. 어떤 곳에서는 User []를 다루고, 어떤 곳에서는 Product []를 다룹니다.

이때 타입마다 별도의 컴포넌트를 만드는 것은 비효율적입니다. 그렇다고 any를 쓰자니 타입 안정성이 깨집니다. 이럴 때 필요한 것이 바로 제네릭(Generic)입니다. 제네릭은 타입을 마치 함수의 '인수(Argument)'처럼 취급하여, 사용하는 시점에 타입을 결정하게 해 줍니다.


1. 제네릭(Generic)이란 무엇인가?

제네릭은 한마디로 '타입의 변수화'입니다. 컴포넌트나 함수를 정의할 때는 타입을 비워두었다가, 실제로 사용할 때 구체적인 타입을 주입하는 방식입니다.

1.1 기본적인 제네릭 함수

TypeScript
 
function wrapInArray<T>(value: T): T[] {
  return [value];
}

const stringArray = wrapInArray<string>("Hello"); // T가 string이 됨
const numberArray = wrapInArray<number>(123);     // T가 number가 됨

위 코드에서 <T>는 관습적으로 사용하는 타입 변수명입니다. wrapInArray는 호출되는 순간 입력받은 값의 타입을 추론하여 반환 타입까지 완벽하게 맞물리게 합니다.


2. 실전! 제네릭을 활용한 리액트 컴포넌트

제네릭은 리액트 컴포넌트에서 데이터 목록(List)이나 테이블(Table)을 만들 때 가장 빛을 발합니다.

2.1 Generic List 컴포넌트 설계

다양한 형태의 데이터를 렌더링 할 수 있는 리스트 컴포넌트를 만들어 보겠습니다.

TypeScript
 
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 사용 예시
<List<User> 
  items={users} 
  renderItem={(user) => <span>{user.name}</span>} 
/>

이렇게 하면 renderItem 내부의 user는 자동으로 User 타입이 됩니다. any 없이도 완벽한 자동 완성 기능을 누릴 수 있는 것이죠.


3. 고차 컴포넌트(HOC)에서의 제네릭 활용

고차 컴포넌트(HOC)는 컴포넌트를 인자로 받아 기능을 추가한 새 컴포넌트를 반환하는 패턴입니다. 여기에 제네릭을 입히면 어떤 컴포넌트가 들어와도 타입을 유지한 채로 기능을 덧붙일 수 있습니다.

3.1 로딩 상태를 주입하는 withLoading HOC

컴포넌트에 로딩 스피너 기능을 추가하는 제네릭 HOC 예시입니다.

TypeScript
 
function withLoading<P extends object>(
  WrappedComponent: React.ComponentType<P>
) {
  return function WithLoadingComponent({ isLoading, ...props }: { isLoading: boolean } & P) {
    if (isLoading) return <Spinner />;
    return <WrappedComponent {...(props as P)} />;
  };
}

여기서 P extends object는 "인자로 들어오는 Props 타입은 객체 형태여야 한다"는 제약을 거는 것입니다. 이를 통해 원본 컴포넌트가 가진 Props 타입을 그대로 보존하면서 isLoading이라는 새로운 Prop만 추가할 수 있습니다.


4. 제네릭 제약 조건 (Constraints)

제네릭을 무조건 자유롭게 두는 것보다, 특정 조건을 만족할 때만 사용 가능하도록 제한하는 것이 더 안전합니다. extends 키워드를 사용합니다.

TypeScript
 
interface HasId {
  id: string;
}

// T는 반드시 id를 가진 객체여야 함
function logId<T extends HasId>(item: T) {
  console.log(item.id);
}

이렇게 제약을 걸면 logId({ name: 'Yumina' })는 에러가 발생하지만, logId({ id: '1', name: 'Yumina' })는 통과됩니다. 실무에서 API 응답 형태를 보장할 때 자주 쓰는 패턴입니다.

타입스크립트(TypeScript)로 규모가 큰 프로젝트를 진행하다 보면 비슷한 형태의 타입을 반복해서 정의하게 되는 순간이 옵니다. 예를 들어, 전체 사용자 정보를 담은 User 타입이 있는데, 프로필 수정 페이지에서는 일부 정보만 필요하고, 회원가입 페이지에서는 특정 정보가 빠져야 하는 경우입니다.

이때마다 UpdateUser, RegisterUser 처럼 새로운 인터페이스를 일일이 만드는 것은 매우 비효율적입니다. 원본 타입이 수정되면 연관된 모든 타입을 찾아가서 고쳐야 하기 때문이죠. 리액트의 컴포넌트 재사용처럼, 타입스크립트에도 '타입 재사용'을 위한 강력한 도구가 있습니다. 바로 유틸리티 타입(Utility Types)입니다.


1. 왜 유틸리티 타입인가? (DRY 원칙)

개발 원칙 중 하나인 DRY(Don't Repeat Yourself, 반복하지 마라)는 타입 정의에도 적용됩니다. 유틸리티 타입을 사용하면 하나의 'Source of Truth(진실의 근원)' 타입을 두고, 이를 필요에 따라 변형하여 사용할 수 있습니다.


2. Partial<T>: 모든 속성을 선택 사항으로

Partial은 가장 자주 쓰이는 유틸리티 타입 중 하나입니다. 기존 타입의 모든 속성을 optional(?)로 바꾸어 줍니다.

2.1 실무 활용: 데이터 업데이트(Update)

프로필 수정 기능에서는 사용자가 이름만 바꿀 수도 있고, 이메일만 바꿀 수도 있습니다. 모든 필드를 보낼 필요가 없을 때 Partial이 제격입니다.

TypeScript
 
interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}

// 모든 필드가 선택 사항이 됨
function updateUser(id: string, fieldsToUpdate: Partial<User>) {
  // ...업데이트 로직
}

updateUser("123", { name: "Yumina" }); // email, age가 없어도 에러가 나지 않음

3. Pick<T, K>: 필요한 것만 골라 담기

Pick은 기존 타입에서 특정 속성(Key)들만 골라내어 새로운 타입을 만듭니다.

3.1 실무 활용: 리스트 아이템 UI

사용자 상세 정보는 아주 많지만, 목록 화면(List)에서는 이름과 이메일만 보여주면 될 때 유용합니다.

TypeScript
 
// User 타입에서 'name'과 'email'만 추출
type UserSummary = Pick<User, 'name' | 'email'>;

const summary: UserSummary = {
  name: "Yumina",
  email: "dev@example.com"
};

4. Omit<T, K>: 필요 없는 것만 빼기

Omit은 Pick과 반대입니다. 특정 속성들만 제외한 나머지를 모두 가져옵니다.

4.1 실무 활용: 회원가입 시 ID 제외

DB에 저장될 때는 id가 자동으로 생성되므로, 회원가입 폼에서 입력받는 데이터 타입에는 id가 없어야 합니다.

TypeScript
 
// User 타입에서 'id'만 제외하고 나머지 모두 사용
type RegisterFormData = Omit<User, 'id'>;

const newUser: RegisterFormData = {
  name: "Yumina",
  email: "new@example.com",
  age: 25
};

5. Readonly<T>: 불변성 유지하기

객체의 속성을 수정할 수 없게 만듭니다. Redux나 Zustand 같은 상태 관리 라이브러리에서 상태의 **불변성(Immutability)**을 강제하고 싶을 때 유용합니다.

TypeScript
 
const config: Readonly<User> = {
  id: "0",
  name: "System",
  email: "admin@system.com",
  age: 99
};

// config.name = "Hack"; // 에러 발생: 읽기 전용 속성이므로 할당할 수 없습니다.

6. 고급 팁: 유틸리티 타입 조합하기

유틸리티 타입은 중첩해서 사용할 수 있습니다. 예를 들어, "업데이트 요청을 보낼 때, ID는 필수이고 나머지 정보는 선택 사항"인 타입을 만들고 싶다면 어떡할까요?

TypeScript
 
type UpdateRequest = Pick<User, 'id'> & Partial<Omit<User, 'id'>>;

const request: UpdateRequest = {
  id: "123",        // 필수
  name: "Changed"   // 선택
};

이런 식으로 조합하면 극도로 정교하고 안전한 타입 정의가 가능해집니다.

타입스크립트(TypeScript)를 처음 접하면 가장 먼저 배우는 단어가 있습니다. 바로 any입니다. 어떤 타입이든 허용한다는 마법 같은 단어지만, 아이러니하게도 any를 남발하는 순간 우리는 타입스크립트를 사용하는 가장 큰 이유인 '타입 안전성'을 포기하게 됩니다.

실무에서 외부 API 응답이나 동적 데이터를 다룰 때, 우리는 데이터의 정확한 형태를 알 수 없는 상황에 직면합니다. 이때 습관적으로 any를 쓰기보다 더 안전하고 견고한 unknown 타입을 선택해야 합니다. 오늘은 왜 unknown이 any보다 우월한지, 그리고 unknown 데이터를 안전하게 요리하는 타입 가드(Type Guard) 기법을 상세히 알아보겠습니다.


1. any: 모든 안전장치를 해제하는 위험한 열쇠

any는 타입스크립트의 타입 검사기(Type Checker)를 완전히 무력화합니다.

TypeScript
 
let value: any = 10;
value.toUpperCase(); // 컴파일 타임에 에러가 발생하지 않음 -> 런타임에 에러 발생!

위 코드에서 value는 숫자지만 any 타입이기 때문에 문자열 메서드인 toUpperCase 호출을 허용합니다. 결국 이 오류는 사용자가 앱을 실행하는 도중 "Uncaught TypeError"라는 이름으로 터지게 됩니다. 사실상 자바스크립트를 쓰는 것과 다를 바 없는 상태가 되는 것이죠.


2. unknown: "무엇인지 모르니 확인하고 쓰세요"

unknown 타입은 any와 마찬가지로 모든 값을 할당받을 수 있습니다. 하지만 결정적인 차이점은 '사용할 때' 발생합니다.

2.1 unknown의 제약 사항

unknown 타입으로 정의된 변수는 타입을 정적으로 확정하기 전까지는 어떠한 동작도 수행할 수 없습니다.

TypeScript
 
let value: unknown = 10;

// 에러 발생: 'value'의 형식이 'unknown'입니다.
value.toUpperCase(); 

// 타입을 확인해야만 사용 가능
if (typeof value === "string") {
  console.log(value.toUpperCase()); // 이제 안전하게 사용 가능!
}

이처럼 unknown은 개발자에게 "이 데이터의 타입을 먼저 검증하라"라고 강제합니다. 컴파일러가 우리 대신 안전장치를 한 번 더 확인해 주는 셈입니다.


3. unknown을 안전하게 다루는 법: 타입 가드(Type Guard)

unknown 데이터의 정체를 밝히기 위해 사용하는 기법이 바로 타입 가드입니다.

3.1 typeof 연산자 (기본 타입 검사)

숫자, 문자열, 불리언 등 자바스크립트의 기본 타입을 확인할 때 사용합니다.

3.2 instanceof 연산자 (클래스/객체 검사)

특정 클래스의 인스턴스인지 확인할 때 유용합니다.

TypeScript
 
function processDate(input: unknown) {
  if (input instanceof Date) {
    console.log(input.toISOString()); // Date 객체임이 보장됨
  }
}

3.3 사용자 정의 타입 가드 (Type Predicates)

가장 강력하고 실무에서 많이 쓰이는 방식입니다. is 키워드를 사용하여 함수가 특정 타입임을 명시적으로 알려줍니다.

TypeScript
 
interface User {
  name: string;
  age: number;
}

// User 타입인지 확인하는 가드 함수
function isUser(target: any): target is User {
  return (
    typeof target === "object" &&
    target !== null &&
    "name" in target &&
    "age" in target
  );
}

function greet(input: unknown) {
  if (isUser(input)) {
    console.log(`안녕하세요, ${input.name}님!`); // 여기서 input은 자동으로 User 타입이 됨
  }
}

4. 실무 예시: API 에러 처리

API 통신 중 발생하는 에러(catch 문의 error 객체)는 기본적으로 unknown 타입(TS 4.4 이상)입니다. 이때 unknown과 타입 가드를 활용하면 매우 견고한 에러 처리가 가능합니다.

TypeScript
 
try {
  await fetchData();
} catch (error: unknown) {
  if (error instanceof AxiosError) {
    // axios 에러인 경우 처리
    console.error(error.response?.data.message);
  } else if (error instanceof Error) {
    // 일반 에러인 경우 처리
    console.error(error.message);
  } else {
    // 그 외의 경우
    console.error("알 수 없는 에러 발생", error);
  }
}

이전처럼 (error as any). message라고 썼을 때 발생할 수 있는 런타임 폭탄을 원천 차단할 수 있습니다.


5. any는 절대 쓰면 안 되나요?

현실적으로 any가 필요한 순간도 있습니다.

  • 마이그레이션 중인 프로젝트에서 타입을 일일이 지정하기 벅찰 때.
  • 타입 정의가 너무 복잡하여 생산성을 심각하게 해칠 때.
  • 제네릭 등으로 도저히 해결 안 되는 복잡한 고차 함수를 짤 때.

하지만 이는 어디까지나 '임시방편'이어야 합니다. any를 썼다면 반드시 TODO 주석을 남기고 나중에 적절한 타입으로 교체해야 합니다.

+ Recent posts