A-1: TypeScript 입문 — JS에 붙는 가드레일
목차 51
안녕하세요, 홍순구입니다. 오늘부터 여러분은 프레임워크의 세계로 들어갑니다.
지난 과목에서 여러분은 순수 HTML/CSS/JS 만으로 인스타그램 클론을 만들어 봤어요. document.createElement 로 게시물 카드를 찍어내고, fetch 로 데이터를 받아오고, 이벤트를 붙여 좋아요를 눌렀죠. 화면은 분명히 잘 돌아갔습니다.
그런데 이런 코드를 한번 떠올려 볼게요.
// 지난 과목에서 이렇게 썼죠
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 응답을 타입으로 선언하는 데까지 갑니다.
🎯 학습 목표
- 기본 타입을 선언하고, 타입 추론과 명시 선언을 언제 각각 쓸지 판단할 수 있다
type과interface를 상황에 맞게 구분해 쓰고, 옵셔널·리터럴·유니온으로 값의 범위를 좁힐 수 있다- 인스타그램 API 응답(User·Post·Comment)을 인터페이스로 선언해 이후 모듈의 토대를 만든다
Step 1: "기본 타입 명시하기 — 값 옆에 이름표를 붙인다"
실습 준비
눈으로만 보면 안 늘어요. 직접 쳐보면서 따라오셔야 합니다. 터미널을 열고 작업할 폴더를 만들어 주세요.
mkdir -p apps/web-spa/src/types
cd apps/web-spa
npm init -y
npm install -D typescript
그리고 지금 위치(apps/web-spa/)에 tsconfig.json 파일을 만들어 아래를 그대로 붙여넣습니다. TypeScript 에게 "이렇게 검사해 달라"고 알려주는 설정 파일이에요.
{
"compilerOptions": {
"target": "es2025",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["src"]
}
strict 가 오늘의 숨은 주인공인데, 자세한 건 Step 6 에서 다룰게요. 지금은 "가장 깐깐하게 검사해 달라"는 뜻으로만 알아두시면 됩니다.
이제 코드를 쓰고 이 명령으로 검사합니다.
npx tsc --noEmit
에러가 없으면 아무것도 안 나와요. 조용하면 성공입니다. VS Code 를 쓰신다면 파일을 열어두는 것만으로 빨간 줄이 실시간으로 보이니 훨씬 편해요.
앞으로 나오는 코드는 전부 지금 위치의 src/types/ 안에 파일을 만들어 넣으시면 됩니다. 참고로 코드 블록 맨 위 주석에 적힌 경로(apps/web-spa/src/types/...)는 프로젝트 맨 바깥에서 본 위치예요. 지금 apps/web-spa 안에 계시니 src/types/ 부터 만들면 됩니다.
타입을 붙이는 법
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 을 더하면 이어붙이기가 되니까요.
배열은 두 가지로 쓸 수 있어요
배열 타입은 표기법이 두 개인데 뜻은 똑같습니다.
// 배열은 두 가지로 쓸 수 있고 뜻은 같다
export const hashtags: string[] = ['#한강', '#야경', '#서울'];
export const likedUserIds: Array<number> = [7, 12, 45];
string[] 은 "문자열이 여러 개 담긴 배열", Array<number> 는 "숫자가 여러 개 담긴 배열"이에요. 실무에서는 짧은 string[] 쪽을 훨씬 많이 씁니다. Array<number> 형태는 나중에 복잡한 타입을 다룰 때 다시 만나게 돼요.
중요한 건 배열에 타입이 붙으면 그 안에 다른 게 못 들어온다는 점이에요. hashtags 에 숫자 42 를 넣으려고 하면 바로 막힙니다.
함수에도 이름표를 붙입니다
함수는 매개변수와 반환값 양쪽에 타입을 답니다.
/** 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)이라고 해요.
// apps/web-spa/src/types/inference.ts
// 타입을 안 적었는데 TS 가 string 으로 읽어낸다
export const defaultCaption = '오늘의 한 컷';
: string 을 안 썼지만 TypeScript 는 defaultCaption 이 문자열이라는 걸 압니다. 여기에 숫자를 넣으려 하면 똑같이 막혀요. 그래서 이럴 때는 굳이 타입을 적을 필요가 없습니다.
const 와 let 은 다르게 읽힙니다
재미있는 건 const 와 let 의 추론이 다르다는 점이에요.
// const 는 값 하나로 좁게, let 은 string 으로 넓게 읽어낸다
export const followLabel = '팔로우';
export let currentTab = 'feed';
followLabel 은 const 라서 앞으로 절대 안 바뀝니다. 그래서 TypeScript 는 이걸 "문자열"이 아니라 아예 '팔로우' 라는 값 자체로 읽어요. 반면 currentTab 은 let 이라 나중에 바뀔 수 있으니 넉넉하게 "문자열"로 봅니다.
이 차이는 Step 5 에서 리터럴 타입을 배울 때 진가를 발휘해요. 지금은 "const 는 더 좁게 읽힌다" 정도만 기억해 주세요.
추론에 맡기면 곤란해지는 경우
추론이 항상 좋은 건 아닙니다. 대표적인 게 빈 배열이에요.
// 빈 배열은 추론에 맡기면 곤란해진다 — 무엇이 들어올지 미리 적어준다
export const initialHashtags: string[] = [];
const initialHashtags = [] 라고만 쓰면 TypeScript 는 뭐가 들어올 배열인지 알 도리가 없습니다. 값이 하나도 없으니 짐작할 근거가 없으니까요.
그래도 파일 안에서만 쓰는 변수라면 넘어가 줍니다. 뒤이어 push 로 넣는 값을 보고 타입을 뒤늦게 채워주거든요. 그런데 위 코드처럼 export 를 붙여 다른 파일에서 쓸 수 있게 열어두면 얘기가 달라집니다. 바깥에서 쓸 타입을 지금 확정해야 하는데 근거가 없으니, 아무것도 못 들어오는 배열로 굳어버려요. 이 상태에서 값을 넣으려 하면 이렇게 막힙니다.
Argument of type '"#한강"' is not assignable to parameter of type 'never'.
never 는 "해당하는 값이 하나도 없다"는 뜻의 타입이에요. 그래서 빈 배열로 시작할 때는 미리 string[] 이라고 알려주는 게 맞습니다.
함수의 입구와 출구에는 적어주세요
가장 중요한 원칙입니다.
// 함수 경계에서는 반환 타입을 적어 계약을 고정한다
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 — 어디에 뭘 써야 할까"
타입에 이름을 붙여 재사용하는 방법이 두 가지 있습니다. interface 와 type 이에요. 처음 배울 때 가장 헷갈리는 부분이라 기준을 확실히 잡고 갈게요.
백엔드가 내려주는 객체는 interface 로
// apps/web-spa/src/types/types-vs-interfaces.ts
// 백엔드가 내려주는 공개 객체는 interface 로 선언한다
export interface Author {
id: number;
username: string;
profileImageUrl: string;
}
interface 는 객체의 모양을 정의합니다. "작성자에게는 id 와 username 과 프로필 이미지 주소가 있다"는 약속이에요. 이렇게 선언해두면 어디서든 Author 라는 이름으로 쓸 수 있습니다.
interface 의 장점은 확장이 자연스럽다는 거예요.
// interface 는 extends 로 넓히는 흐름이 자연스럽다
export interface VerifiedAuthor extends Author {
verifiedAt: string;
}
extends 는 "Author 가 가진 것 전부에 더해서"라는 뜻입니다. VerifiedAuthor 는 id, username, profileImageUrl 을 다 갖고 거기에 verifiedAt 이 하나 더 붙습니다.
그리고 중요한 성질이 하나 있습니다. Author 를 받는 함수에 VerifiedAuthor 를 넣어도 통과해요.
export function mentionOf(author: Author): string {
return `@${author.username}`;
}
VerifiedAuthor 는 Author 가 요구하는 걸 전부 갖고 있으니 자격이 충분하거든요. TypeScript 는 "이름이 같은지"가 아니라 "모양이 맞는지"로 판단합니다. 이걸 구조적 타이핑(structural typing)이라고 불러요.
객체가 아닌 모양은 type 으로
// 객체가 아닌 모양(별칭·여러 값 중 하나)은 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: "옵셔널 — 안 들어올 수도 있는 값"
인스타그램 프로필을 떠올려 보세요. 사용자 이름은 반드시 있습니다. 그런데 소개글은요? 안 쓴 사람도 많죠. 웹사이트 링크는 더 그렇고요.
이렇게 "있을 수도 있고 없을 수도 있는" 속성을 표현하는 게 물음표(?)입니다.
// apps/web-spa/src/types/optional.ts
// ? 가 붙은 자리는 "없을 수도 있다"는 뜻이다
export interface ProfileSummary {
username: string;
bio?: string;
websiteUrl?: string;
}
username 은 물음표가 없으니 필수입니다. 빠뜨리면 에러가 나요. bio 와 websiteUrl 은 물음표가 붙었으니 없어도 됩니다.
옵셔널을 쓰면 확인이 강제됩니다
여기가 핵심이에요. bio 를 그냥 꺼내 쓰려고 하면 TypeScript 가 막습니다.
'profile.bio' is possibly 'undefined'.
"profile.bio 는 undefined 일 수도 있다"는 뜻이에요. 없을 수도 있다고 선언해놓고 있는 것처럼 쓰니까 막는 겁니다. 그래서 확인을 거쳐야 해요.
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 에도 있던 ?? 를 쓰면 짧아집니다.
// ?? 를 쓰면 같은 처리를 한 줄로 줄일 수 있다
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 으로 선언하면 어떻게 될까요?
// 순수 JS 시절
function sortLabel(sort) {
if (sort === 'latest') return '최신순';
// ...
}
sortLabel('lastest'); // 오타! 아무도 안 막아줌
'lastest' 라고 오타를 쳐도 JS 는 조용합니다. 함수는 그냥 엉뚱한 값을 돌려주고, 화면에는 이상한 게 나와요. 이런 오타는 눈으로 찾기도 어렵습니다.
값 하나하나가 타입이 됩니다
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분 헤매는 일이 사라지는 순간이에요.
쓰는 쪽도 편해집니다
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.json 의 strict 옵션입니다.
{
"compilerOptions": {
"strict": true
}
}
이 한 줄이 켜져 있으면 TypeScript 가 훨씬 깐깐해집니다. 그리고 이건 반드시 켜두셔야 해요. 실무 프로젝트에서 strict 를 끄는 건 가드레일을 세워놓고 나사를 빼는 것과 같습니다.
strict 가 잡는 대표 사례
가장 자주 만나는 건 "찾았는데 없을 수도 있는" 경우예요.
// 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'.
그래서 확인을 거쳐야 통과해요.
// 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 문법이 있습니다.
export enum PostStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
}
그런데 요즘 실무에서는 이걸 잘 안 씁니다. 이유가 있어요.
TypeScript 코드는 브라우저에서 그대로 돌지 않습니다. 실행 전에 타입을 걷어내고 순수 JavaScript 로 바꾸는 과정을 거쳐요. 그런데 enum 은 이때 사라지지 않고 진짜 코드로 남습니다. 위의 enum 과 type 을 나란히 두고 변환해 보면 차이가 확실해요.
// enum 은 이런 JavaScript 로 남는다
export var PostStatus;
(function (PostStatus) {
PostStatus["DRAFT"] = "draft";
PostStatus["PUBLISHED"] = "published";
})(PostStatus || (PostStatus = {}));
// 반면 type 으로 선언한 것은 흔적도 없이 사라진다
type 은 완전히 증발했는데 enum 은 다섯 줄짜리 코드를 남겼죠. 타입을 표현하려고 쓴 문법인데 결과물 용량을 차지하는 겁니다. 요즘 빌드 도구들은 "타입 문법은 어차피 지울 것"이라고 전제하고 빠르게 지워버리는 방식으로 동작하는데, enum 만 혼자 예외라서 자꾸 걸림돌이 됐어요.
그래서 이렇게 씁니다
// 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' (둘 다 얻음) |
쓰는 쪽
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 가 실제로 이런 모양의 데이터를 내려줍니다. 필드 이름도 거기에 맞췄어요.
사용자
// 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 에서 배운 그대로입니다.
게시물
/** 피드 한 장 — 백엔드가 내려주는 이름을 그대로 받는다 */
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시간 전"처럼 표시하려면 이걸 변환해야 하는데, 그건 나중에 다룹니다. 지금 중요한 건 실제로 오는 게 문자열이니 타입도 문자열이어야 한다는 점이에요. 실제와 다르게 선언하면 타입이 거짓말을 하게 됩니다.
댓글
/** 게시물에 달리는 댓글 */
export interface Comment {
id: number;
postId: number;
username: string;
content: string;
createdAt: string;
}
postId 로 어느 게시물의 댓글인지 연결합니다.
타입이 있으면 이런 게 됩니다
이제 이 타입들을 쓰는 함수를 만들어 볼게요.
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) 타입 설계하기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 인터페이스 선언 | Story 를 interface 로 선언했는가 (공개 객체이므로) |
20 |
| 옵셔널 사용 | 텍스트 필드에만 ? 를 붙였는가. 나머지는 필수로 두었는가 |
20 |
| 리터럴 유니온 | 배경색을 세 값만 허용하는 유니온으로 좁혔는가 (string 으로 두지 않았는가) |
20 |
| as const satisfies | 상수 객체에 두 문법을 함께 썼는가 | 20 |
| 함수 타이핑 | 매개변수와 반환 타입을 모두 적었는가. 1000 경계 처리가 맞는가 | 20 |
풀이 예시
// 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 에만 물음표를 붙인 게 핵심입니다. 스토리에 글을 안 쓰는 사람이 많으니 없을 수 있고, 나머지는 스토리가 존재하는 이상 반드시 있어야 하는 값이에요.
background 를 string 으로 두지 않은 것도 중요합니다. 'gradiant' 같은 오타를 실행 전에 잡으려면 값의 범위를 좁혀야 해요.
formatViewCount 를 밖으로 빼고 export 를 붙이지 않은 건 이 파일 안에서만 쓰기 때문입니다. 필요해지면 그때 열어주면 돼요.
자주 나오는 실수
viewCount 를 string 으로 선언하는 경우가 있습니다. 화면에 "1.2천" 이라고 문자열로 보여주니 헷갈리기 쉬운데, 서버가 주는 원본은 숫자예요. 화면 표시용으로 바꾸는 건 함수가 할 일이고, 타입은 실제로 오는 값을 그대로 반영해야 합니다.
또 하나는 createdAt 을 Date 로 선언하는 경우입니다. 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 + 1 이 1201 이 아니라 '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 to 와 is 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 의 타입은 컴파일 시점의 약속이지 런타임의 보증이 아닙니다. 그래서 저는 서버 응답이 들어오는 경계에서는 실행 시점 스키마 검증을 따로 두고, 그 스키마에서 타입을 파생시켜 검증 규칙과 타입이 어긋나지 않도록 만듭니다."