문서 읽는 데 64분 · A1

A-1: TypeScript 입문 — JS에 붙는 가드레일

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

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

안녕하세요, 홍순구입니다. 오늘부터 여러분은 프레임워크의 세계로 들어갑니다.

지난 과목에서 여러분은 순수 HTML/CSS/JS 만으로 인스타그램 클론을 만들어 봤어요. document.createElement 로 게시물 카드를 찍어내고, fetch 로 데이터를 받아오고, 이벤트를 붙여 좋아요를 눌렀죠. 화면은 분명히 잘 돌아갔습니다.

그런데 이런 코드를 한번 떠올려 볼게요.

JavaScript
// 지난 과목에서 이렇게 썼죠
function renderPost(post) {
  likeCountEl.textContent = post.likeCount;
}

여기서 백엔드가 어느 날 응답 필드 이름을 likeCount 에서 likes 로 바꿨다고 해봅시다. 이 코드는 에러를 내지 않아요. 그냥 화면에 undefined 라고 조용히 찍힙니다. 여러분이 그걸 발견하는 시점은 언제일까요? 대부분 브라우저를 새로고침하고 "어? 왜 이러지" 하는 순간이에요. 운이 나쁘면 사용자가 먼저 발견합니다.

JavaScript 는 자유롭습니다. 자유롭다는 건 "뭐든 해도 막지 않는다"는 뜻이기도 해요. 오타를 쳐도, 없는 속성을 꺼내 써도, 숫자가 와야 할 곳에 문자열을 넣어도 실행 직전까지 아무도 말해주지 않습니다.

오늘 배울 TypeScript 는 그 자유로운 JS 에 가드레일(guardrail, 도로 옆 안전 난간)을 붙이는 도구예요. 코드를 실행하기 전에, 여러분이 타이핑하는 그 순간에 에디터가 빨간 줄로 알려줍니다. "그 속성 없는데요?" 하고요.

참고로 TypeScript 는 얼마 전 7.0 이 정식 공개되면서 컴파일러(우리가 쓴 코드를 검사하고 실행 가능한 형태로 바꿔주는 프로그램) 자체를 Go 언어로 다시 만들었어요. 덕분에 타입 검사 속도가 이전보다 8~12배 빨라졌습니다. 문법은 그대로고 검사기만 빨라진 거라, 오늘 배우는 내용은 버전과 상관없이 그대로 쓰입니다.

텍스트
 오늘의 여정

 Step 1~2   기본 타입 · 타입 추론 vs 명시
    │
 Step 3~4   type vs interface · 옵셔널(?)
    │
 Step 5~6   리터럴/유니온 · strict 가 잡아주는 버그
    │
 Step 7~8   enum 대신 const + satisfies · 인스타 API 타입 선언
    
 User · Post · Comment 가 타입으로 선언된 상태로 마무리

💡 오늘 수업의 핵심 — "실행하기 전에 알려주는 코드"

JS 는 실행해야 문제를 알 수 있고, TS 는 타이핑하는 중에 알려줍니다. 오늘은 그 차이를 만드는 최소한의 문법을 익히고, 인스타그램 API 응답을 타입으로 선언하는 데까지 갑니다.

🎯 학습 목표

  • 기본 타입을 선언하고, 타입 추론과 명시 선언을 언제 각각 쓸지 판단할 수 있다
  • typeinterface 를 상황에 맞게 구분해 쓰고, 옵셔널·리터럴·유니온으로 값의 범위를 좁힐 수 있다
  • 인스타그램 API 응답(User·Post·Comment)을 인터페이스로 선언해 이후 모듈의 토대를 만든다

Step 1: "기본 타입 명시하기 — 값 옆에 이름표를 붙인다"

실습 준비

눈으로만 보면 안 늘어요. 직접 쳐보면서 따라오셔야 합니다. 터미널을 열고 작업할 폴더를 만들어 주세요.

Bash
mkdir -p apps/web-spa/src/types
cd apps/web-spa
npm init -y
npm install -D typescript

그리고 지금 위치(apps/web-spa/)에 tsconfig.json 파일을 만들어 아래를 그대로 붙여넣습니다. TypeScript 에게 "이렇게 검사해 달라"고 알려주는 설정 파일이에요.

JSON
{
  "compilerOptions": {
    "target": "es2025",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

strict 가 오늘의 숨은 주인공인데, 자세한 건 Step 6 에서 다룰게요. 지금은 "가장 깐깐하게 검사해 달라"는 뜻으로만 알아두시면 됩니다.

이제 코드를 쓰고 이 명령으로 검사합니다.

Bash
npx tsc --noEmit

에러가 없으면 아무것도 안 나와요. 조용하면 성공입니다. VS Code 를 쓰신다면 파일을 열어두는 것만으로 빨간 줄이 실시간으로 보이니 훨씬 편해요.

앞으로 나오는 코드는 전부 지금 위치의 src/types/ 안에 파일을 만들어 넣으시면 됩니다. 참고로 코드 블록 맨 위 주석에 적힌 경로(apps/web-spa/src/types/...)는 프로젝트 맨 바깥에서 본 위치예요. 지금 apps/web-spa 안에 계시니 src/types/ 부터 만들면 됩니다.

타입을 붙이는 법

TypeScript 의 문법은 생각보다 단순해요. 변수 이름 뒤에 콜론(:)을 찍고 타입을 적으면 끝입니다.

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

// 게시물 한 장이 화면에 뿌리는 가장 기본적인 값들
export const postId: number = 1;
export const caption: string = '한강 야경 성공';
export const isLiked: boolean = false;

postId: number 는 "이 변수에는 숫자만 들어온다"는 선언이에요. 여기에 문자열을 넣으면 어떻게 될까요? 직접 해보면 에디터가 이렇게 말해줍니다.

텍스트
Type 'string' is not assignable to type 'number'.

"문자열은 숫자 타입에 넣을 수 없다"는 뜻이죠. 순수 JS 였다면 postId = '1' 이 아무 문제 없이 통과하고, 나중에 postId + 1 을 했을 때 2 가 아니라 '11' 이라는 황당한 값이 나왔을 거예요. 문자열 '1' 에 숫자 1 을 더하면 이어붙이기가 되니까요.

배열은 두 가지로 쓸 수 있어요

배열 타입은 표기법이 두 개인데 뜻은 똑같습니다.

TypeScript
// 배열은 두 가지로 쓸 수 있고 뜻은 같다
export const hashtags: string[] = ['#한강', '#야경', '#서울'];
export const likedUserIds: Array<number> = [7, 12, 45];

string[] 은 "문자열이 여러 개 담긴 배열", Array<number> 는 "숫자가 여러 개 담긴 배열"이에요. 실무에서는 짧은 string[] 쪽을 훨씬 많이 씁니다. Array<number> 형태는 나중에 복잡한 타입을 다룰 때 다시 만나게 돼요.

중요한 건 배열에 타입이 붙으면 그 안에 다른 게 못 들어온다는 점이에요. hashtags 에 숫자 42 를 넣으려고 하면 바로 막힙니다.

함수에도 이름표를 붙입니다

함수는 매개변수와 반환값 양쪽에 타입을 답니다.

TypeScript
/** 12345 → "1.2만", 9999 → "9,999" */
export function formatLikeCount(count: number): string {
  if (count >= 10000) {
    return `${Math.floor(count / 1000) / 10}만`;
  }
  return count.toLocaleString('ko-KR');
}

(count: number) 는 "숫자를 받는다", 소괄호 뒤의 : string 은 "문자열을 돌려준다"는 뜻이에요. 이렇게 적어두면 이 함수를 쓰는 사람은 문서를 안 봐도 사용법을 압니다. 에디터가 자동완성으로 알려주거든요.

그리고 만약 이 함수 안에서 실수로 숫자를 반환하면, 반환 타입을 string 으로 적어둔 덕분에 그 함수 안에서 바로 잡힙니다.

💡 한 줄 정리

변수·함수에 콜론으로 타입을 적어두면, 잘못된 값이 들어오는 순간을 실행 전에 잡아낼 수 있어요.

🙋 학생 질문 — "타입을 다 적으려니 코드가 길어져요. 꼭 이렇게 다 써야 하나요?"

아주 좋은 질문이고, 답은 "아니요"입니다. 실제로 위 예시에서 const postId: number = 1 은 조금 과한 편이에요. 숫자 1 을 넣었으니 TypeScript 도 이미 숫자인 걸 알거든요.

지금은 문법을 익히려고 일부러 다 적었습니다. 다음 Step 에서 "어디는 적고 어디는 생략하는가"를 정확히 나눠볼 거예요. 결론만 미리 말하면, 변수 하나하나보다 함수의 입구와 출구에 적는 게 훨씬 중요합니다.


Step 2: "타입 추론 vs 명시 — 똑똑한 컴파일러를 언제 믿을까"

TypeScript 는 여러분이 타입을 안 적어도 값을 보고 알아서 짐작합니다. 이걸 타입 추론(type inference)이라고 해요.

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

// 타입을 안 적었는데 TS 가 string 으로 읽어낸다
export const defaultCaption = '오늘의 한 컷';

: string 을 안 썼지만 TypeScript 는 defaultCaption 이 문자열이라는 걸 압니다. 여기에 숫자를 넣으려 하면 똑같이 막혀요. 그래서 이럴 때는 굳이 타입을 적을 필요가 없습니다.

const 와 let 은 다르게 읽힙니다

재미있는 건 constlet 의 추론이 다르다는 점이에요.

TypeScript
// const 는 값 하나로 좁게, let 은 string 으로 넓게 읽어낸다
export const followLabel = '팔로우';
export let currentTab = 'feed';

followLabelconst 라서 앞으로 절대 안 바뀝니다. 그래서 TypeScript 는 이걸 "문자열"이 아니라 아예 '팔로우' 라는 값 자체로 읽어요. 반면 currentTablet 이라 나중에 바뀔 수 있으니 넉넉하게 "문자열"로 봅니다.

이 차이는 Step 5 에서 리터럴 타입을 배울 때 진가를 발휘해요. 지금은 "const 는 더 좁게 읽힌다" 정도만 기억해 주세요.

추론에 맡기면 곤란해지는 경우

추론이 항상 좋은 건 아닙니다. 대표적인 게 빈 배열이에요.

TypeScript
// 빈 배열은 추론에 맡기면 곤란해진다 — 무엇이 들어올지 미리 적어준다
export const initialHashtags: string[] = [];

const initialHashtags = [] 라고만 쓰면 TypeScript 는 뭐가 들어올 배열인지 알 도리가 없습니다. 값이 하나도 없으니 짐작할 근거가 없으니까요.

그래도 파일 안에서만 쓰는 변수라면 넘어가 줍니다. 뒤이어 push 로 넣는 값을 보고 타입을 뒤늦게 채워주거든요. 그런데 위 코드처럼 export 를 붙여 다른 파일에서 쓸 수 있게 열어두면 얘기가 달라집니다. 바깥에서 쓸 타입을 지금 확정해야 하는데 근거가 없으니, 아무것도 못 들어오는 배열로 굳어버려요. 이 상태에서 값을 넣으려 하면 이렇게 막힙니다.

텍스트
Argument of type '"#한강"' is not assignable to parameter of type 'never'.

never 는 "해당하는 값이 하나도 없다"는 뜻의 타입이에요. 그래서 빈 배열로 시작할 때는 미리 string[] 이라고 알려주는 게 맞습니다.

함수의 입구와 출구에는 적어주세요

가장 중요한 원칙입니다.

TypeScript
// 함수 경계에서는 반환 타입을 적어 계약을 고정한다
export function buildProfileUrl(username: string): string {
  return `https://instagram.com/${username}`;
}

매개변수 타입은 사실상 필수예요. 안 적으면 TypeScript 가 이렇게 경고합니다.

텍스트
Parameter 'username' implicitly has an 'any' type.

any 는 "아무거나"라는 뜻의 타입이에요. any 가 되는 순간 그 값은 검사 대상에서 빠집니다. 오타를 쳐도, 없는 메서드를 불러도 아무도 안 막아요. 타입을 붙인 의미가 사라지는 거죠. 그래서 any 는 가능하면 피하는 게 좋습니다.

반환 타입은 사실 안 적어도 추론됩니다. 그래도 적기를 권하는 이유는 의도를 고정하기 위해서예요. 반환 타입을 적어두면, 나중에 함수 내부를 고치다가 실수로 다른 걸 반환했을 때 그 함수 안에서 바로 에러가 납니다. 안 적어두면 그 함수는 조용히 통과하고, 저 멀리 그 함수를 쓰는 곳에서 이상한 에러가 나요. 문제가 생긴 곳과 에러가 뜨는 곳이 멀어질수록 디버깅은 어려워집니다.

💡 한 줄 정리

변수는 추론에 맡기고, 함수의 매개변수와 반환 타입은 명시하세요. 빈 배열처럼 짐작할 근거가 없는 값도 직접 적어줍니다.

🙋 학생 질문 — "any 를 쓰면 안 된다면서 왜 문법에 있는 거죠?"

기존 JavaScript 코드를 TypeScript 로 옮길 때 필요하기 때문이에요. 수만 줄짜리 프로젝트를 하루아침에 전부 타입 붙일 수는 없잖아요. 그럴 때 일단 any 로 막아두고 조금씩 정확한 타입으로 바꿔 나갑니다.

any 는 "임시 통행증"에 가까워요. 새로 짜는 코드에서 처음부터 any 를 쓰는 건 가드레일을 달아놓고 스스로 걷어내는 것과 같습니다.

참고로 "타입을 모르겠다"를 안전하게 표현하는 unknown 이라는 타입도 있는데, 이건 값을 쓰기 전에 확인을 강제하는 더 엄격한 친구예요. 다음 시간에 제대로 다룹니다.


Step 3: "type vs interface — 어디에 뭘 써야 할까"

타입에 이름을 붙여 재사용하는 방법이 두 가지 있습니다. interfacetype 이에요. 처음 배울 때 가장 헷갈리는 부분이라 기준을 확실히 잡고 갈게요.

백엔드가 내려주는 객체는 interface 로

TypeScript
// apps/web-spa/src/types/types-vs-interfaces.ts

// 백엔드가 내려주는 공개 객체는 interface 로 선언한다
export interface Author {
  id: number;
  username: string;
  profileImageUrl: string;
}

interface 는 객체의 모양을 정의합니다. "작성자에게는 id 와 username 과 프로필 이미지 주소가 있다"는 약속이에요. 이렇게 선언해두면 어디서든 Author 라는 이름으로 쓸 수 있습니다.

interface 의 장점은 확장이 자연스럽다는 거예요.

TypeScript
// interface 는 extends 로 넓히는 흐름이 자연스럽다
export interface VerifiedAuthor extends Author {
  verifiedAt: string;
}

extends 는 "Author 가 가진 것 전부에 더해서"라는 뜻입니다. VerifiedAuthor 는 id, username, profileImageUrl 을 다 갖고 거기에 verifiedAt 이 하나 더 붙습니다.

그리고 중요한 성질이 하나 있습니다. Author 를 받는 함수에 VerifiedAuthor 를 넣어도 통과해요.

TypeScript
export function mentionOf(author: Author): string {
  return `@${author.username}`;
}

VerifiedAuthorAuthor 가 요구하는 걸 전부 갖고 있으니 자격이 충분하거든요. TypeScript 는 "이름이 같은지"가 아니라 "모양이 맞는지"로 판단합니다. 이걸 구조적 타이핑(structural typing)이라고 불러요.

객체가 아닌 모양은 type 으로

TypeScript
// 객체가 아닌 모양(별칭·여러 값 중 하나)은 type 으로 쓴다
export type PostId = number;
export type SortDirection = 'asc' | 'desc';

type 은 더 자유롭습니다. 위처럼 숫자에 PostId 라는 별명을 붙일 수도 있고, 세로 막대(|)로 "이것 중 하나"를 표현할 수도 있어요. interface 로는 이게 안 됩니다. interface 는 객체 모양 전용이거든요.

SortDirection 같은 형태를 유니온 타입이라고 하는데, Step 5 에서 본격적으로 다룰게요.

그래서 기준은

상황 선택
API 응답처럼 공개되는 객체 모양 interface
여러 값 중 하나 (유니온) type
기존 타입에 별명 붙이기 type
확장(extends)이 자주 일어남 interface

솔직히 말씀드리면 둘의 기능은 상당 부분 겹칩니다. 객체 모양을 정의하는 데는 type 도 잘 되고, 팀에 따라 type 만 쓰는 곳도 있어요. 그래서 이건 "틀리면 안 되는 규칙"이 아니라 "팀이 정하는 약속"에 가깝습니다.

이 강의에서는 위 표를 기준으로 갈게요. 공개 객체는 interface, 유니온은 type 입니다.

💡 한 줄 정리

API 응답 같은 공개 객체는 interface, 여러 값 중 하나를 고르는 유니온은 type 으로 씁니다.

🙋 학생 질문 — "겹치는 게 많으면 그냥 하나만 쓰면 안 되나요?"

실제로 그렇게 하는 팀도 많습니다. type 하나로 통일하는 팀도 있고요. 둘 중 하나만 골라도 대부분의 코드는 문제없이 작성됩니다.

다만 딱 하나 못 하는 게 있어요. interface 로는 유니온을 만들 수 없습니다. interface SortDirection = 'asc' | 'desc' 같은 문법 자체가 없어요. 그래서 "interface 만 쓰겠다"는 불가능하고, "type 만 쓰겠다"는 가능합니다.

반대로 interface 에만 있는 성질도 있어요. 같은 이름으로 두 번 선언하면 합쳐집니다. 외부 라이브러리의 타입에 내 프로젝트 전용 속성을 얹을 때 쓰는 기능인데, 지금 단계에서는 만날 일이 거의 없습니다.


Step 4: "옵셔널 — 안 들어올 수도 있는 값"

인스타그램 프로필을 떠올려 보세요. 사용자 이름은 반드시 있습니다. 그런데 소개글은요? 안 쓴 사람도 많죠. 웹사이트 링크는 더 그렇고요.

이렇게 "있을 수도 있고 없을 수도 있는" 속성을 표현하는 게 물음표(?)입니다.

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

// ? 가 붙은 자리는 "없을 수도 있다"는 뜻이다
export interface ProfileSummary {
  username: string;
  bio?: string;
  websiteUrl?: string;
}

username 은 물음표가 없으니 필수입니다. 빠뜨리면 에러가 나요. biowebsiteUrl 은 물음표가 붙었으니 없어도 됩니다.

옵셔널을 쓰면 확인이 강제됩니다

여기가 핵심이에요. bio 를 그냥 꺼내 쓰려고 하면 TypeScript 가 막습니다.

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

"profile.bioundefined 일 수도 있다"는 뜻이에요. 없을 수도 있다고 선언해놓고 있는 것처럼 쓰니까 막는 겁니다. 그래서 확인을 거쳐야 해요.

TypeScript
export function renderBio(profile: ProfileSummary): string {
  if (profile.bio === undefined) {
    return '소개글이 아직 없어요';
  }
  return profile.bio;
}

if 로 걸러낸 다음부터 TypeScript 는 profile.bio 가 문자열이라는 걸 압니다. 위쪽에서 undefined 인 경우를 이미 돌려보냈으니까요.

순수 JS 였다면 어땠을까요? profile.bio.length 를 그냥 썼을 거고, 소개글 없는 사용자의 프로필에 들어가는 순간 Cannot read properties of undefined 라는 빨간 에러와 함께 화면이 멈췄을 겁니다. 그것도 개발 중이 아니라 사용자 브라우저에서요.

한 줄로 줄이기

매번 if 를 쓰긴 번거롭죠. JS 에도 있던 ?? 를 쓰면 짧아집니다.

TypeScript
// ?? 를 쓰면 같은 처리를 한 줄로 줄일 수 있다
export function renderWebsite(profile: ProfileSummary): string {
  return profile.websiteUrl ?? '링크 없음';
}

?? 는 왼쪽이 null 이나 undefined 일 때만 오른쪽 값을 씁니다. 지난 과목에서 배운 그 문법 그대로예요.

⚠️ 한 가지 주의할 게 있어요. ?? 는 빈 문자열('')이나 0 은 그대로 통과시킵니다. 웹사이트 주소를 지워서 빈 문자열이 된 사용자는 "링크 없음" 이 아니라 빈 칸으로 나와요. 이게 원하는 동작인지는 상황마다 다르니 한 번 생각해 보고 쓰셔야 합니다.

💡 한 줄 정리

물음표(?)로 "없을 수도 있음"을 선언하면, TypeScript 가 값을 쓰기 전에 확인하도록 강제해 줍니다.

🙋 학생 질문 — "bio?: string 이랑 bio: string | undefined 는 뭐가 다른가요?"

거의 같지만 딱 하나 다릅니다. 객체를 만들 때 그 속성을 아예 빼도 되는지 여부예요.

bio?: string{ username: 'soongu' } 처럼 bio 를 통째로 생략해도 됩니다. 반면 bio: string | undefined 는 생략할 수 없어서 { username: 'soongu', bio: undefined } 라고 명시적으로 적어야 해요.

API 응답처럼 "필드 자체가 안 올 수도 있는" 경우에는 물음표가 자연스럽습니다. 반대로 "값은 반드시 있는데 비어 있을 수 있다"를 표현하고 싶으면 후자가 더 정확하고요. 실무에서는 물음표를 훨씬 자주 씁니다.


Step 5: "리터럴과 유니온 — 정해진 값만 허용하기"

피드 정렬 방식을 생각해 볼게요. 최신순, 인기순, 팔로잉. 이 셋 중 하나입니다. 그런데 이걸 string 으로 선언하면 어떻게 될까요?

JavaScript
// 순수 JS 시절
function sortLabel(sort) {
  if (sort === 'latest') return '최신순';
  // ...
}

sortLabel('lastest');  // 오타! 아무도 안 막아줌

'lastest' 라고 오타를 쳐도 JS 는 조용합니다. 함수는 그냥 엉뚱한 값을 돌려주고, 화면에는 이상한 게 나와요. 이런 오타는 눈으로 찾기도 어렵습니다.

값 하나하나가 타입이 됩니다

TypeScript 에서는 값 자체를 타입으로 쓸 수 있어요.

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

// 값 하나하나가 곧 타입이 된다 — 이 셋 말고는 못 들어온다
export type FeedSort = 'latest' | 'popular' | 'following';
export type MediaKind = 'image' | 'video' | 'carousel';

'latest' 는 "문자열"이 아니라 "정확히 latest 라는 문자열"이라는 타입이에요. 이걸 리터럴 타입이라고 합니다. 그리고 세로 막대(|)로 이어 붙이면 "이 중 하나"라는 유니온 타입이 되고요.

이제 오타를 쳐볼까요? 실제로 이런 메시지가 뜹니다.

텍스트
Type '"lastest"' is not assignable to type 'FeedSort'. Did you mean '"latest"'?

막아주는 것도 고마운데 철자까지 고쳐줍니다. "혹시 latest 를 쓰려던 거 아닌가요?" 하고요. 오타 때문에 30분 헤매는 일이 사라지는 순간이에요.

쓰는 쪽도 편해집니다

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

이 함수를 쓸 때 sortLabel( 까지만 치면 에디터가 가능한 값 세 개를 목록으로 띄워줍니다. 문서를 찾아볼 필요가 없어요.

그리고 마지막 return '팔로잉' 에 주목해 주세요. if 두 개로 'latest''popular' 를 걸러냈으니 남은 건 'following' 뿐입니다. TypeScript 도 그걸 알고 있어요. 만약 나중에 FeedSort 에 네 번째 값을 추가하면, 이 함수는 그 값을 팔로잉으로 잘못 처리하게 됩니다. 그래서 유니온에 값을 추가할 때는 그 값을 쓰는 함수들을 함께 살펴봐야 해요.

💡 한 줄 정리

리터럴 유니온으로 허용할 값을 못 박으면, 오타를 컴파일 단계에서 잡고 에디터 자동완성까지 얻습니다.

🙋 학생 질문 — "값이 열 개, 스무 개면 전부 세로 막대로 이어야 하나요?"

문법적으로는 됩니다. 다만 그 정도로 많아지면 관리가 번거로워져요. 값을 추가할 때마다 유니온을 고쳐야 하니까요.

그럴 때 쓰는 게 다음 Step 에서 배울 const 객체 패턴입니다. 값들을 객체 하나에 모아두고 거기서 타입을 뽑아 쓰는 방식이에요.

그리고 애초에 값이 스무 개나 되는 유니온이 필요한지도 한번 의심해 볼 만합니다. 그런 경우는 유니온이 아니라 그냥 문자열로 두고 서버에서 검증하는 게 나을 때도 있어요.


Step 6: "strict 모드 — 가장 많은 버그를 잡아주는 설정"

지금까지 본 에러 중 일부는 사실 설정 하나 덕분에 뜬 거예요. tsconfig.jsonstrict 옵션입니다.

JSON
{
  "compilerOptions": {
    "strict": true
  }
}

이 한 줄이 켜져 있으면 TypeScript 가 훨씬 깐깐해집니다. 그리고 이건 반드시 켜두셔야 해요. 실무 프로젝트에서 strict 를 끄는 건 가드레일을 세워놓고 나사를 빼는 것과 같습니다.

strict 가 잡는 대표 사례

가장 자주 만나는 건 "찾았는데 없을 수도 있는" 경우예요.

TypeScript
// apps/web-spa/src/types/strict-demo.ts

export interface StoredPost {
  id: number;
  content: string;
}

// find 는 "못 찾았을 때"가 있어서 undefined 가 섞인 타입을 돌려준다
export function findPost(posts: StoredPost[], id: number): StoredPost | undefined {
  return posts.find((post) => post.id === id);
}

배열의 find 는 조건에 맞는 게 없으면 undefined 를 돌려줍니다. 그래서 반환 타입이 StoredPost | undefined 예요. 유니온 타입이죠. Step 5 에서 배운 그 문법이 여기서도 쓰입니다.

이제 이 결과를 그냥 꺼내 쓰면 이렇게 막힙니다.

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

그래서 확인을 거쳐야 통과해요.

TypeScript
// strict 는 못 찾은 경우를 처리해야만 통과시켜 준다
export function contentOf(posts: StoredPost[], id: number): string {
  const found = findPost(posts, id);
  if (found === undefined) {
    return '(삭제된 게시물입니다)';
  }
  return found.content;
}

여기서 벌어진 일을 정리해 볼게요. TypeScript 가 강제한 건 문법이 아니라 사고예요. "게시물을 못 찾으면 화면에 뭘 보여줄 건가?" 이 질문에 답하지 않으면 코드가 통과되지 않습니다.

순수 JS 로 짤 때 우리는 이 질문을 자주 건너뛰었어요. 대부분의 경우 게시물은 있으니까요. 그러다 삭제된 게시물 링크를 누른 사용자에게서 오류 신고가 들어옵니다. 이렇게 코드가 실제로 실행되는 도중에 터지는 오류를 런타임 에러라고 불러요.

텍스트
 순수 JS
   findPost() ─ 못 찾음 ─ undefined ─ .content 접근 ─ 런타임 에러
                                              (사용자 화면에서 발생)

 TypeScript + strict
   findPost() ─ 못 찾을 수 있음 ─ 에디터가 즉시 표시
                                        (코드 쓰는 중에 발견)

strict 가 켜주는 다른 검사들

strict 는 사실 여러 옵션을 한 번에 켜는 스위치예요. 대표적인 둘만 짚고 갈게요.

켜지는 검사 잡아주는 것
strictNullChecks null·undefined 를 확인 없이 쓰는 코드
noImplicitAny 타입을 안 적어서 any 가 되어버린 매개변수

Step 2 에서 봤던 Parameter 'username' implicitly has an 'any' type. 이 바로 noImplicitAny 가 낸 메시지입니다.

설정 파일 전체는 나중에 따로 다룰게요. 지금은 strict: true 하나만 확실히 기억해 주세요.

💡 한 줄 정리

strict: true 는 "없을 수도 있는 값"을 확인 없이 쓰지 못하게 막아, 런타임 에러를 코드 작성 시점으로 끌어옵니다.

🙋 학생 질문 — "에러 나는 게 귀찮아서 느낌표(!)를 붙이면 사라지던데요?"

맞습니다. found!.content 처럼 느낌표를 붙이면 "내가 보증하니까 넘어가"라는 뜻이 되어 에러가 사라져요. 이걸 non-null assertion(값이 없지 않다고 개발자가 단언하는 표시)이라고 합니다.

문제는 이게 검사를 끄는 것일 뿐 값을 만들어주지는 않는다는 거예요. 실제로 undefined 가 들어오면 결국 런타임 에러가 납니다. 가드레일을 치워버린 셈이죠.

정말 확신이 있을 때만, 그것도 아주 가끔 쓰세요. 대부분의 경우 if 로 확인하거나 ?? 로 기본값을 주는 게 맞습니다. 코드 리뷰에서 느낌표가 보이면 "여기 정말 확실한가요?" 하고 묻는 게 좋은 습관이에요.


Step 7: "enum 대신 const 객체 + satisfies"

게시물의 상태를 표현한다고 해봅시다. 임시저장, 공개됨, 보관됨. 다른 언어를 해보신 분이라면 열거형(enum)이 떠오를 거예요. TypeScript 에도 enum 문법이 있습니다.

TypeScript
export enum PostStatus {
  DRAFT = 'draft',
  PUBLISHED = 'published',
}

그런데 요즘 실무에서는 이걸 잘 안 씁니다. 이유가 있어요.

TypeScript 코드는 브라우저에서 그대로 돌지 않습니다. 실행 전에 타입을 걷어내고 순수 JavaScript 로 바꾸는 과정을 거쳐요. 그런데 enum 은 이때 사라지지 않고 진짜 코드로 남습니다. 위의 enumtype 을 나란히 두고 변환해 보면 차이가 확실해요.

JavaScript
// enum 은 이런 JavaScript 로 남는다
export var PostStatus;
(function (PostStatus) {
    PostStatus["DRAFT"] = "draft";
    PostStatus["PUBLISHED"] = "published";
})(PostStatus || (PostStatus = {}));

// 반면 type 으로 선언한 것은 흔적도 없이 사라진다

type 은 완전히 증발했는데 enum 은 다섯 줄짜리 코드를 남겼죠. 타입을 표현하려고 쓴 문법인데 결과물 용량을 차지하는 겁니다. 요즘 빌드 도구들은 "타입 문법은 어차피 지울 것"이라고 전제하고 빠르게 지워버리는 방식으로 동작하는데, enum 만 혼자 예외라서 자꾸 걸림돌이 됐어요.

그래서 이렇게 씁니다

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

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

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

// as const 로 값을 고정하고, satisfies 로 셋 다 PostStatus 인지 검사받는다
export const POST_STATUS = {
  DRAFT: 'draft',
  PUBLISHED: 'published',
  ARCHIVED: 'archived',
} as const satisfies PostStatusMap;

세 줄짜리 조합인데 각각 역할이 다릅니다. 하나씩 볼게요.

type PostStatus 는 Step 5 의 리터럴 유니온이에요. 허용되는 값 셋을 정의합니다.

as const 는 "이 객체 안의 값들을 절대 안 바뀌는 것으로 읽어라"는 표시예요. 이게 없으면 POST_STATUS.DRAFT 가 그냥 string 으로 읽힙니다. 붙이면 정확히 'draft' 로 읽혀요. Step 2 에서 본 const 의 좁은 추론과 같은 원리입니다.

satisfies PostStatusMap 이 오늘의 주인공이에요. "이 객체가 PostStatusMap 모양에 맞는지 검사해 달라"는 요청입니다.

satisfies 가 하는 일

검사 효과를 보려면 오타를 내보면 됩니다. PUBLISHED: 'publish' 라고 잘못 쓰면 이런 메시지가 떠요.

텍스트
Type '"publish"' is not assignable to type 'PostStatus'. Did you mean '"published"'?

Step 5 에서 봤던 그 친절한 제안이 여기서도 나옵니다.

그런데 여기서 궁금해질 거예요. 그냥 const POST_STATUS: PostStatusMap = {...} 라고 쓰면 안 되나? 검사는 똑같이 될 텐데요.

차이가 있습니다. 그렇게 쓰면 POST_STATUS.DRAFT 의 타입이 PostStatus 가 되어버려요. 즉 "셋 중 하나"라는 넓은 정보만 남고 "정확히 draft" 라는 정보는 사라집니다. satisfies 는 검사만 하고 원래의 좁은 타입은 그대로 남겨줘요. 검사도 받고 정확한 값도 지키는 겁니다.

쓰는 방식 모양 검사 POST_STATUS.DRAFT 의 타입
as const 없음 'draft'
: PostStatusMap 로 적기 있음 PostStatus (넓어짐)
as const satisfies 있음 'draft' (둘 다 얻음)

쓰는 쪽

TypeScript
export function statusLabel(status: PostStatus): string {
  if (status === POST_STATUS.DRAFT) {
    return '임시저장';
  }
  if (status === POST_STATUS.PUBLISHED) {
    return '공개됨';
  }
  return '보관됨';
}

문자열 'draft' 를 직접 쓰지 않고 POST_STATUS.DRAFT 를 쓰면, 나중에 값이 바뀌어도 한 곳만 고치면 됩니다. 오타 위험도 줄고요.

🌟 정리하면 이 패턴이 오늘날의 기본값입니다. enum 은 오래된 코드에서 만나면 읽을 수 있으면 되고, 새로 짤 때는 const 객체와 satisfies 를 쓰세요.

💡 한 줄 정리

as const satisfies 조합은 값의 정확한 타입을 지키면서 모양 검사까지 받는 방법이라, enum 을 대신하는 오늘날의 기본 패턴입니다.

🙋 학생 질문 — "PostStatusMap 인터페이스를 따로 만드는 게 좀 번거로워 보여요."

정확한 지적이에요. 실제로 PostStatus 를 세 번 반복해서 적고 있죠. 값이 늘어나면 이 인터페이스도 같이 늘려야 하고요. 중복입니다.

이걸 깔끔하게 줄이는 방법이 있습니다. 이미 있는 타입에서 새 타입을 뽑아내는 기법인데, 그러면 이 인터페이스를 손으로 쓸 필요가 없어져요.

타입에서 타입을 뽑아내는 기법을 배우는 시간에 이 인터페이스를 지우게 될 거예요. 지금은 "중복이 보인다"는 감각을 느끼신 것만으로 충분합니다. 그 불편함이 나중 수업의 출발점이거든요.


Step 8: "인스타그램 API 응답을 타입으로 선언하기"

지금까지 배운 걸 전부 모아 실전에 쓸 차례입니다. 우리가 앞으로 이 과목 내내 다룰 인스타그램 클론의 핵심 데이터 세 가지를 타입으로 선언할 거예요.

오전에 배우는 백엔드 과목에서 만든 API 가 실제로 이런 모양의 데이터를 내려줍니다. 필드 이름도 거기에 맞췄어요.

사용자

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

/** 프로필 화면에 뜨는 사용자 */
export interface User {
  id: number;
  username: string;
  profileImageUrl: string;
  bio?: string;
  followerCount: number;
  followingCount: number;
}

bio 에만 물음표가 붙었죠. 소개글은 안 쓴 사람이 있으니까요. Step 4 에서 배운 그대로입니다.

게시물

TypeScript
/** 피드 한 장 — 백엔드가 내려주는 이름을 그대로 받는다 */
export interface Post {
  id: number;
  username: string;
  profileImageUrl: string;
  imageUrl: string;
  mediaKind: MediaKind;
  content: string;
  hashtagNames: string[];
  likeCount: number;
  commentCount: number;
  liked: boolean;
  createdAt: string;
}

여기 볼 게 몇 개 있어요.

mediaKind: MediaKind 는 Step 5 에서 만든 리터럴 유니온을 그대로 가져다 쓴 겁니다. 사진인지 동영상인지 여러 장인지, 셋 중 하나만 들어올 수 있어요. 파일 위쪽에서 import { MediaKind } from './literals'; 로 불러왔습니다.

hashtagNames: string[] 는 Step 1 의 배열 타입이고요.

createdAt: string 을 눈여겨봐 주세요. 날짜인데 왜 문자열일까요? JSON 에는 날짜 타입이 없기 때문입니다. 서버가 "2026-07-26T21:00:00" 같은 문자열로 보내줘요. 화면에 "3시간 전"처럼 표시하려면 이걸 변환해야 하는데, 그건 나중에 다룹니다. 지금 중요한 건 실제로 오는 게 문자열이니 타입도 문자열이어야 한다는 점이에요. 실제와 다르게 선언하면 타입이 거짓말을 하게 됩니다.

댓글

TypeScript
/** 게시물에 달리는 댓글 */
export interface Comment {
  id: number;
  postId: number;
  username: string;
  content: string;
  createdAt: string;
}

postId 로 어느 게시물의 댓글인지 연결합니다.

타입이 있으면 이런 게 됩니다

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

TypeScript
export function feedSummary(post: Post): string {
  return `@${post.username} · 좋아요 ${post.likeCount} · 댓글 ${post.commentCount}`;
}

export function isPopular(post: Post): boolean {
  return post.likeCount >= 1000;
}

post. 까지만 치면 에디터가 이 게시물이 가진 속성 전부를 목록으로 보여줍니다. 오타를 치면 즉시 빨간 줄이 그어지고요.

그리고 필수 필드를 빠뜨리면 이런 메시지가 뜹니다.

텍스트
Type '{ id: number; username: string; }' is missing the following properties
from type 'Post': profileImageUrl, imageUrl, mediaKind, content, and 5 more.

뭐가 빠졌는지 이름까지 알려줘요. 맨 처음에 봤던 undefined 가 화면에 조용히 찍히던 문제, 이제 코드를 쓰는 중에 잡힙니다.

텍스트
 처음 — 순수 JS
   post.likeCount 를 post.likeCont 로 잘못 침
      화면에 undefined 가 조용히 출력됨
      새로고침하다가 뒤늦게 발견

 지금 — TypeScript
   post.likeCont 라고 치는 순간
      그 줄에 즉시 빨간 줄
      무엇이 빠졌는지 이름까지 안내

💡 한 줄 정리

API 응답을 인터페이스로 선언해두면 자동완성과 오타 검출을 얻고, 서버와 프론트 사이의 약속이 코드에 남습니다.

🙋 학생 질문 — "서버가 실제로 이 모양대로 준다는 보장이 있나요?"

없습니다. 그리고 이건 정말 중요한 질문이에요.

우리가 선언한 Post 는 "서버가 이렇게 줄 것이다"라는 우리 쪽의 믿음일 뿐입니다. 서버가 어느 날 필드 이름을 바꾸거나 null 을 보내면, TypeScript 는 그걸 모릅니다. 타입 검사는 코드를 쓰고 검사하는 동안에만 일어나고, 실행 중에 오는 데이터는 검사하지 않거든요.

그래서 실무에서는 받아온 데이터를 실행 시점에 한 번 더 검사하는 도구를 함께 씁니다. 폼 검증을 다루는 모듈에서 그 도구를 만나게 될 거예요.

지금 기억하실 건 이겁니다. 타입은 컴파일 시점의 약속이지 실행 시점의 보증이 아니다. 오늘 마지막 생각해볼 주제에서 이 얘기를 더 깊이 다뤄볼게요.


마무리

오늘 배운 핵심 세 가지

💡 타입은 실행 전에 알려준다

순수 JS 는 잘못된 값이 들어와도 실행할 때까지 조용합니다. TypeScript 는 타이핑하는 중에 알려줘요. 문제를 발견하는 시점이 사용자 브라우저에서 내 에디터로 당겨지는 것, 이게 오늘 얻은 가장 큰 변화입니다.

💡 좁게 선언할수록 많이 잡힌다

string 보다 'latest' | 'popular' | 'following' 이 낫고, 물음표로 "없을 수 있음"을 정확히 표현하면 확인을 강제받습니다. 값의 범위를 좁게 잡을수록 TypeScript 가 잡아주는 실수도 많아져요.

💡 API 응답은 인터페이스로 선언한다

User·Post·Comment 를 인터페이스로 만들어 뒀습니다. 앞으로 이 과목에서 다루는 모든 화면이 이 세 타입 위에 올라갑니다.

다음 시간 예고

오늘 FeedSort'latest' | 'popular' | 'following' 셋 중 하나로 좁혀 놨죠. 그런데 이렇게 여러 값 중 하나만 올 수 있는 타입은, 막상 쓸 때 지금 들어온 게 그중 무엇인지 확인해야 합니다. sortLabel 에서 if (sort === 'latest') 로 하나씩 갈라냈던 게 바로 그 일이었어요.

다음 시간에는 값을 갈라내는 방법을 제대로 배웁니다. 문자열인지 숫자인지 확인하는 방법, 객체에 어떤 필드가 있는지로 종류를 나누는 방법, 그리고 "이 값은 게시물이 맞다"고 판정해주는 검사 함수를 직접 만드는 방법까지요.

여기서 한 걸음 더 나가면 재미있는 게 나옵니다. 좋아요 버튼을 누르면 요청 중인 순간이 있고, 성공한 순간이 있고, 실패한 순간이 있죠. 세 상황은 필요한 정보가 서로 다릅니다. 실패했을 때만 에러 메시지가 필요하니까요. 상황마다 모양이 다른 타입을 만들고, 처리를 빠뜨린 상황이 있으면 컴파일러가 알려주게 하는 방법을 다룹니다.

오늘 불편했던 것들은 그 다음 시간에 해결합니다. Step 7 에서 PostStatusMap 을 손으로 세 번 반복한 것, Post 의 열한 개 필드 중 카드에는 몇 개만 필요한 것, import 로 타입을 불러올 때 더 정확하게 쓰는 방법이 거기서 이어져요.


과제

[구현] 스토리(Story) 타입 설계하기

인스타그램의 스토리는 24시간 뒤 사라지는 짧은 게시물이에요. 오늘 배운 문법만 써서 스토리 관련 타입을 설계해 주세요.

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

  • Story 인터페이스: 아이디, 작성자 이름, 프로필 이미지 주소, 미디어 주소, 만든 시각, 조회수를 담습니다. 스토리에는 글을 안 쓰는 사람이 많으니 텍스트는 있을 수도 없을 수도 있어요.
  • 스토리 배경색을 정하는 값은 white, black, gradient 셋 중 하나만 허용되게 해주세요.
  • 그 셋을 모아두는 상수 객체를 as const satisfies 를 써서 만들어 주세요.
  • Story 를 받아 "@username · 조회 1.2천" 형태의 문자열을 돌려주는 함수를 하나 만들어 주세요. 조회수가 1000 이상이면 천 단위로 줄이고, 아니면 숫자를 그대로 씁니다.

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

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

오늘 만든 apps/web-spa/src/types/instagram.ts 를 열고, 아래 네 가지를 하나씩 시도해 본 뒤 에디터가 뭐라고 하는지 기록해 주세요.

  • Post 객체를 만들면서 imageUrl 을 빼먹기
  • likeCount'1200' 처럼 따옴표를 씌운 숫자 넣기
  • mediaKind'photo' 넣기
  • bio 가 없는 User 를 만들고 user.bio.length 를 호출하기

각각 어떤 메시지가 뜨는지, 그 메시지가 무슨 뜻인지 한 줄씩 적어보세요. 에러 메시지를 읽는 습관이 앞으로의 속도를 좌우합니다.


생각해볼 주제

1. 타입은 버그를 막기 위한 것일까, 의도를 전하기 위한 것일까

오늘 우리는 타입이 오타와 실수를 잡아주는 장면을 여러 번 봤습니다. 그런데 잘 생각해 보면 formatLikeCount(count: number): string 이라는 한 줄은 "이 함수는 숫자를 받아 문자열을 준다"는 설명이기도 해요.

혼자 하는 프로젝트라면 어느 쪽이 더 중요할까요? 열 명이 함께하는 프로젝트라면 답이 달라질까요?

2. 팀에서 type 과 interface 중 하나만 쓰자고 한다면

오늘 우리는 "공개 객체는 interface, 유니온은 type" 이라는 기준을 세웠습니다. 그런데 어떤 팀은 "전부 type 으로 통일하자"고 하고, 어떤 팀은 반대로 갑니다.

이런 규칙을 정하는 게 왜 필요할까요? 그리고 규칙을 정할 때 "무엇이 더 옳은가"와 "무엇이 더 헷갈리지 않는가" 중 어느 쪽이 기준이 되어야 할까요?

3. 타입 선언은 서버가 보내는 데이터를 보증해 주는가

우리는 Post 인터페이스에 likeCount: number 라고 적었습니다. 그런데 서버가 실수로 "1200" 이라는 문자열을 보내면 어떻게 될까요? TypeScript 는 이걸 잡아낼까요?

타입 검사가 언제 일어나는지 떠올려 보시고, 잡아내지 못한다면 우리가 선언한 타입은 무엇을 보장하는 것인지 생각해 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 스토리(Story) 타입 설계하기

채점 포인트

항목 확인 내용 배점
인터페이스 선언 Storyinterface 로 선언했는가 (공개 객체이므로) 20
옵셔널 사용 텍스트 필드에만 ? 를 붙였는가. 나머지는 필수로 두었는가 20
리터럴 유니온 배경색을 세 값만 허용하는 유니온으로 좁혔는가 (string 으로 두지 않았는가) 20
as const satisfies 상수 객체에 두 문법을 함께 썼는가 20
함수 타이핑 매개변수와 반환 타입을 모두 적었는가. 1000 경계 처리가 맞는가 20

풀이 예시

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

export type StoryBackground = 'white' | 'black' | 'gradient';

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

export const STORY_BACKGROUND = {
  WHITE: 'white',
  BLACK: 'black',
  GRADIENT: 'gradient',
} as const satisfies StoryBackgroundMap;

export interface Story {
  id: number;
  username: string;
  profileImageUrl: string;
  mediaUrl: string;
  text?: string;
  background: StoryBackground;
  viewCount: number;
  createdAt: string;
}

export function storySummary(story: Story): string {
  return `@${story.username} · 조회 ${formatViewCount(story.viewCount)}`;
}

function formatViewCount(count: number): string {
  if (count >= 1000) {
    return `${Math.floor(count / 100) / 10}천`;
  }
  return String(count);
}

text?: string 에만 물음표를 붙인 게 핵심입니다. 스토리에 글을 안 쓰는 사람이 많으니 없을 수 있고, 나머지는 스토리가 존재하는 이상 반드시 있어야 하는 값이에요.

backgroundstring 으로 두지 않은 것도 중요합니다. 'gradiant' 같은 오타를 실행 전에 잡으려면 값의 범위를 좁혀야 해요.

formatViewCount 를 밖으로 빼고 export 를 붙이지 않은 건 이 파일 안에서만 쓰기 때문입니다. 필요해지면 그때 열어주면 돼요.

자주 나오는 실수

viewCountstring 으로 선언하는 경우가 있습니다. 화면에 "1.2천" 이라고 문자열로 보여주니 헷갈리기 쉬운데, 서버가 주는 원본은 숫자예요. 화면 표시용으로 바꾸는 건 함수가 할 일이고, 타입은 실제로 오는 값을 그대로 반영해야 합니다.

또 하나는 createdAtDate 로 선언하는 경우입니다. JSON 에는 날짜 타입이 없어서 서버는 문자열을 보냅니다. Date 라고 적어두면 타입은 통과하지만 실제로는 문자열이 들어와서, story.createdAt.getTime() 을 부르는 순간 실행 중에 깨져요.

💡 튜터의 한마디

타입을 설계할 때 스스로에게 이렇게 물어보세요. "서버가 이 필드를 안 보낼 수도 있나?" 그렇다면 물음표를 붙이고, "값이 정해진 몇 개 중 하나인가?" 그렇다면 유니온으로 좁힙니다. 이 두 질문만 습관이 되어도 타입 설계의 절반은 끝납니다.

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

채점 포인트

항목 확인 내용 배점
네 가지 시도 네 가지를 모두 직접 실행해 보고 결과를 기록했는가 40
메시지 이해 각 메시지가 무슨 뜻인지 자기 말로 설명했는가 40
차이 인식 어떤 것이 값의 문제이고 어떤 것이 구조의 문제인지 구분했는가 20

풀이 예시

imageUrl 을 빼먹었을 때

텍스트
Type '{ id: number; username: string; }' is missing the following properties
from type 'Post': profileImageUrl, imageUrl, mediaKind, content, and 5 more.

객체에 필수 속성이 빠졌다는 뜻입니다. 친절하게도 무엇이 빠졌는지 이름을 나열해 주고, 너무 많으면 "and 5 more" 로 줄여서 보여줘요. 이건 개별 값의 문제가 아니라 객체 전체의 구조가 약속과 다르다는 지적입니다.

likeCount'1200' 을 넣었을 때

텍스트
Type 'string' is not assignable to type 'number'.

숫자가 와야 하는데 문자열을 넣었다는 뜻이에요. 눈으로 보면 둘 다 1200 이라 같아 보이지만 TypeScript 에게는 완전히 다른 값입니다. 이 오류가 없었다면 likeCount + 11201 이 아니라 '12001' 이 되는 버그로 이어졌을 거예요.

mediaKind'photo' 를 넣었을 때

텍스트
Type '"photo"' is not assignable to type 'MediaKind'.

MediaKind'image' | 'video' | 'carousel' 셋만 허용하는데 'photo' 는 그중에 없습니다. 문자열이긴 하지만 허용된 문자열이 아니라는 거죠. 값의 종류가 아니라 값 자체를 검사하고 있다는 게 리터럴 유니온의 힘입니다.

bio 가 없는데 user.bio.length 를 불렀을 때

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

bio 는 물음표가 붙어 있어서 없을 수도 있는데, 확인 없이 .length 를 꺼내려 했다는 뜻이에요. 앞의 세 개가 "넣을 때" 막은 것이라면 이건 "꺼내 쓸 때" 막은 것입니다.

네 가지는 성격이 둘로 나뉩니다. 앞의 셋은 값을 넣는 시점의 검사이고, 마지막 하나는 값을 꺼내 쓰는 시점의 검사예요. 타입은 데이터가 들어오는 입구와 나가는 출구 양쪽을 지킵니다.

💡 튜터의 한마디

에러 메시지를 무서워하지 마세요. TypeScript 의 메시지는 "무엇이" "어떤 타입이어야 하는데" "무엇이 왔는지"를 거의 항상 알려줍니다. 처음엔 영어라서 부담스럽겠지만, is not assignable tois possibly 'undefined' 두 문장만 익숙해져도 대부분의 메시지가 읽힙니다.


🤔 [생각해볼 주제 1] 타입은 버그를 막기 위한 것일까, 의도를 전하기 위한 것일까

문제 상황 요약

formatLikeCount(count: number): string 이라는 선언 한 줄은 두 가지 일을 동시에 합니다. 잘못된 값이 들어오는 걸 막고, 동시에 이 함수의 사용법을 설명하죠. 그렇다면 둘 중 어느 쪽이 타입의 본질일까요?

튜터의 가이드 및 해설

결론부터 말하면 둘 다 맞지만, 프로젝트가 커질수록 무게중심은 의도 전달 쪽으로 옮겨갑니다.

혼자서 200줄짜리 스크립트를 짤 때는 버그 방지가 거의 전부예요. 내가 방금 쓴 코드니까 의도는 머릿속에 다 있고, 타입은 오타나 잡아주는 도구에 가깝습니다.

그런데 열 명이 3년간 유지보수하는 코드베이스에서는 상황이 완전히 달라집니다. 여러분이 어떤 함수를 열었을 때 알고 싶은 건 "여기에 뭘 넣어야 하고 뭐가 나오는가"예요. 타입이 없으면 함수 본문을 끝까지 읽거나, 그 함수를 호출하는 다른 코드를 찾아 헤매야 합니다. 타입이 있으면 첫 줄만 보면 끝나고요.

이 관점에서 보면 타입은 "항상 최신인 문서"입니다. 주석은 코드를 고치면서 갱신을 잊으면 거짓말을 시작하지만, 타입은 코드와 어긋나는 순간 타입 검사에서 바로 걸려요. 그래서 절대 낡지 않습니다.

그리고 이 차이는 실무 판단에도 영향을 줍니다. "버그 방지"만 목적이라면 타입을 대충 string 으로 두고 실행해서 확인하면 되죠. 하지만 "의도 전달"이 목적이라면 'latest' | 'popular' | 'following' 처럼 좁게 적을 이유가 생깁니다. 읽는 사람에게 훨씬 많은 걸 알려주니까요.

한 가지 균형은 잡아야 합니다. 의도 전달을 극단으로 밀면 타입이 지나치게 복잡해져서, 정작 읽기 어려워지는 역설이 생겨요. 타입을 이해하는 데 함수 본문보다 오래 걸린다면 그건 과한 겁니다.

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

"타입은 컴파일러를 위한 게 아니라 다음에 이 코드를 열어볼 사람을 위한 겁니다. 주석은 낡지만 타입은 코드와 어긋나는 순간 타입 검사에서 걸리기 때문에, 저는 타입을 항상 최신 상태가 보장되는 유일한 문서라고 생각합니다."

🤔 [생각해볼 주제 2] 팀에서 type 과 interface 중 하나만 쓰자고 한다면

문제 상황 요약

어떤 팀은 "전부 type 으로 통일"을, 어떤 팀은 "객체는 무조건 interface"를 규칙으로 둡니다. 이런 규칙은 왜 필요하고, 정할 때 무엇을 기준으로 삼아야 할까요?

튜터의 가이드 및 해설

먼저 사실관계를 정리할게요. 두 문법의 기능은 대부분 겹칩니다. 객체 모양을 정의하는 일은 둘 다 잘 해요. 차이가 나는 건 좁은 영역입니다.

할 수 있는 일 interface type
객체 모양 정의
확장 ○ (extends) ○ (&)
유니온 정의
같은 이름으로 다시 선언해 합치기

type 하나로 통일하는 건 가능하지만, interface 하나로 통일하는 건 불가능합니다. 유니온을 못 만드니까요.

여기서 중요한 질문이 나옵니다. 기능 차이가 이렇게 작은데 왜 규칙을 정할까요?

답은 일관성 자체가 가치이기 때문입니다. 규칙이 없으면 코드 리뷰마다 "이건 왜 type 이죠?" 같은 논쟁이 반복되고, 파일마다 스타일이 달라져 읽는 사람의 인지 부하가 올라갑니다. 어느 쪽을 고르든 얻는 이득보다, 매번 고민하지 않아도 된다는 이득이 훨씬 커요.

그래서 기준은 "무엇이 더 옳은가"가 아니라 "무엇이 더 헷갈리지 않는가"가 되어야 합니다. 기술적 우열을 가리려는 논쟁은 대체로 결론이 안 나요. 차이가 거의 없으니까요.

실무에서 자주 쓰는 절충안은 이 강의에서 택한 방식입니다. 공개 객체는 interface, 유니온은 type. interface 는 확장이 자연스럽고 에러 메시지에 이름이 그대로 나와서 읽기 편하고, 유니온은 어차피 type 밖에 못 하니 자연스럽게 역할이 나뉩니다.

한 가지 덧붙이면, 이 규칙은 팀 문서에 한 줄로 적어두고 넘어갈 성격의 것입니다. 여기에 회의 시간을 오래 쓰고 있다면 그 자체가 신호예요. 더 중요한 결정이 뒤에 밀려 있을 가능성이 큽니다.

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

"기능 차이가 거의 없는 선택지에서는 무엇이 옳은지보다 팀이 매번 고민하지 않는 게 더 중요하다고 봅니다. 저희는 공개 객체는 interface, 유니온은 type 으로 한 줄 규칙만 정해두고 리뷰에서 이 논쟁이 반복되지 않게 했습니다."

🤔 [생각해볼 주제 3] 타입 선언은 서버가 보내는 데이터를 보증해 주는가

문제 상황 요약

우리는 Post 인터페이스에 likeCount: number 라고 적었습니다. 그런데 서버가 실수로 "1200" 이라는 문자열을 보내면 TypeScript 가 잡아낼까요?

튜터의 가이드 및 해설

답은 "잡아내지 못한다"입니다. 그리고 이건 TypeScript 를 이해하는 데 가장 중요한 사실 중 하나예요.

이유는 타입 검사가 일어나는 시점에 있습니다. 타입은 코드를 쓰고 검사하는 동안에만 존재해요. 빌드가 끝나면 타입 정보는 전부 사라지고 순수 JavaScript 만 남습니다. Step 7 에서 type 선언이 흔적도 없이 사라지는 걸 직접 봤죠. 그 사라진 코드가 실행 중에 서버 응답을 검사해 줄 수는 없습니다.

그러니 우리가 선언한 Post 는 사실 이런 뜻입니다. "서버가 이렇게 줄 것이라고 우리가 믿는 모양." 보증이 아니라 가정이에요.

그래서 실제로 이런 사고가 납니다. 서버가 필드 이름을 바꿨는데 프론트 타입은 그대로라면, 빌드는 멀쩡히 통과하고 화면에서 undefined 가 뜹니다. 타입을 붙이기 전과 똑같은 증상이죠. 타입이 지켜주는 건 우리가 쓴 코드 안에서의 일관성이지, 바깥에서 들어오는 데이터의 진실성이 아닙니다.

여기서 실무의 해법이 나옵니다. 경계에서 한 번 검사하는 거예요. 서버 응답이 우리 앱에 들어오는 그 지점에서, 실행 시점에 실제로 값을 확인하는 코드를 둡니다. 모양이 다르면 화면을 그리기 전에 걸러내는 거죠. 이걸 해주는 도구를 폼 검증을 다루는 모듈에서 만나게 됩니다.

그때 얻는 이득이 하나 더 있어요. 검사 규칙을 한 번 정의하면 거기서 타입까지 함께 뽑아낼 수 있습니다. 손으로 쓴 인터페이스와 검사 규칙이 따로 놀다가 어긋나는 문제가 사라져요.

정리하면 이렇습니다. 내부는 타입으로 지키고, 외부와 만나는 경계는 실행 시점 검사로 지킵니다. 둘은 대체재가 아니라 서로 다른 구간을 맡는 짝입니다.

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

"TypeScript 의 타입은 컴파일 시점의 약속이지 런타임의 보증이 아닙니다. 그래서 저는 서버 응답이 들어오는 경계에서는 실행 시점 스키마 검증을 따로 두고, 그 스키마에서 타입을 파생시켜 검증 규칙과 타입이 어긋나지 않도록 만듭니다."

전체 목록 리액트