문서 읽는 데 52분 · A2

A-2: TypeScript 심화 ① — 좁히기와 판별 유니온

목차 33
전체 6강 중 2강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다. 지난 시간에 우리는 타입을 좁게 선언하는 법을 배웠어요.

FeedSort'latest' | 'popular' | 'following' 셋 중 하나로 못 박았고, bio 에는 물음표를 붙여 "없을 수도 있다"고 표시했죠. 값의 범위를 좁게 잡을수록 TypeScript 가 잡아주는 실수도 많아진다고 했습니다.

그런데 좁게 선언한 값을 막상 쓰려고 하면 재미있는 일이 벌어집니다. 검색창을 하나 만든다고 해볼게요. 검색창에는 해시태그 이름이 들어올 수도 있고, 게시물 번호가 들어올 수도 있습니다.

TypeScript
function searchLabel(term: string | number): string {
  return `#${term.trim()} 검색`;
}

에디터에 빨간 줄이 그어집니다.

텍스트
Property 'trim' does not exist on type 'string | number'.
  Property 'trim' does not exist on type 'number'.

읽어보면 이유가 분명해요. term 은 문자열일 수도 있고 숫자일 수도 있습니다. 숫자에는 trim() 이 없죠. TypeScript 입장에서는 "둘 중 뭐가 올지 모르니 둘 다 되는 것만 허락하겠다"는 겁니다.

순수 JS 였다면 어땠을까요? 아무 말 없이 실행됐을 거예요. 그리고 숫자가 들어온 순간 term.trim is not a function 이라는 에러가 터집니다. 어디서요? 사용자 브라우저에서요.

TypeScript 는 미리 막아주는 대신 우리에게 숙제를 하나 줍니다. "이 값이 지금 둘 중 무엇인지 나한테 알려줘." 그 알려주는 방법이 오늘의 주제예요. 영어로는 narrowing, 우리말로 옮기면 좁히기입니다. 여러 가능성 중에 지금 무엇인지 범위를 좁혀주는 일이죠.

텍스트
 오늘의 여정

 Step 1~2   typeof 와 in 으로 값을 갈라내기
    │
 Step 3     좁히기를 함수로 만들기 (사용자 정의 타입 가드)
    │
 Step 4     판별 유니온 — 좋아요 상태를 상황별 모양으로 나누기
    │
 Step 5     never — 빠뜨린 상황을 컴파일러가 잡게 하기
    
 좋아요 버튼이 어떤 상황에서도 안전하게 그려지는 상태로 마무리

💡 오늘 수업의 핵심 — "좁게 선언한 값은, 갈라내야 쓸 수 있다"

여러 가능성을 담은 타입은 선언만으로 끝나지 않습니다. 쓰는 쪽에서 "지금은 이것"이라고 갈라줘야 비로소 그 타입의 기능이 열려요. 오늘은 갈라내는 도구들을 익히고, 인스타그램 좋아요 버튼의 상태를 안전하게 모델링하는 데까지 갑니다.

🎯 학습 목표

  • typeof·in·없음 확인으로 유니온 타입을 갈라내고, 갈라낸 가지 안에서 그 타입의 기능을 쓸 수 있다
  • value is Post 형태의 사용자 정의 타입 가드를 만들어, 서버에서 온 정체불명의 값을 안전하게 다룰 수 있다
  • 판별 유니온으로 좋아요 요청 상태를 모델링하고, never 로 빠뜨린 상황을 컴파일 단계에서 잡을 수 있다

Step 1: "유니온을 받으면 왜 못 쓸까 — typeof 로 갈라내기"

오프닝에서 막혔던 검색창을 마저 만들어 봅시다. 먼저 검색어의 타입에 이름을 붙일게요.

TypeScript
// apps/web-spa/src/types/narrowing.ts

import { Post, User } from './instagram';

// 검색창에는 해시태그 이름(문자열)도, 게시물 번호(숫자)도 들어온다
export type SearchTerm = string | number;

이제 SearchTerm 을 받아 안내 문구를 만드는 함수를 씁니다. 핵심은 typeof 로 한 번 갈라주는 것이에요.

TypeScript
// typeof 로 가른 가지 안에서는 그 타입의 기능이 열린다
export function searchLabel(term: SearchTerm): string {
  if (typeof term === 'string') {
    return `#${term.trim()} 검색`;
  }
  return `${term.toLocaleString('ko-KR')}번 게시물`;
}

빨간 줄이 사라졌습니다. 무슨 일이 일어난 걸까요?

텍스트
 term 의 타입은 string | number

 ┌─ typeof term === 'string' 인 가지
 │    여기서 term 은 string 으로 좁혀짐  term.trim() 사용 가능
 │
 └─ 그 밖의 가지
      여기서 term 은 number 로 좁혀짐  term.toLocaleString() 사용 가능

if 안으로 들어간 순간 TypeScript 는 termstring 으로 취급합니다. 그래서 trim() 이 열려요. 그리고 if 를 빠져나온 아래쪽에서는 문자열 가능성이 이미 걸러졌으니 number 로 취급합니다. 그래서 toLocaleString() 이 열리고요.

여기서 진짜 중요한 게 하나 있어요. 우리가 새로 배운 문법은 없습니다. typeof 는 순수 JS 에도 있던 연산자예요.

JavaScript
// 순수 JS 시절에도 이렇게 썼죠
function searchLabel(term) {
  if (typeof term === 'string') {
    return '#' + term.trim() + ' 검색';
  }
  return term + '번 게시물';
}

문법은 똑같습니다. 달라진 건 읽는 쪽이에요. 순수 JS 에서 typeof 확인은 나만 아는 약속이었습니다. 내가 확인했다는 사실을 아무도 기억해주지 않으니, 조금 아래에서 term.trim() 을 또 쓰면 그때도 나 혼자 조심해야 했죠.

TypeScript 에서는 컴파일러가 그 확인을 따라 읽습니다. 내가 if 로 갈라놓으면 그 안쪽 전체에서 타입이 좁혀진 상태로 유지돼요. 같은 코드인데 검사기가 함께 읽어주는 것, 이게 오늘 얻는 첫 번째 변화입니다.

어떤 방식으로 갈라도 됩니다

typeof 만 좁히기 도구인 건 아니에요. TypeScript 는 우리가 평소에 쓰는 확인들을 대부분 알아봅니다.

TypeScript
// 값 비교로도 좁혀진다
if (sort === 'latest') {
  // 여기서 sort 는 'latest'
}

// 배열인지 확인해도 좁혀진다
if (Array.isArray(value)) {
  // 여기서 value 는 배열
}

지난 시간 sortLabel 에서 if (sort === 'latest') 로 하나씩 확인했던 게 기억나시나요? 그것도 사실은 좁히기였어요. 그때는 이름을 안 붙였을 뿐입니다.

💡 한 줄 정리

typeof 같은 평범한 확인을 컴파일러가 함께 읽어주기 때문에, if 로 한 번 갈라주면 그 안에서 해당 타입의 기능이 열립니다.

🙋 학생 질문 — "그냥 처음부터 string 으로만 받으면 안 되나요?"

되는 경우도 많습니다. 그리고 그게 더 나을 때도 많아요.

유니온은 공짜가 아닙니다. 받는 쪽마다 갈라내는 코드를 써야 하니까요. 그러니 "정말 두 종류가 다 들어오는가"를 먼저 의심해 보는 게 맞습니다. 숫자를 문자열로 바꿔서 넘겨줄 수 있다면 그게 더 단순하죠.

다만 검색창처럼 입력 자체가 두 종류인 상황도 분명히 있습니다. 그럴 때 억지로 한 타입으로 뭉개면, 함수 안에서 "이 문자열이 사실은 숫자인가?"를 다시 판별해야 해요. 그건 유니온보다 훨씬 위험합니다. 컴파일러가 도와줄 수 없거든요.

기준은 이렇게 잡으시면 됩니다. 값의 종류가 실제로 여러 가지면 유니온으로 정직하게 표현하고, 하나로 정리할 수 있으면 정리한다.


Step 2: "in 과 없음 — 객체와 빈 값을 갈라내기"

검색 결과 화면을 만들어 봅시다. 인스타그램에서 검색하면 계정도 나오고 게시물도 나오죠. 두 종류가 섞여 있습니다.

TypeScript
// 검색 결과에는 계정과 게시물이 섞여 나온다
export type SearchHit = User | Post;

이제 결과 하나를 받아 제목을 만들려는데, 여기서 typeof 는 쓸모가 없어요. User 도 객체고 Post 도 객체라서 typeof 는 둘 다 'object' 를 돌려주거든요. 둘을 구분하지 못합니다.

먼저 좁히지 않고 그냥 써보면 이렇게 막힙니다.

텍스트
Property 'followerCount' does not exist on type 'User | Post'.
  Property 'followerCount' does not exist on type 'Post'.

메시지 두 번째 줄이 친절해요. "Post 에는 그 필드가 없다"고 콕 집어줍니다. 유니온 중에 어느 쪽이 문제인지 알려주는 거죠.

한쪽에만 있는 필드로 가릅니다

User 에는 followerCount 가 있고 Post 에는 없습니다. 반대로 Post 에는 imageUrl 이 있고 User 에는 없어요. 이렇게 한쪽에만 있는 필드가 좋은 표지판이 됩니다. 확인은 in 연산자로 해요.

TypeScript
// 둘 다 가진 필드로는 못 가른다 — 한쪽에만 있는 필드를 in 으로 확인한다
export function hitTitle(hit: SearchHit): string {
  if ('imageUrl' in hit) {
    return `게시물 · @${hit.username}`;
  }
  return `계정 · @${hit.username} · 팔로워 ${hit.followerCount}`;
}

'imageUrl' in hit 은 "이 객체에 imageUrl 이라는 키가 있는가"를 묻는 표현입니다. 이것도 순수 JS 에 원래 있던 연산자예요. 있으면 Post, 없으면 User 로 TypeScript 가 갈라줍니다.

두 가지에서 hit.username 을 똑같이 쓰고 있다는 점도 봐주세요. 두 타입 모두 username 을 갖고 있으니 좁히기 전에도 쓸 수 있는 필드입니다. 좁히기가 필요한 건 한쪽에만 있는 필드죠.

없을 수도 있는 값도 갈라내야 합니다

지난 시간에 배운 옵셔널을 다시 볼게요. Userbio 는 물음표가 붙어 있어서 없을 수도 있는 값입니다. 확인 없이 쓰면 이렇게 막혀요.

텍스트
'user.bio' is possibly 'undefined'.

그래서 지난 시간에 이렇게 썼죠.

TypeScript
// undefined 만 걸러내면 빈 문자열은 그대로 통과한다
export function bioLength(user: User): number {
  if (user.bio === undefined) {
    return 0;
  }
  return user.bio.length;
}

지난 시간에는 이걸 "옵셔널을 쓰면 확인이 강제된다"고만 설명했어요. 이제 정체를 정확히 말할 수 있습니다. 저 if 도 좁히기였습니다. string | undefined 에서 undefined 를 걸러내 string 으로 좁힌 거예요.

! 로 거르면 조금 더 걸러집니다

같은 일을 이렇게 쓸 수도 있어요.

TypeScript
// bio 는 없을 수도 있어서, 확인을 거쳐야 문자열로 다룰 수 있다
export function bioPreview(user: User): string {
  if (!user.bio) {
    return '소개글이 아직 없어요';
  }
  return user.bio.slice(0, 20);
}

두 방식은 같아 보이지만 다릅니다. === undefined 는 정확히 undefined 만 걸러내요. 그래서 빈 문자열 '' 은 통과합니다. 반면 !user.bio 는 빈 문자열도 함께 걸러내죠. JS 에서 '' 은 거짓으로 취급되니까요.

어느 쪽이 맞을까요? 상황에 따라 다릅니다. 소개글을 화면에 보여주는 bioPreview 는 빈 문자열도 "소개글 없음"으로 처리하는 게 자연스러워요. 반면 길이를 세는 bioLength 는 빈 문자열의 길이가 정직하게 0 이면 됩니다.

⚠️ 숫자에는 이 방식을 조심해서 쓰세요. likeCount0 일 때 !likeCount 는 참이 됩니다. "좋아요가 없음"과 "좋아요 수가 0"을 뭉개버리는 실수가 여기서 자주 나와요.

💡 한 줄 정리

객체끼리는 typeof 로 못 가르니 한쪽에만 있는 필드를 in 으로 확인하고, 없을 수도 있는 값은 === undefined! 중 의도에 맞는 쪽을 고릅니다.

🙋 학생 질문 — "필드 이름을 문자열로 적는 게 불안한데요. 오타 나면요?"

좋은 감각입니다. 그리고 결론부터 말씀드리면, 오타는 잡히지 않습니다.

'imageUrl' in hit'imagUrl' in hit 으로 바꿔 보세요. 에디터는 아무 말도 하지 않습니다. in 은 원래 "이 키가 있느냐"를 묻는 연산자라, 없는 키를 물어보는 것 자체는 잘못이 아니거든요. 답이 거짓일 뿐이죠.

그래서 이 코드는 조용히 망가집니다. 'imagUrl' in hit 은 언제나 거짓이 되고, 게시물이 들어와도 계정 문구가 나와요. 에러는 나지 않습니다.

문제가 하나 더 있어요. 두 타입이 우연히 비슷해지면 표지판이 힘을 잃습니다. User 가 나중에 imageUrl 필드를 갖게 되면 이 코드는 또 조용히 망가져요. 컴파일러는 키가 있는지만 볼 뿐, 그 키가 좋은 표지판인지는 판단하지 못하니까요.

정리하면 in 은 분명 쓸 수 있는 도구지만 우연에 기대는 방식입니다. 어느 필드가 한쪽에만 있다는 사실은 언제든 바뀔 수 있으니까요. 그래서 실무에서는 표지판을 우연에 맡기지 않고 처음부터 하나 심어둡니다. Step 4 에서 그 방법을 배울 거예요.


Step 3: "사용자 정의 타입 가드 — 좁히기를 함수로 만든다"

지난 시간 마지막에 이런 질문이 나왔어요. "서버가 실제로 이 모양대로 준다는 보장이 있나요?" 없다고 답했죠. 오늘 그 얘기를 조금 더 해봅시다.

우리가 Post 인터페이스를 선언한 건 "서버가 이렇게 줄 것이다"라는 우리 쪽 믿음입니다. 타입 검사는 코드를 쓰는 동안에만 일어나요. 실행 중에 날아오는 JSON 은 검사하지 않습니다.

그러면 정직하게 표현해 봅시다. 서버에서 막 도착한 값의 타입은 무엇일까요? 우리는 모릅니다. 모른다는 걸 정직하게 적는 타입이 unknown 이에요.

TypeScript
function feedTitleOf(payload: unknown): string {
  return `@${payload.username}`;
}
텍스트
'payload' is of type 'unknown'.

unknown 은 아무것도 허락하지 않습니다. 점을 찍는 것조차 막아요. 답답해 보이지만 이게 정확한 태도입니다. 모르는 값으로는 아무것도 하면 안 되니까요.

확인을 거치면 열립니다

그럼 앞에서 배운 걸로 확인해 봅시다. 객체인지 보고, null 이 아닌지 보고요.

TypeScript
if (typeof value === 'object' && value !== null) {
  return String(value.username);
}
텍스트
Property 'username' does not exist on type 'object'.

아직 부족합니다. "객체다"까지만 알아냈지 "어떤 필드를 가진 객체인지"는 모르거든요. 필드 하나하나를 확인해야 합니다. 그런데 이 확인을 쓰는 쪽마다 반복하면 코드가 금방 지저분해져요.

그래서 확인을 함수로 묶습니다. 여기서 오늘의 새 문법이 등장해요.

TypeScript
// apps/web-spa/src/types/guards.ts

import { Post } from './instagram';

// value is Post — true 를 돌려주면 그때부터 Post 로 다뤄도 된다고 알려주는 표시
export function isPost(value: unknown): value is Post {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'username' in value &&
    'imageUrl' in value &&
    'likeCount' in value &&
    typeof value.id === 'number' &&
    typeof value.username === 'string' &&
    typeof value.imageUrl === 'string' &&
    typeof value.likeCount === 'number'
  );
}

눈여겨볼 곳은 반환 타입입니다. boolean 이 아니라 value is Post 라고 적혀 있어요. 이걸 타입 서술(type predicate)이라고 부릅니다. "이 함수가 참을 돌려주면, 넘긴 값을 Post 로 취급해도 좋다"고 컴파일러에게 알려주는 표시예요.

함수 본문을 보시면 새로운 문법이 하나도 없습니다. Step 1 의 typeof, Step 2 의 in, null 확인. 앞 두 Step 에서 배운 것만으로 만들어졌어요. 그리고 'id' in value 로 키가 있는지 확인한 다음에야 typeof value.id 로 타입을 볼 수 있다는 순서도 눈여겨봐 주세요. 순서가 바뀌면 컴파일러가 막습니다.

쓰는 쪽이 편해집니다

TypeScript
// 배열은 모든 요소가 통과해야 게시물 목록이다
export function isPostArray(value: unknown): value is Post[] {
  return Array.isArray(value) && value.every(isPost);
}

// 가드를 통과하기 전에는 payload 로 아무것도 할 수 없다
export function feedTitleOf(payload: unknown): string {
  if (!isPost(payload)) {
    return '알 수 없는 응답';
  }
  return `@${payload.username} · 좋아요 ${payload.likeCount}`;
}

if (!isPost(payload)) 로 아닌 경우를 먼저 돌려보냈습니다. 그 아래부터 payloadPost 예요. 점을 찍으면 자동완성도 뜹니다.

순수 JS 시절에도 우리는 이런 검사 함수를 만들었어요.

JavaScript
// 순수 JS 시절 — 검사는 했지만 그 결과가 코드에 남지 않았죠
function isPost(value) {
  return value && typeof value.id === 'number';
}

if (isPost(data)) {
  console.log(data.username);   // 확인했다는 걸 나만 기억함
}

달라진 건 검사 결과가 코드에 남는다는 점입니다. value is Post 한 줄 덕분에 "확인을 통과했다"는 사실이 타입으로 이어져요. 검사와 그 검사를 믿는 코드가 따로 놀지 않습니다.

⚠️ 다만 분명히 해둘 게 있어요. 이 가드는 우리가 손으로 쓴 검사입니다. Post 에 필드를 하나 추가하면 이 함수도 같이 고쳐야 하고, 안 고쳐도 컴파일러는 아무 말 안 해요. 검사 항목과 타입 선언이 어긋날 수 있다는 뜻입니다. 이 문제를 근본적으로 푸는 도구는 폼 검증을 다루는 모듈에서 만나게 됩니다. 타입과 검사를 한 곳에서 같이 만들어내는 방식이에요.

💡 한 줄 정리

value is Post 로 반환 타입을 적으면 확인 결과가 타입으로 이어져서, 정체를 모르는 서버 응답도 통과한 뒤부터 안전하게 다룰 수 있습니다.

🙋 학생 질문 — "unknown 대신 any 를 쓰면 이 고생을 안 해도 되지 않나요?"

맞습니다. any 를 쓰면 에러가 전부 사라져요. 그래서 위험합니다.

any 는 "검사하지 마"라는 뜻입니다. payload.username 도 되고, payload.usrname 도 되고, payload.아무거나() 도 됩니다. 전부 통과하고 전부 런타임까지 갑니다. TypeScript 를 쓰면서 TypeScript 를 끄는 셈이에요.

unknown 은 "나도 모른다"는 뜻입니다. 모르니까 확인하기 전에는 아무것도 못 하게 막죠. 둘 다 "타입을 모르는 값"을 나타내지만, any 는 검사를 포기하고 unknown 은 검사를 미룹니다.

실무 기준은 단순해요. 외부에서 들어오는 값은 unknown 으로 받고 가드로 좁힌다. any 는 정말 어쩔 수 없을 때만, 그것도 왜 썼는지 주석을 남기고 씁니다.


Step 4: "판별 유니온 — 좋아요 상태를 상황별 모양으로 나눈다"

이제 오늘의 중심으로 갑니다. 좋아요 버튼을 만들어 볼게요.

버튼 하나지만 상황이 여럿입니다. 아직 아무것도 안 한 상태, 서버에 요청을 보내고 기다리는 상태, 성공해서 새 좋아요 수를 받은 상태, 실패해서 사유를 보여줘야 하는 상태. 순수 JS 시절에는 이걸 보통 이렇게 관리했어요.

JavaScript
// 순수 JS 시절 — 플래그를 여러 개 두고 조합으로 표현했죠
let isLoading = false;
let isError = false;
let errorMessage = null;
let likeCount = 0;

돌아가긴 합니다. 문제는 이 네 변수가 만들어내는 조합이 실제 상황보다 훨씬 많다는 거예요.

텍스트
 불리언 플래그로 관리하면 말이 안 되는 조합이 생깁니다

   isLoading=true, isError=true    로딩 중인데 실패했다?
   isError=true, likeCount=5       실패했는데 좋아요 수가 있다?
   isLoading=false, isError=false  아직 안 누른 상태? 성공한 상태?

 판별 유니온으로 관리하면 상황이 딱 네 가지입니다

   status: 'idle'       liked
   status: 'pending'    liked
   status: 'success'    liked, likeCount
   status: 'failed'     liked, message

마지막 줄이 특히 아파요. 플래그가 전부 거짓일 때 그게 "아직 안 누른 상태"인지 "성공한 상태"인지 구분이 안 됩니다. 그래서 플래그를 하나 더 만들게 되고, 조합은 더 늘어나죠.

근본 원인은 이겁니다. 상황마다 필요한 정보가 다른데, 변수들은 상황과 상관없이 항상 다 존재한다는 것.

상황마다 모양을 따로 만듭니다

해법은 단순해요. 상황별로 타입을 따로 만들고, 그중 하나라고 선언하는 겁니다.

TypeScript
// apps/web-spa/src/types/like-state.ts

// 상황마다 필요한 정보가 다르다 — 그래서 모양을 따로 만든다
interface LikeIdle {
  status: 'idle';
  liked: boolean;
}

interface LikePending {
  status: 'pending';
  liked: boolean;
}

interface LikeSuccess {
  status: 'success';
  liked: boolean;
  likeCount: number;
}

interface LikeFailed {
  status: 'failed';
  liked: boolean;
  message: string;
}

// status 가 판별 필드 — 이 값 하나로 어느 모양인지 정해진다
export type LikeState = LikeIdle | LikePending | LikeSuccess | LikeFailed;

핵심은 네 타입이 모두 status 라는 필드를 갖되, 그 값이 서로 다른 리터럴 타입이라는 점이에요. 'idle', 'pending', 'success', 'failed'. 겹치는 값이 하나도 없습니다.

이렇게 겹치지 않는 리터럴 필드를 판별 필드(discriminant)라고 부르고, 이런 유니온을 판별 유니온(discriminated union)이라고 합니다. Step 2 에서 "표지판을 우연에 맡기지 말고 심어두자"고 했던 게 바로 이거예요. imageUrl 이 우연히 한쪽에만 있길 기대하는 대신, status 라는 표지판을 처음부터 설계에 넣은 겁니다.

그리고 messageLikeFailed 에만 있다는 점을 봐주세요. 실패했을 때만 사유가 있으면 되니까요. likeCount 도 성공했을 때만 있습니다. 상황에 없는 정보는 타입에도 없어요.

판별 필드로 갈라내면 그 상황의 필드가 열립니다

갈라내기 전에 likeCount 를 쓰려 하면 이렇게 막힙니다.

텍스트
Property 'likeCount' does not exist on type 'LikeState'.
  Property 'likeCount' does not exist on type 'LikeIdle'.

LikeIdle 에는 없다고 정확히 지목해 줍니다. 이제 switch 로 갈라 볼게요.

TypeScript
export function likeButtonLabel(state: LikeState): string {
  switch (state.status) {
    case 'idle':
      return state.liked ? '좋아요 취소' : '좋아요';
    case 'pending':
      return '처리 중...';
    case 'success':
      return `좋아요 ${state.likeCount}`;
    case 'failed':
      return `실패 · ${state.message}`;
  }
}

case 안에서 state 가 그 상황의 타입으로 좁혀집니다. 'success' 가지에서는 likeCount 가 열리고, 'failed' 가지에서는 message 가 열려요. 반대로 'success' 가지에서 state.message 를 쓰려고 하면 막힙니다. 성공에는 사유가 없으니까요.

말이 안 되는 조합을 아예 만들 수 없게 된 겁니다. "실패했는데 좋아요 수가 있는" 상태는 이 타입으로 표현할 방법이 없어요.

💡 한 줄 정리

상황마다 다른 모양을 만들고 겹치지 않는 판별 필드로 묶으면, 말이 안 되는 조합을 애초에 만들 수 없고 각 가지에서 그 상황의 정보만 열립니다.

🙋 학생 질문 — "상태가 네 개뿐인데 타입을 네 개나 만드는 게 과하지 않나요?"

지금은 과해 보일 수 있어요. 그런데 기준을 조금 바꿔서 생각해 봅시다. 타입을 몇 개 만드느냐가 아니라, 만들 수 있는 잘못된 상태가 몇 개냐를 보세요.

플래그 방식에서 불리언 두 개와 값 두 개면 표현 가능한 조합이 수십 가지가 됩니다. 그중 실제로 말이 되는 건 네 가지뿐이고요. 나머지는 전부 버그가 될 수 있는 상태예요. 그리고 그 버그는 컴파일러가 못 잡습니다.

판별 유니온은 표현 가능한 상태를 정확히 네 가지로 묶습니다. 잘못된 상태를 만들 방법 자체를 없애는 거예요. 실무에서는 이걸 "잘못된 상태를 표현 불가능하게 만든다"고 표현합니다.

물론 상황이 둘뿐이고 각 상황의 필드가 똑같다면 굳이 이렇게까지 할 필요는 없어요. 판단 기준은 이렇습니다. 상황마다 필요한 정보가 다른가? 다르면 판별 유니온이 값을 합니다.


Step 5: "never — 빠뜨린 상황을 컴파일러가 잡게 하기"

판별 유니온을 만들었으니 이제 좋아요 버튼을 언제 잠글지 정해 봅시다. 요청을 보내고 기다리는 동안에만 잠그면 되겠죠.

TypeScript
export function likeButtonDisabled(state: LikeState): boolean {
  switch (state.status) {
    case 'pending':
      return true;
    case 'idle':
    case 'success':
      return false;
  }
}

잘 돌아갑니다. 그런데 여기 문제가 하나 숨어 있어요. 'failed' 를 빠뜨렸습니다.

실패 상태가 들어오면 이 함수는 아무 case 에도 걸리지 않고 undefined 를 돌려줘요. 버튼이 잠기지도 풀리지도 않은 이상한 상태가 됩니다. 그리고 지금 이 코드에 대해 아무도 경고해 주지 않았죠.

이 문제는 앞으로 계속 커집니다. LikeState'retrying' 같은 상태를 하나 더 추가한다고 해봅시다. 그러면 statusswitch 하는 함수가 코드베이스 전체에 몇 개나 있을까요? 그 전부를 손으로 찾아서 고쳐야 합니다. 하나라도 놓치면 조용히 망가지고요.

값이 하나도 없는 타입

TypeScript 에는 never 라는 타입이 있습니다. "이 타입에 해당하는 값은 하나도 없다"는 뜻이에요. 어떤 값도 never 타입의 변수에 넣을 수 없습니다.

이 성질을 거꾸로 이용하면 재미있는 일을 할 수 있어요.

TypeScript
// never 를 받는 함수는 "여기까지 올 수 없다"는 뜻이다
export function assertNever(value: never): never {
  throw new Error(`처리하지 않은 좋아요 상태입니다: ${JSON.stringify(value)}`);
}

이 함수는 never 만 받습니다. 그러니 여기에 뭔가를 넘길 수 있다면 그건 앞에서 다 걸러내지 못했다는 뜻이에요.

TypeScript
export function likeButtonDisabled(state: LikeState): boolean {
  switch (state.status) {
    case 'pending':
      return true;
    case 'idle':
    case 'success':
    case 'failed':
      return false;
    default:
      return assertNever(state);
  }
}

네 가지를 다 처리했으니 default 에 도달할 방법이 없습니다. 그래서 그 지점에서 state 의 타입은 never 로 줄어들어 있고, assertNever 에 무사히 넘어갑니다.

하나라도 빠뜨리면 컴파일이 멈춥니다

이제 아까처럼 'failed' 를 빼보세요. 이런 메시지가 뜹니다.

텍스트
Argument of type 'LikeFailed' is not assignable to parameter of type 'never'.

메시지를 읽어보면 감동적이에요. 빠뜨린 상황의 이름을 컴파일러가 직접 지목해 줍니다. LikeFailed 를 처리 안 했다고요. 어디를 고쳐야 하는지 찾아다닐 필요가 없습니다.

이걸 완전성 검사(exhaustiveness check)라고 부릅니다. 유니온의 모든 경우를 빠짐없이 다뤘는지 컴파일러가 확인해 주는 방식이에요.

진짜 값어치는 나중에 드러납니다. 언젠가 LikeState 에 새 상태를 추가하는 날, 고쳐야 할 모든 switch 가 한꺼번에 빨간 줄로 표시돼요. 하나씩 고치다 보면 컴파일이 통과하고, 그 순간 빠뜨린 곳이 없다는 게 보장됩니다.

지난 시간 코드를 다시 봅시다

지난 시간에 쓴 sortLabel 을 떠올려 볼게요.

TypeScript
export function sortLabel(sort: FeedSort): string {
  if (sort === 'latest') {
    return '최신순';
  }
  if (sort === 'popular') {
    return '인기순';
  }
  return '팔로잉';
}

그때 이런 이야기를 했습니다. "FeedSort 에 네 번째 값을 추가하면 이 함수는 그 값을 팔로잉으로 잘못 처리하게 된다"고요.

이제 왜 그런지 정확히 보입니다. 마지막 return 이 남은 경우를 전부 받아버리기 때문이에요. 완전성 검사가 없는 구조입니다. 값이 늘어나도 컴파일러는 조용하고, 화면에만 이상하게 나오죠.

switchassertNever 로 바꾸면 그 함정이 사라집니다. 값이 늘어나는 순간 컴파일러가 막아서니까요.

🌟 실무에서는 유니온을 switch 로 다루는 함수에 완전성 검사를 기본으로 넣습니다. 지금 당장은 안 넣어도 돌아가지만, 유니온에 값이 추가되는 날 그 차이가 드러나거든요.

💡 한 줄 정리

never 를 받는 함수를 default 에 두면, 유니온에 값이 추가됐을 때 고쳐야 할 곳을 컴파일러가 빠짐없이 지목해 줍니다.

🙋 학생 질문 — "switch 말고 if 로 써도 완전성 검사가 되나요?"

됩니다. 좁히기는 switch 전용 기능이 아니거든요.

TypeScript
if (state.status === 'idle') return false;
if (state.status === 'pending') return true;
if (state.status === 'success') return false;
if (state.status === 'failed') return false;
return assertNever(state);

이렇게 써도 똑같이 동작합니다. 마지막 줄에 도달했을 때 남은 게 없으면 statenever 가 되니까요.

다만 판별 유니온에는 switch 를 권합니다. 판별 필드 하나를 기준으로 갈린다는 게 눈에 바로 들어오고, case 를 나열하는 형태라 빠진 게 있는지 읽기도 쉬워요. if 를 늘어놓으면 조건마다 다른 필드를 볼 수도 있어서 구조가 흐려집니다.

그리고 알아두면 좋은 게 하나 더 있어요. default 없이 switch 만 쓰고 모든 case 에서 return 하는 방식으로도 비슷한 효과를 얻을 수 있습니다. 함수 반환 타입을 적어두면 빠뜨렸을 때 이런 메시지가 나오거든요.

텍스트
Function lacks ending return statement and return type does not include 'undefined'.

"이 함수에 마지막 return 이 없다"는 뜻입니다. 빠뜨렸다는 사실은 알려주죠. 다만 assertNever 쪽이 낫습니다. 이 메시지는 어떤 경우를 빠뜨렸는지까지는 안 알려주니까요.


마무리

오늘 배운 핵심 세 가지

💡 좁게 선언한 값은 갈라내야 쓸 수 있다

유니온으로 여러 가능성을 표현하면 선언은 정확해지지만, 쓰는 쪽에서 "지금은 이것"이라고 갈라줘야 그 타입의 기능이 열립니다. typeof·in·없음 확인 모두 순수 JS 에 있던 문법이에요. 달라진 건 컴파일러가 그 확인을 함께 읽어준다는 점입니다.

💡 모르는 값은 unknown 으로 받고 가드로 좁힌다

서버 응답은 우리가 선언한 대로 온다는 보장이 없습니다. unknown 으로 정직하게 받고, value is Post 를 반환하는 가드로 확인을 통과시킨 뒤에 다뤄요. 검사 결과가 타입으로 이어지기 때문에 검사와 그걸 믿는 코드가 따로 놀지 않습니다.

💡 상황마다 모양이 다르면 판별 유니온으로 나눈다

플래그를 여러 개 두면 말이 안 되는 조합이 잔뜩 생깁니다. 상황별로 타입을 나누고 겹치지 않는 판별 필드로 묶으면 잘못된 상태를 만들 방법 자체가 사라져요. 여기에 never 완전성 검사를 얹으면 상태가 늘어날 때 고칠 곳을 컴파일러가 짚어줍니다.

다음 시간 예고

오늘 LikeState 를 만들면서 네 개의 인터페이스에 liked: boolean 을 네 번 반복해 적었죠. 눈에 걸리셨을 겁니다.

지난 시간에도 비슷한 게 있었어요. PostStatusMap 을 손으로 세 번 반복했고, Post 는 필드가 열한 개인데 카드를 그릴 땐 그중 몇 개만 필요했습니다. 그렇다고 비슷한 인터페이스를 하나 더 손으로 쓰면 나중에 한쪽만 고쳐서 어긋나기 십상이고요.

다음 시간에는 이미 있는 타입에서 새 타입을 뽑아내는 방법을 배웁니다. Post 에서 필요 없는 필드만 빼서 PostCardProps 를 만들거나, 필요한 것만 골라내는 식이에요. 원본 하나만 고치면 거기서 뽑아낸 타입들이 알아서 따라옵니다.

여기에 더해 지금까지 import 로 타입을 불러왔는데, 그걸 더 정확하게 쓰는 방법까지 다룹니다.


과제

[구현] 스토리 조회 상태를 판별 유니온으로 설계하기

지난 시간 과제에서 Story 타입을 만드셨죠. 이번에는 스토리 목록을 불러오는 화면의 상태를 설계해 주세요.

요구사항은 다음과 같습니다.

  • 화면 상태는 네 가지입니다. 아직 요청 전, 불러오는 중, 다 불러옴, 실패함.
  • 다 불러온 상태에는 스토리 배열이 있어야 하고, 실패한 상태에는 사유 메시지가 있어야 합니다. 나머지 상태에는 그런 정보가 없어야 해요.
  • 판별 필드 이름은 status 로 하고, StoryFeedState 라는 이름으로 묶어 주세요.
  • 상태를 받아 화면에 띄울 문구를 돌려주는 함수를 만들어 주세요. 다 불러왔는데 배열이 비어 있으면 "아직 올라온 스토리가 없어요"를 돌려줍니다.
  • 그 함수에 완전성 검사를 넣어 주세요.
  • 정체를 모르는 값이 Story 인지 판정하는 타입 가드도 하나 만들어 주세요.

파일은 apps/web-spa/src/types/story-state.ts 에 만들어 주세요.

[탐구] 좁히기가 언제 풀리는지 확인해 보기

좁히기는 한 번 하면 끝까지 유지될 것 같지만, 풀리는 경우가 있습니다. 아래 네 가지를 직접 쳐보고 에디터가 뭐라고 하는지 기록해 주세요.

  • let term: string | numbertypeof 로 좁힌 다음, 그 안에서 term = 7 처럼 다시 할당하고 그 아래에서 term.trim() 을 호출해 보기
  • user.bioif (user.bio !== undefined) 로 좁힌 다음, 그 안에서 [1, 2].forEach(() => user.bio.length) 처럼 콜백 안에서 써보기
  • typeof value === 'object' 만으로 좁히고 value.username 에 접근해 보기
  • LikeStateif (state.status !== 'idle') 로 좁힌 뒤, 그 안에서 state.status 에 마우스를 올려 어떤 타입으로 나오는지 확인해 보기

각각 에러가 나는지, 난다면 어떤 메시지인지, 왜 그런지 한 줄씩 적어보세요. 좁히기가 유지되는 조건을 감으로 익히는 게 목표입니다.


생각해볼 주제

1. 타입 가드는 진짜 안전을 보장해 줄까

오늘 만든 isPost 는 필드 네 개를 확인합니다. 그런데 Post 에는 필드가 열한 개예요. 나머지 일곱 개는 확인하지 않았습니다.

이 가드를 통과한 값을 Post 로 다뤄도 정말 괜찮을까요? 확인하는 필드를 열한 개로 늘리면 안전해질까요? 그리고 나중에 Post 에 필드를 추가할 때 이 가드를 같이 고치는 걸 무엇이 보장해 줄까요?

2. 완전성 검사를 팀 규칙으로 강제할 것인가

assertNever 를 넣으면 유니온이 늘어날 때 고칠 곳을 컴파일러가 짚어줍니다. 좋은 일이죠. 그런데 유니온을 다루는 모든 함수에 이걸 넣으면 코드가 길어지고, 상태가 두 개뿐인 곳에서는 과해 보이기도 합니다.

여러분이 팀의 규칙을 정한다면 어디까지 강제하시겠어요? "모든 switch 에 필수"와 "판단에 맡김" 사이에서 기준을 하나 세워본다면 무엇을 근거로 삼으시겠습니까?

3. 잘못된 상태를 표현 불가능하게 만든다는 것

오늘 우리는 플래그 네 개를 판별 유니온 네 개로 바꿨습니다. 표현 가능한 상태의 수가 수십 가지에서 네 가지로 줄었어요.

그런데 이 원칙을 끝까지 밀어붙이면 어떻게 될까요? 예를 들어 "좋아요 수는 0 이상"이라는 규칙도 타입으로 표현하고 싶어질 수 있습니다. 타입으로 막을 수 있는 것과, 막으려 하면 오히려 복잡해지는 것의 경계는 어디쯤일까요?

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 스토리 조회 상태를 판별 유니온으로 설계하기

채점 포인트

항목 확인 내용 배점
판별 필드 네 타입 모두 status 를 갖고, 값이 서로 겹치지 않는 리터럴인가 20
상황별 필드 분리 stories 는 다 불러온 상태에만, message 는 실패 상태에만 두었는가 25
유니온 묶기 StoryFeedState 로 네 타입을 묶었는가 15
빈 배열 처리 다 불러왔는데 배열이 비었을 때를 따로 분기했는가 20
완전성 검사 never 를 받는 함수를 default 에 두었는가 10
타입 가드 반환 타입을 value is Story 로 적었는가 10

풀이 예시

TypeScript
// apps/web-spa/src/types/story-state.ts

import type { Story } from './story';

interface StoryFeedIdle {
  status: 'idle';
}

interface StoryFeedLoading {
  status: 'loading';
}

interface StoryFeedLoaded {
  status: 'loaded';
  stories: Story[];
}

interface StoryFeedFailed {
  status: 'failed';
  message: string;
}

export type StoryFeedState =
  | StoryFeedIdle
  | StoryFeedLoading
  | StoryFeedLoaded
  | StoryFeedFailed;

function assertNever(value: never): never {
  throw new Error(`처리하지 않은 스토리 상태입니다: ${JSON.stringify(value)}`);
}

export function storyFeedMessage(state: StoryFeedState): string {
  switch (state.status) {
    case 'idle':
      return '스토리를 불러올 준비가 됐어요';
    case 'loading':
      return '스토리를 불러오는 중이에요';
    case 'loaded':
      if (state.stories.length === 0) {
        return '아직 올라온 스토리가 없어요';
      }
      return `스토리 ${state.stories.length}개`;
    case 'failed':
      return `불러오지 못했어요 · ${state.message}`;
    default:
      return assertNever(state);
  }
}

StoryFeedIdleStoryFeedLoadingstatus 말고 아무것도 없다는 게 포인트예요. 요청 전이나 불러오는 중에는 스토리도 없고 실패 사유도 없으니까요. 없는 정보를 타입에 두지 않는 게 판별 유니온의 값어치입니다.

타입 가드는 이렇게 씁니다.

TypeScript
export function isStory(value: unknown): value is Story {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'username' in value &&
    'mediaUrl' in value &&
    'background' in value &&
    'viewCount' in value &&
    typeof value.id === 'number' &&
    typeof value.username === 'string' &&
    typeof value.mediaUrl === 'string' &&
    typeof value.viewCount === 'number' &&
    (value.background === 'white' ||
      value.background === 'black' ||
      value.background === 'gradient')
  );
}

background 를 확인하는 방식을 봐주세요. 다른 필드는 typeof 로 종류만 보면 되지만, background 는 세 값 중 하나여야 하니 값 자체를 비교합니다. 리터럴 유니온을 검사할 때는 typeof 로 부족해요.

text 는 확인 항목에서 뺐습니다. 옵셔널 필드라 없어도 정상이니까요. 있으면 문자열인지까지 보고 싶다면 (!('text' in value) || typeof value.text === 'string') 처럼 쓰면 됩니다.

자주 나오는 실수

가장 흔한 건 storiesmessage 를 모든 상태에 옵셔널로 두는 경우입니다.

TypeScript
// 이렇게 하면 판별 유니온의 이점이 사라집니다
interface StoryFeedState {
  status: 'idle' | 'loading' | 'loaded' | 'failed';
  stories?: Story[];
  message?: string;
}

문법은 맞고 컴파일도 통과해요. 그런데 status'loaded' 인데 stories 가 없는 상태를 여전히 만들 수 있습니다. 게다가 쓰는 쪽에서 state.stories 를 볼 때마다 없을 수도 있다는 확인을 또 해야 하죠. 상태를 나눈 의미가 없어집니다.

또 하나는 빈 배열을 별도 status 로 만드는 경우예요. 'empty' 라는 상태를 추가하는 겁니다. 틀린 건 아니지만 권하지는 않아요. 서버 입장에서 "빈 목록을 성공적으로 받아온 것"과 "목록을 받아온 것"은 같은 사건이거든요. 비었는지는 배열 길이를 보면 알 수 있으니 상태를 늘릴 이유가 없습니다. 상태는 서버와의 대화 단계를 나타내고, 내용물 판단은 데이터를 보고 하는 게 깔끔합니다.

💡 튜터의 한마디

판별 유니온을 설계할 때는 상태를 먼저 나열하지 말고, 상황마다 "이때 화면이 알아야 하는 정보가 뭐지?"를 물어보세요. 요청 전에는 아무것도 필요 없고, 실패했을 때는 사유가 필요합니다. 필요한 정보가 달라지는 경계가 곧 상태의 경계예요. 그렇게 나누면 필드를 어디에 둘지 고민할 일이 없어집니다.

🎯 [과제 2 예시답안] 좁히기가 언제 풀리는지 확인해 보기

채점 포인트

항목 확인 내용 배점
재할당 좁힌 뒤 다른 타입을 넣으면 좁히기가 다시 계산된다는 걸 확인했는가 25
콜백 콜백 안에서 좁히기가 유지되지 않는다는 걸 확인했는가 25
object 좁히기 object 까지만 좁히면 필드 접근이 안 된다는 걸 확인했는가 25
판별 필드 제외 !== 로 좁히면 나머지 유니온이 남는다는 걸 확인했는가 25

풀이 예시

1. 좁힌 뒤 재할당

TypeScript
let term = input;              // string | number
if (typeof term === 'string') {
  term = 7;
  return term.trim();
}
텍스트
Property 'trim' does not exist on type 'number'.

좁히기는 한 번 하고 끝나는 도장이 아니라, 값이 바뀔 때마다 다시 계산되는 흐름입니다. term = 7 을 한 순간 TypeScript 는 그 아래에서 termnumber 로 봅니다. 메시지가 'string | number' 가 아니라 'number' 라는 점이 그 증거예요. 재할당된 값을 정확히 따라가고 있습니다.

2. 콜백 안에서 쓰기

TypeScript
if (user.bio !== undefined) {
  return [1, 2].map(() => user.bio.length);
}
텍스트
'user.bio' is possibly 'undefined'.

밖에서 분명히 확인했는데도 콜백 안에서는 좁히기가 풀립니다. 이유는 콜백이 언제 실행될지 TypeScript 가 모르기 때문이에요. map 은 지금 바로 실행하지만, 어떤 함수는 콜백을 나중에 부릅니다. 그사이 누군가 user.bio 를 지웠을 수도 있죠. 그래서 안전한 쪽을 택합니다.

해결은 간단해요. 좁힌 값을 지역 변수에 담으면 됩니다.

TypeScript
const bio = user.bio;
if (bio !== undefined) {
  return [1, 2].map(() => bio.length);
}

const 로 담으면 바뀔 일이 없으니 좁히기가 콜백 안까지 유지됩니다.

3. object 까지만 좁히기

TypeScript
if (typeof value === 'object' && value !== null) {
  return String(value.username);
}
텍스트
Property 'username' does not exist on type 'object'.

object 는 "객체이긴 하다"까지만 말해줄 뿐 어떤 필드를 가졌는지는 말해주지 않습니다. 필드를 쓰려면 'username' in value 로 키가 있는지 먼저 확인해야 해요.

4. 판별 필드를 !== 로 좁히기

TypeScript
if (state.status !== 'idle') {
  const check: 'nope' = state.status;
}
텍스트
Type '"pending" | "success" | "failed"' is not assignable to type '"nope"'.

일부러 엉뚱한 타입에 넣어보면 에러 메시지가 남은 타입을 알려줍니다. 'idle' 하나만 빠지고 나머지 셋이 그대로 남았어요. 앞의 세 경우와 달리 여기서는 좁히기가 제대로 유지됩니다. 판별 필드는 === 로도 !== 로도 좁혀지고, 셋을 다 걸러내면 마지막엔 never 가 되고요. Step 5 의 완전성 검사가 바로 이 성질 위에 서 있습니다.

💡 튜터의 한마디

좁히기가 풀리는 경우를 외우려 하지 마세요. 원리 하나만 잡으면 됩니다. TypeScript 는 "이 값이 그때까지 안 바뀐다고 확신할 수 있는가"를 봅니다. 재할당하면 확신이 깨지고, 콜백은 언제 실행될지 몰라서 확신할 수 없어요. 그래서 실무 습관은 이겁니다. 좁힌 값을 const 로 꺼내 쓰기. 이 하나로 대부분의 문제가 사라집니다.


🤔 [생각해볼 주제 1] 타입 가드는 진짜 안전을 보장해 줄까

문제 상황 요약

isPost 는 필드 열한 개 중 네 개만 확인합니다. 이 가드를 통과하면 TypeScript 는 그 값을 완전한 Post 로 취급해요. 확인하지 않은 일곱 개에 대해서도 마찬가지입니다.

확인 항목을 열한 개로 늘리면 안전해질까요? 그리고 나중에 Post 에 필드를 추가할 때 이 가드를 같이 고치는 걸 무엇이 보장해 줄까요?

튜터의 가이드 및 해설

먼저 정확히 짚고 갈 게 있습니다. value is Post 는 검사가 아니라 선언이에요. TypeScript 는 함수 본문이 정말 Post 인지 확인하는지 검증하지 않습니다. 우리가 "참을 돌려주면 Post 로 봐라"라고 말하면 그대로 믿어요.

극단적으로 이렇게 써도 컴파일은 통과합니다.

TypeScript
export function isPost(value: unknown): value is Post {
  return true;
}

그러니 타입 가드의 안전성은 전적으로 우리가 본문을 얼마나 성실하게 썼느냐에 달려 있습니다. 컴파일러가 보장해 주는 게 아니에요.

그럼 열한 개를 다 확인하면 되느냐. 그것도 근본 해법은 아닙니다. 문제는 확인 항목의 개수가 아니라 타입 선언과 검사 코드가 서로 다른 곳에 있다는 구조거든요. Post 에 필드를 하나 추가해도 isPost 는 아무 경고 없이 그대로 통과합니다. 두 곳이 어긋나도 컴파일러는 조용해요. 시간이 지날수록 어긋날 확률만 쌓입니다.

그래서 실무는 방향을 바꿉니다. 타입에서 검사를 만들어내는 대신, 검사에서 타입을 만들어내요. 스키마를 한 번 정의하면 거기서 검사 함수와 타입이 함께 나오는 방식입니다. 정의가 하나뿐이니 어긋날 수가 없죠. 폼 검증을 다루는 모듈에서 이 도구를 만나게 될 겁니다.

그렇다고 손으로 쓴 가드가 쓸모없는 건 아닙니다. 외부 라이브러리 없이 한두 곳만 확인하면 되는 상황, 검사 규칙이 단순한 상황에서는 여전히 좋은 선택이에요. 중요한 건 이게 무엇을 보장하고 무엇을 보장하지 않는지 알고 쓰는 겁니다.

한 가지 더. 검사를 어디서 하느냐도 생각해 볼 만합니다. 응답이 들어오는 지점 한 곳에서 검사하고 그 뒤로는 믿는 방식이 일반적이에요. 화면 곳곳에서 매번 확인하면 코드가 검사로 뒤덮이고, 정작 실패했을 때 어디서 걸러야 할지도 흐려집니다. 경계에서 한 번 막고 안쪽은 신뢰하는 구조가 관리하기 좋습니다.

🎯 면접관을 홀리는 핵심 멘트

"타입 서술(value is T)은 컴파일러가 검증해 주는 게 아니라 개발자가 컴파일러에게 하는 약속입니다. 본문이 틀려도 컴파일은 통과하죠. 그래서 저는 손으로 쓴 가드를 임시 방편으로 보고, 실제로는 스키마에서 타입과 검사를 함께 뽑아내는 방식을 씁니다. 정의가 하나면 어긋날 일이 없으니까요. 그리고 검증은 응답이 들어오는 경계 한 곳에서 하고, 그 안쪽 코드는 타입을 신뢰하게 만듭니다."

🤔 [생각해볼 주제 2] 완전성 검사를 팀 규칙으로 강제할 것인가

문제 상황 요약

assertNever 를 넣으면 유니온이 늘어날 때 고칠 곳을 컴파일러가 짚어줍니다. 그런데 유니온을 다루는 모든 함수에 넣으면 코드가 길어지고, 상태가 두 개뿐인 곳에서는 과해 보이기도 해요.

팀 규칙을 정한다면 어디까지 강제하시겠어요? 무엇을 근거로 기준을 세우시겠습니까?

튜터의 가이드 및 해설

이 질문은 "완전성 검사가 좋은가"를 묻는 게 아닙니다. 좋은 건 이미 확인했어요. 진짜 질문은 "비용을 언제 치를 것인가"입니다.

완전성 검사의 비용은 지금 당장 발생합니다. default 절 세 줄, 헬퍼 함수 하나. 반면 이득은 나중에, 그것도 유니온에 값이 추가될 때만 발생해요. 그러니 판단 기준은 단순해집니다. 이 유니온이 앞으로 늘어날 가능성이 얼마나 되는가.

늘어날 게 거의 확실한 곳들이 있습니다. 요청 상태(로딩·성공·실패에 재시도나 취소가 붙습니다), 알림 종류(좋아요·댓글에 팔로우·멘션·DM 이 붙죠), 권한 등급 같은 것들이요. 이런 곳은 처음부터 넣어두는 게 남는 장사입니다.

반대로 'asc' | 'desc' 같은 유니온은 늘어날 일이 없어요. 여기에 완전성 검사를 넣는 건 형식만 채우는 겁니다.

그런데 저는 규칙을 "판단에 맡김"으로 두는 걸 권하지 않아요. 판단에 맡기면 실제로는 아무도 안 넣게 되거든요. 지금 당장은 아쉬울 게 없으니까요. 그리고 정작 유니온이 늘어나는 날, 넣어뒀어야 할 곳에 없다는 걸 알게 됩니다.

그래서 현실적인 절충안은 이겁니다. 판별 유니온을 switch 로 다루는 경우에는 기본으로 넣되, 값이 둘뿐이고 도메인상 늘어날 이유가 없는 유니온은 빼도 된다. 판단이 필요한 범위를 좁혀주는 거예요.

한 걸음 더 나가면 이걸 사람이 아니라 도구에 맡길 수도 있습니다. 린트 규칙 중에 switch 의 완전성을 검사해 주는 게 있어서, assertNever 를 손으로 쓰지 않아도 빠뜨린 case 를 잡아줍니다. 규칙을 문서에 적어두고 지키기를 바라는 것보다, 도구가 자동으로 막아주는 쪽이 항상 낫습니다.

마지막으로 균형 감각 하나. 완전성 검사는 유니온이 늘어날 때 고칠 곳을 알려주는 도구지, 코드 품질의 척도가 아닙니다. 이걸 규칙으로 만들 때 목적을 잊고 형식만 남으면, 개발자들은 default 에 기계적으로 붙이고 정작 그 안에서 무슨 일이 벌어지는지는 안 보게 돼요.

🎯 면접관을 홀리는 핵심 멘트

"완전성 검사의 비용은 지금 치르고 이득은 유니온이 늘어날 때 돌아옵니다. 그래서 저는 '늘어날 유니온인가'를 기준으로 잡습니다. 요청 상태나 알림 종류처럼 확실히 늘어나는 건 기본으로 넣고, asc | desc 처럼 고정된 건 뺍니다. 다만 완전히 판단에 맡기면 아무도 안 넣더군요. 그래서 판별 유니온의 switch 는 기본 적용으로 두고, 가능하면 린트 규칙으로 자동화합니다. 사람이 지키는 규칙보다 도구가 막아주는 규칙이 오래갑니다."

🤔 [생각해볼 주제 3] 잘못된 상태를 표현 불가능하게 만든다는 것

문제 상황 요약

플래그 네 개를 판별 유니온으로 바꾸면서 표현 가능한 상태가 수십 가지에서 네 가지로 줄었습니다. 잘못된 상태를 만들 방법 자체를 없앤 거죠.

그런데 이 원칙을 끝까지 밀어붙이면 어떻게 될까요? "좋아요 수는 0 이상"이라는 규칙도 타입으로 막고 싶어질 수 있습니다. 타입으로 막을 수 있는 것과, 막으려 하면 오히려 복잡해지는 것의 경계는 어디일까요?

튜터의 가이드 및 해설

경계를 가르는 기준은 하나입니다. 그 규칙을 코드를 쓰는 시점에 알 수 있는가.

status'failed' 인데 likeCount 를 읽는 실수는 코드를 쓰는 시점에 드러납니다. 어떤 값이 들어올지 몰라도 "실패 상태에는 좋아요 수 필드가 없다"는 건 구조에서 이미 정해져 있으니까요. 이런 건 타입이 잘 막습니다.

반면 "좋아요 수는 0 이상"은 성격이 다릅니다. 이건 값에 대한 규칙이고, 값은 실행 중에 정해져요. 서버가 -1 을 보낼지는 코드를 쓰는 시점에 알 수 없습니다. 타입은 실행 중에 오는 값을 검사하지 않으니 막을 방법이 없어요.

물론 우회 기법이 없는 건 아닙니다. 브랜드 타입이라고 해서, 검증을 통과한 숫자에만 표시를 달아 일반 숫자와 구분하는 방법이 있어요. 다만 이걸 쓰면 그 타입을 만드는 함수, 다른 곳으로 넘길 때의 변환, 산술 연산 처리까지 딸려옵니다. 얻는 것에 비해 치르는 값이 커요.

그래서 실무에서는 역할을 이렇게 나눕니다. 구조에 대한 규칙은 타입이 맡고, 값에 대한 규칙은 실행 중 검증이 맡는다. "실패 상태에는 좋아요 수가 없다"는 타입으로, "좋아요 수는 0 이상"은 검증으로. 둘을 섞으려 하면 양쪽 다 어설퍼집니다.

한 가지 더 생각해 볼 게 있어요. 타입을 정교하게 만들수록 읽기가 어려워진다는 점입니다. 팀에서 아무도 못 읽는 타입은 안전장치가 아니라 장애물이에요. 저는 타입 설계를 볼 때 "이걸 처음 보는 팀원이 30초 안에 무슨 뜻인지 알까"를 기준으로 삼습니다. 그 선을 넘으면 안전을 조금 포기하더라도 단순하게 갑니다.

결국 판별 유니온이 널리 쓰이는 이유도 여기 있어요. 얻는 안전은 크고 읽는 비용은 거의 없습니다. status: 'failed' 를 보면 누구나 무슨 뜻인지 압니다. 좋은 타입 설계는 정교함이 아니라 이 균형에서 나옵니다.

🎯 면접관을 홀리는 핵심 멘트

"저는 구조에 대한 규칙과 값에 대한 규칙을 나눠서 봅니다. '실패 상태에는 좋아요 수가 없다'는 구조라서 판별 유니온으로 막고, '좋아요 수는 0 이상'은 값이라서 실행 중 검증으로 막습니다. 타입은 실행 중 값을 검사하지 않으니까요. 브랜드 타입 같은 기법으로 값까지 타입에 밀어넣을 수는 있지만, 팀원이 못 읽는 타입은 안전장치가 아니라 장애물이 됩니다. 판별 유니온이 널리 쓰이는 건 얻는 안전에 비해 읽는 비용이 거의 없기 때문입니다."

전체 목록 리액트