문서 읽는 데 67분 · A3

A-3: TypeScript 심화 ② — 타입 파생과 모듈 위생

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

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

안녕하세요, 홍순구입니다. 지난 시간에 좋아요 버튼의 상태를 네 가지로 나눠 봤죠. 그중 두 개만 다시 옮겨 적어 볼게요.

TypeScript
interface LikeIdle {
  status: 'idle';
  liked: boolean;
}

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

두 개만 봐도 눈에 걸리는 게 있습니다. liked: boolean 이요. 실제 코드에서는 네 번 반복됩니다.

지금은 한 줄이라 참을 만해요. 그런데 좋아요 상태에 postId 를 하나 추가해야 한다면 어떻게 될까요? 네 곳을 전부 고쳐야 합니다. 세 곳만 고치고 한 곳을 빠뜨리면요? 컴파일러는 아무 말도 하지 않습니다. 네 인터페이스는 서로 아무 관계가 없으니까요.

비슷한 게 A-1 에도 있었습니다. POST_STATUS 상수를 검사하려고 PostStatusMap 이라는 인터페이스를 손으로 썼죠. PostStatus 를 세 번 반복해서요. 그때 이렇게 말씀드렸어요. "나중에 이 인터페이스를 지우게 될 거예요."

오늘이 그날입니다. 주제는 하나로 요약됩니다. 타입을 손으로 두 번 쓰지 않는 방법.

텍스트
 오늘의 여정

 Step 1~2   있는 타입에서 새 타입 뽑아내기 (Pick · Omit · Partial · Required)
    │
 Step 3     키 집합에 값 매달기 (Record)
    │
 Step 4     함수와 필드에서 타입 꺼내기 (ReturnType · NonNullable)
    │
 Step 5     애너테이션 · satisfies · as 중 무엇을 쓸까
    │
 Step 6     타입 임포트를 정확하게 (import type)
    
 원본 하나만 고치면 나머지가 따라오는 타입 묶음으로 마무리

💡 오늘 수업의 핵심 — "타입은 두 번 쓰지 않는다"

같은 정보를 두 곳에 적으면 언젠가 한쪽만 고치게 되고, 그 어긋남은 컴파일러도 못 잡습니다. 이미 있는 타입에서 새 타입을 뽑아내면 원본 하나만 고쳐도 나머지가 따라옵니다. 오늘은 뽑아내는 도구들을 익히고, 코드베이스의 임포트까지 정리합니다.

🎯 학습 목표

  • Pick·Omit·Partial·Required·Record 로 이미 있는 타입에서 새 타입을 뽑아낼 수 있다
  • ReturnType·NonNullable 로 함수와 필드에서 타입을 꺼내고, 애너테이션·satisfies·as 중 무엇을 쓸지 판단할 수 있다
  • import typeverbatimModuleSyntax 로 타입 임포트와 값 임포트를 정확히 구분할 수 있다

Step 1: "이미 있는 타입에서 새 타입을 뽑는다 — Pick 과 Omit"

방금 본 liked 반복부터 정리하고 시작하죠. 여기엔 새 문법이 필요 없습니다. A-1 에서 배운 extends 로 됩니다.

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

// 네 상태가 공통으로 갖는 정보는 한 곳에만 적는다
interface LikeBase {
  liked: boolean;
}

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

interface LikePending extends LikeBase {
  status: 'pending';
}

interface LikeSuccess extends LikeBase {
  status: 'success';
  likeCount: number;
}

interface LikeFailed extends LikeBase {
  status: 'failed';
  message: string;
}

liked 가 한 번만 등장합니다. 이제 좋아요 상태에 공통 필드를 추가할 일이 생기면 LikeBase 한 곳만 고치면 돼요.

걱정되는 게 하나 있으시죠. 지난 시간에 만든 switch 가 여전히 동작할까요? 동작합니다. extends 로 나눠 써도 각 인터페이스가 최종적으로 갖는 필드는 그대로거든요. status 로 갈라내면 그 상황의 필드가 열리는 것도 똑같습니다.

그런데 extends 는 넓히는 방향뿐입니다

이제 카드 얘기로 넘어갑시다. A-1 에서 만든 Post 는 필드가 열한 개예요. 그런데 프로필 화면의 그리드 한 칸에는 사진 한 장과 종류 배지만 뜹니다. 캡션도, 해시태그도, 팔로워 수도 필요 없어요.

그리드 한 칸을 그리는 함수에 Post 전체를 넘기면 어떻게 될까요? 돌아가긴 합니다. 다만 그 함수가 실제로 뭘 쓰는지 읽는 사람이 알 수 없어요. 열한 개 중에 세 개만 쓰는데 열한 개를 요구하는 셈이니까요.

그럼 필요한 세 개짜리 인터페이스를 손으로 하나 만들까요? 그러면 오프닝에서 본 문제가 그대로 돌아옵니다. PostimageUrl 이 나중에 이름이 바뀌면 이쪽은 조용히 어긋나요.

extends 로는 해결이 안 됩니다. extends 는 있는 것에 더하는 방향이지 빼는 방향이 아니거든요.

텍스트
 extends 는 더하는 방향

   Author ────── VerifiedAuthor      (필드가 늘어난다)

 지금 필요한 건 빼는 방향

   Post ──────── PostThumbnail       (필드가 줄어든다)
                  
                  └── extends 로는 갈 수 없다

남길 것을 적는다 — Pick

TypeScript 에는 이미 있는 타입에서 일부만 골라내는 도구가 준비돼 있습니다.

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

// 프로필 그리드 한 칸에는 이미지와 종류 배지만 있으면 된다 — 남길 것을 적는다
export type PostThumbnail = Pick<Post, 'id' | 'imageUrl' | 'mediaKind'>;

읽는 법은 간단해요. Pick<원본, '남길 필드' | '남길 필드'>. 꺾쇠 안에 원본 타입을 먼저 쓰고, 그다음에 남길 필드 이름을 문자열로 나열합니다.

여기서 눈여겨볼 게 있어요. 필드 이름을 문자열로 적는데 이건 오타가 잡힙니다. 지난 시간 in 연산자에서는 오타가 안 잡혔죠. 차이가 뭘까요? in 은 "이 키가 있느냐"를 실행 중에 묻는 연산자라 없는 키를 물어도 잘못이 아니었습니다. Pick 은 "Post 에 있는 필드 중에서 고르라"는 요구라, 없는 이름을 적으면 그 자체가 잘못이에요.

이제 이 타입을 쓰는 함수를 만들어 볼게요.

TypeScript
import { mediaBadge } from './literals';

// 사진에는 배지를 붙이지 않는다
export function thumbnailBadge(thumb: PostThumbnail): string | null {
  if (thumb.mediaKind === 'image') {
    return null;
  }
  return mediaBadge(thumb.mediaKind);
}

A-1 에서 만든 mediaBadge 를 그대로 재사용했습니다. 그리고 이 함수 안에서 thumb.content 를 써보면 이렇게 막혀요.

텍스트
Property 'content' does not exist on type 'PostThumbnail'.

고르지 않은 필드는 아예 없는 겁니다. "쓸 수는 있지만 안 쓰는 것"이 아니라 "존재하지 않는 것"이에요. 이 함수가 무엇을 쓰는지가 타입에 정확히 드러납니다.

뺄 것을 적는다 — Omit

카드는 사정이 반대입니다. 피드에 뜨는 카드는 Post 의 거의 전부를 씁니다. 사용자 이름도, 프로필 사진도, 캡션도, 좋아요 수도 다 그려요. 안 쓰는 게 두 개뿐입니다.

이럴 때 아홉 개를 나열하는 건 미련하죠. 반대로 적는 도구를 씁니다.

TypeScript
// 카드는 해시태그 목록과 원본 시각 문자열을 쓰지 않는다 — 뺄 것을 적는다
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;

// hashtagNames·createdAt 은 이 타입에 없으니 여기서 꺼내 쓸 수 없다
export function cardHeadline(props: PostCardProps): string {
  return `@${props.username} · 좋아요 ${props.likeCount}`;
}

해시태그는 캡션 아래 별도 줄을 그리는 컴포넌트가 따로 맡고, 시각은 카드 위쪽이 "3시간 전" 형태로 바꿔서 보여줍니다. 카드 본체는 둘 다 안 써요.

PickOmit 은 방향만 반대입니다. 고르는 쪽이 짧으면 Pick, 빼는 쪽이 짧으면 Omit. 그게 전부예요.

원본을 고치면 따라옵니다

오늘의 핵심이 여기서 드러납니다. PostsavedByMe: boolean 이라는 필드를 하나 추가한다고 해봅시다. 어떻게 될까요?

텍스트
 Post 에 필드 추가

   PostThumbnail  : 그대로 (id·imageUrl·mediaKind 만 골랐으니까)
   PostCardProps  : savedByMe 가 자동으로 생김 (뺀 두 개 말고는 다 따라오니까)

   손으로 쓴 인터페이스였다면  아무 일도 안 일어남 (그래서 어긋남)

우리가 한 일은 없는데 두 타입이 알아서 정확한 상태를 유지합니다. 손으로 썼다면 세 곳을 기억해서 고쳐야 했고, 하나를 빠뜨려도 아무도 알려주지 않았을 거예요.

💡 한 줄 정리

Pick 은 남길 필드를, Omit 은 뺄 필드를 적어 원본에서 새 타입을 뽑아내고, 원본이 바뀌면 뽑아낸 타입이 알아서 따라옵니다.

🙋 학생 질문 — "그냥 Post 를 그대로 넘기면 안 되나요? 어차피 필드가 더 있다고 문제가 되는 건 아니잖아요."

실행에는 문제가 없습니다. 그런데 두 가지를 잃어요.

첫째는 읽는 사람의 정보입니다. thumbnailBadge(post: Post) 라고 적혀 있으면 이 함수가 열한 개 중 무엇을 쓰는지 알려면 본문을 다 읽어야 해요. PostThumbnail 이라고 적혀 있으면 시그니처만 봐도 세 개라는 걸 압니다. 타입은 검사만 하는 게 아니라 설명도 한다고 A-1 마지막에 이야기했죠. 이게 그 이야기의 연장입니다.

둘째는 재사용 범위예요. Post 를 요구하면 Post 전체를 가진 곳에서만 이 함수를 쓸 수 있습니다. 검색 결과나 추천 목록처럼 필드가 몇 개만 내려오는 화면에서는 못 써요. 반대로 세 개만 요구하면 세 개만 있는 곳에서도 쓸 수 있습니다.

정리하면 함수가 실제로 필요한 만큼만 요구하는 게 좋습니다. 그리고 그 "만큼"을 손으로 다시 쓰지 않는 방법이 오늘 배우는 파생이고요.


Step 2: "다 없어도 되게, 다 있어야 하게 — Partial 과 Required"

프로필 수정 화면을 만들어 봅시다. A-1 에서 만든 ProfileSummary 를 다시 볼게요. username 은 필수고, biowebsiteUrl 에는 물음표가 붙어 있었죠.

수정 화면에서 사용자가 소개글만 고쳤다고 해봅시다. 서버에 무엇을 보내야 할까요? 실무에서는 보통 바꾼 것만 보냅니다. 안 건드린 필드까지 다 보내면 두 사람이 동시에 수정할 때 서로의 변경을 덮어쓰거든요.

그러면 "바꾼 것만 담는 타입"이 필요합니다. username 도 안 보낼 수 있어야 하니, 전부 없어도 되는 타입이어야 해요.

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

// 수정 폼은 바꾼 필드만 보낸다 — 물음표를 전부 붙인 모양
export type ProfileEditDraft = Partial<ProfileSummary>;

Partial 은 원본의 모든 필드에 물음표를 붙입니다. username 도 이제 없을 수 있어요. 필드 이름을 하나도 안 적었다는 점을 봐주세요. ProfileSummary 에 필드가 늘어나면 이 타입도 알아서 늘어납니다.

반대 방향도 있습니다

Required 는 정반대예요. 물음표를 전부 걷어냅니다.

TypeScript
// 반대로 물음표를 전부 걷어내면 빠짐없이 채워진 모양이 된다
export type CompleteProfile = Required<ProfileSummary>;

이게 왜 쓸모 있을까요? 인스타그램에는 "프로필을 완성하세요" 같은 안내가 있죠. 소개글과 링크까지 다 채운 사용자에게는 그 안내를 안 띄웁니다. 그러니까 "다 채워진 프로필"이라는 상태가 실제로 존재하는 겁니다.

텍스트
 ProfileSummary       서버가 내려주는 그대로 · username 필수 · bio? · websiteUrl?

   Partial  ──  ProfileEditDraft    (전부 선택 — 수정할 때 보낼 것)
   Required ──  CompleteProfile     (전부 필수 — 안내를 숨겨도 되는 상태)

두 파생 타입을 같이 쓰면 수정 반영 함수가 이렇게 됩니다.

TypeScript
export function applyProfileEdit(
  current: CompleteProfile,
  draft: ProfileEditDraft,
): CompleteProfile {
  return {
    username: draft.username ?? current.username,
    bio: draft.bio ?? current.bio,
    websiteUrl: draft.websiteUrl ?? current.websiteUrl,
  };
}

A-1 에서 배운 ?? 가 여기서 정확히 맞아떨어집니다. 초안에 값이 있으면 그걸 쓰고, 없으면 원래 값을 유지해요.

그리고 draft.username 을 확인 없이 쓰려고 하면 이렇게 막힙니다.

텍스트
'draft.username' is possibly 'undefined'.

Partial 을 통과한 순간 username 도 없을 수 있는 값이 됐으니까요. 지난 시간에 배운 좁히기를 거쳐야 쓸 수 있습니다.

판정 함수도 파생 타입으로

지난 시간에 만든 사용자 정의 타입 가드를 여기에 얹어 볼게요.

TypeScript
// 통과하면 물음표가 없는 모양으로 다뤄도 된다
export function isProfileComplete(profile: ProfileSummary): profile is CompleteProfile {
  return profile.bio !== undefined && profile.websiteUrl !== undefined;
}

반환 타입에 적힌 게 CompleteProfile 입니다. 우리가 손으로 만든 타입이 아니라 원본에서 뽑아낸 타입이죠. 이 가드를 통과한 값은 그 뒤부터 물음표 없는 프로필로 다뤄집니다. 지난 시간의 도구와 오늘의 도구가 자연스럽게 붙는 곳이에요.

⚠️ PartialRequired 는 한 겹만 적용됩니다. 필드 안에 또 객체가 들어 있으면 그 안쪽까지는 안 건드려요. 중첩된 객체까지 전부 선택 사항으로 만들고 싶다면 직접 처리해야 합니다. 실무에서 자주 걸려 넘어지는 곳입니다.

💡 한 줄 정리

Partial 은 모든 필드를 없어도 되게, Required 는 모두 있어야 하게 바꿔주고, 필드 이름을 하나도 적지 않으니 원본이 늘어나도 그대로 따라옵니다.

🙋 학생 질문 — "수정 폼에 쓸 거면 처음부터 필드에 물음표를 붙여서 선언하면 되지 않나요?"

그러면 원본이 망가집니다.

ProfileSummary 는 서버가 내려주는 프로필의 모습이에요. 거기서 username 은 반드시 있습니다. 계정 이름 없는 프로필은 존재할 수 없으니까요. 그런데 수정 폼 때문에 물음표를 붙이면, 프로필을 화면에 그리는 모든 곳에서 "이름이 없을 수도 있다"는 확인을 하게 됩니다. 있지도 않은 경우를 위해서요.

여기서 기억하실 원칙이 하나 있어요. 타입은 그 데이터의 진실을 적는 것이지, 특정 화면의 사정을 적는 게 아닙니다. 화면의 사정은 화면 쪽에서 파생시키면 돼요.

그래서 원본은 정직하게 두고, 수정 폼이라는 상황에 맞는 모습을 Partial 로 따로 뽑아내는 겁니다. 원본과 화면용 타입이 각자 정확해지는 거죠.


Step 3: "키 집합에 값을 매단다 — Record"

A-1 에서 만든 sortLabel 을 다시 봅시다.

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

지난 시간에 이 함수의 문제를 짚었죠. 마지막 return 이 남은 경우를 전부 받아버려서, 정렬이 하나 늘어나도 컴파일러가 조용합니다.

그런데 문제가 하나 더 있어요. 이건 값을 꺼내는 일인데 로직처럼 생겼다는 겁니다. 정렬 세 개에 라벨이 하나씩 붙어 있을 뿐인데 if 세 줄을 읽어야 알 수 있죠. 이런 건 사실 표에 가깝습니다.

키와 값을 표로

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

// 키 집합 × 값 타입 — 세 정렬 각각에 라벨을 하나씩 매단다
export const SORT_LABEL: Record<FeedSort, string> = {
  latest: '최신순',
  popular: '인기순',
  following: '팔로잉',
};

// if 를 늘어놓는 대신 맵에서 꺼낸다
export function sortLabelOf(sort: FeedSort): string {
  return SORT_LABEL[sort];
}

Record<키, 값> 은 "이 키들 각각에 이 타입의 값이 하나씩 붙은 객체"를 뜻합니다. 여기서는 FeedSort 의 세 값이 키가 되고, 값은 전부 문자열이에요.

sortLabelOf 를 보시면 분기가 사라졌습니다. 대괄호로 꺼내기만 하죠. 정렬이 늘어나도 이 함수는 손댈 필요가 없습니다.

빠뜨리면 컴파일러가 막습니다

여기가 오늘 제일 통쾌한 곳이에요. following 을 일부러 빼보세요.

텍스트
Property 'following' is missing in type '{ latest: string; popular: string; }'
but required in type 'Record<FeedSort, string>'.

빠뜨린 키의 이름을 정확히 지목해 줍니다. 반대로 없는 키를 넣어도 막혀요.

텍스트
Object literal may only specify known properties, and 'deleted' does not exist
in type 'Record<PostStatus, string>'.

이 감각, 지난 시간에 느껴보셨죠. never 로 완전성 검사를 했을 때 빠뜨린 상황을 컴파일러가 지목해 줬잖아요. Record 는 같은 일을 데이터에 대해 해줍니다. switch 가 아니라 객체를 쓰는데도 빠짐없이 다뤘는지 확인받는 거예요.

텍스트
 같은 안전장치, 다른 표현

   A-2   switch + assertNever
            처리 안 한 상황을 컴파일러가 지목

   A-3   Record<유니온, 값>
            값을 안 매단 키를 컴파일러가 지목

값에 객체를 둘 수도 있습니다

라벨 하나로 부족한 경우도 많죠. 게시물 상태마다 화면에 쓸 정보가 두 가지라면 값에 객체를 둡니다.

TypeScript
// 값 자리에는 객체도 올 수 있다
interface StatusView {
  label: string;
  visibleToOthers: boolean;
}

export const STATUS_VIEW: Record<PostStatus, StatusView> = {
  draft: { label: '임시저장', visibleToOthers: false },
  published: { label: '공개됨', visibleToOthers: true },
  archived: { label: '보관됨', visibleToOthers: false },
};

export function statusViewOf(status: PostStatus): StatusView {
  return STATUS_VIEW[status];
}

세 상태 각각에 라벨과 공개 여부가 붙었습니다. 상태가 늘어나면 이 객체에 한 줄 추가하라고 컴파일러가 요구할 거예요.

약속을 지킬 시간입니다

A-1 에서 이런 걸 손으로 썼던 게 기억나시나요?

TypeScript
interface PostStatusMap {
  DRAFT: PostStatus;
  PUBLISHED: PostStatus;
  ARCHIVED: PostStatus;
}

PostStatus 를 세 번 반복했고, 값이 늘어나면 이 인터페이스도 같이 늘려야 했습니다. 그때 "나중에 지우게 될 거예요"라고 했죠. 지금 지웁니다.

TypeScript
// apps/web-spa/src/types/enum-alternative.ts

export type PostStatus = 'draft' | 'published' | 'archived';

// 키는 PostStatus 를 대문자로 바꾼 것, 값은 PostStatus — 손으로 쓰던 인터페이스가 사라졌다
export const POST_STATUS = {
  DRAFT: 'draft',
  PUBLISHED: 'published',
  ARCHIVED: 'archived',
} as const satisfies Record<Uppercase<PostStatus>, PostStatus>;

인터페이스가 통째로 사라졌습니다. 대신 Record 한 줄이 그 역할을 해요.

키 부분을 봐주세요. Uppercase<PostStatus> 라고 적혀 있습니다. Uppercase 는 문자열 리터럴을 전부 대문자로 바꿔주는 도구예요. 'draft' | 'published' | 'archived' 를 넣으면 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' 가 나옵니다.

그래서 이제 PostStatus 에 값을 하나 추가하면 무슨 일이 벌어질까요? 키 집합이 자동으로 넓어지고, POST_STATUS 에 그 키를 안 넣었다고 컴파일러가 요구합니다. 우리가 고칠 곳은 PostStatus 한 줄뿐이에요. A-1 에서 느꼈던 그 불편함이 완전히 없어졌습니다.

💡 한 줄 정리

Record<키, 값> 은 유니온의 모든 값에 데이터를 하나씩 매달게 강제해서, 빠뜨린 키를 컴파일러가 지목하게 만듭니다.

🙋 학생 질문 — "그럼 라벨을 꺼내는 함수는 전부 Record 로 바꾸는 게 맞나요?"

값을 꺼내기만 한다면 대체로 그렇습니다. 다만 두 가지를 구분해 보세요.

Record 가 잘 맞는 건 "키 하나에 값 하나"가 고정된 경우예요. 라벨, 아이콘 이름, 색상, 정렬 순서 같은 것들이죠. 이런 건 표로 두는 게 읽기도 쉽고 빠뜨림도 막힙니다.

반면 값을 꺼내는 게 아니라 계산을 하는 경우엔 함수가 맞습니다. 예를 들어 좋아요 수에 따라 문구가 달라진다면 그건 표에 못 담아요. 입력이 정해진 몇 가지가 아니라 숫자 전체니까요.

한 가지 더 있습니다. Record 의 값은 파일을 읽는 순간 전부 만들어집니다. 값이 무겁다면, 예를 들어 함수 호출 결과나 큰 객체라면 필요할 때 계산하는 편이 나을 수도 있어요. 라벨 같은 문자열은 전혀 문제가 없습니다.


Step 4: "함수와 필드에서 타입을 꺼낸다 — ReturnType 과 NonNullable"

지금까지는 이미 이름이 붙은 타입에서 뽑아냈습니다. 이번에는 이름이 없는 것에서 뽑아내 볼게요.

카드 한 장을 그리는 데 필요한 값들을 한 번에 만들어 주는 함수를 씁니다.

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

// 반환 타입을 따로 선언하지 않은 함수
export function toCardView(post: Post) {
  return {
    headline: `@${post.username}`,
    caption: post.content,
    likeLabel: `좋아요 ${post.likeCount}`,
    commentLabel: `댓글 ${post.commentCount}`,
  };
}

반환 타입을 안 적었습니다. A-1 에서 배운 추론이 알아서 해주니까요. 네 개의 문자열을 가진 객체라고 TypeScript 가 읽습니다.

그런데 이 결과를 받는 함수를 하나 더 만들려면 곤란해져요. 매개변수에 타입 이름이 필요한데, 그 타입에는 이름이 없거든요. 인터페이스를 손으로 하나 쓸까요? 그러면 오늘 내내 피해온 중복이 또 생깁니다. toCardView 에 필드를 하나 추가하면 그 인터페이스도 같이 고쳐야 하고요.

함수가 돌려주는 것에서 꺼냅니다

TypeScript
// 함수가 무엇을 돌려주는지에서 타입을 꺼낸다 — 여기서 typeof 는 값을 가리키는 표시다
export type CardView = ReturnType<typeof toCardView>;

export function cardAlt(view: CardView): string {
  return `${view.headline} — ${view.caption}`;
}

ReturnType<함수타입> 은 그 함수가 돌려주는 것의 타입을 꺼냅니다. 이제 toCardView 에 필드를 추가하면 CardView 가 알아서 따라와요.

여기서 잠깐, 이 typeof 는 다릅니다

여기가 오늘 가장 헷갈리기 쉬운 곳이라 따로 짚고 갈게요. 지난 시간에도 typeof 를 썼습니다. 그런데 그때와 지금은 완전히 다른 일을 합니다.

텍스트
 같은 글자, 다른 세계

   A-2 에서 쓴 typeof
     if (typeof term === 'string')
     실행 중에 값의 종류를 물어본다. 결과는 'string' 같은 문자열.

   오늘 쓴 typeof
     ReturnType<typeof toCardView>
     타입을 적는 칸에서만 쓴다. 저 이름이 가리키는 값의 타입을 꺼낸다.
     실행되지 않는다.

글자가 같아서 헷갈리지만 사는 세계가 다릅니다. 하나는 실행되는 코드고, 하나는 타입을 적는 칸에 들어가는 표현이에요. 구분하는 방법은 단순합니다. 꺾쇠 안이나 타입 이름을 적는 칸에 있으면 타입 쪽입니다.

왜 이런 게 필요할까요? toCardView 는 함수라서 값입니다. 그런데 우리가 원하는 건 그 값의 타입이죠. 값에서 타입으로 건너가는 다리가 필요한데, 그 다리가 타입 칸의 typeof 예요.

필드 하나만 꺼내기

타입 안의 필드 하나만 꺼낼 수도 있습니다. 대괄호를 씁니다.

TypeScript
// 대괄호로 타입 안의 필드 하나를 꺼낸다
export type LikeCount = Post['likeCount'];

객체에서 값을 꺼낼 때 쓰던 대괄호와 모양이 같아요. 다만 이건 타입 칸에서 쓰는 것이라 Post 라는 타입 안의 likeCount 필드의 타입을 꺼냅니다. 여기서는 number 가 나오죠.

없음을 걷어내기

이걸 옵셔널 필드에 써보면 재미있는 일이 생깁니다. ProfileSummary['bio'] 는 무슨 타입일까요? 물음표가 붙어 있었으니 string | undefined 입니다.

소개글 길이를 세는 함수를 이 타입으로 만들면 이렇게 막혀요.

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

맞는 말이지만, 이 함수는 소개글이 있는 사용자에게만 호출할 생각이었어요. 함수 안에서 매번 확인하는 대신 "없음은 안 들어온다"를 타입에 적고 싶습니다.

TypeScript
// bio 는 string | undefined — 없음을 걷어내면 string 만 남는다
export type Bio = NonNullable<ProfileSummary['bio']>;

export function bioBadge(bio: Bio): string {
  return `소개글 ${bio.length}자`;
}

NonNullable 은 유니온에서 nullundefined 만 걸러냅니다. string | undefined 에서 string 만 남죠. 이제 함수 안에서 확인 없이 bio.length 를 쓸 수 있습니다. 확인은 부르는 쪽에서 하고, 이 함수는 값이 있다는 걸 전제로 하는 거예요.

🌟 정리하면 오늘 배운 도구들은 원본이 어디 있느냐만 다릅니다. 다른 타입에 있으면 Pick·Omit·Partial·Required, 함수에 있으면 ReturnType, 필드에 있으면 대괄호. 공통점은 하나예요. 손으로 다시 쓰지 않는다는 것.

💡 한 줄 정리

ReturnType 은 함수가 돌려주는 것에서, 대괄호는 타입 안의 필드에서 타입을 꺼내고, NonNullable 은 거기서 없음을 걷어냅니다.

🙋 학생 질문 — "그러면 함수 반환 타입은 아예 안 적는 게 좋은 건가요?"

아니요, 오히려 반대에 가깝습니다.

반환 타입을 적으면 좋은 게 하나 있어요. 함수 본문을 잘못 고쳤을 때 그 함수에서 바로 에러가 납니다. 안 적으면 추론된 타입이 조용히 바뀌고, 그 함수를 쓰는 먼 곳에서 에러가 나요. 원인을 찾기가 훨씬 어렵습니다.

그래서 실무 기준은 대략 이렇습니다. 다른 파일에서 가져다 쓰는 함수에는 반환 타입을 적는다. 파일 안에서만 쓰는 짧은 함수는 추론에 맡겨도 된다.

그럼 ReturnType 은 언제 쓸까요? 반환 타입에 이름을 붙일 만큼의 값어치는 없는데 그 타입을 다른 데서 참조해야 할 때입니다. toCardView 처럼 화면에 넘길 값들을 조립하는 함수가 딱 그런 경우예요. 인터페이스를 하나 더 만들면 이름은 생기지만 고칠 곳도 하나 더 생기거든요. 오늘 배운 것들이 다 그렇듯, 판단 기준은 "이 정보를 두 곳에 적게 되는가"입니다.


Step 5: "애너테이션 · satisfies · as — 셋 중 무엇을 쓸까"

지금까지 값에 타입을 붙이는 방법이 여러 개 나왔습니다. A-1 에서 콜론을 찍고 타입을 적었고, satisfies 도 배웠죠. 그리고 오늘 세 번째가 등장합니다. 셋을 나란히 놓고 정리할 시간이에요.

피드를 불러올 때 쓸 설정 객체를 예로 들겠습니다.

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

export interface FeedOption {
  sort: FeedSort;
  pageSize: number;
}

// ① 애너테이션 — 검사를 받고, sort 의 타입은 FeedSort 로 넓어진다
export const annotatedOption: FeedOption = {
  sort: 'latest',
  pageSize: 12,
};

// ② satisfies — 검사도 받고, sort 는 'latest' 그대로 남는다
export const checkedOption = {
  sort: 'latest',
  pageSize: 12,
} satisfies FeedOption;

// ③ as — 검사를 건너뛴다. pageSize 가 없는데도 통과한다
export const assertedOption = {
  sort: 'latest',
} as FeedOption;

①과 ②는 A-1 에서 다뤘습니다. 차이를 다시 확인해 볼게요. 'latest' 만 받는 함수에 넘겨보면 드러납니다.

TypeScript
// 좁은 타입만 받는 함수 — satisfies 로 검사한 값만 그대로 들어간다
export function isLatestOnly(sort: 'latest'): boolean {
  return sort === 'latest';
}

checkedOption.sort 는 그냥 들어갑니다. annotatedOption.sort 를 넣으면 이렇게 막혀요.

텍스트
Argument of type 'FeedSort' is not assignable to parameter of type '"latest"'.
  Type '"popular"' is not assignable to type '"latest"'.

애너테이션을 붙이는 순간 sortFeedSort 로 넓어졌기 때문입니다. satisfies 는 검사만 하고 좁은 타입을 그대로 남겨둔다는 게 여기서 눈에 보이죠.

③번이 오늘의 새 문법입니다

as 를 타입 단언(type assertion)이라고 부릅니다. "이 값을 이 타입으로 취급하라"고 컴파일러에게 말하는 문법이에요.

여기서 중요한 건 이겁니다. as 는 확인하지 않습니다. 우기는 겁니다.

위 코드의 ③번을 다시 보세요. pageSize 가 없습니다. 그런데 통과했어요. 애너테이션이나 satisfies 였다면 "필드가 빠졌다"고 막혔을 겁니다. as 는 검사 자체를 건너뛰니까 아무 말이 없죠.

그리고 이 객체를 쓰는 코드는 이렇게 생겼습니다.

TypeScript
// 타입상으로는 언제나 number 다
export function pageSizeOf(option: FeedOption): number {
  return option.pageSize;
}

assertedOption 을 넘기면 무슨 일이 벌어질까요? 타입은 number 라고 되어 있는데 실제로 나오는 값은 undefined 입니다. 페이지 크기가 undefined 인 채로 API 요청이 나가고, 문제는 한참 뒤 서버 응답이 이상할 때 드러나요. 우리가 A-1 첫 시간에 없애려고 했던 그 상황이 정확히 돌아온 겁니다.

지난 시간의 가드와 비교해 보세요

정체를 모르는 서버 응답을 다루는 두 가지 방법을 나란히 두면 차이가 선명합니다.

TypeScript
// 확인을 거친 값만 Post 로 다룬다
export function safeTitle(payload: unknown): string {
  if (!isPost(payload)) {
    return '알 수 없는 응답';
  }
  return `@${payload.username}`;
}

// 확인 없이 Post 라고 우긴다 — 막는 것이 아무것도 없다
export function unsafeTitle(payload: unknown): string {
  return `@${(payload as Post).username}`;
}

둘 다 컴파일은 통과합니다. 그런데 null 을 넘겨보면 위쪽은 "알 수 없는 응답"을 돌려주고, 아래쪽은 실행 중에 터집니다.

지난 시간에 unknown 을 배우면서 "모르는 값으로는 아무것도 하면 안 된다"고 했죠. as 는 그 원칙을 우회하는 문이에요. unknown 이 막아둔 걸 억지로 여는 겁니다.

as 에도 한계는 있습니다

완전히 아무거나 되는 건 아니에요. 너무 동떨어진 타입으로는 못 우깁니다.

텍스트
Conversion of type 'number' to type 'Post' may be a mistake because neither type
sufficiently overlaps with the other. If this was intentional, convert the
expression to 'unknown' first.

숫자를 게시물이라고 우기려다 막힌 메시지입니다. 그런데 마지막 문장을 읽어보세요. "정말 그럴 생각이면 먼저 unknown 으로 바꾸라"고 알려줍니다. 두 번 우기면 뚫린다는 뜻이에요. 이 방법이 쓰이는 곳이 아주 없지는 않지만, 코드에서 이걸 보면 "여기 뭔가 잘못됐다"는 신호로 읽는 게 맞습니다.

⚠️ A-1 에서 배운 as const 는 이름만 비슷할 뿐 완전히 다릅니다. as const 는 "이 값들을 안 바뀌는 것으로 좁게 읽어달라"는 요청이라 안전을 늘리는 쪽이에요. 반면 as 타입 은 검사를 건너뛰니 안전을 줄이는 쪽입니다. 방향이 정반대예요.

그래서 무엇을 쓸까

순서를 정해두면 헷갈리지 않습니다.

방법 모양 검사 리터럴 유지 언제
애너테이션 const x: T = {} 받음 넓어짐 그 타입으로만 쓸 값. 기본 선택
satisfies T 받음 유지됨 검사도 받고 정확한 값도 필요할 때
as T 건너뜀 우기는 대로 컴파일러가 모르는 사실을 내가 확실히 알 때만

세 번째 줄의 조건이 핵심입니다. "내가 확실히 안다"가 아니라 "컴파일러가 알 방법이 없는데 내가 확실히 안다"예요. 대부분의 경우엔 확인 한 번으로 컴파일러도 알게 만들 수 있고, 그럴 수 있다면 그쪽이 맞습니다.

🌟 코드 리뷰에서 as 를 보면 "이 자신감의 근거가 무엇인가"를 묻는 게 좋은 습관입니다. 근거를 말로 설명할 수 있으면 주석으로 남기고, 설명이 안 되면 확인 코드로 바꾸세요.

💡 한 줄 정리

애너테이션과 satisfies 는 검사를 받고 as 는 검사를 건너뛰므로, as 는 컴파일러가 알 수 없는 사실을 내가 확실히 알 때만 씁니다.

🙋 학생 질문 — "그럼 as 는 아예 안 쓰는 게 낫지 않나요? 왜 있는 거죠?"

정말로 필요한 곳이 있긴 합니다. 다만 생각보다 훨씬 드물어요.

전형적인 경우는 컴파일러가 우리보다 정보가 적을 때입니다. 예를 들어 브라우저에서 요소를 하나 찾아오면 TypeScript 는 그게 어떤 종류의 요소인지 알 방법이 없습니다. 우리는 그게 입력창이라는 걸 알죠. 우리가 그 화면을 직접 썼으니까요. 이런 곳에서 as 가 정당해집니다.

또 한 가지는 테스트나 임시 코드에서 필요한 필드만 채운 가짜 값을 만들 때예요. 다만 이건 편의를 위해 안전을 파는 거라, 진짜 코드로 새어 나가지 않게 조심해야 합니다.

기억하실 기준은 이겁니다. as 를 쓰고 싶어질 때 먼저 물어보세요. 확인 한 번으로 컴파일러도 알게 만들 수 있나? 만들 수 있으면 그렇게 하는 게 맞고, 정말 방법이 없을 때만 as 를 쓰되 왜 안전한지 한 줄 남기세요.


Step 6: "타입 임포트를 정확하게 — import type"

마지막입니다. 지금까지 다른 파일의 타입을 가져다 쓸 때 이렇게 적었어요.

TypeScript
import { Post } from './instagram';
import { MediaKind } from './literals';

아무 문제 없이 동작했습니다. 그런데 여기 숨어 있는 애매함이 하나 있어요.

A-1 에서 이야기했듯 TypeScript 코드는 브라우저에서 그대로 돌지 않습니다. 타입을 걷어내고 순수 JavaScript 로 바꾼 다음 실행되죠. 그러면 위 코드에서 Post 는 어떻게 될까요? 사라져야 합니다. 인터페이스는 실행 중에 존재하지 않으니까요.

그런데 저 import 줄만 봐서는 Post 가 타입인지 값인지 알 수 없습니다. 알아내려면 ./instagram 파일을 열어서 확인해야 해요. 파일 하나만 보고 판단할 수 없다는 게 문제입니다.

이게 왜 문제가 되는지 보여드릴게요

같은 코드를 두 가지로 써서 실제로 변환해 봤습니다. 먼저 표시 없이 쓴 쪽입니다.

JavaScript
// 표시 없이 쓴 코드를 변환하면
import { MediaKind, mediaBadge } from './fake-literals';
export function badge(kind) {
    return mediaBadge(kind);
}

MediaKind 가 결과물에 그대로 남았습니다. 실행 중에는 존재하지 않는 이름인데도요. 반면 표시를 붙이면 이렇게 됩니다.

JavaScript
// type 표시를 붙인 코드를 변환하면
import { mediaBadge } from './fake-literals';
export function badge(kind) {
    return mediaBadge(kind);
}

타입은 깨끗이 사라지고 실제로 쓰는 값만 남았어요.

왜 이런 차이가 날까요? 요즘 빌드 도구들은 파일을 하나씩 따로 변환합니다. 그래야 빠르거든요. 그런데 파일 하나만 봐서는 MediaKind 가 타입인지 알 수 없으니, 도구는 "일단 남겨두자"는 판단을 하게 됩니다. A-1 에서 enum 을 이야기할 때 나왔던 그 사정과 같은 뿌리예요. 타입 문법은 지워질 거라고 전제하고 빠르게 처리하는 시대라는 것.

그래서 우리가 적어줍니다

해법은 단순합니다. 타입을 가져올 때 그렇다고 표시하는 거예요.

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

import type { MediaKind } from './literals';

import 뒤에 type 한 단어가 붙었습니다. 이제 이 줄만 봐도 "타입만 가져오니 변환할 때 지워도 된다"는 게 분명해요. 파일을 열어볼 필요가 없습니다.

값과 타입을 같은 파일에서 가져올 때는 타입 쪽에만 표시를 붙일 수도 있어요.

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

// 값과 타입을 같은 곳에서 불러올 땐 타입 쪽에만 표시를 붙인다
import { POST_STATUS, type PostStatus } from './enum-alternative';

POST_STATUS 는 실제로 존재하는 상수라 남아야 하고, PostStatus 는 타입이라 사라져야 합니다. 한 줄 안에서 둘을 구분한 거예요.

규칙으로 강제하기

문제는 사람이 매번 기억해야 한다는 겁니다. 깜빡하면 다시 애매한 임포트가 생기죠. 그래서 설정으로 강제합니다.

JSON
// apps/web-spa/tsconfig.json
{
  "compilerOptions": {
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

verbatimModuleSyntax 는 "적힌 그대로 내보낸다"는 뜻입니다. type 표시가 있으면 지우고, 없으면 남깁니다. 파일 하나만 보고 판단하겠다는 선언이에요. 그러니 우리에게도 정확히 적으라고 요구합니다.

이걸 켜고 검사를 돌리면 지금까지 쓴 파일들이 한꺼번에 반응합니다. 실제로 여섯 개 파일에서 열 군데가 걸렸어요.

텍스트
'MediaKind' is a type and must be imported using a type-only import
when 'verbatimModuleSyntax' is enabled.

무엇이 타입인지, 어느 줄인지 전부 알려줍니다. 하나씩 import type 으로 고치면 검사가 통과해요. 한 번 고생하고 나면 그 뒤로는 깜빡할 수가 없습니다. 빠뜨리는 즉시 막히니까요.

내보낼 때도 같은 규칙이 적용됩니다.

텍스트
Re-exporting a type when 'verbatimModuleSyntax' is enabled requires using 'export type'.

다른 파일의 타입을 다시 내보낼 때는 export type 이라고 적으라는 뜻이에요.

오늘 우리가 한 일

텍스트
 켜기 전 :  import { Post } from './instagram'
               이게 타입인지 값인지 이 줄만 봐선 모름
               변환 도구가 판단을 못 해 결과물에 남기도 함

 켠 후   :  import type { Post } from './instagram'
               타입이라고 적혀 있음. 변환할 때 확실히 사라짐
               빠뜨리면 검사가 막아줌

A-1 에서 심어둔 마지막 숙제까지 오늘 정리됐습니다. enum 대신 const 객체를 쓴 것도, 임포트에 표시를 붙인 것도 같은 이야기예요. 타입은 실행 전에 사라지는 것이니, 사라진다는 사실을 코드에 정확히 적어두자는 겁니다.

💡 한 줄 정리

import type 은 그 임포트가 변환할 때 사라진다는 걸 파일 하나만 보고도 알 수 있게 하고, verbatimModuleSyntax 는 그렇게 적도록 강제합니다.

🙋 학생 질문 — "표시를 안 붙여도 지금까지 잘 돌아갔는데, 굳이 켜야 하나요?"

지금은 안 켜도 큰 문제가 없습니다. 우리 코드가 작고, 검사기가 프로젝트 전체를 함께 보고 있으니까요.

문제는 규모가 커지고 도구가 늘어날 때 생깁니다. 개발 서버, 빌드, 테스트 도구가 각자 다른 방식으로 파일을 변환하는데, 애매한 임포트는 도구마다 다르게 처리될 수 있어요. 그러면 "개발 중에는 되는데 빌드하면 안 되는" 종류의 문제가 나옵니다. 원인을 찾기 가장 괴로운 부류죠.

그리고 이런 설정은 늦게 켤수록 비쌉니다. 지금 우리는 파일 여섯 개에서 열 군데를 고쳤어요. 파일이 삼백 개일 때 켰다면 어땠을까요.

그래서 기준은 이렇습니다. 강하게 하는 설정은 프로젝트가 작을 때 켜둔다. 지금 켜면 열 줄, 나중에 켜면 하루짜리 작업이 됩니다.


마무리

오늘 배운 핵심 세 가지

💡 같은 정보를 두 곳에 적지 않는다

Pick·Omit·Partial·Required 로 원본에서 새 타입을 뽑아내면, 원본을 고쳤을 때 뽑아낸 타입들이 알아서 따라옵니다. 손으로 쓴 중복은 언제든 한쪽만 고쳐질 수 있고, 그 어긋남은 컴파일러도 못 잡아요.

💡 빠뜨림을 컴파일러가 지목하게 만든다

Record<유니온, 값> 은 유니온의 모든 값에 데이터를 매달게 강제합니다. 지난 시간의 never 완전성 검사와 같은 안전장치가 데이터 쪽에도 있는 거예요. 그리고 Uppercase 같은 도구를 얹으면 키 목록까지 원본에서 따라옵니다.

💡 컴파일러에게 우기지 말고 알려준다

애너테이션과 satisfies 는 검사를 받고, as 는 검사를 건너뜁니다. import type 은 반대로 컴파일러와 빌드 도구에게 정확한 정보를 알려주는 쪽이죠. 오늘 배운 것들의 방향은 하나입니다. 사람이 기억해야 할 일을 도구가 확인해 주는 쪽으로 옮기는 것.

다음 시간 예고

A 카테고리의 순수 TypeScript 구간이 오늘로 끝났습니다. 지금 우리 코드베이스에는 인스타그램의 데이터가 타입으로 정리돼 있어요. 서버 응답을 선언했고, 정체 모를 값을 갈라내는 가드를 만들었고, 화면마다 필요한 모양을 원본에서 뽑아냈습니다.

특히 오늘 만든 PostCardProps 를 기억해 주세요. Post 에서 카드에 필요한 것만 남긴 타입이었죠. 이름 끝에 붙은 Props 가 눈에 걸리셨을 겁니다.

다음 시간부터 React 를 시작합니다. React 에서 화면 한 조각은 함수로 만들고, 그 함수가 받는 값을 props 라고 불러요. 그러니까 오늘 만든 그 타입은 다음 시간에 만들 카드 컴포넌트의 입구가 됩니다. 순수 JS 로 document.createElement 를 늘어놓아 만들던 그 카드가, 이번엔 타입이 붙은 함수 하나로 다시 태어나요.

준비는 이미 끝났습니다. 데이터의 모습을 아는 상태로 화면을 만들기 시작하는 겁니다.


과제

[구현] 스토리 화면에 필요한 타입을 파생으로 만들기

A-1 과제에서 Story 타입을 만드셨죠. 이번에는 그 타입에서 화면에 필요한 타입들을 뽑아내 주세요. 새 인터페이스를 손으로 쓰지 않는 게 목표입니다.

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

  • 스토리 목록 맨 위에 뜨는 동그란 아이콘에는 작성자 이름과 프로필 이미지 주소만 필요합니다. Story 에서 그 둘만 골라 StoryRingProps 를 만들어 주세요.
  • 스토리를 전체 화면으로 볼 때는 조회수를 빼고 전부 씁니다. 빼는 쪽으로 StoryViewerProps 를 만들어 주세요.
  • 스토리를 올리기 전 임시 저장하는 초안은 아직 아무것도 안 정해졌을 수 있습니다. 모든 필드가 없어도 되는 StoryDraft 를 만들어 주세요.
  • 배경색 세 가지 각각에 화면에 쓸 CSS 색상 문자열을 매다는 상수를 Record 로 만들어 주세요. 셋 중 하나라도 빠뜨리면 검사에서 막히는지 확인해 보세요.
  • 스토리 하나를 받아 화면에 쓸 값 몇 개를 담은 객체를 돌려주는 함수를 반환 타입 없이 만들고, 그 반환 타입을 ReturnType 으로 꺼내 이름을 붙여 주세요.

파일은 apps/web-spa/src/types/story-derived.ts 에 만들어 주세요. 임포트에는 오늘 배운 표시를 정확히 붙여 주시고요.

[탐구] 파생 타입을 일부러 어겨보기

아래 다섯 가지를 하나씩 시도해 보고, 에디터가 뭐라고 하는지와 그게 무슨 뜻인지 한 줄씩 적어 주세요.

  • Pick<Post, 'id' | 'imagUrl'> 처럼 필드 이름에 오타를 내보기
  • Omit<Post, 'imagUrl'> 처럼 뺄 필드 이름에 오타를 내보기 (앞의 경우와 결과가 다릅니다)
  • Record<FeedSort, string> 에서 키를 하나 빼보기, 그리고 없는 키를 하나 더해보기
  • Partial<ProfileSummary> 타입의 값에서 username.length 를 확인 없이 호출해 보기
  • const option = { sort: 'latest' } as FeedOption 을 만든 뒤 option.pageSize 에 마우스를 올려 타입을 확인하고, 실제로 출력해서 값과 비교해 보기

특히 첫 두 개는 결과가 왜 다른지 꼭 생각해 보세요. 오늘 배운 두 도구가 필드 이름을 어떻게 다르게 취급하는지가 거기서 드러납니다.


생각해볼 주제

1. 파생이 깊어지면 오히려 읽기 어려워지지 않을까

오늘 우리는 원본에서 타입을 뽑아냈습니다. 그런데 뽑아낸 타입에서 또 뽑아내고, 거기서 또 뽑아내면 어떻게 될까요? Partial<Omit<Pick<Post, ...>, ...>> 같은 게 나올 수 있습니다.

중복이 없다는 점에서는 좋습니다. 그런데 이 타입에 실제로 어떤 필드가 들어 있는지 알려면 여러 단계를 머릿속으로 풀어야 해요. 중복을 없애는 이득과 읽는 비용 사이에서 어디쯤 선을 그으시겠습니까?

2. as 를 팀 규칙으로 금지할 것인가

as 는 검사를 건너뛰니 위험하지만, 정말 필요한 곳이 아주 없지는 않습니다. 어떤 팀은 도구로 아예 막아두고, 어떤 팀은 판단에 맡깁니다.

여러분이 규칙을 정한다면 어느 쪽을 고르시겠어요? 그리고 금지하는 쪽을 골랐다면, 정말 필요한 상황이 왔을 때 팀은 그걸 어떻게 처리해야 할까요?

3. 엄격한 설정은 언제 켜는 게 맞을까

오늘 verbatimModuleSyntax 를 켰더니 여섯 개 파일이 걸렸고, 열 군데를 고쳐서 통과시켰습니다. 아직 파일이 얼마 없어서 금방 끝났죠.

그런데 이미 수백 개 파일이 쌓인 프로젝트라면 어떨까요? 한 번에 다 고치는 것과, 새 파일에만 적용하고 기존 파일은 천천히 옮기는 것 중 무엇이 나을까요? 그리고 그 판단에 영향을 주는 요소는 무엇일까요?

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 스토리 화면에 필요한 타입을 파생으로 만들기

채점 포인트

항목 확인 내용 배점
Pick 파생 StoryRingPropsPick 으로 만들고 두 필드만 골랐는가 15
Omit 파생 StoryViewerPropsOmit 으로 만들고 조회수만 뺐는가 15
Partial 파생 StoryDraftPartial 로 만들었는가 10
Record 상수 배경 세 값 각각에 색상을 매달고, 하나 빼면 막히는 걸 확인했는가 25
ReturnType 반환 타입을 적지 않은 함수에서 타입을 꺼내 이름을 붙였는가 20
임포트 표시 타입만 가져오는 임포트에 type 을 붙였는가 15

새 인터페이스를 손으로 하나도 쓰지 않았다면 이 과제의 핵심을 잡으신 겁니다.

풀이 예시

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

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

// 스토리 링에는 이름과 프로필 이미지만 뜬다 — 남길 것을 적는다
export type StoryRingProps = Pick<Story, 'username' | 'profileImageUrl'>;

// 전체 화면 뷰어는 조회수만 빼고 다 쓴다 — 뺄 것을 적는다
export type StoryViewerProps = Omit<Story, 'viewCount'>;

// 올리기 전 초안은 아직 아무것도 안 정해졌을 수 있다
export type StoryDraft = Partial<Story>;

세 줄에 새 인터페이스가 하나도 없다는 게 포인트예요. Story 에 필드를 추가하면 StoryViewerPropsStoryDraft 는 저절로 따라옵니다. StoryRingProps 는 두 개만 골랐으니 그대로 남고요.

배경색 맵은 Record 로 만듭니다.

TypeScript
// 배경 세 가지 각각에 화면에 쓸 색상을 매단다
export const STORY_BACKGROUND_CSS: Record<StoryBackground, string> = {
  white: '#ffffff',
  black: '#000000',
  gradient: 'linear-gradient(45deg, #f09433, #dc2743, #bc1888)',
};

export function backgroundCssOf(background: StoryBackground): string {
  return STORY_BACKGROUND_CSS[background];
}

여기서 gradient 를 지워보면 이런 메시지가 뜹니다.

텍스트
Property 'gradient' is missing in type '{ white: string; black: string; }'
but required in type 'Record<StoryBackground, string>'.

배경색이 하나 늘어나는 날 이 상수에 한 줄 추가하라고 컴파일러가 요구할 거예요.

마지막은 ReturnType 입니다.

TypeScript
// 반환 타입을 적지 않는다
export function toStoryView(story: Story) {
  return {
    ring: `@${story.username}`,
    background: backgroundCssOf(story.background),
    hasText: story.text !== undefined,
  };
}

// 그 함수에서 타입을 꺼내 이름을 붙인다
export type StoryView = ReturnType<typeof toStoryView>;

export function storyAlt(view: StoryView): string {
  return view.hasText ? `${view.ring} 스토리 (글 있음)` : `${view.ring} 스토리`;
}

toStoryView 에 값을 하나 더 담으면 StoryView 가 알아서 넓어집니다. 손댈 곳이 함수 한 곳뿐이에요.

한 걸음 더 — A-1 과제도 정리할 수 있습니다

A-1 과제에서 이런 걸 손으로 쓰셨을 겁니다.

TypeScript
interface StoryBackgroundMap {
  WHITE: StoryBackground;
  BLACK: StoryBackground;
  GRADIENT: StoryBackground;
}

오늘 배운 걸로 지울 수 있어요.

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

export const STORY_BACKGROUND = {
  WHITE: 'white',
  BLACK: 'black',
  GRADIENT: 'gradient',
} as const satisfies Record<Uppercase<StoryBackground>, StoryBackground>;

수업에서 PostStatusMap 을 지운 것과 똑같은 방법입니다. 여러분의 과제 코드에서도 같은 중복이 사라지는지 확인해 보세요.

자주 나오는 실수

초안 타입을 Partial<StoryViewerProps> 로 만드는 경우. 뷰어에서 조회수를 뺐으니 초안에서도 빼야 할 것 같지만 아닙니다. 둘은 목적이 다른 타입이에요. 뷰어는 "화면에 그릴 것"이고 초안은 "저장할 것"입니다. 파생의 원본은 그 타입의 의미를 기준으로 고르세요.

Record 대신 그냥 객체로 선언하는 경우. 색상 맵을 타입 없이 const STORY_BACKGROUND_CSS = { white: '#fff', ... } 로만 두면 값이 빠져도 아무도 안 알려줍니다. 이 과제의 목적은 색상을 담는 게 아니라 빠뜨림을 컴파일러가 막게 하는 거예요.

임포트에 type 을 안 붙이는 경우. StoryStoryBackground 는 둘 다 타입입니다. import type 으로 가져와야 해요. 안 붙이면 검사에서 TS1484 로 막힙니다.

💡 튜터의 한마디

이 과제에서 여러분이 만든 파생 타입은 곧 진짜로 쓰입니다. StoryRingProps 라는 이름을 잘 봐두세요. 다음 시간에 React 를 시작하면 화면 한 조각이 함수가 되고, 그 함수가 받는 값을 props 라고 부릅니다. 오늘 만든 타입이 그 함수의 입구가 될 거예요. 지금 정확하게 만들어 두면 다음 시간이 훨씬 편합니다.

🎯 [과제 2 예시답안] 파생 타입을 일부러 어겨보기

채점 포인트

항목 확인 내용 배점
Pick 오타 막힌다는 것과 제안까지 뜬다는 걸 기록했는가 20
Omit 오타 막히지 않는다는 걸 확인하고 왜인지 설명했는가 30
Record 키 누락과 추가 두 경우의 메시지가 다르다는 걸 기록했는가 20
Partial 확인 옵셔널이 되어 좁히기가 필요해진 이유를 적었는가 15
as 의 거짓말 타입과 실제 값이 다르다는 걸 눈으로 확인했는가 15

풀이 예시

1. Pick<Post, 'id' | 'imagUrl'> — 막힙니다.

텍스트
Type '"id" | "imagUrl"' does not satisfy the constraint 'keyof Post'.
  Type '"imagUrl"' is not assignable to type 'keyof Post'. Did you mean '"imageUrl"'?

Pick 의 두 번째 칸은 "Post 가 가진 필드 이름 중에서"라는 제약이 걸려 있습니다. 없는 이름을 적으면 그 제약을 어긴 거예요. 게다가 비슷한 이름까지 제안해 줍니다.

2. Omit<Post, 'imagUrl'> — 막히지 않습니다.

에러가 하나도 안 납니다. 그리고 이게 이 과제에서 가장 중요한 발견이에요.

Omit 의 두 번째 칸에는 Pick 같은 제약이 없습니다. 아무 문자열이나 받아요. 그래서 없는 이름을 적으면 "그 이름은 없으니 뺄 것도 없다"가 되고, 결과적으로 아무것도 안 빠진 타입이 나옵니다.

직접 확인해 보세요. Omit<Post, 'imagUrl'> 타입의 값에서 imageUrl 을 꺼내면 잘 됩니다. 빠졌어야 할 필드가 그대로 살아 있는 거죠.

왜 이렇게 만들어졌을까요? Omit 은 "여기 있을 수도 있는 필드를 뺀다"는 용도로도 쓰이기 때문입니다. 여러 타입에 공통으로 적용하는 코드에서는 그 필드가 없는 타입도 들어올 수 있거든요. 유연함을 얻은 대신 오타 검출을 잃은 겁니다.

실무에서는 이 함정을 아는 게 중요합니다. Omit 을 쓸 때는 필드 이름을 직접 타이핑하지 말고 에디터 자동완성으로 고르세요. Pick 은 오타를 잡아주지만 Omit 은 안 잡아준다는 걸 기억하시고요.

3. Record<FeedSort, string> 의 키 조작 — 두 경우의 메시지가 다릅니다.

키를 빼면 이렇게 뜹니다.

텍스트
Property 'following' is missing in type '{ latest: string; popular: string; }'
but required in type 'Record<FeedSort, string>'.

없는 키를 더하면 다르게 뜹니다.

텍스트
Object literal may only specify known properties, and 'deleted' does not exist
in type 'Record<PostStatus, string>'.

앞의 것은 "있어야 할 게 없다", 뒤의 것은 "없어야 할 게 있다"예요. Record 는 양쪽 다 막아서 키 집합을 정확히 일치시킵니다.

4. Partial<ProfileSummary> 에서 username.length — 막힙니다.

텍스트
'draft.username' is possibly 'undefined'.

Partial 이 모든 필드에 물음표를 붙였으니 username 도 없을 수 있는 값이 됐습니다. 지난 시간에 배운 좁히기를 거쳐야 쓸 수 있어요. if (draft.username !== undefined) 로 확인하거나 draft.username ?? '' 처럼 기본값을 주면 됩니다.

5. as 로 만든 값 — 타입과 실제가 다릅니다.

option.pageSize 에 마우스를 올리면 number 라고 나옵니다. 그런데 출력해 보면 undefined 가 나와요.

텍스트
 타입이 말하는 것   pageSize: number
 실제로 있는 것     pageSize 없음  undefined

 이 둘이 어긋나 있는데 아무도 경고하지 않는다

as 는 검사를 건너뛰니까요. 이게 수업에서 "우기는 것"이라고 표현한 이유입니다. 그리고 이 어긋남은 실행 중에, 그것도 한참 뒤에 드러납니다.

자주 나오는 실수

2번에서 "에러가 안 나니까 통과"라고 넘어가는 경우. 이 과제의 핵심이 바로 거기예요. 에러가 안 나는 게 정답인 게 아니라, 왜 안 나는지가 배울 점입니다. 안 났다면 그 다음에 imageUrl 을 꺼내 보는 데까지 가야 발견이 완성됩니다.

💡 튜터의 한마디

오늘 배운 도구들은 전부 "컴파일러가 대신 기억해 준다"는 이득을 줍니다. 그런데 2번에서 봤듯 그 이득의 범위는 도구마다 다릅니다. 어디까지 막아주고 어디부터는 안 막아주는지 아는 게 도구를 제대로 쓰는 거예요. 새 도구를 만나면 오늘처럼 일부러 어겨 보세요. 막히는 지점이 곧 그 도구가 보장하는 범위입니다.

🤔 [생각해볼 주제 1] 파생이 깊어지면 오히려 읽기 어려워지지 않을까

문제 상황 요약

파생은 중복을 없앱니다. 그런데 파생에서 또 파생하면 Partial<Omit<Pick<Post, ...>, ...>> 같은 표현이 나와요. 중복은 없지만 어떤 필드가 들어 있는지 알려면 여러 단계를 풀어야 합니다.

중복을 없애는 이득과 읽는 비용 사이에서 어디쯤 선을 그어야 할까요?

튜터의 가이드 및 해설

먼저 이 긴장이 진짜라는 걸 인정하고 시작합시다. 파생은 공짜가 아니에요. 원본 하나만 고치면 된다는 이득을 얻는 대신, 이 타입이 무엇인지 알려면 원본까지 따라가야 하는 비용을 냅니다.

판단의 기준을 하나 제안드릴게요. 파생이 관계를 표현하고 있는가, 아니면 그냥 결과를 만들고 있는가.

PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'> 는 관계를 표현합니다. "카드가 받는 값은 게시물에서 이 둘을 뺀 것"이라는 사실이 코드에 적혀 있어요. 원본과의 관계 자체가 정보입니다.

반면 세 겹을 감싸서 나온 타입은 대개 관계를 표현하지 않습니다. 원하는 필드 목록을 만들려고 연산을 늘어놓은 것에 가깝죠. 그럴 바에는 그 목록을 직접 적는 게 읽기 쉽습니다.

실무에서 쓰는 요령을 몇 가지 말씀드리면 이렇습니다.

두 겹까지는 대체로 괜찮습니다. Partial<PostCardProps> 정도는 읽는 데 부담이 없어요. 세 겹부터는 중간 단계에 이름을 붙이는 걸 고려하세요. 이름이 붙으면 그 이름 자체가 설명이 됩니다.

에디터로 확인하는 습관을 기르세요. 타입 이름에 마우스를 올리면 실제로 어떤 필드가 들어 있는지 펼쳐서 보여줍니다. 머릿속으로 계산하지 않아도 돼요. 이게 가능하다는 점 때문에 파생의 읽기 비용은 생각보다 낮습니다.

그리고 진짜 위험은 다른 곳에 있습니다. 깊은 파생보다 훨씬 나쁜 건 원본이 잘못 잡힌 파생이에요. 예를 들어 화면용 타입에서 또 화면용 타입을 뽑으면, 첫 화면이 바뀔 때 관계없는 두 번째 화면까지 영향을 받습니다. 파생은 항상 진짜 원본에서 하는 게 좋습니다. 서버 응답 타입 같은 것에서요.

정리하면 선을 긋는 기준은 겹의 개수가 아니라 관계의 의미입니다. 원본과의 관계가 말이 되면 파생이 맞고, 억지로 만들어낸 결과라면 직접 쓰는 게 맞아요.

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

"파생은 중복 제거가 아니라 관계 표현으로 봅니다. Omit<Post, ...> 라고 적으면 '카드는 게시물에서 이걸 뺀 것'이라는 사실이 코드에 남죠. 그래서 원본과의 관계가 설명되는 한 겹은 유지하고, 필드 목록을 만들려고 연산을 쌓는 단계에 들어가면 중간에 이름을 붙이거나 직접 선언합니다. 그리고 파생은 항상 서버 응답 같은 진짜 원본에서 합니다. 화면용 타입에서 또 파생하면 관계없는 화면끼리 묶여버리니까요."

🤔 [생각해볼 주제 2] as 를 팀 규칙으로 금지할 것인가

문제 상황 요약

as 는 검사를 건너뛰니 위험합니다. 그런데 컴파일러가 알 수 없는 사실을 우리가 아는 경우도 분명히 있어요. 어떤 팀은 도구로 아예 막고, 어떤 팀은 판단에 맡깁니다.

어느 쪽을 고르시겠어요? 그리고 금지하는 쪽을 골랐다면 정말 필요한 상황은 어떻게 처리해야 할까요?

튜터의 가이드 및 해설

이 질문의 함정은 "금지냐 허용이냐"를 양자택일로 보게 만든다는 겁니다. 실무에서 잘 굴러가는 팀들은 대개 세 번째 답을 씁니다.

전면 금지의 문제부터 봅시다. as 를 도구로 완전히 막으면 정당한 경우까지 막힙니다. 그러면 사람들은 우회로를 찾아요. 규칙을 끄는 주석을 달거나, 더 나쁘게는 any 를 쓰거나 함수 시그니처를 느슨하게 바꿉니다. 위험이 사라진 게 아니라 눈에 안 띄는 곳으로 옮겨간 거죠. 규칙이 만들어내는 우회로가 원래 문제보다 나쁜 경우입니다.

완전 허용의 문제는 반대예요. as 는 쓰기 쉽고 즉시 에러가 사라집니다. 마감이 급할 때 가장 먼저 손이 가는 도구고, 한 번 쓰기 시작하면 코드베이스에 조용히 번집니다. 그리고 이건 리뷰에서 잡기도 어려워요. 한 줄짜리 변경이라 눈에 안 띄거든요.

그래서 세 번째 답은 "허용하되 눈에 띄게 만든다"입니다. 구체적으로는 이렇게 합니다.

린트 규칙을 경고 단계로 둡니다. 막지는 않지만 표시가 남아요. 그러면 리뷰에서 자연스럽게 보이고, 코드베이스에 몇 개나 있는지 셀 수도 있습니다. 숫자가 늘어나면 그 자체가 신호가 되죠.

그리고 쓸 때는 근거를 한 줄 남기는 규칙을 둡니다. "왜 이게 안전한가"를 적을 수 없다면 그건 안전하지 않다는 뜻이에요. 이 규칙 하나만으로도 습관적인 as 는 대부분 걸러집니다. 설명을 쓰다가 "어, 확인하면 되는구나" 하고 코드를 고치게 되거든요.

한 가지 더 구분할 게 있습니다. 테스트 코드와 실제 코드에 같은 기준을 적용할 필요는 없어요. 테스트에서 필요한 필드만 채운 가짜 값을 만드는 건 흔하고 합리적입니다. 이럴 땐 테스트 폴더에만 규칙을 풀어주는 게 실용적이에요.

마지막으로 규칙을 정할 때의 원칙 하나. 규칙은 사람을 못 믿어서 만드는 게 아니라 사람이 기억할 필요를 없애려고 만드는 겁니다. as 를 볼 때마다 "이거 괜찮나" 고민하지 않아도 되게, 근거가 적혀 있으면 통과하는 구조를 만드는 거예요.

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

"전면 금지는 우회로를 만듭니다. as 를 막으면 사람들은 any 를 쓰거나 규칙을 끄는 주석을 달죠. 위험이 사라지는 게 아니라 더 안 보이는 곳으로 옮겨갑니다. 그래서 저는 경고 수준으로 두고 근거를 주석으로 남기는 규칙을 씁니다. 왜 안전한지 못 적으면 그건 안전하지 않다는 신호고, 설명을 쓰다가 확인 코드로 고치게 되는 경우가 대부분이에요. 테스트 코드는 예외로 두고요."

🤔 [생각해볼 주제 3] 엄격한 설정은 언제 켜는 게 맞을까

문제 상황 요약

verbatimModuleSyntax 를 켜자 여섯 개 파일에서 열 군데가 걸렸습니다. 파일이 얼마 없어 금방 끝났죠.

수백 개 파일이 쌓인 프로젝트라면 어떨까요? 한 번에 다 고치는 것과, 새 파일에만 적용하고 기존 파일은 천천히 옮기는 것 중 무엇이 나을까요?

튜터의 가이드 및 해설

먼저 가장 확실한 결론부터. 켤 거라면 빨리 켜는 게 압도적으로 쌉니다. 오늘 우리가 낸 비용은 열 줄이었어요. 파일이 삼백 개일 때 켰다면 며칠짜리 작업이 됐을 겁니다. 그리고 그 사이에 쌓인 코드는 전부 잘못된 습관 위에 쓰인 것이고요.

그런데 현실에서는 대개 늦게 켜게 됩니다. 프로젝트를 시작할 때는 설정을 고민할 여유가 없고, 여유가 생겼을 땐 이미 커져 있죠. 그러니 진짜 질문은 "그 상황에서 어떻게 하느냐"입니다.

한 번에 고치는 쪽이 유리한 경우가 있습니다. 변경이 기계적일 때예요. 오늘 우리가 한 일을 떠올려 보세요. 컴파일러가 어느 파일 어느 줄인지 전부 알려줬고, 고치는 방법도 한 가지였습니다. 이런 변경은 도구로 일괄 처리할 수 있고, 실제로 그런 도구도 있습니다. 이럴 땐 나눠서 하는 게 오히려 손해예요. 두 가지 상태가 공존하는 기간이 길어지면 그동안 새로 쓰는 코드가 어느 쪽을 따라야 하는지 헷갈립니다.

나눠서 가는 쪽이 유리한 경우는 변경에 판단이 필요할 때입니다. 예를 들어 strict 를 켜면 "이 값이 없을 수 있다"는 지적이 수백 개 나오는데, 각각을 어떻게 처리할지는 사람이 정해야 해요. 기본값을 줄지, 예외를 던질지, 위쪽에서 막을지가 다 다릅니다. 이런 건 한 번에 못 합니다. 새 코드에만 적용하고 기존 코드는 손댈 때마다 옮기는 방식이 현실적이에요.

그래서 판단 기준은 이렇습니다. 고치는 방법이 하나로 정해져 있으면 한 번에, 각 지점마다 판단이 필요하면 나눠서.

여기에 두 가지를 덧붙이고 싶습니다.

나눠서 갈 때는 끝나는 날짜를 정하세요. "천천히 옮긴다"는 대개 "영원히 안 옮긴다"가 됩니다. 두 상태가 공존하는 코드베이스는 새로 온 사람에게 특히 혼란스러워요. 어느 쪽이 맞는 방식인지 알 수 없으니까요.

그리고 켜는 순간을 잘 고르세요. 큰 기능 개발 중에 켜면 충돌이 쏟아집니다. 배포 직후처럼 잔잔한 때가 좋아요.

마지막으로 오늘 우리가 한 선택의 의미를 짚고 갈게요. 우리는 파일이 얼마 없을 때 켰습니다. 앞으로 이 프로젝트에 파일이 수백 개 쌓이겠지만, 그 전부가 처음부터 정확한 임포트로 쓰일 겁니다. 이게 초기 설정이 값어치를 하는 방식이에요. 비용은 지금 열 줄이고, 이득은 앞으로 계속 쌓입니다.

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

"엄격한 설정은 프로젝트가 작을 때 켜는 게 압도적으로 쌉니다. 이미 커진 상태라면 변경의 성격을 봅니다. 고치는 방법이 하나로 정해진 기계적 변경이면 한 번에 처리하고, 각 지점마다 판단이 필요한 변경이면 새 코드부터 적용하고 기존 코드는 손댈 때 옮깁니다. 나눠서 갈 땐 반드시 마감일을 정합니다. 두 방식이 공존하는 코드베이스는 새로 합류한 사람이 어느 쪽을 따라야 할지 알 수 없거든요."

전체 목록 리액트