웹사이트의 성능은 단순히 '데이터가 얼마나 빨리 도착하는가'에 달려 있지 않습니다. 더 중요한 것은 '도착한 데이터를 얼마나 빨리 화면에 그려내는가'입니다. 브라우저가 HTML, CSS, JavaScript를 받아서 화면에 픽셀로 변환하는 일련의 과정을 중요 렌더링 경로(Critical Rendering Path, CRP)라고 부릅니다. 이 경로를 단축하는 것이 곧 성능 최적화의 정석입니다.


1. 렌더링의 5단계 공정 (CRP)

브라우저는 화면을 그리기 위해 크게 5가지 단계를 거칩니다.

1.1 DOM 트리 구축 (Parsing)

브라우저가 HTML 문서를 읽어 내려가며 태그들을 트리 구조의 노드들로 변환합니다. 이것이 우리가 잘 아는 DOM(Document Object Model)입니다.

1.2 CSSOM 트리 구축

HTML을 읽다가 <link>나 <style> 태그를 만나면 CSS를 파싱하여 CSSOM(CSS Object Model) 트리를 만듭니다. CSS는 상속 구조를 가지기 때문에 이 트리가 완성되어야 다음 단계로 넘어갈 수 있습니다.

1.3 렌더 트리(Render Tree) 형성

DOM과 CSSOM이 결합하여 실제 화면에 '보이는' 노드들로만 구성된 렌더 트리가 만들어집니다. display: none이 설정된 요소는 이 트리에서 제외됩니다.

1.4 레이아웃(Layout / Reflow)

렌더 트리의 각 노드가 화면의 정확히 어느 위치에, 어느 정도 크기로 배치될지 계산하는 과정입니다. 상대적인 수치(%, em)가 절대적인 픽셀(px)로 변환되는 시점입니다.

1.5 페인트(Paint)

계산된 위치를 바탕으로 실제 화면에 픽셀을 채워 넣습니다. 텍스트, 색상, 이미지, 효과 등이 시각적으로 나타나는 단계입니다.


2. 성능의 적: 리플로우(Reflow)와 리페인트(Repaint)

사용자의 인터랙션(클릭, 스크롤, 애니메이션)으로 화면의 요소가 변경되면 위 과정이 다시 발생합니다.

  • Reflow: 요소의 크기나 위치가 바뀌어 레이아웃 단계부터 다시 실행되는 것을 의미합니다. 매우 비용이 많이 드는 작업입니다. (예: width, height, margin 변경)
  • Repaint: 위치는 그대로고 색상이나 가시성만 바뀌어 레이아웃 계산 없이 페인트만 다시 수행하는 것입니다. (예: color, background-color 변경)

최적화 팁: 가능하다면 레이아웃과 페인트를 건너뛰고 Composite(합성) 단계만 거치는 속성(transform, opacity)을 사용하여 애니메이션을 구현하세요. 이는 GPU를 활용하므로 훨씬 부드럽습니다.


3. CRP를 단축하는 3가지 실무 전략

3.1 리소스 우선순위 지정 (Preload & Preconnect)

중요한 리소스(폰트, 메인 이미지)는 브라우저가 먼저 발견하도록 힌트를 주세요.

HTML
 
<link rel="preload" href="main-font.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preconnect" href="https://api.your-service.com">

3.2 렌더링 차단 리소스 제거

CSS는 렌더링을 차단하고, JS는 파싱을 차단합니다.

  • CSS: 필요한 최소한의 CSS만 상단에 배치하고 나머지는 비동기로 로드하세요.
  • JS: <script> 태그에 async나 defer 속성을 사용하여 HTML 파싱이 멈추지 않게 하세요. (일반적으로 defer가 권장됩니다.)

3.3 콘텐츠 가시성 제어 (content-visibility)

최신 브라우저에서 지원하는 content-visibility: auto 속성을 사용하면 화면 밖에 있는 요소의 렌더링 계산을 브라우저가 자동으로 생략합니다. 긴 랜딩 페이지에서 성능을 획기적으로 높일 수 있습니다.


4. 실무 트러블슈팅: 레이아웃 스래싱(Layout Thrashing) 방지

JS로 스타일을 변경할 때, 쓰기(Write)와 읽기(Read)를 반복하면 브라우저는 정확한 값을 알려주기 위해 매번 레이아웃을 다시 계산해야 합니다.

JavaScript
 
// Bad: 레이아웃 스래싱 발생
for (let i = 0; i < boxes.length; i++) {
  const width = boxes[i].offsetWidth; // 읽기 (Layout 발생)
  boxes[i].style.width = width + 10 + 'px'; // 쓰기 (Invalidate Layout)
}

// Good: 읽기와 쓰기를 분리
const widths = boxes.map(box => box.offsetWidth); // 한꺼번에 읽기
boxes.forEach((box, i) => {
  box.style.width = widths[i] + 10 + 'px'; // 한꺼번에 쓰기
});

5. 결론: 사용자 경험의 0.1초를 잡는 법

렌더링 원리를 이해하는 것은 "어떻게 화면을 띄울 것인가"를 넘어 "어떻게 하면 사용자가 로딩을 느끼지 못하게 할 것인가"에 대한 답을 찾는 과정입니다.

오늘의 요약:

  1. DOM + CSSOM = Render Tree임을 기억하세요.
  2. Reflow를 유발하는 속성 사용을 최소화하고 GPU 가속(transform)을 활용하세요.
  3. deferpriority 설정을 통해 중요 경로 리소스를 먼저 로드하세요.
  4. Layout Thrashing을 피하기 위해 DOM 읽기/쓰기 로직을 최적화하세요.

Next.js App Router의 핵심은 "모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Components)다"라는 선언입니다. 과거 Pages Router 시절에는 모든 컴포넌트가 브라우저로 전송되어 하이드레이션(Hydration) 과정을 거쳐야 했지만, 이제는 서버에서만 실행되고 결과물인 HTML만 브라우저로 전달되는 컴포넌트를 만들 수 있게 되었습니다.

하지만 개발을 하다 보면 인터랙션(클릭, 상태 관리)이 필요한 시점이 오고, 자연스럽게 'use client'를 선언하게 됩니다. 이때 가장 중요한 역량은 "어디까지를 서버 영역으로 두고, 어디서부터 클라이언트 영역으로 나눌 것인가"를 결정하는 설계 능력입니다.


1. 서버 컴포넌트(RSC) vs 클라이언트 컴포넌트(RCC)

먼저 두 컴포넌트의 역할과 제약을 명확히 구분해야 합니다.

1.1 서버 컴포넌트 (Default)

  • 실행 위치: 서버에서만 실행됩니다.
  • 장점: 브라우저로 전송되는 자바스크립트 번들 크기가 줄어들며, 서버 자원(DB, 파일 시스템)에 직접 접근할 수 있습니다. 보안에 민감한 API 키 등을 숨기기에도 유리합니다.
  • 제약: useState, useEffect 같은 훅이나 onClick 같은 이벤트 핸들러를 사용할 수 없습니다. 브라우저 API(window, localStorage)에도 접근할 수 없습니다.

1.2 클라이언트 컴포넌트 ('use client')

  • 실행 위치: 서버에서 프리렌더링된 후 브라우저에서 하이드레이션됩니다.
  • 장점: 사용자와의 인터랙션이 가능하고 브라우저 API를 모두 사용할 수 있습니다.
  • 제약: 자바스크립트 번들 용량을 차지하며, 데이터 패칭 시 서버 컴포넌트보다 네트워크 비용이 더 발생할 수 있습니다.

2. 효율적인 경계 설계 전략 (Component Pattern)

성능을 최적화하기 위해서는 "클라이언트 컴포넌트를 컴포넌트 트리의 가장 말단으로 밀어내는 것"이 핵심입니다.

2.1 리프 컴포넌트 패턴 (Leaf Component Pattern)

페이지 전체를 'use client'로 만드는 대신, 상태가 필요한 작은 조각만 별도의 클라이언트 컴포넌트로 분리하세요.

  • Bad: 검색 결과 페이지 전체를 클라이언트 컴포넌트로 선언 (검색 결과 리스트까지 모두 JS 번들에 포함됨)
  • Good: 검색어 입력을 받는 SearchBar만 클라이언트 컴포넌트로 만들고, 결과 리스트는 서버 컴포넌트로 유지
TypeScript
 
// SearchPage.tsx (Server Component)
import SearchBar from './SearchBar'; // 'use client'
import SearchResults from './SearchResults'; // Server Component

export default function SearchPage() {
  return (
    <main>
      <h1>검색 페이지</h1>
      {/* 인터랙션이 필요한 부분만 클라이언트 영역 */}
      <SearchBar /> 
      {/* 데이터 렌더링은 서버 영역 */}
      <SearchResults />
    </main>
  );
}

3. 서버 컴포넌트 안에 클라이언트 컴포넌트 넣기 (Composition)

서버 컴포넌트에서 클라이언트 컴포넌트를 불러오는 것은 쉽지만, 그 반대는 불가능하다고 생각하는 경우가 많습니다. 하지만 컴포넌트 합성(Composition)을 이용하면 클라이언트 컴포넌트 '내부'에 서버 컴포넌트를 배치할 수 있습니다.

TypeScript
 
// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>토글</button>
      {/* children으로 들어온 서버 컴포넌트는 여전히 서버에서 실행됨 */}
      {isOpen && children}
    </div>
  );
}

// Page.tsx (Server Component)
export default function Page() {
  return (
    <ClientWrapper>
      <HeavyServerComponent /> {/* 클라이언트 컴포넌트 안에서도 서버 컴포넌트의 이점 유지 */}
    </ClientWrapper>
  );
}

이 패턴을 활용하면 레이아웃이나 상태를 관리하는 래퍼(Wrapper)는 클라이언트 쪽에 두면서도, 실제 무거운 데이터 렌더링은 서버 컴포넌트로 처리하는 고도화된 설계가 가능해집니다.


4. 데이터 패칭의 최적 위치

App Router에서는 데이터 패칭을 서버 컴포넌트에서 수행하는 것을 강력히 권장합니다.

  • 이유: 서버에서 직접 DB에 접근하므로 속도가 빠르고, 클라이언트-서버 간의 Waterfall 현상을 방지할 수 있습니다. 또한 패칭된 데이터를 하위 클라이언트 컴포넌트로 Props를 통해 쉽게 전달할 수 있습니다.

5. 실무 체크리스트: 언제 'use client'를 쓸까?

필요한 기능 서버 컴포넌트 클라이언트 컴포넌트
데이터 패칭 (Fetching Data) ❌ (권장 안 함)
백엔드 자원 직접 접근 (DB, API)
보안 정보 유지 (API Keys, Tokens)
상태 관리 및 생명주기 (useState, useEffect)
브라우저 API 사용 (window, localStorage)
커스텀 훅 사용 (대부분의 UI 관련 훅)

6. 결론: "서버가 먼저, 클라이언트는 나중에"

Next.js App Router 설계의 미학은 '자바스크립트 다이어트'에 있습니다. 최대한 많은 로직을 서버로 옮기고, 클라이언트는 오직 사용자의 손가락이 닿는 곳(Click, Type, Swipe)에만 집중하게 하세요.

오늘의 요약:

  • 기본은 서버: 모든 파일은 일단 서버 컴포넌트로 시작하세요.
  • 말단 분리: 상태가 필요한 부분만 핀셋으로 집어내듯 클라이언트 컴포넌트로 만드세요.
  • 합성 활용: 클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받을 수 있음을 잊지 마세요.
  • 데이터는 상단 서버에서: 데이터 패칭은 가능한 트리 상단의 서버 컴포넌트에서 해결하세요.

타입스크립트(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