readonly 배열 타입인데 push()가 컴파일 에러 없이 실행된 이유
문제 발생
함수가 배열을 변경하지 않는다는 것을 타입으로 보장하려고 매개변수를 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[]처럼 타입 단언으로 타입 검사를 우회하면, 그 시점부터는 아무 제약 없이 원본 배열을 직접 변경할 수 있습니다 — 컴파일러는 "내가 그렇다고 말했으니 믿겠다"는 태도로 이후 검사를 하지 않습니다.
해결 방안
readonly는 "실수로 변경하는 것을 막는 도구"이지 "악의적이거나 의도적인 변경을 막는 보안 장치"가 아니라는 것을 이해해야 합니다 — 팀 내부 코드베이스에서 실수를 방지하는 용도로는 충분히 유용하지만, 신뢰할 수 없는 코드로부터 데이터를 보호하는 용도로는 쓸 수 없습니다.- 정말로 런타임에도 변경이 불가능하게 만들어야 한다면
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
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.