TypeScript: readonly 키워드와 Readonly 유틸리티 타입 정밀 분석

TypeScript readonly 키워드와 Readonly 유틸리티 타입 가이드
3초 요약: 이렇게 고치세요
  • 원인: 자바스크립트 const는 재할당만 막을 뿐 객체 속성 수정은 못 막아서, 여러 곳에서 참조를 공유하는 상태가 의도치 않게 바뀔 수 있음
  • 해결: 속성 하나만 고정하려면 readonly / 객체 전체는 Readonly<T> / 배열의 push·sort 같은 변형 메소드까지 막으려면 ReadonlyArray<T>
  • 주의: ReadonlyArray를 정렬해야 하면 원본을 건드리지 말고 [...arr].sort()처럼 복제본을 만들어야 함
type FrozenUser = Readonly<UserProfile>; // 모든 속성 읽기 전용으로 동결
↓ 왜 이런 에러가 나는지 원리 보기

여러 컴포넌트가 같은 상태 객체를 참조하는 구조에서는, 의도치 않은 곳에서 값이 바뀌어 버그로 이어지는 경우가 있습니다. TypeScript의 readonly 키워드와 Readonly<T> 유틸리티 타입을 쓰면 이런 실수를 컴파일 단계에서 미리 막을 수 있습니다. 각각의 쓰임새와 차이를 정리합니다.


자바스크립트 객체 가변성 문제와 readonly의 차이점

자바스크립트의 const 변수 선언은 엄밀히 말하면 '값의 재할당'만 막을 뿐, 객체 내부의 속성을 수정하는 것(const obj = {}; obj.name = 'kim';)은 전혀 제한하지 못합니다. 런타임 가변성(Mutability)으로 인해 여러 모듈이 공유하는 상태 데이터가 나도 모르게 수정되어 오동작하는 고질병이 발생합니다.

타입스크립트는 이러한 구조적 취약점을 보완하기 위해 readonly 제어자를 제공합니다. readonly가 선언된 인터페이스나 타입 객체는 한 번 초기화되면 값을 재정의(Mutate)할 수 없도록 강제 컴파일 가드를 가동합니다. 한 걸음 더 나아가 제네릭 기반의 내장 유틸리티인 Readonly<Type>은 기존에 선언된 객체 구조의 모든 하위 프로퍼티를 읽기 전용 상태로 일괄 전환시켜 줍니다. 이러한 컴파일 수준의 안전장치는 여러 개발자가 함께 작업하는 코드에서 특정 상태를 실수로 수정하는 것을 사전에 차단해 줍니다.

readonly 속성의 런타임 제어 메커니즘

일반 자바스크립트 객체 const dbConfig = { port: 3000 } -> dbConfig.port = 8080 -> 런타임에 값 변조 허용 -> 연결 실패 버그 발생.
readonly 적용 객체 interface DB { readonly port: number; } -> 값 수정 감지 시 컴파일러 오류 즉시 반환 -> 사전에 배포 중단 방지.

실무에서 데이터 불변성을 제어하는 노하우

서버에서 받아온 원본 데이터를 프론트엔드 상태관리(Redux/Zustand 등) 라이브러리에 그대로 바인딩해 화면에 노출하는 구조에서는, 하위 렌더 컴포넌트 중 하나가 원본 배열을 실수로 정렬(Array.prototype.sort())하거나 값을 임의로 조작해버리는 사이드 이펙트 버그가 자주 발생합니다. 특히 여러 컴포넌트가 같은 참조를 공유하는 구조일수록 이런 문제는 발견하기도 어렵고 원인 추적도 까다롭습니다.

이런 상황에서 유효한 대응 방법은 원본 데이터 수신 인터페이스에 readonly 필터링을 적용하고, 특히 배열 형태의 데이터는 ReadonlyArray<Type> 타입으로 선언해 두는 것입니다. 자바스크립트 배열 메소드 중 push, pop, sort, reverse 등 원본을 훼손하는 파괴적 메소드가 사용되는 순간 타입스크립트 컴파일 단계에서 바로 에러로 잡히기 때문에, 배포 전에 문제를 원천적으로 걸러낼 수 있습니다.

전역 스토어에 담긴 목록을 화면 컴포넌트 하나가 sort()로 정렬해서 보여주다가, 다른 화면에서 그 스토어를 참조할 때 원래 순서가 아니라 정렬된 순서로 나오는 버그를 겪은 적이 있습니다. 원인을 찾는 데 한참 걸렸는데, 스토어 타입에 ReadonlyArray를 걸어두니 sort() 호출 자체가 컴파일 에러로 바로 드러나서 비슷한 실수를 미리 막을 수 있었습니다.


실전 활용: readonly 및 Readonly<T> 구현 코드

인터페이스 속성과 전체 객체 제어, 그리고 읽기 전용 배열을 제어하는 타입스크립트 실전 코드 예시입니다.

interface UserProfile {
  id: number;
  name: string;
  role: string;
}

// 1. 개별 프로퍼티에 readonly 선언
interface ImmutableUser {
  readonly id: number; // 변경 불가
  name: string;        // 변경 가능
}

// 2. Readonly<T> 유틸리티 타입을 통한 전체 프로퍼티의 읽기전용 일괄 동결
type FrozenUser = Readonly<UserProfile>;


// 실무 활용 및 테스트 예제

function processUser() {
  const userA: ImmutableUser = { id: 1004, name: "홍길동" };

  // 에러: Cannot assign to 'id' because it is a read-only property.
  userA.id = 7777;
  userA.name = "이순신"; // OK (일반 속성이므로 변경 가능)

  const frozenUser: FrozenUser = {
    id: 2000,
    name: "강감찬",
    role: "admin"
  };

  // 에러: Readonly 유틸리티 타입이 적용되어 전 필드 수정 불가
  // Cannot assign to 'name' because it is a read-only property.
  frozenUser.name = "을지문덕";

  // 3. ReadonlyArray를 활용한 파괴적 배열 메소드 차단
  const originScores: ReadonlyArray<number> = [90, 85, 100];

  // 에러: Property 'push' does not exist on type 'readonly number[]'.
  originScores.push(50);

  // 에러: Property 'sort' does not exist on type 'readonly number[]'.
  originScores.sort();

  // 안전한 데이터 복제 및 비파괴적 배열 연산
  const copiedScores = [...originScores].sort(); // 복제 후 정렬은 통과
}

해결 코드의 라인별 동작 해설

위 코드에서 첫 번째 ImmutableUser 인터페이스는 개발자가 인지해야 할 주요 식별자인 idreadonly 접두사를 걸어두었기 때문에, userA.id = 7777과 같은 재할당 연산 시도를 컴파일 타임에 즉각 적발합니다. 두 번째 FrozenUser는 타입스크립트에 내장된 Readonly<UserProfile> 제네릭이 가동하여 내부의 모든 속성을 readonly id: number; readonly name: string; readonly role: string; 형식으로 딥 매핑합니다.

가장 유용한 실전 패턴은 ReadonlyArray<number> 선언 부분입니다. 자바스크립트의 기본 배열은 참조 타입이라 sort()를 돌리면 원본 배열의 순서가 통째로 바뀌게 됩니다. 이로 인해 메모리를 공유하는 다른 모듈에 치명적인 데이터 왜곡 버그가 생기는데, ReadonlyArray는 원본 변환 메소드를 아예 차단함으로써 개발자가 반드시 스프레드 연산자([...originScores])를 사용하여 가공용 복제 배열을 만들어서 우회 개발하도록 코딩 패턴을 강제해 줍니다.


요약: readonly vs Readonly<T> vs ReadonlyArray

readonly 제어자는 인터페이스나 클래스의 개별 속성 단위로 변경을 막아 식별자나 생성일처럼 고정되어야 할 값을 보호하는 데 적합합니다. Readonly<Type>은 기존 타입 구조 전체를 한 번에 읽기 전용으로 동결하므로 API 응답 데이터처럼 통째로 받아온 객체를 다룰 때 유용합니다. ReadonlyArray<Type>은 push, sort 등 배열 원본을 변형하는 메소드 자체를 타입 레벨에서 차단해, 여러 곳에서 참조를 공유하는 목록 데이터를 보호하는 용도로 주로 쓰입니다.

추가 추천 자료 및 공식 레퍼런스

TypeScript의 내장 유틸리티 타입 작동 사양은 아래 공식 문서에서 확인할 수 있습니다.

호마다의 웹개발 팁

배열을 다룰 때는 ReadonlyArray로 push나 sort 같은 원본 변형 메소드 자체를 막아두면, 여러 곳에서 같은 배열을 참조하다 생기는 사이드 이펙트를 줄일 수 있습니다. 참조 타입과 힙 메모리 관련 기초가 헷갈린다면 [호마다의 IT 개발 입문 블로그] 도 참고해보세요.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법