.png)
자바스크립트 instanceof 안 쓰고 클래스 타입 판별하는 방법
클래스 타입 체크는 하고 싶지만 순환 참조 문제는 피하고 싶을 때
자바스크립트에서는 instanceof 연산자를 활용해서 객체의 클래스를 확인할 수 있습니다. 바로 이런 식이죠.
import { Dog } from "./dog";
import { Animal } from "./animal";
const dog = new Dog();
console.log(dog instanceof Animal) // ✅ Dog 클래스가 Animal 클래스를 상속한다면 true를 반환합니다.대부분 경우는 instanceof로 충분합니다. 하지만 instanceof를 쓰고 싶지 않을 때도 있습니다. 특히 순환참조 문제를 피하기 위해서입니다. instanceof 뒤에는 타입이 오지 못합니다. 클래스를 값으로서 불러와야 합니다. 그런데 클래스를 마구잡이로 불러오다 보면 순환 참조 문제가 생기기 쉽습니다.
instanceof를 쓰고 싶지 않을 때
이건 제가 실제로 겪었던 일입니다. 저는 세 가지 클래스를 가지고 있었습니다. InnerWall, Wall, Hanging 클래스입니다. InnerWall(가벽)은 Wall(벽)의 한 종류로 Wall을 상속합니다. Hanging은 벽에 걸리는 문이나 창문 같은 클래스의 부모 클래스입니다.
// inner_wall.ts
import { Wall } from "./wall";
class InnerWall extends Wall {}// wall.ts
import { Hanging } from "./hanging";
class Wall {
update() {
const hangings = this.scene.findChildrenOf(Hanging);
}
}// hanging.ts
import { InnerWall } from "./inner_wall";
class Hanging {
update() {
const innerWalls = this.scene.findChildrenOf(InnerWall);
}
}세 클래스 사이 의존관계를 잘 살펴보세요. Inner Wall > Wall > Hanging > InnerWall 순서로 순환합니다. InnerWall 클래스는 벽에 관한 기능을 상속하기 위해 Wall 클래스를 필요로 합니다. Wall 클래스는 자신에게 걸려 있는 요소들을 확인하기 위해 Hanging 클래스를 필요로 합니다. 그리고 Hanging 클래스는 벽마다 걸리는 방식을 달리 하기 위해서 InnerWall을 따로 불러옵니다.
하지만 이러면 결국 "Class extends value undefined is not a constructor or null"이라는 에러가 뜹니다. 순환참조 오류입니다. InnerWall이 상속해야 하는 Wall이 아직 정의되지 않았다는 뜻입니다. Wall을 정의하기 위해서는 InnerWall이 필요하기 때문이죠.
사실 가장 쉬운 해결책은 이런 식으로 코드를 쓰지 않는 것입니다. 더 섬세한 추상화가 필요하겠죠. 하지만 현실 세계에서는 이런 코드가 가끔 필요하기도 합니다. 추상화가 능사는 아닙니다. 생산성이 떨어질 수 있습니다. 설계도 구현도 번거롭기 때문입니다. 우리는 아름다운 코드를 위해 코드를 쓰는 것이 아닙니다. 제품을 만들기 위해 코드를 씁니다.
instanceof 뒤에는 타입이 오지 못한다
만약 import type을 사용할 수 있다면 아래처럼 코드를 고쳐 쓸 수 있을 겁니다.
// inner_wall.ts
import { Wall } from "./wall";
class InnerWall extends Wall {}// wall.ts
import type { Hanging } from "./hanging"; // 🗑️ 런타임에 제거됨
import { Entity } from "./entity";
class Wall extends Entity {
update() {
const hangings = this.scene.findChildrenOf(Hanging);
}
}// hanging.ts
import type { InnerWall } from "./inner_wall"; // 🗑️ 런타임에 제거됨
import { Entity } from "./entity";
class Hanging extends Entity {
update() {
const innerWalls = this.scene.findChildrenOf(InnerWall);
}
}이러면 wall.ts 파일에서 Hanging 클래스를 불러오는 부분과 hanging.ts 파일에서 InnerWall 클래스를 불러오는 부분은 런타임에 제거됩니다. 따라서 순환 참조 문제는 해결됩니다.
하지만 문제가 있습니다. 이번에는 "'Hanging' cannot be used as a value because it was imported using 'import type'" 오류가 뜰 겁니다. Hanging 클래스를 import type 구문을 이용해 타입으로 불러왔기 때문에 값으로서 사용할 수 없다는 이야기입니다. 왜일까요?
위 예시에서 findChildrenOf 함수는 매개변수로 받은 클래스를 instanceof 연산자로 비교해 동일한 타입의 객체를 되돌려주는 임의의 함수입니다.
class Scene {
function findChildrenOf<T>(t: { new(): T; }): T[] {
return this.scene.children.filter((child): child is T => child instanceof t)
}
}여기서 문제는 instanceof 연산자를 사용하기 위해서는 비교할 클래스 자체를 import 해야 한다는 점입니다. 반대로 말해서, instanceof 연산자 뒤에 오는 대상은 타입일 수 없습니다.
import { InnerWall } from "./inner_wall";
import type { Wall } from "./wall";
const innerWall = new InnerWall();
console.log(innerWall instanceof Wall) // ❌ 'Wall'을 값으로 사용할 수 없습니다.instanceof 연산자 뒤에 값이 필요한 이유는 단순합니다. 타입스크립트에서 타입은 런타임에 지워지기 때문입니다. 비교할 대상이 없어지는 것입니다. 만약 타입이 런타임에 존재한다고 해도 instanceof 내부 동작을 이해하면 이유는 더 명확합니다. instanceof가 실제로 비교하는 건 이렇습니다.
Animal.prototype === Object.getPrototypeOf(dog) 혹은 그 상위 prototype 체인즉 prototype을 가진 자바스크립트 함수 객체가 필요하다는 것이죠. 따라서 import type을 사용하는 방법만으로는 지금 문제를 해결할 수 없습니다.
Custom instanceof 함수 만들기
클래스를 값으로 사용하지 않고 타입 체크를 하는 방법은 없을까요? import type을 쓰면 순환 참조 문제는 해결되니까요.
여기서 한 번 아래와 같은 코드를 상상해 보는 겁니다.
// wall.ts
import type { Hanging } from "./hanging"; // 🗑️ 런타임에 제거됨
import { Entity } from "./entity";
class Wall extends Entity {
update() {
const hangings = this.scene.findChildrenOf<Hanging>("Hanging");
}
}어떤가요? 이 코드에 문법적 오류는 없습니다. 하지만 벌써부터 걱정이 듭니다. 왜냐하면 첫째로, "Hanging"이라는 문자열과 Hanging 클래스는 글자만 같을 뿐 서로 관련이 없습니다. 둘째, 그렇기 때문에 findChildrenOf 함수가 Hanging 클래스 타입을 반환할 수 있을지 의문입니다. 물론 지금처럼 명시적으로 제네릭을 부여할 수 있습니다. 그렇지만 위험은 남아있습니다. 매개변수로 넘기는 값이 문자열이기 때문에 자동완성 기능을 사용할 수도 없습니다.
바뀐 findChildrenOf 함수 구현부를 살펴봅시다.
// scene.ts
class Scene {
findChildrenOf<T>(name: string): T[] {
const isInstanceOf = (obj: any): obj is T => {
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (proto.constructor.name === name) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
};
return this.children.filter(isInstanceOf)
}
}자체적으로 instanceOf 함수를 구현했습니다. 그리고 생성자의 이름, 즉 런타임에서 확인할 수 있는 클래스 이름끼리 비교하도록 했습니다. 참고로 constructor.name은 클래스 이름을 반환합니다. 즉 Wall 클래스에서는 "Wall"을 반환하죠.
클래스 맵 활용하기
만약 매번 제네릭을 매칭시켜 넣어주기가 불편하고
const hangings = this.scene.findChildrenOf<Hanging>("Hanging")제네릭을 넣지 않으면서도 문자열만으로 IDE에서 타입 추론을 받고 싶다면
const hangings = this.scene.findChildrenOf("Hanging") // 제네릭을 넣지 않았지만 hangings: Hanging[]로 추론클래스 맵을 활용할 수 있습니다. as const 키워드와 함께 말이죠.
// class_map.ts
import { Wall } from "./wall";
import { InnerWall } from "./inner_wall";
import { Hanging } from "./hanging";
const CLASS_MAP = {
Wall,
InnerWall,
Hanging,
} as const
export type ClassMap = typeof CLASS_MAP;이 상수 클래스맵을 아까 findChildrenOf에서 활용하는 겁니다. ClassMap 타입을 import type 구문을 활용해 불러오세요. CLASS_MAP은 온갖 의존성을 갖고 있습니다. 타입 추론을 위해 IDE에서만 활용하고 런타임에 지워버리는 겁니다.
// scene.ts
import { type ClassMap } from "./class_map";
class Scene {
function findChildrenOf<T keyof ClassMap>(name: T): InstanceOf<ClassMap[T]>[] {
const isInstanceOf = (obj: any): obj is InstanceOf<ClassMap[T]> => {
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (proto.constructor.name === name) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
};
return this.children.filter(isInstanceOf)
}
}이러면 타입스크립트 환경에서 findChildrenOf 함수의 매개변수로 넣을 수 있는 값은 CLASS_MAP 객체의 키들로 제한됩니다. 클래스 이름이죠. 자동완성 기능 덕분에 매개변수 자리에서 모든 클래스 이름이 뜹니다. 제네릭 T는 클래스 이름과 같게 됩니다. 그리고 InstanceOf<ClassMap[T]> 타입에 의해 리턴 타입은 클래스가 됩니다.
이제 타입 추론을 받을 수 있게 된 겁니다. 여전히 클래스를 만들 때마다 CLASS_MAP을 업데이트 해주어야 하는 번거로움이 있기는 하지만 말입니다.
난독화 문제 해결하기
한 가지 문제가 남아 있습니다. 빌드 설정에 따라 코드가 난독화되면 constructor.name이 바뀔 수 있습니다. Wall 클래스라 할지라도 난독화 이후에는 전혀 다른 이름이 됩니다. 따라서 다른 비교 방식을 찾아야 합니다.
가장 정확한 방법은 생성자를 직접 비교하는 것입니다. 이런와 같은 코드는 어떨까요?
// scene.ts
import { type ClassMap, CLASS_MAP } from "./class_map";
class Scene {
function findChildrenOf<T keyof ClassMap>(name: T): InstanceOf<ClassMap[T]>[] {
const isInstanceOf = (obj: any): obj is InstanceOf<ClassMap[T]> => {
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (proto.constructor === CLASS_MAP[name]) { // 생성자끼리 비교
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
};
return this.children.filter(isInstanceOf)
}
}비교는 가능하지만 다시금 순환참조 문제를 불러일으킵니다. 키를 이용해 생성자라는 '값'을 가져오기 위해서는 CLASS_MAP이라는 '값'이 필요하게 되죠. 모든 의존성을 다시 끌고올 겁니다.
여기에는 여러 방법이 있겠습니다. 그러나 저는 isInstanceOf를 scene.ts 파일이 아니라 class_map.ts 파일에서 구현하는 방법을 사용했습니다.
// scene.ts
import { type ClassMap, CLASS_MAP } from "./class_map";
class Scene {
isInstanceOf<T>(obj: never, name: string): obj is T {
throw new Error("isInstanceOf method is not implemented.");
}
findChildrenOf<T>(name: string): T[] {
return this.children.filter((child) => isInstanceOf(child, name))
}
}// class_map.ts
import { Wall } from "./wall";
import { InnerWall } from "./inner_wall";
import { Hanging } from "./hanging";
import { Scene } from "./scene";
const CLASS_MAP = {
Wall,
InnerWall,
Hanging,
} as const
export type ClassMap = typeof CLASS_MAP;
Scene.prototype.isInstanceOf = function<T>(obj: never, name: string): obj is T {
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (proto.constructor === CLASS_MAP[name]) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
}이렇게 하면 난독화 문제도 해결할 수 있겠습니다. 대신 isInstanceOf 함수를 호출하기 전에 CLASS_MAP을 불러오기는 해야 합니다.
마무리
정리해 보겠습니다. 타입만 남기고 값 참조를 피하려면 import type과 클래스 맵을 조합해 타입 체크를 중앙화하는 방식이 현실적입니다. 난독화와 순환참조는 생성자 비교를 한곳에서 처리하면 피해갈 수 있습니다.
완벽한 설계는 드물고 실용성이 더 중요하니, 필요하면 CLASS_MAP을 업데이트하며 이 절충안을 사용해 보시기 바랍니다.



.png)