문서 읽는 데 64분 · A1

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

목차 51
전체 59강 중 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의 타입은 컴파일 시점의 약속이지 런타임의 보증이 아닙니다. 그래서 저는 서버 응답이 들어오는 경계에서는 실행 시점 스키마 검증을 따로 두고, 그 스키마에서 타입을 파생시켜 검증 규칙과 타입이 어긋나지 않도록 만듭니다."

전체 목록 리액트