지난 주, 성능 모니터링 대시보드에 차트가 그려지지 않는 이슈가 발생했습니다. 콘솔에는 아무런 에러도 없었는데 말이죠.
import ReactWidget from '@meursyphus/flitter-react';
import { LineChart } from '@meursyphus/flitter-chart';
// 차트 컴포넌트 예시
const Chart = () => (
<ReactWidget
width="800px"
height="600px"
widget={LineChart({
data: chartData
})}
/>
);
원인을 찾아보니, flitter 라이브러리를 1.2.0에서 1.3.0으로 업데이트한 게 문제였습니다.
"어라? 마이너 버전 업데이트인데 왜 호환성 문제가...?"
디버깅을 해보니 충격적인 사실을 발견했습니다.
// flitter 엔진 내부
if (!(element instanceof RenderObject)) {
throw new Error('Invalid render element');
}
console.log(element.type); // 'RenderObject'
console.log(element instanceof RenderObject); // false (뭐야?)
console.log(element.constructor.name); // 'RenderObject' (더 뭐야??)
분명 타입은 맞는데 instanceof
가 거짓말을 하고 있었어요.
도대체 무슨 일이 벌어진 걸까요?
Node.js의 의존성 구조를 살펴보니...
my-project/
├── node_modules/
│ ├── flitter@1.3.0/
│ └── flitter-chart@1.0.0/
│ └── node_modules/
│ └── flitter@1.2.0/
flitter-chart가 자신만의 flitter 1.2.0 버전을 참조하고 있었네요! 인터페이스는 완벽히 동일한데도 JavaScript 엔진은 이 둘을 전혀 다른 클래스로 인식하고 있었던 거죠.
이런 상황에서 우리의 구원자가 될 수 있는 게 바로 Symbol.hasInstance
입니다. 어떻게 해결했는지, 그리고 이런 Symbol들의 숨겨진 힘에 대해 하나씩 알아볼까요?
면접에서 가끔 Symbol에 대해 물어보곤 하죠.
"Javascript의 7번째 원시 타입이에요. 유일한 값을 만들 때 쓰이는..."
여기까지는 많이들 답변하시더라고요.
하지만 Symbol의 진짜 강력한 점은 따로 있습니다. JavaScript 엔진의 내부 동작을 개발자가 원하는 대로 바꿀 수 있다는 거죠.
이게 무슨 말일까요? 우리가 겪은 문제를 통해 살펴봅시다.
flitter로 차트를 구현할 때, 우리는 이런 컴포넌트들을 만듭니다:
import {
Container,
type BoxDecoration,
type EdgeInsets,
} from "@meursyphus/flitter";
export default function Bar({
thickness,
direction,
color,
decoration,
margin,
}: {
thickness: number;
direction: "horizontal" | "vertical";
color?: string;
decoration?: BoxDecoration;
margin?: EdgeInsets;
}) {
return Container({
color,
decoration,
height: direction === "horizontal" ? thickness : undefined,
width: direction === "vertical" ? thickness : undefined,
margin,
});
}
여기서 Container
를 포함한 모든 flitter 엘리먼트들은 내부적으로 RenderObject
를 상속받고 있어요.
그리고 디버깅을 쉽게 하기 위해 각 엘리먼트는 자신의 타입을 문자열로 가지고 있죠:
console.log(Container({ color: 'red' }).type) // 'Container'
근데 갑자기 차트가 안 그려지기 시작했습니다.
분명 타입도 맞고, 인터페이스도 같은데 말이죠.
// flitter 엔진 내부
function render(element) {
console.log(element.type); // 'Container'
console.log(element instanceof RenderObject); // false (???)
if (!(element instanceof RenderObject)) {
throw new Error('Invalid render element');
}
// ... 렌더링 로직
}
원인은 바로 Node.js의 패키지 구조에 있었습니다:
my-project/
├── node_modules/
│ ├── flitter@1.3.0/
│ │ └── src/
│ │ └── elements/
│ │ ├── RenderObject.js # 우리가 참조하는 버전
│ │ └── Container.js
│ │
│ └── flitter-chart@1.0.0/
│ └── node_modules/
│ └── flitter@1.2.0/
│ └── src/
│ └── elements/
│ ├── RenderObject.js # 차트가 참조하는 버전
│ └── Container.js
flitter-chart는 자신만의 flitter 1.2.0 버전을 사용하고 있었고,
우리 프로젝트는 1.3.0 버전을 사용하고 있었던 거죠.
마이너 버전이 다른 것뿐이라 인터페이스는 완벽하게 같았지만,
Node.js는 이 둘을 서로 다른 모듈로 취급합니다.
그래서 instanceof
검사가 실패하고 있었던 거예요!
어떻게 해결할 수 있을까요? 바로 여기서 Symbol.hasInstance
가 등장합니다...
문제를 발견했으니 해결해야겠죠?
가장 단순한 방법은 flitter 엔진 내부의 모든 타입 체크 로직을 수정하는 겁니다.
// 이런 코드를
if (!(element instanceof RenderObject)) {
throw new Error('Invalid render element');
}
// 이렇게 바꾸기
if (!element?.type || element.type !== 'RenderObject') {
throw new Error('Invalid render element');
}
하지만... flitter 전체 코드베이스에서 instanceof
를 사용하는 모든 부분을 찾아 수정해야 합니다.
게다가 나중에 새로운 엘리먼트 타입이 추가될 때마다 이 검사 로직도 업데이트해야 하죠.
더 우아한 방법이 없을까요?
자바스크립트의 instanceof
연산자는 실제로 내부적으로 Symbol.hasInstance
라는 특별한 메서드를 호출합니다.
이 메서드만 오버라이드하면 라이브러리의 기존 코드는 전혀 건드리지 않아도 됩니다!
// flitter의 RenderObject.js
class RenderObject {
static [Symbol.hasInstance](instance) {
return instance?.type && instance.type === 'RenderObject';
}
constructor(type) {
this.type = type;
}
}
이제 어떤 버전의 flitter에서 생성된 엘리먼트든, instanceof
검사를 통과할 수 있습니다.
// flitter@1.3.0의 RenderObject로 체크
const container = Container({ color: 'red' }); // flitter@1.2.0에서 생성된 엘리먼트
console.log(container instanceof RenderObject); // true
// 라이브러리 내부의 기존 코드가 그대로 동작!
if (!(element instanceof RenderObject)) {
throw new Error('Invalid render element');
}
이게 바로 Symbol의 강력함입니다.
자바스크립트 엔진의 내부 동작을 건드리지 않고도, 우리가 원하는 대로 바꿀 수 있죠.
덕분에 라이브러리의 기존 코드는 전혀 수정하지 않고도 버전 호환성 문제를 해결할 수 있었습니다.
이런 마법 같은 일은 instanceof
뿐만이 아닙니다.
DOM API를 사용하다 보면 이런 코드를 자주 보게 되죠...
면접에서 가끔 이런 질문을 받습니다.
"NodeList와 Array의 차이점이 뭔가요?"
여러분은 어떻게 답변하시나요?
"NodeList는 배열이 아니라서 map, filter 같은 메서드를 쓸 수 없어요..."
정도로 답변하시는 분들이 많더라고요.
그런데 이상한 점을 발견했습니다.
const divs = document.querySelectorAll('div');
console.log(Array.isArray(divs)); // false
console.log(divs.map); // undefined
// 근데 이건 된다?
for (const div of divs) {
div.classList.add('active');
}
// 심지어 이것도 된다!
const divsArray = [...divs];
배열도 아닌데 어떻게 for...of
와 스프레드 연산자가 동작하는 걸까요?
비밀은 Symbol.iterator
에 있습니다.
이 심볼은 객체를 순회 가능하게 만드는 마법의 열쇠입니다.
const divs = document.querySelectorAll('div');
console.log(Symbol.iterator in divs); // true
// 실제로는 이런 함수가 구현되어 있습니다
NodeList.prototype[Symbol.iterator] = function*() {
for (let i = 0; i < this.length; i++) {
yield this[i];
}
};
우리도 이 마법을 사용할 수 있습니다!
const myObject = {
name: '김개발',
age: 25,
skills: ['JavaScript', 'TypeScript', 'React'],
// 이 부분이 핵심입니다
[Symbol.iterator]: function* () {
yield this.name;
yield this.age;
yield* this.skills;
}
};
// 이제 이런 게 가능합니다
for (const value of myObject) {
console.log(value);
}
// 출력:
// "김개발"
// 25
// "JavaScript"
// "TypeScript"
// "React"
// 스프레드 연산자도 사용할 수 있습니다
const values = [...myObject];
console.log(values); // ["김개발", 25, "JavaScript", "TypeScript", "React"]
이걸 응용하면 우리만의 커스텀 컬렉션을 만들 수도 있죠.
예를 들어, 페이지네이션이 있는 데이터를 다루는 컬렉션을 만들어봅시다:
class PaginatedData {
constructor(fetcher, pageSize = 10) {
this.fetcher = fetcher;
this.pageSize = pageSize;
}
async *[Symbol.asyncIterator]() {
let page = 1;
while (true) {
const data = await this.fetcher(page, this.pageSize);
if (data.length === 0) break;
for (const item of data) {
yield item;
}
page++;
}
}
}
// 사용 예:
const users = new PaginatedData(
(page, pageSize) => fetch(`/api/users?page=${page}&size=${pageSize}`)
.then(r => r.json())
);
// 이제 이런 게 가능합니다!
for await (const user of users) {
console.log(user.name);
}
자, 여기까지 우리는 Symbol.hasInstance
와 Symbol.iterator
를 살펴봤는데요.
사실 이것보다 훨씬 더 많은 마법들이 있습니다.
// 객체를 문자열로 변환할 때의 동작을 바꿀 수 있어요
class CustomError {
get [Symbol.toStringTag]() {
return 'MyError';
}
}
console.log(new CustomError().toString()); // "[object MyError]"
// 객체를 숫자나 문자열로 변환할 때의 동작도 커스텀할 수 있죠
const myObject = {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return 42;
if (hint === 'string') return 'Hello';
return true;
}
};
console.log(+myObject); // 42
console.log(`${myObject}`); // "Hello"
// 심지어 문자열 검색 동작도 바꿀 수 있어요
const customSearch = {
[Symbol.search](str) {
return str.indexOf('원하는 글자');
}
};
이제 Symbol이 왜 중요한지 아시겠나요?
단순히 유니크한 값을 만드는 게 아니라, 자바스크립트의 기본 동작을 우리 마음대로 바꿀 수 있게 해주는 강력한 도구입니다.
다음에 이런 상황이 온다면 Symbol을 떠올려보세요:
Symbol.hasInstance
Symbol.iterator
Symbol.toPrimitive
Symbol.toStringTag
그리고 면접에서 Symbol 관련 질문을 받으시면 이렇게 답변해보세요:
"Symbol은 단순히 유일한 값을 만드는 것 외에도, Well-known Symbol들을 통해 자바스크립트의 내부 동작을 커스터마이징할 수 있게 해줍니다. 예를 들어 최근에 제가 라이브러리 버전 차이로 인한 instanceof
문제를 Symbol.hasInstance
로 해결한 적이 있는데요..."
자바스크립트를 더 깊이 이해하고 싶다면, Symbol이라는 비밀 병기를 잘 알아두시면 좋겠죠? 😉
해당 라이브러리는 여기에 있어요
https://github.com/meursyphus/flitter
github star 한번씩 눌러주시면 너무나 감사합니다!