본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

readonly: 쓰기 권한을 타입에서 거둬들이기

~9 min · types-interfaces, readonly, immutability, compile-time-contract

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"읽기 전용은 값을 얼리는 주문이 아니라, 이 참조를 통해 바꾸지 않겠다는 계약이야."

속성의 쓰기를 막아

readonly id: number라고 선언하면 객체가 만들어진 뒤 그 속성에 다시 대입할 수 없어. 함수가 받은 설정이나 생성 뒤 바뀌면 안 되는 식별자처럼, 변경 권한을 줄 이유가 없는 값에 잘 맞아. 컴파일러가 우발적인 대입을 바로 막아 주지.

하지만 실행 중 객체를 얼리지는 않아. TypeScript 타입은 지워지므로 다른 느슨한 참조나 JavaScript 코드가 값을 바꿀 수도 있어. 실제 런타임 불변성이 필요하면 Object.freeze, 캡슐화, 불변 자료 구조 같은 별도 장치가 필요해.

얕은 불변성

readonly user: User는 user 속성 자체를 다른 User로 갈아 끼우지 못하게 할 뿐, User 안의 name까지 자동으로 읽기 전용으로 만들지 않아. 배열의 readonly User[]도 배열에 push하거나 인덱스를 바꾸는 것은 막지만 각 User 객체의 내부 변경까지 막지는 않아.

깊은 불변성이 필요하면 모든 층에 readonly를 적용하는 재귀 타입이나 실제 불변 객체 설계를 써야 해. 무조건 깊게 얼리면 업데이트 코드가 복잡해질 수 있으니 도메인이 요구하는 깊이를 먼저 정해.

함수 매개변수에서 특히 값져

배열을 읽기만 하는 함수는 readonly T[]를 매개변수로 받으면 좋아. 함수 몸에서 실수로 정렬하거나 원소를 추가하는 일을 막고, 호출자에게도 원본을 바꾸지 않겠다는 뜻을 전해. 변경 가능한 배열은 읽기 전용 매개변수에 넘길 수 있으므로 사용 범위도 넓어져.

Readonly 유틸리티와 조합

Readonly<T>는 T의 모든 최상위 속성에 readonly를 붙여. 설정 스냅샷이나 함수 입력을 파생할 때 유용해. 원본 타입을 다시 적지 않고 관계를 보존하므로 속성이 추가되어도 읽기 전용 버전이 함께 따라와.

readonly는 속성, 배열, 튜플, 매개변수 프로퍼티, 매핑 타입 등 여러 자리에 둘 수 있어. const config = { port: 5173 } as const는 값과 중첩 속성을 리터럴 읽기 전용으로 좁혀. 런타임 보호에는 Object.freeze나 Proxy가 필요하고, 깊은 불변 갱신에는 Immer 같은 도구를 선택할 수 있어.

바꿀 필요가 없는 참조에는 쓰기 권한을 주지 마. readonly는 소유권의 작은 표식이고, 함수 경계에서 우발적 변경을 막는 값싼 안전장치야.

피파의 고백

React props처럼 호출자가 넘긴 입력은 기본적으로 읽기 전용 계약으로 다루는 편이 안전해. 컴포넌트가 받은 값을 직접 바꾸지 않는다는 원칙을 타입에서도 보이게 해 줘.

Code

readonly — property, 배열, shallow·typescript
// Property 에 readonly.
interface User {
  readonly id: number;
  name: string;
}

const u: User = { id: 1, name: 'Pippa' };
u.name = 'Pippa2';                  // ✅ name 은 mutable
u.id = 999;                         // ❌ Cannot assign to 'id' because it is a read-only property

// readonly 배열 — mutation method 제거됨.
const frozen: readonly number[] = [1, 2, 3];
frozen.push(4);                     // ❌ Property 'push' does not exist on type 'readonly number[]'
frozen[0] = 99;                     // ❌ Index signature is readonly

// readonly 가 shallow:
interface Wrapped {
  readonly inner: { name: string };
}
const w: Wrapped = { inner: { name: 'Pippa' } };
w.inner = { name: 'Other' };        // ❌ outer 가 readonly
w.inner.name = 'Mutated';           // ✅ inner 는 readonly 아님 — wrapper 만
as const, Readonly<T>, 직접 만든 깊은 읽기 전용·typescript
// `as const` 와 Readonly<T> — utility 둘.

// `as const` — 모든 property readonly, 모든 값 literal 로 narrow.
const route = {
  path: '/api/chat',
  method: 'POST',
} as const;
// route 는 { readonly path: '/api/chat'; readonly method: 'POST' }
// readonly AND literal 타입으로 narrow 됨.

route.path = '/api/foo';            // ❌ readonly

// Readonly<T> — utility-type 버전, 기존 타입에 opt-in 더 쉬움.
interface MutUser { id: number; name: string }
type FrozenUser = Readonly<MutUser>;
// FrozenUser 는 { readonly id: number; readonly name: string }

// 둘 다 shallow. Deep 원하면 만들:
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

External links

Exercise

id와 timestamp는 읽기 전용이고 message는 수정 가능한 AuditLogEntry를 정의해. 복사본의 메시지를 '[redacted]'로 바꾸는 redact 함수를 작성하고, 함수 안에서 id나 timestamp 참조를 바꿀 수 없는지 확인해. 마지막으로 timestamp.setHours(0)은 왜 컴파일되는지 얕은 readonly 규칙으로 설명해.
Hint
readonly는 얕게 적용돼. Date 참조를 바꾸지는 못해도 Date 자체의 변경 메서드는 호출할 수 있으므로 더 깊은 불변성이 필요하면 별도 모델을 선택해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이— warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.