[Effective Typescript - 아이템 17] readonly: 의도치 않은 값의 변경으로 인해 생기는 오류 방지하기

Song·2021년 11월 23일
1

readonly 란

  • 값의 속성을 읽기 전용으로 설정해주는 Typescript의 타입시스템 기능이다.
  • 함수가 매개변수로 받는 값을 변경없이 그대로 사용해야할 때 적합하며
  • 외부 클래스나 함수에서도 호출이 가능하지만 값의 변경은 불가능하므로 내부에서 미리 값을 초기화 해줘야한다.

readonly가 필요한 이유

사실 readonly를 사용하지 않더라도 JS는 암묵적으로 매개변수를 변경하지 않는다고 가정한다.
하지만 이러한 방법은 항상 명확하지는 않기 때문에 readonly를 명시적으로 선언하는 것이
컴파일러와 코드를 읽는 사람에게 좋다
.

삼각수 예제

/*
   1️⃣ . `printTriangles` 함수에서는 값을 1씩 증가시키며 `numsArr` 배열에 값을 추가하고
   2️⃣ . `arraySum` 함수를 통해 합쳐진 값을 3️⃣ 과 같이 콘솔를 통해 출력하려고한다.
*/

// printTriangles
function printTriangles(n: number) {
  const numsArr = [];
  for (let i = 0; i < n; i++) { // 1️⃣
    numsArr.push(i); 
    console.log(arraySum(numsArr)); // 3️⃣ 
  }
}

// arraySum
function arraySum(paramsArr: number[]) { // 🙈 readonly 없이 일반 매개변수가 넘어왔다.
  let sum = 0,
  let num: number;
  while ((num = paramsArr.pop()) !== undefined) { // 🙈 .pop()을 통해 배열의 값을 변경한다.
    sum += num; // 2️⃣
  }
  return sum;
}

위 코드를 실행시킨 결과는 (듀듕)

우리가 기대했던 배열의 합 (1, 1+2, 1+2+3..)이 아닌 아래와 같이 변수 i 가 증가된 값만 나온다.

우리가 예상한 결과가 아닌 이유

자바스크립트에서는 매개변수로 받은 값이더라도 .pop(), .push()배열의 속성을 변경하는 메소드를 사용하면 배열을 값을 변경할 수 있는데 이러한 기능이 타입스크립트에도 적용되었기 때문이다.

arraySum 함수에서도 매개변수로 받은 배열의 값이 .pop()으로 인해 변경되며 numsArr은 빈 배열로 초기화되는 상황이 발생한 것이다.

이 때는 readonly 접근 제어자를 추가해서 매개변수의 타입을 변경해주면되는데, 아래와 같이 제약 사항이 발생하는 것을 볼 수 있다 😩

// arraySum
function arraySum(readonly paramsArr: number[]) {
  //...
  paramsArr.pop()	// .pop() 오류 발생!
}

readonly[ ]와 [ ]는 다르다구

일반 배열에서 사용되는 메소드가 오류나는 이유는 readonly []와 일반 []와 구분되는 특징이 있기 때문이다.

  • .pop(), .push() 등 배열 속성을 변경하는 메서드의 호출이 안되지만
    .concat() 같이 원본을 수정하지 않고 새 배열을 반환하는 메서드는 사용이 가능하다.
  • so, 배열의 요소나 length는 읽을 수 있지만 변경은 할 수 없다.
  • 일반 배열을 readonly에 할당할 수 있지만 그 반대는 불가능하다.
const a: number[] = [1,2,3] // 성공
const b: readonly number[] = a // 성공
const c: number[] = b // 실패

readonly, 작동은 어떻게 하는걸까?

readonly로 매개변수를 선언하면 아래와 같이 작동한다.
1. 타입스크립트가 함수 내에서 매개변수 값이 변경되는 지 아닌 지 검사한다.
2. 호출하는 쪽에서는 함수가 매개변수를 변경하는 않겠다는 약속을 받는다.

const와 readonly의 차이 😎

const와 readonly는 초기 때 할당된 값을 변경할 수 없다는 공통점이 있지만 다른점은 아래와 같다.

Const

  • 변수 참조를 위한 것이다.
  • 변수에 다른 값을 할당할 수 없다.
const ex = '123';
ex = '456' // ⛔️ 변경 불가

readonly

  • 속성을 위한 것이다.
type readonlyA = {
  readonly barA: string
};

const x: readonlyA = {barA: 'baz'}; 
x.barA = 'quux'; // ⛔️ 변경 불가
  • 위 예제처럼 속성 barA는 변경이 불가능하지만 아래 예제처럼barB에 할당된 object값은 변경이 가능하다.
type readonlyB = {
  readonly barB: { baz: string }
};

const y: readonlyB = {barB: {baz: 'quux'}};
y.barB.baz = 'zebranky'; // 👌 변경 가능

barB.baz에 새로운 값이 할당될 수 있는 이유는 readonly얕게 동작하는 것과 관련이 있다.
barB가 참조 (refer)하고 있는 값 자체는 변경될 수 없지만 얘가 readonly라고 그 안에 있는 속성들이 모두 동일한 접근 제어자를 가지고 있는 것이 아니다.


비슷한 예로 배열에 readonly가 존재한다고 배열 자체가 readonly가 되지 않는다.

const dates: readonly Date[] = [new Date()];
dates.push(new Date()); // ⛔️ .push()가 존재안한다는 오류 발생
dates[0].setFullYear(2022); //👌 하지만 다른 위치를 참조할 시 변경 이 가능하다.

이러한 경우는 속성이 아닌 객체에 사용되는 Readonly 제네릭에도 해당된다.

interface Outer {
  inner: {
    x: number;
  }
}
const o: ReadOnly<Outer> = { inner: { x:0 }};
o.inner = { x: 1 }; // ⛔️ 변경 불가
o.inner.x = 1; // 👌 o.inner.x는 readonly가 아니므로 변경 가능

결론

  • 앞서 말했듯 readonly가 없어도 값이 변경되는 않는 경우도 있다.
    하지만, 예외사항은 항상 존재한다. 그렇기 때문에 readonly를 사용해서 의도치 않은 오류를
    미연에 방지하고 개발자 관점에서도 읽기 쉬운 코드를 만들어 낼 수 있다는 것을 항상 기억하자.

  • 현재 readonly의 deep type를 별도로 지원하지 않으나 제네릭을 통해 구현 가능하다.
    하지만, 복잡해질 수 있으니 이미 존재하는 라이브러리 (예. ts-essential에 있는 DeepReadonly 제네릭)을 사용할 수 있다.

참고

profile
Learn From Yesterday, Live Today, Hope for Tomorrow

0개의 댓글