iBetter Books
수정

선택적 속성과 읽기 전용

지금까지 인터페이스에 정의한 속성은 모두 필수였습니다. 하나라도 빠지면 TypeScript가 오류를 냈습니다. 그런데 현실에서는 꼭 필요한 정보와 있어도 되고 없어도 되는 정보가 함께 있습니다.

회원가입 양식을 생각해봅시다. 이름과 이메일은 반드시 입력해야 하지만, 전화번호나 생일은 선택 사항인 경우가 많습니다. TypeScript는 이런 현실을 그대로 표현할 수 있습니다.

선택적 속성 (?)

속성 이름 뒤에 ?를 붙이면 "있어도 되고 없어도 된다"는 뜻이 됩니다.

interface UserProfile {  name: string;          // 필수  email: string;         // 필수  phone?: string;        // 선택적 (있어도 되고 없어도 됨)  birthday?: Date;       // 선택적  bio?: string;          // 선택적}// 필수 속성만 채워도 됩니다const minimalUser: UserProfile = {  name: "이민준",  email: "[email protected]"};// 선택적 속성도 함께 채울 수 있습니다const fullUser: UserProfile = {  name: "박지수",  email: "[email protected]",  phone: "010-1234-5678",  bio: "TypeScript를 좋아합니다."};

phone은 없어도 오류가 나지 않습니다. ? 덕분에 TypeScript는 이 속성이 있을 수도, 없을 수도 있다는 걸 알고 있습니다.

선택적 속성 사용 시 주의점

선택적 속성은 값이 string 또는 undefined가 될 수 있습니다. TypeScript는 이 사실을 알기 때문에, 값을 그냥 사용하면 경고를 냅니다.

function sendSms(user: UserProfile): void {  console.log(user.phone.toUpperCase());  // 오류!  // 'user.phone'이 'undefined'일 수 있습니다}

phone이 없는 사용자에게 SMS를 보내려 하면 런타임 오류가 발생할 것입니다. TypeScript는 이 위험을 미리 알려줍니다.

해결 방법은 먼저 값이 있는지 확인하는 것입니다.

function sendSms(user: UserProfile): void {  if (user.phone) {    console.log(`${user.phone}으로 SMS를 보냅니다.`);  } else {    console.log("전화번호가 없어 SMS를 보낼 수 없습니다.");  }}

또는 옵셔널 체이닝(?.)을 써서 간결하게 처리할 수 있습니다.

function printPhone(user: UserProfile): void {  console.log(user.phone?.toUpperCase() ?? "전화번호 없음");}

user.phoneundefined이면 ?. 이후 접근을 건너뛰고 undefined를 반환합니다. ??는 앞의 값이 null 또는 undefined일 때 뒤의 기본값을 사용합니다.

읽기 전용 (readonly)

이번엔 반대 상황입니다. 한번 정해진 값이 절대 바뀌면 안 되는 경우가 있습니다.

데이터베이스 레코드의 id는 생성 후 변경하면 안 됩니다. 설정 파일의 서버 포트는 한 번 로드하면 수정하면 안 됩니다. readonly 키워드를 쓰면 TypeScript가 이 규칙을 강제합니다.

interface Config {  readonly host: string;  readonly port: number;  maxConnections: number;   // 이건 변경 가능}const config: Config = {  host: "localhost",  port: 3000,  maxConnections: 100};config.maxConnections = 200;   // 정상: 읽기 전용이 아닙니다config.port = 8080;            // 오류: 읽기 전용 속성이므로 'port'에 할당할 수 없습니다

readonly 속성에 값을 할당하려 하면 컴파일 오류가 발생합니다. 런타임 이전에 실수를 잡아줍니다.

읽기 전용 배열

배열에도 readonly를 적용할 수 있습니다.

interface TodoList {  readonly items: readonly string[];}const list: TodoList = {  items: ["공부하기", "운동하기"]};list.items.push("독서하기");   // 오류: 'readonly string[]'에 'push' 속성이 없습니다list.items = ["새 목록"];      // 오류: 읽기 전용 속성이므로 할당할 수 없습니다

readonly string[]은 배열 자체도, 배열 안의 요소를 변경하는 메서드(push, pop, splice 등)도 사용할 수 없게 막습니다. 읽는 것만 가능합니다.

Readonly 유틸리티 타입 맛보기

TypeScript는 편리한 내장 유틸리티 타입을 제공합니다. Readonly<T>는 타입 T의 모든 속성을 한 번에 readonly로 만들어줍니다.

interface User {  name: string;  age: number;  email: string;}type ReadonlyUser = Readonly<User>;// 위는 아래와 동일합니다// type ReadonlyUser = {//   readonly name: string;//   readonly age: number;//   readonly email: string;// }const user: ReadonlyUser = {  name: "이민준",  age: 22,  email: "[email protected]"};user.name = "김민준";   // 오류: 읽기 전용 속성입니다

일일이 readonly를 붙이는 대신, 기존 인터페이스에 Readonly<>를 감싸면 됩니다. 특히 함수에서 "이 객체를 읽기만 할 것이다"는 의도를 표현할 때 유용합니다.

function displayUser(user: Readonly<User>): void {  console.log(`${user.name} (${user.age}세)`);  // 이 함수 안에서 user의 속성을 변경하려 하면 오류가 납니다}

선택적 속성과 읽기 전용을 함께 쓰기

두 기능을 자연스럽게 결합할 수 있습니다.

interface Article {  readonly id: number;           // 읽기 전용 필수 속성  title: string;                 // 일반 필수 속성  content: string;               // 일반 필수 속성  readonly createdAt: Date;      // 읽기 전용 필수 속성  updatedAt?: Date;              // 선택적 속성 (수정된 적 없으면 없음)  tags?: string[];               // 선택적 속성}const article: Article = {  id: 1,  title: "TypeScript 입문",  content: "TypeScript는 JavaScript의 슈퍼셋입니다.",  createdAt: new Date("2024-01-01")};// 나중에 수정하면 updatedAt을 추가합니다article.updatedAt = new Date("2024-06-15");   // 정상: 선택적이지만 readonly가 아닙니다article.id = 2;                               // 오류: id는 readonly입니다

idcreatedAt은 생성 후 절대 바뀌면 안 되므로 readonly, updatedAt은 처음엔 없다가 수정이 발생하면 생기므로 선택적 속성으로 설계했습니다.

실전 예제 — 설정 객체 패턴

선택적 속성과 읽기 전용은 함수 설정 객체 패턴에서 특히 자주 쓰입니다.

interface FetchOptions {  readonly url: string;          // 필수, 변경 불가  method?: "GET" | "POST" | "PUT" | "DELETE";   // 선택적, 기본값 GET  headers?: Record<string, string>;             // 선택적  timeout?: number;              // 선택적, 기본값 5000}function fetchData(options: FetchOptions): void {  const method = options.method ?? "GET";  const timeout = options.timeout ?? 5000;  console.log(`${method} ${options.url} (타임아웃: ${timeout}ms)`);}// 최소한의 옵션만 전달fetchData({ url: "/api/users" });// 상세 옵션 지정fetchData({  url: "/api/posts",  method: "POST",  headers: { "Content-Type": "application/json" },  timeout: 10000});

url은 필수이고 변경할 수 없습니다. 나머지는 선택적이며 필요할 때만 전달합니다. 이 패턴은 TypeScript 코드 전반에서 매우 흔하게 볼 수 있습니다.


PART 03에서는 타입을 조합하고 정제하는 여러 도구를 배웠습니다. interface로 객체 구조를 설계하고, |로 여러 타입 중 하나를 허용하고, &로 타입을 합치고, ?readonly로 속성을 섬세하게 제어했습니다.

다음 PART 04에서는 함수에 타입을 입히는 방법을 집중적으로 다룹니다. 매개변수 타입, 반환 타입, 그리고 함수 자체를 타입으로 표현하는 방법을 배웁니다. 타입 시스템의 가장 중요한 활용 무대인 함수로 넘어가봅시다.