티스토리 뷰

728x90
SMALL

배경

2022 feconf에서 진짜 흥미로운 세션을 봤다. react18버전부터 제공하는 useSyncExternalStore를 이용해서 간단하게 store를 만들고 이걸 모델 클래스와 연결하는 방식인데 너무 마음에 들었다. 요즘 객체지향, 리팩터링 공부를 하다보니 자연스럽게 클래스에 대한 공부를 많이하는데 리덕스로는 이걸 적용할 수 없다. 클래스를 사용하지 않는다고 객체지향적인 사고를 할 수 없다는 것은 절대 아니다. 하지만 클래스가 없기 때문에 할 수 없는 것들이 너무 많다. 세션중에 연사분이 클래스를 격하게 쓰고 싶다고 하셨는데 내 마음을 말하는 것 같았다. 세션을 열심히 보고 코드 분석도 해보고 bdd 개념도 찾아봤다. 그리고 최근에 코드스피츠라는 곳에서 todolist를 객체지향적으로 구현하는 방법에 대해 강의를 들었는데 이 두가지를 이용해서 리액트로 todolist를 만들어봤다. 

https://github.com/yoonminsang/play-ground/pull/2

 

useSyncExternalStore를 이용한 투두리스트(w/tdd) by yoonminsang · Pull Request #2 · yoonminsang/play-ground

개요 및 변경 사항 코드스피츠 78강의 투두리스트와 fe-conf-2022의 상태관리 세션을 듣고 구현한 투두리스트 Model Model에 Folder와 Task 클래스 정의 Folder는 title과 task배열을 가지고 있다. Task는 title과

github.com

 

이것도 마음에 들지만 조금 불필요한 보일러플레이트들이 있었다. 그리고 이걸 기반으로 한 라이브러리 소스코드를 공개해주셨다. 보니까 문법적으로 모르는 부분이 조금 있어서 코드를 까보면서 공부해보려고 한다. 오픈소스라이브러리 까보는 것은 진짜 좋은 공부법인데 리덕스정도를 제외하고는 까본적이 없다. 파일이 엄청 많지 않아서 코드를 까보면서 테스트코드도 어떻게 짜는지 배울 생각이다. 추가적으로 조금 다르게 사용하고 싶어서 라이브러리를 내 입맛대로 변형해서 사용해볼것이다.(MIT라서 출처만 표시하면 문제 없을 것같다.)

 

소스코드분석

들어가기전에 접근자 프로퍼티에 대한 개념을 모르면 어려울 수 있다. 아래는 간단한 설명 영상이다.

https://www.youtube.com/watch?v=sVcNqP_P4Dg&ab_channel=%EC%9C%A4%EC%9C%A4 

또한 Proxy와 Reflect에 대해서 더 알고 싶다면 아래 링크를 먼저 보는 것을 추천한다.

https://ko.javascript.info/proxy

constants.ts

export const STORE_GLUE_PROPERTY_KEY = '##store';
export const CACHE_PROPERTY_KEY = '##cache';

types.ts

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends (...args: any) => any ? K : never;
}[keyof T];

export type NonFunctionProperties<T> = Omit<T, FunctionPropertyNames<T>>;

 

어렵다... 친구한테 전화해서 설명을 받고서 이해했다.

 

[K in keyof T]: T[K]

이건 엄청 기본적인 문법이다. T의 key가 key값이고 T의 key에 해당하는 value가 value값이라는 뜻이다.

보통 단일적으로 쓰일일은 거의 없다. 왜냐하면 그냥 타입을 선언하는 것과 똑같기 때문이다.

간단한 예시 코드를 만들어보면 다음과 같다.

type A<T> = { [K in keyof T]: T[K] };
interface Props {
  hi: string;
}
const a: A<Props> = { hi: 'hi' };
const b: Props = { hi: 'hi' };

 

extends (...args:any) => any ? K : never;

처음에 any ? K : never로 생각해서 이해를 못했다. 그게 아니라 

((...args:any) => any) ? K : never로 끊어서 읽어야한다.

즉 함수면 K, 함수가 아니라면 never라는 뜻이다. 이때 never는 타입 추론 예외를 제거하는 방법으로 사용된다.

 

{~~}[keyof T]

이건 ~~의 key만 불러온다는 뜻이다.

 

FunctionPropertyNames

즉 함수들의 프로퍼티(키) 이름들만 받아오는 타입이라는 뜻이다.(사실 네이밍을 보면 유추할 수 있다.)

 

type NonFunctionProperties<T> = Omit<T, FunctionPropertyNames<T>>;

T중에서 위의 타입을 제거했다. 즉 함수가 아닌 프로퍼티 이름들을 받아오는 타입이다.

utils.ts

테스트 공통 로직

class Person {
  name = 'Peter Parker';

  age = 15;

  get nameAndAge() {
    return `${this.name} (${this.age})`;
  }

  get ageAndName() {
    return `${this.age} ${this.name}`;
  }

  grow() {
    this.age += 1;
  }
}

const context = describe;

let consoleError: (...args: unknown[]) => void;

beforeEach(() => {
  consoleError = console.error;
  console.error = jest.fn();
});

afterEach(() => {
  console.error = consoleError;
});

areEqual

export function areEqual(a: object, b: object) {
  const keys = Reflect.ownKeys(a);
  return keys.length === Reflect.ownKeys(b).length && keys.every((key) => Reflect.get(a, key) === Reflect.get(b, key));
}

객체가 동일한지 비교하는 함수다. 조금 특이한? 점이라면 객체 리터럴을 타입으로 받는게 아니라 object를 타입으로 받는다. 즉 모든 객체에대한 비교를 하는 함수라는 뜻이다. Reflect라는 용어를 처음 봤다. 외부 라이브러리나 타입스크립트인줄 알았는데 자바스크립트 문법이다. 

 

Reflect.ownKeys는 프로퍼티 네임들을 가져오는 함수다. 정확히는 Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target)) 이것과 똑같은 함수다. 프로퍼티 이름이 Symbol인 경우도 한번에 가져올 수 있다는 점이 조금 더 편하다. 용도에 맞게 사용하자.

Object.keys와 유사하지만 다르다. Object.keys는 enumarable한 값들만 가져오지만 Relfect.ownKeys는 모든 프로퍼티 네임들을 불러온다. 

 

Reflect.get으로 객체의 프로퍼티에 접근할 수 있다. a[key]와 유사하다. 보통은 Proxy와도 많이 사용된다.

 

예시코드

Reflect.ownKeys([])
// ['length']

const object1 = {};

Object.defineProperty(object1, 'property1', {
  value: 42,
  enumarable:false
});

Object.keys(object1)
// []

Reflect.ownKeys(object1)
// ['property1']

 

테스트 코드

test('areEqual', () => {
  const a = { name: 'Peter Parker' };
  const b = { name: 'Peter Parker' };
  const c = { name: 'Miles Morales' };

  expect(areEqual(a, b)).toBeTruthy();
  expect(areEqual(a, c)).toBeFalsy();
});

그냥 간단하게 a,b,c 객체 리터럴을 만들고 테스트한다. Object.defineProperty를 해서 좀 더 세부적으로 테스트를 하나? 생각했는데 그건 아니였다. 얼만큼 테스트해야하는지는 항상 어려운 문제다. 내가 작성한 테스트코드를 봐줄 멘토가 있으면 좋겠다. 내년에는 교육과정을 알아볼생각이 있다. 

 

getPropertyOf

export function getPrototypeOf(target: object): object {
  const prototype = Reflect.getPrototypeOf(target);
  if (!prototype) {
    throw new Error('Cannot find prototype');
  }
  return prototype;
}

이건 Object.getPrototypeOf와 매우 유사하다. 더 옛날 방법으로는 __proto__가 있다. 

 

테스트코드

describe('getPrototypeOf', () => {
  context('with prototype', () => {
    it('return prototype', () => {
      const proto = {};
      const obj = Object.create(proto);

      expect(getPrototypeOf(obj)).toBe(proto);
    });
  });

  context('without prototype', () => {
    it('throws error', () => {
      const nullPrototype = getPrototypeOf({});

      expect(() => {
        getPrototypeOf(nullPrototype);
      }).toThrow();
    });
  });
});

context를 이용한 bdd 방식이다. 참고로 bdd(behavior driven development)는 행위주도개발이다. tdd(test driven development)와는 조금 다르다. 사실 두가지를 모두 적용하는 방법론이 좋긴하다. 행위주도인만큼 행동별로 케이스를 나눈다. 지금은 간단한 유틸함수라서 행동이 여러가지로 나뉘지 않지만 실제 프로젝트에서는 굉장히 복잡한 로직들이 돌아간다. 아래는 우형 기술블로그에서 가져온 시나리오다.

 

지금은 prototype이 있는 경우와 없는 경우 두개의 context로 나눠서 테슽트했다. 지금같은 경우는 context내부에 테스트케이스가 하나라서 context없이 테스트해도 괜찮을 거라는 생각도 들기는 한다. 하지만 bdd적인 관점에서는 위의 방법이 좋은 것 같다. 일단 나와 생각이 다르더라도 최대한 보고 뽑아먹을 생각이다.

 

getOwnPropertyDescriptor

type KeyType = string | symbol;

export function getOwnPropertyDescriptor(target: object, key: KeyType) {
  const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
  if (!descriptor) {
    throw new Error(`Property Not Found: ${String(key)}`);
  }
  return descriptor;
}

이것도 getOwnPropertyDescriptor와 매우 유사하다. 위에서 비슷한 맥락을 했기 때문에 설명은 생략한다.

 

테스트 코드

describe('getOwnPropertyDescriptor', () => {
  const person = {
    name: 'Peter Parker',
  };

  context('with valid key', () => {
    it('returns property descriptor', () => {
      const descriptor = getOwnPropertyDescriptor(person, 'name');

      expect(descriptor.value).toBe('Peter Parker');
      expect(descriptor.writable).toBeTruthy();
    });
  });

  context('with invalid key', () => {
    it('throws error', () => {
      expect(() => {
        getOwnPropertyDescriptor(person, 'xxx');
      }).toThrow();
    });
  });
});

여기서도 bdd에 따라 유효한 키가 있는 경우와 없는 경우로 context를 나눴다. 테스트내용은 접근자프로퍼티에 대한 기본 개념을 알면 쉽게 알 수 있는 내용이라 생략한다.

 

ownKeys

export function ownKeys(target: object) {
  return Reflect.ownKeys(target);
}

조금 이해가 안되는 부분이다. areEqual에서는 Relfect로 접근했는데 밑에서 함수를 만들거면 왜 그렇게 접근을 했을까? 단순히 가벼운 내용이라서 빠트린걸까? 내 생각은 그런데 코드 작성하신분의 의도를 알 수는 없기 때문에 함부로 판단하지는 않겠다. 설명은 위에서 해서 패스

 

좀 보다보니까 유틸함수 내부에서는 ownKeys라는 함수를 사용하지 않는다. 하지만 외부 함수에서는 ownKeys라는 함수를 사용한다. 아마 외부 함수에서는 Reflect같은 생소한 방법을 은닉하기 위한 방법이 아닐까 생각이 든다. 역시 빠트린게 아니라 의도된거였다.

 

테스트코드

test('ownKeys', () => {
  const person = new Person();

  expect(ownKeys(person)).toEqual(['name', 'age']);
});

이것도 엄청 가볍게 테스트했다. 대충 인지했다. 어떤식인지

 

getGetterKeys

export function getGetterKeys(target: object) {
  const keys = (obj: object) =>
    Reflect.ownKeys(obj)
      .filter((key) => !String(key).startsWith('_'))
      .filter((key) => {
        const descriptor = Reflect.getOwnPropertyDescriptor(obj, key);
        return descriptor?.get;
      });
  return [...keys(getPrototypeOf(target)), ...keys(getPrototypeOf(getPrototypeOf(target)))];
}

드디어 좀 복잡해보이는 로직이 나왔다. 위에 내용을 이해했다면 사실 어렵지는 않을 것이다.

필터를 거는데 첫번째는 _로 시작하지 않는 키들을 제외하고 그다음에는 getter가 있는 것들만 필터링해준다. 

그리고 keys함수에 target의 프로토타입을 넣은 값과 keys함수에 target의 프로토타입의 프로토타입을 넣은 값을 배열로 받아서 리턴해준다. 프로토타입에 두번 접근하는 이유는 상속을 받는 경우를 생각해서 그런것같다.(추측. 전체 코드를 읽다보면 나중에 알게되겠지)

 

테스트코드

test('getGetterKeys', () => {
  const person = new Person();

  expect(getGetterKeys(person)).toEqual(['nameAndAge', 'ageAndName']);
});

이번에도 엄청 간단히 테스트코드를 짰다. _로 걸러주는 부분과 프로토타입의 프로토타입에 접근하는 부분은 왜 테스트를 하지 않는걸까? 잘 모르겠다. 필터를 걸기때문에 커버리지는 100이지만 이건 진정한 의미의 커버리지 100이 아니다. 물론 커버리지 100이 옳다는 것도 아니다. 하지만 이런 놓쳐질수 있는 케이스를 테스트하지 않는게 맞는걸까? 내가 테스트코드에 대한 지식이 부족해서 그렇게 생각하는 걸까? 아직은 알 수 없다. 의문점을 가지고 더 공부해보면 알겠지

 

takeSnapshot

export function takeSnapshot<T extends object>(target: T, propertyKeys: KeyType[]): NonFunctionProperties<T> {
  const snapshot = propertyKeys
    .filter((key) => !String(key).startsWith('#'))
    .reduce(
      (acc, key) => ({
        ...acc,
        [String(key)]: Reflect.get(target, key),
      }),
      {},
    );
  Reflect.setPrototypeOf(snapshot, {});
  return snapshot as NonFunctionProperties<T>;
}

코드가 쉽다. #으로 시작하는 키를 제외하고 target에 맞는 key와 value값을 할당해준다.(symbol도 string으로 변경해서)

그리고 setPrototypeOf로 프로토타입을 변경해준다. 객체리터럴로 변경했기 때문에 사실상 기존에 가지고 있던 프로토타입을 없애고 가장 기본이 되는 객체 리터럴의 프로토타입으로 재할당한다고 생각하면 된다. 근데 왜?? 인지는 아직 모르겠다. 내부 코드를 보다보면 알겠지.

 

테스트 코드

test('takeSnapshot', () => {
  const person = new Person();
  const keys = ownKeys(person);

  expect(takeSnapshot(person, keys)).toEqual({
    name: 'Peter Parker',
    age: 15,
  });
});

이것도 역시나 엄청 간단하게 테스트했다. 왜인지는 위와 같은 이유로 잘 모르겠다.

 

attachGetters

export function attachGetters(
  target: object,
  {
    obj,
    keys,
  }: {
    obj: object;
    keys: KeyType[];
  },
) {
  const sourceProto = getPrototypeOf(obj);
  const targetProto = getPrototypeOf(target);

  Reflect.defineProperty(targetProto, CACHE_PROPERTY_KEY, {
    value: {},
  });

  keys.forEach((key) => {
    const descriptor = getOwnPropertyDescriptor(sourceProto, key);
    Reflect.defineProperty(targetProto, key, {
      get() {
        const cache: Record<KeyType, any> = this[CACHE_PROPERTY_KEY];
        if (!(key in cache)) {
          cache[key] = descriptor.get?.apply(this);
        }
        return cache[key];
      },
    });
  });
}

어렵다... 사실 접근자 프로퍼티와 apply의 개념 정도만 알아도 바로 이해할 수 있기는 하다. 실무에서 이런 개념을 안쓰다보니 좀 무뎌져서 그런것같다. 마법은 없다. 마법같은 방법은 누군가가 고생해서 만들어놨을뿐이다.

 

먼저 sourceProto와 targetProto에 각 객체의 프로토타입을 할당한다.

그리고 targetproto 객체에 CACHE_PROPERTY_KEY라는 프로퍼티 이름에 value를 {}로 할당한다. 참고로 이렇게 할당하면 configurable, enumerable, writable이 모두 false로 할당된다.

 

이제 keys로 forEach문을 돌린다.

먼저 descriptor 변수에 source프로토타입과 키 값을 매칭시켜서 저장해주고

target프로토타입의 프로퍼티네임이 key인곳에 gettter를 정의해준다.

cache라는 변수는 처음에는 위에서 정의한 {}가 저장될것이다.(그다음은 아래로직을 본 다음 알 수 있음)

if문으로 분기를 치는데 그럴바에는 key를 set으로 바꿨다가 spread로 하는게 더 깔끔하지 않나? 싶기도 하다. 연산시간을 줄일필요는 없어보이는 함순데말이다. 

어쨋든 cache[key]에 descriptor의 key에 해당하는 getter 객체를 할당한다. 조금 특이한 점은 apply를 사용해서 this값을 전달한다. 이건 테스트코드를 보면 이유를 쉽게 알 수 있다. 

 

아 그리고 여기서 defineProperty를 했기 때문에 getter를 호출할 때 get 함수가 실행된다. 즉 if문 분기는 꼭 필요한 로직이였다... 같은 getter를 호출할때마다 descriptor.get?.apply(this)를 호출하고 cache[key]에 재할당한다면 그건 좀 문제다.

 

테스트 코드

test('attachGetters', () => {
  const person = new Person();

  const snapshot = takeSnapshot(person, ownKeys(person));

  attachGetters(snapshot, { obj: person, keys: getGetterKeys(person) });

  expect(snapshot.nameAndAge).toBe('Peter Parker (15)');
});

위에서 apply를 적용한 이유를 테스트코드를 보면 알 수 있다고 말했었다. 보면 snapshot을 attachGetters의 첫번째 인자로 넣는다. 그리고 takeSnapshot함수에서 return 하기전에 prototype을 {}로 재할당해준다. 그렇기 때문에 apply로 this를 전달해주는 것이다. 

 

후기

가볍게 여겼는데 좀 힘들었다. 오랜만에 자바스크립트 기본기를 공부해서 좋았다. 그리고 잘 만든 테스트코드도 봐서 좋았다. 이제 빨리 다음 코드를 읽고 싶다. 사실 어느정도 유틸함수를 보고 감이 오기는 했다. 빨리 다 까보고 수정하고 싶은걸 수정해봐야겠다. 그리고 내가 생각한 코드의 사용성이 괜찮으면 받아줄지는 모르겠지만 pr도 하나 날려봐야겠다.

 

참조글

https://techblog.woowahan.com/8942/

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getPrototypeOf

 

해당 라이브러리

https://github.com/seed2whale/usestore-ts

 

 

728x90
LIST
댓글
공지사항