Skip to main content

객체를 잘 설계하면 코드가 덜 무섭다

지금까지는 문자열, 숫자, 배열, 함수처럼 비교적 단순한 재료를 다뤘습니다. 그런데 실제 프로그램은 대개 여러 값을 한 덩어리로 묶은 객체를 중심으로 움직입니다. 사용자 정보, 상품 정보, 게시글, 결제 내역, 설정값까지 거의 다 객체입니다.

예를 들어 쇼핑몰에서 상품 하나를 표현한다고 해봅시다.

const product = {
  id: 1,
  name: "키보드",
  price: 59000,
  inStock: true,
}

이런 객체는 보기엔 단순하지만, 프로젝트가 커질수록 여기저기서 계속 등장합니다. 그때마다 id는 number였나?, price는 문자열이었나?, inStock이 없을 수도 있나? 같은 질문이 생깁니다. TypeScript는 바로 이 지점에서 빛이 납니다. 객체의 모양을 타입으로 설명할 수 있기 때문입니다.1

현실의 데이터를 코드로 옮기는 순간부터 TypeScript의 진짜 재미가 시작된다는 말은 괜한 과장이 아닙니다. 이제부터 우리는 "이 객체는 어떤 모습이어야 하는가?"를 코드로 설계하는 법을 배웁니다.

object 타입부터 보자: 되긴 되는데, 너무 대충이다

TypeScript에는 object라는 타입이 있습니다. 이름만 보면 뭔가 든든해 보입니다.

let item: object

item = { name: "노트북" }
item = [1, 2, 3]
item = new Date()

이 코드는 모두 가능합니다. 왜냐하면 object는 말 그대로 원시값이 아닌 것을 넓게 가리키기 때문입니다. 즉, "이건 객체 계열이야" 정도만 알려 줄 뿐, 그 안에 어떤 속성이 있는지는 알려 주지 못합니다.2

그래서 아래처럼 쓰면 금방 답답해집니다.

let user: object = {
  name: "하늘",
  age: 20,
}

// user.name

user.name에 접근하려 하면 TypeScript는 기뻐하지 않습니다. object라고만 적어 두면, 그 안에 name이 있다는 사실을 모르기 때문입니다.

즉, object는 "뭔가 객체다"라는 말만 할 뿐, 어떤 객체인지 설명하지 못하는 타입입니다. 초보자가 자주 하는 실수 중 하나가 바로 이것입니다. 객체를 엄격하게 다루고 싶다면 object만으로는 부족합니다.

정말 필요한 건 "객체의 모양"이다

우리가 원하는 건 이런 정보입니다.

  • name은 문자열이어야 한다

  • age는 숫자여야 한다

  • email은 있을 수도, 없을 수도 있다

이런 식으로 속성 이름과 타입을 구체적으로 적는 것을 객체 타입이라고 생각하면 쉽습니다.1

가장 직접적인 방법은 타입을 바로 써 버리는 것입니다.

const user: { name: string; age: number } = {
  name: "하늘",
  age: 20,
}

이제 TypeScript는 user.name이 문자열이고, user.age가 숫자라는 걸 정확히 압니다.

console.log(user.name.toUpperCase()) // 가능
console.log(user.age.toFixed(0))     // 가능

반대로 잘못된 값을 넣으면 바로 알려 줍니다.

const user: { name: string; age: number } = {
  name: "하늘",
  age: "20", // 오류
}

이 방식은 아주 좋습니다. 다만 객체 구조가 길어질수록 타입을 계속 반복해서 써야 한다는 단점이 있습니다.

타입을 이름 붙여 재사용하자

객체 타입을 여러 군데에서 쓴다면 이름을 붙이는 편이 훨씬 좋습니다. 여기서 등장하는 주인공이 interface와 type alias입니다.

둘 다 "이 객체는 이런 모양입니다"라고 설명하는 데 쓸 수 있습니다.

먼저 interface부터 봅시다.

interface User {
  name: string
  age: number
}

const user1: User = {
  name: "하늘",
  age: 20,
}

function printUser(user: User) {
  console.log(`${user.name} (${user.age})`)
}

이제 User라는 이름만 보면 "아, 사용자 객체구나" 하고 의미가 바로 들어옵니다. 타입을 한곳에서 관리할 수 있으니 수정도 쉬워집니다.

같은 일은 type alias로도 할 수 있습니다.

type User = {
  name: string
  age: number
}

처음 보면 둘의 차이가 거의 없어 보입니다. 실제로 객체 모양만 표현할 때는 둘 다 매우 비슷하게 동작합니다.1

그렇다면 질문이 생깁니다.

"둘 중 아무거나 쓰면 되는 거 아닌가요?"

대답은 절반은 맞고, 절반은 아닙니다.

interface: 객체의 계약서를 만들 때 잘 어울린다

interface는 객체의 구조를 설명하는 데 특히 잘 어울립니다. 마치 "이 형식을 따르세요"라고 말하는 계약서 같습니다.

interface Product {
  id: number
  name: string
  price: number
}

const keyboard: Product = {
  id: 1,
  name: "기계식 키보드",
  price: 129000,
}

읽는 사람 입장에서도 꽤 친절합니다. "Product라는 인터페이스를 만족하는 객체구나"라는 뜻이 분명하니까요.

또 interface는 다른 인터페이스를 extends로 자연스럽게 확장할 수 있습니다.1

interface Product {
  id: number
  name: string
  price: number
}

interface DiscountedProduct extends Product {
  discountRate: number
}

const saleItem: DiscountedProduct = {
  id: 2,
  name: "마우스",
  price: 49000,
  discountRate: 10,
}

이 코드는 "할인 상품은 상품의 모든 정보에 할인율이 추가된 것"이라는 뜻을 아주 읽기 좋게 표현합니다.

type alias: 이름표를 붙이는 만능 도구

type은 객체 타입에도 쓸 수 있지만, 거기서 끝나지 않습니다. 문자열, 숫자 같은 기본 타입에도 이름을 붙일 수 있고, 나중에 배울 유니언 타입이나 교차 타입에도 쓸 수 있습니다.3

예를 들어 이런 것도 가능합니다.

type UserId = number
type UserName = string

객체에도 물론 사용할 수 있습니다.

type Product = {
  id: number
  name: string
  price: number
}

또 다른 타입과 합쳐 새 타입을 만들 때도 유용합니다.

type Product = {
  id: number
  name: string
}

type StockInfo = {
  inStock: boolean
  quantity: number
}

type InventoryProduct = Product & StockInfo

const item: InventoryProduct = {
  id: 1,
  name: "모니터",
  inStock: true,
  quantity: 7,
}

여기서 &는 "둘 다 만족해야 한다"는 뜻입니다. 즉 InventoryProduct는 Product이면서 동시에 StockInfo이기도 합니다.

정리하면, type은 객체 설계에도 좋지만 객체 바깥의 타입 세계까지 넓게 다룰 수 있는 도구입니다.

interface와 type, 객체에서는 어떻게 다를까?

객체 타입을 표현할 때 둘은 닮았지만, 중요한 차이도 있습니다.

가장 유명한 차이는 선언 병합입니다. 이름이 어렵지만 개념은 간단합니다. interface는 같은 이름으로 여러 번 선언하면 자동으로 합쳐질 수 있습니다.4

interface Animal {
  name: string
}

interface Animal {
  age: number
}

const dog: Animal = {
  name: "초코",
  age: 3,
}

이렇게 두 인터페이스가 하나처럼 합쳐집니다. 반면 type은 같은 이름을 다시 선언하면 오류가 납니다.4

type Animal = {
  name: string
}

// type Animal = {
//   age: number
// }

이 차이는 보통 라이브러리 타입을 확장하거나, 기존 전역 타입에 속성을 덧붙일 때 중요해집니다. 초보 단계에서는 자주 쓰진 않지만, "아, 인터페이스는 열려 있고 타입 별칭은 닫혀 있구나" 정도만 기억해도 충분합니다.4

그리고 type은 객체 외에도 다양한 타입에 이름을 붙일 수 있지만, interface는 기본적으로 객체 형태를 설명하는 데 집중합니다.3

그래서 실무에서는 대체로 이렇게 생각해도 큰 무리는 없습니다.

  • 객체 구조를 설명할 때는 interface를 자주 쓴다

  • 객체 말고도 여러 타입을 조합해야 할 때는 type이 더 유연하다

  • 팀 안에서는 한 가지 기준을 정하고 일관되게 쓰는 것이 더 중요하다

정답 하나를 외우기보다, 왜 이 도구를 쓰는지 설명할 수 있으면 충분합니다.

선택 속성: 있을 수도 있고 없을 수도 있다

현실의 데이터는 늘 완벽하지 않습니다. 사용자 프로필에 자기소개가 없을 수도 있고, 상품 설명이 아직 입력되지 않았을 수도 있습니다.

이럴 때는 속성 뒤에 ?를 붙입니다.1

interface Profile {
  nickname: string
  bio?: string
}

const a: Profile = {
  nickname: "ts초보",
}

const b: Profile = {
  nickname: "ts고수",
  bio: "타입은 친구다",
}

bio는 없어도 되지만, 있다면 반드시 문자열이어야 합니다.

다만 여기서 한 가지 주의할 점이 있습니다. 선택 속성은 읽을 때 undefined일 가능성이 있습니다.1

function printBio(profile: Profile) {
  console.log(profile.bio?.toUpperCase())
}

안전하게 다루려면 optional chaining(?.)이나 조건문을 쓰는 습관이 좋습니다.

readonly: 바꾸면 안 되는 값이라고 선언하기

어떤 값은 읽기만 하고 함부로 바꾸지 않기를 원할 때가 있습니다. 예를 들어 데이터베이스에서 받은 id 같은 값이 그렇습니다.

이럴 때 readonly를 붙입니다.1

interface User {
  readonly id: number
  name: string
}

const user: User = {
  id: 1,
  name: "하늘",
}

user.name = "구름" // 가능
// user.id = 2      // 오류

이 기능은 런타임에서 객체를 얼려 버리는 마법은 아닙니다. TypeScript가 코드를 작성하는 단계에서 실수를 막아 주는 장치라고 이해하면 됩니다.1

즉, readonly는 "절대 안 바뀐다"보다는 "바꾸지 말자는 약속을 타입으로 적는다"에 가깝습니다.

인덱스 시그니처: 키 이름을 미리 모를 때

가끔은 객체의 속성 이름을 사전에 전부 알 수 없을 때가 있습니다. 예를 들어 학생별 점수표, 언어별 번역문, 태그 모음 같은 경우입니다.

이럴 때는 인덱스 시그니처를 사용합니다.1

interface ScoreBoard {
  [studentName: string]: number
}

const scores: ScoreBoard = {
  민수: 95,
  지연: 88,
  도윤: 100,
}

이 뜻은 "문자열 키를 가지는 모든 속성의 값은 숫자여야 한다"입니다.

그래서 아래 코드는 오류입니다.

const scores: ScoreBoard = {
  민수: 95,
  지연: "88", // 오류
}

인덱스 시그니처는 편리하지만, 너무 넓게 열어 두면 타입이 느슨해질 수 있습니다. 정말로 키 이름을 모를 때만 사용하는 편이 좋습니다. 키를 미리 알 수 있다면 구체적인 속성 이름을 적는 쪽이 더 안전합니다.

객체 리터럴 검사: 오타를 잘 잡아주는 이유

TypeScript가 반가운 순간 중 하나는 속성 이름 오타를 꽤 잘 잡아준다는 점입니다.1

interface SquareConfig {
  color?: string
  width?: number
}

function createSquare(config: SquareConfig) {
  return config
}

createSquare({ colour: "red", width: 100 }) // 오류

여기서 colour는 color의 오타입니다. 자바스크립트였다면 조용히 지나갈 수도 있지만, TypeScript는 "그런 속성은 없는데요?" 하고 알려 줍니다.

이 검사는 특히 객체를 직접 쓰는 순간 강하게 동작합니다. 초보자에게는 아주 든든한 안전벨트입니다. 물론 나중에는 이 규칙의 예외와 우회 방법도 배우게 되겠지만, 지금은 "TypeScript가 오타를 잘 잡아준다"는 감각만 챙기면 충분합니다.

구조적 타이핑: 이름보다 모양이 중요하다

TypeScript는 타입 이름 그 자체보다 객체의 실제 모양을 더 중요하게 봅니다. 이것을 구조적 타이핑이라고 부릅니다.1

말이 무겁지만 뜻은 간단합니다. 필요한 속성을 다 갖추고 있으면, 이름이 달라도 사용할 수 있다는 뜻입니다.

interface User {
  name: string
  age: number
}

const author = {
  name: "한별",
  age: 29,
  job: "writer",
}

const user: User = author

author는 job이라는 추가 속성도 가지고 있지만, User가 요구하는 name과 age를 만족하므로 대입할 수 있습니다.

이 특성 덕분에 TypeScript는 꽤 유연합니다. 하지만 동시에 "필요한 모양이 무엇인지"를 잘 설계해야 실수가 줄어듭니다.

interface를 쓸까, type을 쓸까?

이쯤 되면 다시 처음 질문으로 돌아오게 됩니다.

언제 interface를 쓰고, 언제 type을 써야 할까요?

초보자 기준으로는 이렇게 시작하면 편합니다.

객체 중심이라면 interface

interface Comment {
  id: number
  content: string
  authorName: string
}

데이터 모델, 컴포넌트 props, 클래스가 따를 규칙처럼 "객체의 생김새"를 설명할 때 읽기 좋습니다. 나중에 클래스가 implements로 구현할 때도 자연스럽습니다. 이 부분은 다음 장에서 더 본격적으로 다룹니다.4

조합이 필요하면 type

type ID = number

type Point = {
  x: number
  y: number
}

객체 말고도 기본 타입, 함수 타입, 유니언 타입, 교차 타입처럼 더 넓은 범위를 다뤄야 할 때 유용합니다.3

가장 중요한 건 일관성

프로젝트 안에서 비슷한 역할의 타입을 어떤 파일은 interface, 어떤 파일은 type으로 뒤섞어 쓰면 읽는 사람이 자꾸 멈칫하게 됩니다. "왜 여기만 다르지?"라는 쓸데없는 궁금증이 생기기 때문입니다.

그래서 팀 규칙을 정하고 일관되게 가는 것이 실제로는 가장 실용적입니다.

실전 예시: 게시글 데이터를 설계해 보자

이제 자주 만나는 데이터를 하나 설계해 봅시다. 블로그 게시글입니다.

interface Author {
  id: number
  name: string
}

interface Post {
  id: number
  title: string
  content: string
  author: Author
  publishedAt?: string
  readonly slug: string
}

이 타입에는 지금까지 배운 요소가 다 들어 있습니다.

  • author는 또 다른 객체입니다

  • publishedAt은 선택 속성입니다

  • slug는 읽기 전용입니다

이제 게시글 객체를 만들면 TypeScript가 구조를 검사해 줍니다.

const post: Post = {
  id: 1,
  title: "TypeScript 입문기",
  content: "오늘은 객체 타입을 배웠다.",
  author: {
    id: 100,
    name: "하늘",
  },
  slug: "typescript-intro",
}

잘 설계된 객체 타입은 단지 오류를 줄이는 데서 끝나지 않습니다. 자동 완성도 좋아지고, 함수에 무엇을 넘겨야 하는지도 분명해지며, 나중에 코드를 읽을 때도 머릿속이 훨씬 덜 복잡합니다.

자주 하는 실수 세 가지

1. object면 다 되는 줄 아는 실수

object는 너무 넓습니다. 속성 접근이 필요한 객체라면 구체적인 객체 타입을 쓰세요.

// 아쉬운 방식
let user: object

// 좋은 방식
let user2: { name: string; age: number }

2. 선택 속성을 필수처럼 쓰는 실수

interface UserProfile {
  nickname: string
  imageUrl?: string
}

function printImage(profile: UserProfile) {
  // console.log(profile.imageUrl.toUpperCase()) // 위험
  console.log(profile.imageUrl?.toUpperCase())
}

선택 속성은 없을 수 있습니다. 항상 그 가능성을 염두에 두세요.

3. 타입 이름을 너무 애매하게 짓는 실수

type Data = {
  value: string
}

Data, Info, Item 같은 이름은 처음엔 편해 보여도 나중에는 다 비슷해 보여 헷갈립니다. UserProfile, ProductSummary, OrderItem처럼 무엇을 나타내는지 바로 보이는 이름이 좋습니다.

이번 장의 핵심 정리

이 장에서 꼭 기억할 것은 다음입니다.

  • object는 너무 넓어서 실제 객체 설계에는 부족한 경우가 많다

  • 객체의 속성과 타입을 구체적으로 적어야 안전하게 다룰 수 있다

  • interface와 type alias는 둘 다 객체 타입에 이름을 붙일 수 있다

  • interface는 객체 계약을 표현하고 확장할 때 읽기 좋고, 선언 병합도 가능하다

  • type은 객체뿐 아니라 기본 타입, 조합 타입까지 더 유연하게 다룰 수 있다

  • 선택 속성, 읽기 전용 속성, 인덱스 시그니처는 현실의 데이터를 모델링할 때 아주 자주 쓴다

결국 중요한 건 문법을 외우는 것이 아니라, 데이터의 모양을 정확히 생각하는 습관입니다.

자바스크립트에서는 객체를 그냥 만들고 나중에 고민하는 일이 많았다면, TypeScript에서는 한 발 앞서 이렇게 묻게 됩니다.

"이 데이터는 어떤 모습이어야 하지?"

바로 그 질문을 시작하는 순간, 여러분은 이미 TypeScript답게 사고하기 시작한 것입니다.

참고

1TypeScript Handbook - Object Types

2TypeScript Object Type

3Interfaces vs Types in TypeScript - Stack Overflow

4TypeScript Playground Example - Types vs Interfaces

책 목록

한 번에 이해되는 TypeScript: 초보자를 위한 가장 쉬운 타입스크립트 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기