타입스크립트(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 주석을 남기고 나중에 적절한 타입으로 교체해야 합니다.

리액트(React) 프로젝트의 규모가 커지면 필연적으로 마주하게 되는 현상이 있습니다. 바로 Props Drilling입니다. 최상위 컴포넌트에 있는 데이터를 5단계 아래에 있는 자식 컴포넌트로 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 사용하지 않음에도 불구하고 단순히 '전달'만 하는 고통스러운 상황을 말합니다.

Props Drilling 그 자체는 반드시 나쁜 것은 아니지만, 깊이가 깊어질수록 코드를 추적하기 어렵게 만들고 리팩토링을 불가능하게 만드는 주범이 됩니다. 오늘은 이 문제를 해결하기 위한 세 가지 전략을 단계별로 알아보겠습니다.


1. 첫 번째 단계: 컴포넌트 합성 (Component Composition)

많은 개발자가 Props Drilling을 발견하면 즉시 Context API나 상태 관리 라이브러리를 떠올립니다. 하지만 리액트 팀에서 권장하는 가장 첫 번째 해결책은 컴포넌트 합성입니다.

1.1 children을 활용한 구조 개선

데이터를 하위로 계속 내려보내는 대신, 컴포넌트를 조립하는 방식으로 구조를 변경해 보세요.

JavaScript
 
// 기존 방식 (Drilling 발생)
<Parent data={data}>
  <Intermediate data={data}>
    <Child data={data} />
  </Intermediate>
</Parent>

// 컴포넌트 합성 방식
<Parent>
  <Intermediate>
    <Child data={data} />
  </Intermediate>
</Parent>

이렇게 하면 Intermediate 컴포넌트는 data를 알 필요가 없습니다. 단순히 children으로 받은 내용을 렌더링 하기만 하면 됩니다. 데이터를 직접 사용하는 컴포넌트를 부모 수준으로 끌어올림으로써 의존성을 끊어내는 것이 핵심입니다.


2. 두 번째 단계: Context API (의존성 주입)

합성만으로 해결하기에 구조가 너무 복잡하거나, 앱 전체에서 공유되어야 하는 데이터(테마, 로그인 유저 정보 등)가 있다면 리액트 내장 기능인 Context API를 사용합니다.

2.1 Context API의 목적

Context는 '전역 상태 관리' 도구라기보다 '의존성 주입(Dependency Injection)' 도구에 가깝습니다. 특정 범위(Provider) 내에 있는 모든 컴포넌트가 계층 구조와 상관없이 데이터에 접근할 수 있게 해줍니다.

2.2 주의할 점: 리렌더링 최적화

Context의 값이 바뀌면 해당 Provider를 구독하고 있는 모든 하위 컴포넌트가 리렌더링 됩니다. 따라서 자주 바뀌는 동적인 데이터보다는 변경 빈도가 낮은 설정 데이터를 관리하는 데 적합합니다.

JavaScript
 
const UserContext = createContext();

function App() {
  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext); // 중간 단계 생략하고 바로 사용
  return <div>{user.name}</div>;
}

3. 세 번째 단계: 외부 상태 관리 라이브러리 (Zustand)

복잡하고 빈번하게 변하는 상태(장바구니, 필터링 조건 등)를 관리해야 한다면 전용 라이브러리의 힘을 빌려야 합니다. 과거에는 Redux가 대세였지만, 최근에는 설정이 간편하고 성능이 뛰어난 Zustand가 각광받고 있습니다.

3.1 왜 Zustand인가?

  • 보일러플레이트 제로: Redux처럼 복잡한 초기 설정이 필요 없습니다.
  • 선택적 구독 (Selector): 컴포넌트가 필요한 상태만 선택해서 구독할 수 있어, 불필요한 리렌더링이 발생하지 않습니다.
  • 리액트 외부에서도 접근 가능: 훅(Hook) 형태뿐만 아니라 일반 JS 로직에서도 상태를 읽고 쓸 수 있습니다.

3.2 실전 예시

JavaScript
 
import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>담기</button>;
}

4. 어떤 상황에서 무엇을 선택해야 할까?

가장 중요한 것은 상황에 맞는 도구를 선택하는 선구안입니다.

  1. 컴포넌트 합성: Props 전달 단계가 2~3단계 내외이며, UI 구조를 변경함으로써 해결 가능한 경우. (가장 깔끔한 코드 유지 가능)
  2. Context API: 다크 모드, 현재 언어(i18n), 로그인 정보처럼 앱 전체가 알아야 하지만 자주 바뀌지 않는 정보를 다룰 때.
  3. Zustand: 여러 컴포넌트가 복잡한 상태를 서로 공유하고, 업데이트가 매우 빈번하며, 성능 최적화가 필수적인 경우.

+ Recent posts