
타입 에러가 알려준 착각과 오해
타입 에러가 떴을 때, 혹시 내가 뭔가 잘못 이해하고 있는 건 아닐까 생각해 본 적 있으신가요?
리팩토링을 하다가 기능상으로는 문제없지만, 묘하게 거슬리는 코드를 마주했습니다. Angular 컴포넌트의 @Input 부분이었는데, 부모 컴포넌트가 데이터를 넘겨주는 방식이 아주 '친절하게' 작성되어 있었습니다.
코드는 대략 이런 형태였습니다.
private _item: Item;
// 데이터를 받을 때 (Setter)
@Input()
set data(value: Item | Item[]) {
// 배열이 들어오면 첫 번째 것만 저장하고, 아니면 그대로 저장
this._item = Array.isArray(value) ? value[0] : value;
}
// 데이터를 꺼낼 때 (Getter)
get data(): Item | Item[] {
return this._item;
}
작성자의 의도는 알겠습니다. 외부에서 배열을 던지든 단일 객체를 던지든, 내부에서 알아서 '단일 객체'로 정리해 주겠다는 거죠.
사실 개인적으로는 이런 식의 '무엇이든 받아주는' 코드를 선호하지 않습니다. 데이터의 흐름을 추적하기 어렵게 만들고, 컴포넌트의 경계를 모호하게 만드니까요. 하지만 일단 그 부분은 차치하고, 제 눈에 가장 먼저 들어온 건 타입의 불일치였습니다.
타입이 현실을 반영하지 못하고 있다
데이터를 꺼내 쓰는 get data()의 반환 타입을 보세요.
Item | Item[]
Setter 로직을 보면 내부에 저장되는 _item은 무조건 단일 객체(Item)로 정리됩니다. 즉, 이 컴포넌트 내부에서 data는 절대 배열일 수 없습니다.
그런데 정작 이 변수를 외부로 노출할 때는 여전히 "배열일 수도 있다"는 불확실성을 남겨두고 있습니다. 이미 깔끔하게 정리된 데이터인데, 굳이 사용하는 쪽에서 Array.isArray() 같은 체크를 또 하게 만드는 건 비효율적이죠.
그래서 "내부 데이터가 확실하니, 타입도 솔직하게 고쳐주자"라고 생각하고 Getter의 타입을 수정했습니다.
// 수정 시도: 반환 타입을 실제 값인 Item으로 고정
get data(): Item {
return this._item;
}
하지만 수정하자마자 TypeScript는 가차 없이 빨간 줄을 그었습니다.
"Getter와 Setter의 타입은 같아야 합니다."
잠깐, 내가 뭘 오해한 걸까?
단순히 생각하면 좀 억울합니다. "입력은 유연하게 받되, 출력은 깔끔하게 정리해서 내보내겠다"는 건데, TypeScript는 왜 굳이 입출력 타입이 똑같아야 한다고 강제하는 걸까요?
잠시 모니터를 바라보다 깨달았습니다. 이건 TypeScript가 융통성이 없는 게 아니라, 제가 변수의 '역할'을 혼동하고 있다는 신호였습니다.
우리가 data라는 하나의 속성(Property)을 사용할 때는 상식적인 기대가 있습니다.
"여기에 값을 넣고, 다시 읽으면 넣은 그 값이 나와야 한다."
그런데 지금의 data는 어떨까요?
component.data = [item1, item2]; // 배열을 넣었는데
console.log(component.data); // item1만 나온다?
넣은 값과 꺼낸 값이 다릅니다. 겉으로는 하나의 속성처럼 보이지만, Setter는 입력을 변환하는 역할을, Getter는 내부 상태를 반환하는 역할을 각각 수행하고 있었습니다. 서로 다른 두 가지 책임을 data라는 이름 하나에 억지로 묶어놓은 셈이죠.
타입 시스템 입장에서는 "들어가는 모양과 나오는 모양이 왜 다르냐"고 지적할 수밖에 없었던 것입니다. 애초에 '배열도 받고 객체도 받는' 모호한 입력 구조가 낳은 부작용이기도 했습니다.
역할 분리로 해결하기
결국 해결책은 타입을 억지로 맞추는 게 아니라, 역할을 분리하는 것이었습니다. '입력받는 통로'와 '실제 사용하는 데이터'의 이름을 나누기로 했습니다.
private _item!: Item;
// 1. 입력 전용 (이름을 inputData로 분리하여 역할 명시)
@Input()
set inputData(value: Item | Item[]) {
this._item = Array.isArray(value) ? value[0] : value;
}
// 2. 사용 전용 (이름은 data, 타입은 명확하게 Item)
get data(): Item {
return this._item;
}
이렇게 inputData와 data로 이름을 나누자 모든 게 자연스러워졌습니다.
inputData는 외부에서 값을 주입받는 용도임을 이름에서부터 드러냅니다. (여전히 입력 타입이 섞여 있는 건 마음에 안 들지만, 적어도 내부 로직과는 격리시켰습니다.)data는 내부에서 사용하는 용도입니다. 이제 타입이Item으로 명확해졌고, TypeScript도 에러를 내지 않습니다. 서로 다른 변수니까요.
마무리하며
처음에는 TypeScript의 "Getter/Setter 타입 일치" 제약이 단순한 문법적 고집처럼 느껴졌습니다. 하지만 결과적으로 그 제약 덕분에, 하나의 변수가 너무 많은 책임을 지고 있다는 구조적 문제를 발견할 수 있었습니다.
참고로 TypeScript 4.3부터는 Getter와 Setter의 타입이 달라도 허용됩니다. 하지만 그렇다고 해서 이 글의 교훈이 무의미해지는 건 아닙니다. 문법적으로 허용된다고 해서 좋은 구조인 건 아니니까요. 오히려 "허용되더라도 분리하는 게 맞다"는 판단을 스스로 내릴 수 있어야 합니다.
타입 에러가 떴을 때 any나 as로 타입을 뭉개고 넘어가기보다, "왜 타입 시스템이 이걸 거부할까?"를 고민해 보면 의외로 역할 분리의 힌트를 얻을 때가 많습니다. 코드가 명확하게 역할을 나누고 있다면, 타입은 자연스럽게 따라오기 마련이니까요.
결국 타입 에러는 버그가 아니라, 내가 뭔가 착각하고 있진 않은지 돌아보게 하는 질문이라고 생각합니다.