본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev26

readonly 배열 타입인데 push()가 컴파일 에러 없이 실행된 이유

  • #Engineering Note
  • #Type Safety
  • #TypeScript

문제 발생

함수가 배열을 변경하지 않는다는 것을 타입으로 보장하려고 매개변수를 readonly number[]로 선언했는데, 그 함수 내부의 다른 헬퍼 함수를 거쳐 결국 원본 배열이 변경되는 버그가 있었습니다.

function processData(data: readonly number[]) {
  data.push(1); // 이건 컴파일 에러 — push는 readonly에 없는 메서드

  const mutable = data as number[]; // 타입 단언으로 우회 가능
  mutable.push(1); // 컴파일 에러 없이 원본 배열이 실제로 바뀜
}

원인 분석

readonly number[](또는 ReadonlyArray<number>)는 타입 시스템 차원의 제약입니다 — TypeScript가 그 타입에 push, pop, splice 같은 변형 메서드 자체를 타입 정의에서 제외해서, data.push(...)를 직접 호출하면 "그런 메서드가 없다"는 컴파일 에러가 납니다.

하지만 이 보호는 컴파일 타임 검사일 뿐, 런타임에 실제로 배열을 불변으로 만들지는 않습니다. JavaScript 배열은 원래 가변 객체이고, TypeScript는 컴파일 후 완전히 지워지는(타입 정보가 런타임에 존재하지 않는) 시스템입니다. as number[]처럼 타입 단언으로 타입 검사를 우회하면, 그 시점부터는 아무 제약 없이 원본 배열을 직접 변경할 수 있습니다 — 컴파일러는 "내가 그렇다고 말했으니 믿겠다"는 태도로 이후 검사를 하지 않습니다.

해결 방안

  1. readonly는 "실수로 변경하는 것을 막는 도구"이지 "악의적이거나 의도적인 변경을 막는 보안 장치"가 아니라는 것을 이해해야 합니다 — 팀 내부 코드베이스에서 실수를 방지하는 용도로는 충분히 유용하지만, 신뢰할 수 없는 코드로부터 데이터를 보호하는 용도로는 쓸 수 없습니다.
  2. 정말로 런타임에도 변경이 불가능하게 만들어야 한다면 Object.freeze()를 함께 씁니다.
function processData(data: readonly number[]) {
  const frozen = Object.freeze([...data]);
  // frozen.push(1)은 런타임에도 실패함 (strict mode에서는 TypeError)
}

Object.freeze()는 런타임 보호이고 readonly는 컴파일 타임 보호입니다 — 둘은 서로 다른 계층에서 동작하며, 진짜 불변성이 필요하면 둘 다 필요할 수 있습니다.
3. 타입 단언(as)을 만나면 항상 "왜 이 단언이 필요한지, 정말 안전한지"를 의심하는 습관이 중요합니다 — readonly 같은 안전장치를 우회하는 가장 흔한 통로가 바로 as 단언입니다.
4. 함수형 스타일로 원본을 건드리지 않고 새 배열을 반환하는 패턴([...data, newItem], data.map(...))을 기본으로 삼으면, 애초에 readonly 우회를 시도할 필요 자체가 줄어듭니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.