Symbol이 뭔지는 들어봤는데... 면접에서는 이렇게 답하세요

5
post-thumbnail

버전이 달라서 instanceof가 false를 뱉는다고요?

지난 주, 성능 모니터링 대시보드에 차트가 그려지지 않는 이슈가 발생했습니다. 콘솔에는 아무런 에러도 없었는데 말이죠.

import ReactWidget from '@meursyphus/flitter-react';
import { LineChart } from '@meursyphus/flitter-chart';
// 차트 컴포넌트 예시
const Chart = () => (
  <ReactWidget
    width="800px"
    height="600px"
    widget={LineChart({
      data: chartData
    })}
  />
);

flitter

원인을 찾아보니, 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이 뭔지 아시나요?

면접에서 가끔 Symbol에 대해 물어보곤 하죠.
"Javascript의 7번째 원시 타입이에요. 유일한 값을 만들 때 쓰이는..."
여기까지는 많이들 답변하시더라고요.

하지만 Symbol의 진짜 강력한 점은 따로 있습니다. JavaScript 엔진의 내부 동작을 개발자가 원하는 대로 바꿀 수 있다는 거죠.
이게 무슨 말일까요? 우리가 겪은 문제를 통해 살펴봅시다.

RenderObject 문제 해결하기

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가 등장합니다...

Symbol로 문제 해결하기

문제를 발견했으니 해결해야겠죠?
가장 단순한 방법은 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의 강력함입니다.
자바스크립트 엔진의 내부 동작을 건드리지 않고도, 우리가 원하는 대로 바꿀 수 있죠.
덕분에 라이브러리의 기존 코드는 전혀 수정하지 않고도 버전 호환성 문제를 해결할 수 있었습니다.

Symbol의 다른 활용: 순회 가능한 객체 만들기

이런 마법 같은 일은 instanceof 뿐만이 아닙니다.
DOM API를 사용하다 보면 이런 코드를 자주 보게 되죠...

"NodeList는 배열이 아닌데 왜 for...of가 될까요?"

면접에서 가끔 이런 질문을 받습니다.
"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와 스프레드 연산자가 동작하는 걸까요?

Iterator의 비밀

비밀은 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로 할 수 있는 다른 마법들

자, 여기까지 우리는 Symbol.hasInstanceSymbol.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을 떠올려보세요:

  • 라이브러리 버전이 달라서 타입 체크가 실패할 때 → Symbol.hasInstance
  • 커스텀 객체를 배열처럼 다루고 싶을 때 → Symbol.iterator
  • 객체의 기본 변환 동작을 수정하고 싶을 때 → Symbol.toPrimitive
  • 객체의 문자열 표현을 바꾸고 싶을 때 → Symbol.toStringTag

그리고 면접에서 Symbol 관련 질문을 받으시면 이렇게 답변해보세요:

"Symbol은 단순히 유일한 값을 만드는 것 외에도, Well-known Symbol들을 통해 자바스크립트의 내부 동작을 커스터마이징할 수 있게 해줍니다. 예를 들어 최근에 제가 라이브러리 버전 차이로 인한 instanceof 문제를 Symbol.hasInstance로 해결한 적이 있는데요..."

자바스크립트를 더 깊이 이해하고 싶다면, Symbol이라는 비밀 병기를 잘 알아두시면 좋겠죠? 😉

해당 라이브러리는 여기에 있어요
https://github.com/meursyphus/flitter

github star 한번씩 눌러주시면 너무나 감사합니다!

profile
스벨트쓰고요. 오픈소스 운영합니다

0개의 댓글