LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는 타입 설계 | SEOJing

이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는 타입 설계

2026년 6월 28일·19분 읽기

범위: Effective TypeScript 2판 Item 28–34
오늘의 질문: “타입이 맞는데도 UI 상태 조합이 이상해지는 이유는 뭘까?”

이 글의 원천 앵커

이 글도 기존 Day 6을 다시 쓴 개정판입니다. 이전 글은 핵심을 잡고 있었지만, Item 28–34의 폭에 비해 너무 짧았습니다. 이번 범위는 추론 위치, 유효 상태 모델링, API 입출력 타입 분리, null 경계, union 설계를 함께 봐야 품질이 납니다.

이번 글에서 잃으면 안 되는 포인트는 이겁니다.

  • 타입 추론은 어느 위치에서 일어나느냐에 따라 결과가 달라진다.
  • UI 상태는 여러 optional/boolean보다 discriminated union이 안전한 경우가 많다.
  • API input/output을 같은 타입으로 쓰면 실제 경계가 흐려진다.
  • null, undefined, empty value는 도메인 의미가 다르다.
  • union 설계는 타입 테크닉이 아니라 불가능한 상태를 줄이는 모델링이다.

먼저 보는 코드

ts
type RequestState = {
  loading: boolean;
  error?: string;
  data?: User;
};

많이 보는 형태입니다. 하지만 이 타입은 이상한 조합을 허용합니다.

ts
const state: RequestState = {
  loading: true,
  error: "권한이 없습니다",
  data: { id: "1", name: "Jing" },
};

타입은 맞지만 상태 의미는 이상합니다. 로딩 중이면서 에러이고, 동시에 데이터도 있습니다. 렌더링 코드는 이제 우선순위를 정해야 합니다. 로딩을 보여줄지, 에러를 보여줄지, 데이터를 보여줄지 매번 조건문으로 싸워야 합니다.

더 나은 모델은 불가능한 조합을 처음부터 표현하지 않습니다.

ts
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: User };

API 경계에서 유효한 상태 유니온으로 좁히는 흐름

Item 28–34를 한 줄로 묶으면

이 범위는 문법 팁 모음처럼 보이지만, 실제로는 하나의 흐름입니다.

범위 축코드 리뷰 질문나쁜 냄새
추론 위치타입을 어디서 좁히거나 넓히고 있나?외부 데이터를 as const나 단언으로 믿는다
유효 상태이 타입이 말이 안 되는 조합을 허용하나?loading, error, data가 동시에 존재한다
API 입출력request/response/internal model이 같은 보장을 갖나?User 하나로 form, API, UI를 모두 처리한다
없음의 의미null, undefined, 빈 값이 같은 뜻인가?로딩 전/빈 결과/삭제됨을 한 값으로 합친다
union 설계판별 필드가 branch를 안전하게 좁히나?type: string + optional 필드 여러 개로 버틴다

즉 Day 6의 중심은 “타입을 더 화려하게 쓰기”가 아닙니다. 경계에서 불확실성을 정리하고, UI 내부에서는 가능한 상태만 다루게 만들기입니다.

추론 위치를 먼저 정한다

TypeScript는 선언 위치의 단서를 보고 타입을 추론합니다.

ts
const tabs = ["home", "settings"];
// string[]

const fixedTabs = ["home", "settings"] as const;
// readonly ["home", "settings"]

둘 다 맞는 추론입니다. 첫 번째는 나중에 다른 문자열이 들어갈 수 있는 배열입니다. 두 번째는 정확히 두 값으로 고정된 tuple입니다. 문제는 어떤 추론이 필요한지 코드가 말하지 않을 때 생깁니다.

옵션 목록이라면 리터럴을 보존하는 편이 좋습니다.

ts
const tabs = ["home", "settings"] as const;
type Tab = (typeof tabs)[number];

반대로 서버에서 온 값이라면 as const로 믿게 만드는 건 위험합니다. 외부 값은 좁히는 게 아니라 확인해야 합니다.

ts
function isTab(value: unknown): value is Tab {
  return value === "home" || value === "settings";
}

리뷰 질문은 “타입이 좁은가?”가 아닙니다.

이 값은 코드가 만든 고정 목록인가, 외부에서 들어온 불확실한 값인가?

리더가 헷갈리는 지점은 여기입니다. as const는 값이 좁아지는 도구지만, 항상 안전을 높이는 도구는 아닙니다. 코드 안에서 직접 만든 목록에는 좋습니다. API에서 받은 값을 검증 없이 좁힐 때는 오히려 거짓 확신을 만듭니다.

ts
const serverTab = await fetchCurrentTab();

// 위험: 외부 값을 "home" | "settings"라고 믿게 만들 뿐이다.
const tab = serverTab as Tab;

이 코드는 TypeScript에게만 안심하라고 말합니다. 런타임 값이 "billing"이면 여전히 들어옵니다. Item 28–34를 리뷰할 때는 “컴파일러가 조용한가?”보다 “그 조용함이 검증에서 온 건가, 단언에서 온 건가?”를 먼저 봐야 합니다.

유효한 상태만 표현하기

상태 모델은 UI 복잡도를 직접 바꿉니다.

ts
function UserPanel({ state }: { state: RequestState }) {
  if (state.status === "loading") return <Spinner />;
  if (state.status === "error") return <ErrorMessage message={state.message} />;
  if (state.status === "success") return <Profile user={state.data} />;
  return <EmptyGuide />;
}

이 코드는 각 branch가 명확합니다. success에서만 data가 있고, error에서만 message가 있습니다. Day 5의 narrowing이 여기서 힘을 얻습니다. 잘 설계된 union은 컴파일러가 자연스럽게 좁힐 수 있는 구조를 제공합니다.

상태가 더 많아지면 empty도 분리할 수 있습니다.

ts
type UserListState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "empty" }
  | { status: "success"; users: User[] };

success인데 users.length === 0을 허용할지, 빈 결과를 별도 상태로 볼지는 도메인 판단입니다. 중요한 건 그 판단이 타입에 드러나야 한다는 점입니다.

API input과 output은 같은 타입이 아닐 수 있다

프론트엔드 코드에서 흔한 실수는 form input, API request, API response, UI model을 한 타입으로 합치는 것입니다.

ts
type User = {
  id: string;
  name: string;
  email: string;
};

async function createUser(user: User) {
  await fetch("/api/users", { method: "POST", body: JSON.stringify(user) });
}

생성 요청에는 id가 없을 수 있습니다. 서버 응답에는 createdAt이 붙을 수 있습니다. form에서는 email이 아직 빈 문자열일 수 있습니다. 같은 User 타입으로 모두 처리하면 어느 경계에서 어떤 값이 보장되는지 흐려집니다.

ts
type CreateUserInput = {
  name: string;
  email: string;
};

type UserResponse = {
  id: string;
  name: string;
  email: string;
  createdAt: string;
};

type User = {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
};

이렇게 나누면 귀찮아 보이지만, 경계가 선명해집니다. API response의 createdAt 문자열을 앱 내부 Date로 바꾸는 변환 함수도 자연스럽게 생깁니다.

ts
function toUser(response: UserResponse): User {
  return { ...response, createdAt: new Date(response.createdAt) };
}

리뷰에서는 “타입이 중복된다”보다 “경계가 다르다”를 먼저 봐야 합니다. 중복 제거를 서두르다가 input/output/internal model을 한 타입으로 합치면 나중에 더 큰 비용을 냅니다.

유니온은 “interface 안의 union”보다 “union of interfaces”가 낫다

상태 모델링에서 자주 보이는 중간 형태가 있습니다.

ts
type SaveState = {
  status: "idle" | "saving" | "saved" | "failed";
  savedAt?: Date;
  errorMessage?: string;
};

처음에는 좋아 보입니다. status도 있고 optional 필드도 있습니다. 하지만 이 타입은 아래 값을 막지 못합니다.

ts
const state: SaveState = {
  status: "saved",
  errorMessage: "네트워크 오류",
};

saved인데 에러 메시지가 있습니다. 반대로 failed인데 errorMessage가 빠져도 타입상 가능할 수 있습니다. 판별 필드를 넣었는데도 나머지 필드가 status와 연결되지 않았기 때문입니다.

더 안전한 형태는 상태별 interface를 union으로 묶는 것입니다.

ts
type SaveState =
  | { status: "idle" }
  | { status: "saving" }
  | { status: "saved"; savedAt: Date }
  | { status: "failed"; errorMessage: string };

이제 status === "saved" branch에서 savedAt은 필수이고, failed branch에서 errorMessage도 필수입니다. 이 차이는 작은 문법 차이처럼 보이지만 리뷰 비용을 크게 줄입니다.

tsx
function SaveBanner({ state }: { state: SaveState }) {
  switch (state.status) {
    case "idle":
      return null;
    case "saving":
      return <span>저장 중...</span>;
    case "saved":
      return <span>{state.savedAt.toLocaleTimeString()}에 저장됨</span>;
    case "failed":
      return <span role="alert">{state.errorMessage}</span>;
  }
}

이런 코드에서는 branch마다 접근 가능한 필드가 자연스럽게 정해집니다. UI 리뷰어는 “saved인데 error도 있나?” 같은 조합을 따로 머릿속에서 추적하지 않아도 됩니다.

null과 undefined는 의미가 다르다

null, undefined, 빈 문자열, 빈 배열은 모두 “없음”처럼 보일 수 있지만 의미가 다릅니다.

ts
type FormState = {
  nickname?: string;
};

이 타입에서 nickname이 빠진 것은 아직 입력하지 않았다는 뜻일까요? 사용자가 비워두기로 했다는 뜻일까요? 서버가 값을 안 보냈다는 뜻일까요?

도메인 의미가 다르면 타입도 다르게 표현하는 편이 좋습니다.

ts
type NicknameState =
  | { kind: "not-loaded" }
  | { kind: "empty" }
  | { kind: "present"; value: string };

물론 모든 optional 값을 union으로 바꾸라는 뜻은 아닙니다. 중요한 것은 의미 차이가 UI branch나 저장 로직에 영향을 주는지입니다.

예를 들어 리스트는 경계에서 정규화할 수 있습니다.

ts
function normalizeUsers(users: User[] | null | undefined): User[] {
  return users ?? [];
}

하지만 로딩 중과 빈 결과는 합치면 안 됩니다. 로딩 중은 아직 모르는 상태이고, 빈 결과는 이미 확인한 상태입니다.

경계에서 좁히고, 내부에서 단순하게 쓴다

좋은 타입 설계는 컴포넌트 안 조건문을 늘리는 방향이 아닙니다. 오히려 경계에서 한 번 정리해서 내부를 단순하게 만듭니다.

ts
type UserApiResponse = {
  status: "ok" | "error";
  user?: {
    id?: string;
    name?: string | null;
  };
  message?: string;
};

type UserViewState =
  | { status: "ready"; user: { id: string; name: string } }
  | { status: "empty" }
  | { status: "failed"; message: string };

function toUserViewState(response: UserApiResponse): UserViewState {
  if (response.status === "error") {
    return {
      status: "failed",
      message: response.message ?? "사용자를 불러오지 못했습니다",
    };
  }

  if (!response.user?.id || !response.user.name) {
    return { status: "empty" };
  }

  return {
    status: "ready",
    user: { id: response.user.id, name: response.user.name },
  };
}

UserApiResponse는 지저분합니다. 외부 경계이기 때문입니다. 대신 UserViewState는 단순합니다. 앱 내부 컴포넌트는 이미 검증된 상태만 받습니다. 이 분리를 하지 않으면 모든 컴포넌트가 user?.id, name ?? "", message ?? ... 같은 방어 코드를 반복하게 됩니다.

AI가 만든 프론트엔드 코드에서도 이 문제가 자주 보입니다. API wrapper 없이 컴포넌트 안에서 응답을 바로 렌더링하고, optional chaining으로 모든 오류를 눌러버립니다. 조용해 보이지만 실제로는 “어디에서 보장되는 값인지”가 사라진 코드입니다.

union 설계는 리뷰할 상태 공간을 줄인다

좋은 union은 단순히 TypeScript다운 코드가 아닙니다. 리뷰어가 확인해야 할 조합을 줄입니다.

약한 모델문제더 나은 모델
loading: boolean; data?: T; error?: string모순 조합 가능status 판별 유니온
value: string | null | undefined없음의 의미 섞임not-loaded / empty / present
type: string모든 문자열 허용literal union
request/response/internal 단일 타입경계별 보장 혼합Input / Response / Model 분리

이런 설계는 코드량을 조금 늘릴 수 있습니다. 하지만 렌더링 branch, 테스트 케이스, 에러 처리, AI 코드 리뷰 기준을 훨씬 선명하게 만듭니다.

테스트 관점에서도 차이가 납니다. 약한 모델은 조합 수가 너무 많아서 테스트가 항상 빠집니다.

ts
type WeakState = {
  loading: boolean;
  data?: User[];
  error?: string;
};

이 타입은 최소한 아래 조합을 모두 생각해야 합니다.

  • loading: true, data 있음, error 없음
  • loading: true, data 없음, error 있음
  • loading: false, data 있음, error 있음
  • loading: false, data 없음, error 없음

반면 판별 유니온은 테스트 케이스가 도메인 상태와 거의 1:1로 맞습니다. loading, empty, success, error를 각각 테스트하면 됩니다. 타입 설계가 테스트 설계까지 밀어주는 셈입니다.

AI 코드 리뷰에서 보는 신호

AI가 생성한 TypeScript 코드는 대체로 컴파일을 맞추는 데 능합니다. 하지만 Item 28–34 관점으로 보면 아래 패턴을 자주 놓칩니다.

  1. as SomeType으로 API 응답을 바로 믿는다.
  2. type: string을 두고 실제로는 몇 가지 값만 쓰는 상태를 만든다.
  3. loading, isError, isSuccess boolean을 여러 개 둔다.
  4. form input, request body, response body, UI model을 한 User 타입으로 합친다.
  5. null과 undefined를 전부 ?? ""로 없애서 도메인 의미를 지운다.

이런 코드는 “타입스크립트가 붙어 있다”는 이유만으로 안전해 보입니다. 리뷰에서는 컴파일 통과보다 상태 모델을 먼저 봐야 합니다. 불가능한 상태를 허용하는 타입은 런타임 조건문과 테스트에 빚을 넘깁니다.

프론트엔드 코드 리뷰 체크리스트

  • 상태 타입이 불가능한 조합을 허용하지 않는가?
  • as const가 코드가 만든 고정 목록에 쓰였는가, 외부 데이터를 믿는 데 쓰였는가?
  • form input, API request, API response, internal model을 무리하게 한 타입으로 합치지 않았는가?
  • null/undefined/empty value의 의미가 UI branch에 영향을 주는가?
  • discriminated union이 Day 5의 narrowing과 자연스럽게 이어지는가?
  • 변환 함수가 경계에 있는가, 컴포넌트 여기저기에 흩어져 있는가?
  • 상태별 필수 필드가 판별 필드와 연결되어 있는가, 아니면 optional 필드로 떠 있는가?
  • 단언(as)이 실제 검증을 대신하고 있지 않은가?

Day 6의 결론은 간단합니다. 타입 설계는 필드를 많이 적는 일이 아니라, 리뷰해야 할 상태 공간을 줄이는 일입니다. 좋은 타입은 이상한 조합을 막고, 경계에서 불확실성을 정리하고, UI 내부가 다룰 값을 작게 만듭니다. “타입이 맞다”에서 멈추지 말고 “이 타입이 도메인에서 가능한 상태만 표현하나?”까지 봐야 합니다.

Day 6 타입 리뷰 퀴즈

Quiz1 / 5
Q.`loading: boolean; error?: string; data?: User` 형태의 상태 타입이 위험한 이유는 무엇일까요?

Post Q&A

오케이징에게 물어보기

이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는 타입 설계 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/effective-typescript
파일 14개, 폴더 0개
이펙티브 타입스크립트 2판 Day 1: 타입스크립트를 믿기 전에 알아야 할 경계이펙티브 타입스크립트 2판 Day 2: 타입을 값의 집합으로 보기이펙티브 타입스크립트 2판 Day 3: 타입 반복을 줄이는 리뷰 감각이펙티브 타입스크립트 2판 Day 4: 추론을 살리는 값 생성 패턴이펙티브 타입스크립트 2판 Day 5: 좁혀진 타입은 언제 다시 넓어지는가이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는 타입 설계이펙티브 타입스크립트 2판 Day 7: 문자열보다 도메인 의미를 좁히기이펙티브 타입스크립트 2판 Day 8: any를 밖에 세우고 unknown으로 검증하기이펙티브 타입스크립트 2판 Day 9: 제네릭은 관계를 보존할 때만 강하다이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고 빠지는 분기는 막는다이펙티브 타입스크립트 2판 Day 11: 객체 타입은 열려 있고, 계약은 닫아야 한다이펙티브 타입스크립트 2판 Day 12: 타입은 공개 API의 사용법이다이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과 테스트를 대신하지 않는다이펙티브 타입스크립트 2판 Day 14: JS에서 TS로 옮길 때 먼저 좁혀야 할 경계

같은 섹션의 대표 이미지

14 posts · latest first
런타임 값 검증, DOM 환경, 테스트, 컴파일러 성능으로 이어지는 타입스크립트 경계 다이어그램
Study26. 07. 09.

이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과.

Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
JavaScript 현대화에서 @ts-check, allowJs, 모듈별 전환, noImplicitAny로 이어지는 마이그레이션 단계 다이어그램
Study26. 07. 09.

이펙티브 타입스크립트 2판 Day 14: JS에서 TS로 옮길.

Item 79–83 범위를 바탕으로 JS 현대화, @ts-check와 JSDoc, allowJs, 모듈별 마이그레이션, noImplicitAny를 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
내부 구현 타입에서 문서화된 public API 타입으로 넘어가는 경계 다이어그램
Study26. 07. 08.

이펙티브 타입스크립트 2판 Day 12: 타입은 공개 API의.

Item 67–73 범위를 바탕으로 public API 타입, TSDoc, this callback, module augmentation, TypeScript 기능 선택, source map을 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
Object iteration, Record, tuple/rest, XOR, brand, @types 버전 관리를 연결한 타입 리뷰 다이어그램
Study26. 07. 07.

이펙티브 타입스크립트 2판 Day 11: 객체 타입은 열려.

Item 60–66 범위를 바탕으로 Object iteration, Record, tuple/rest, XOR, brand, @types 버전 경계를 코드 리뷰에서 다루는 방법을 정리합니다.

26. 07. 07.SEOJing
타입 계약, 타입 테스트, never exhaustiveness, 코드 생성 경계를 보여주는 다이어그램
Study26. 07. 06.

이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고.

Item 55–59 범위를 바탕으로 type test, 타입 표시, 꼬리 재귀 한계, 코드 생성, never exhaustiveness를 코드 리뷰에서 다루는 방법을 정리합니다.

26. 07. 06.SEOJing
외부 경계, 제네릭, 조건부 타입, 템플릿 리터럴 타입의 역할과 과한 타입 계산의 위험을 보여주는 다이어그램
Study26. 07. 05.

이펙티브 타입스크립트 2판 Day 9: 제네릭은 관계를 보존할.

Item 49–54 범위를 바탕으로 type coverage, 제네릭, 조건부 타입, 템플릿 리터럴 타입을 코드 리뷰에서 어떻게 판단할지 정리합니다.

26. 07. 05.SEOJing
외부 JSON을 unknown으로 받고 검증 뒤 도메인 타입으로 넘기는 경계와 any가 내부로 번지는 위험을 보여주는 다이어그램
Study26. 07. 04.

이펙티브 타입스크립트 2판 Day 8: any를 밖에 세우고.

Item 42–48 범위를 바탕으로 any, unknown, 타입 단언, monkey patching, soundness 함정을 외부 입력 경계와 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
넓은 문자열과 선택적 필드를 도메인 이름과 유효 상태 유니온으로 좁히는 다이어그램
Study26. 06. 29.

이펙티브 타입스크립트 2판 Day 7: 문자열보다 도메인.

Item 35–41 범위를 바탕으로 string 남용, optional 필드, 특수 값, 도메인 이름 설계를 코드 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
API 응답을 유효한 상태 유니온으로 변환하는 흐름 다이어그램
Study26. 06. 28.

이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는.

Item 28–34 범위를 바탕으로 추론 위치, 유효 상태 모델링, API 입출력, null 경계, union 설계를 프론트엔드 코드 리뷰 관점에서 확장 정리합니다.

26. 06. 28.SEOJing
타입스크립트 추론이 값에서 API 경계로 흐르는 과정을 보여주는 다이어그램
Study26. 06. 27.

이펙티브 타입스크립트 2판 Day 5: 좁혀진 타입은 언제.

Item 22–27 범위를 바탕으로 narrowing, alias, context inference, evolving type, async/type flow를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 27.SEOJing
TypeScript에서 넓은 타입으로 먼저 담으면 key와 literal 정보가 사라지고 리뷰 체크가 약해지는 흐름 다이어그램
Study26. 06. 26.

이펙티브 타입스크립트 2판 Day 4: 추론을 살리는 값 생성.

Effective TypeScript 2판의 Item 16–21을 바탕으로 index signature, 타입 추론 기본, 변수/객체 생성 패턴을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
반복된 타입 선언을 원본 타입에서 파생한 타입으로 바꿔 리뷰 체크를 강하게 만드는 흐름 다이어그램
Study26. 06. 25.

이펙티브 타입스크립트 2판 Day 3: 타입 반복을 줄이는 리뷰.

Effective TypeScript 2판의 Item 11–15를 바탕으로 excess property check, 함수식 타입, type vs interface, readonly, 타입 반복 제거를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 25.SEOJing
TypeScript 타입을 값의 집합, type space, value space, assertion 경계로 정리한 다이어그램
Study26. 06. 24.

이펙티브 타입스크립트 2판 Day 2: 타입을 값의 집합으로.

Effective TypeScript 2판의 Item 6–10을 바탕으로 타입 시스템을 탐색하는 법, 타입을 값의 집합으로 보는 관점, type/value space, 타입 단언의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 24.SEOJing
TypeScript를 JavaScript 위의 정적 타입 계층, 타입 제거, 구조적 타이핑, any의 구멍으로 정리한 다이어그램
Study26. 06. 23.

이펙티브 타입스크립트 2판 Day 1: 타입스크립트를 믿기.

Effective TypeScript 2판의 Item 1–5를 바탕으로 TypeScript와 JavaScript의 관계, tsconfig, 타입 제거, 구조적 타이핑, any의 위험을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 23.SEOJing