본문 바로가기

FrontEnd

제어의 역전을 활용해 타입 친화적인 컨텍스트를 디자인하는 방법

반응형

원문 보기 : https://saul-mirone.github.io/how-to-design-a-type-friendly-context/

 

How to Design a Type Friendly Context

IoC is the key.

saul-mirone.github.io

 

자바스크립트 세계에서, Koa는 하나의 이정표와 같은 역할을 하였습니다.

Koa는 애플리케이션의 핵심은 플러그인을 load하기 위한 코어로서 동작하며, 여러 기능을 제공하기 위해 다양한 플러그인을 번들할 수 있는 역할임을 되새기게 합니다. (ex, 스프링, express)

 

vscode의 확장 프로그램, 웹팩의 플러그인과 같은 비슷한 사례들을 발견할 수 있습니다.

 

자바스크립트의 컨텍스트


Koa의 세계에서 ctx는 마법의 상자입니다.
사용자는 ctx에서 모든 종류의 프로퍼티를 얻을 수 있습니다.
예를 들어 koa-session 플러그인을 설치하면 ctx.session을 얻을 수 있습니다.
그리고 koa-body 플러그인을 설치하면 ctx.request.body를 얻을 수 있습니다.
 
일반적인 Koa 플러그인(미들웨어라고도 함)은 다음과 같습니다.
app.use(async (ctx, next) => {

    // inject props into ctx
    ctx.foo = 'bar';

    const startTime = Date.now();

    await next();

    // do something after other ctx done.
    const endTime = Date.now();
    const duration = endTime - startTime;

    console.log('Ctx duration:', duration);
})

정적 타입 체크


TypeScript와 Flow에서 가져온 정적 타입 시스템이 게임에 합류할 때까지 모든 것이 완벽해 보입니다.
안전한 타입형 검사 및 강력한 편집기 lsp(language server protocol) 기능을 활용하여,
사람들은 타입스크립트를 사용해 대규모 시스템뿐만 아니라 작은 앱 및 도구도 구축할 수 있습니다.
 
 
그러나 Koa가 정적 타입 검사를 만나면 모든 것이 동작을 넘춥니다.
타입 시스템은 실제로 ctx에 있는 속성과 그렇지 않은 속성을 추론할 수 없습니다.
예를 들어 ctx.foo를 호출하면 플러그인이 주입한 foo 속성이 현재 Koa 앱에 로드되었는지 여부를 어떻게 알 수 있을까요?
게다가 타입 시스템이 무엇을 제안해야 할지 모르기 때문에 사용자는 에디터의 힌트를 얻을 수 없습니다.
정적 타입 시스템을 사용하는 언어의 일반적인 문제: 모듈 간에 공유되는 객체를 우아하게 처리하는 방법이 있을까요?

설계


핵심은  IoC를 사용하는 것입니다. 이 패턴을 사용하여 컨텍스트에 타입 정보를 삽입할 수 있습니다.

(주 : 왜 제어의 역전일까? 해당 글에는 안나와있어서 행간을 읽어야한다.

ctx에 데이터를 직접 주입하는게 아니라, createCtx와 createSlice를 만들어 코드가 ctx에 데이터를 대신 주입하게 한다.)

 

koa의 컨텍스트 디자인을 다시 생각해 보겠습니다. 컨텍스트가 ctx.foo와 같이 수정할 수 있는 속성이 있는 객체임을 알 수 있습니다.

이 API를 ctx.get(foo)으로 변환하면 어떨까요?

foo의 생성은 우리가 제어할 수 있는 것이기 때문에 이에 대한 정보를 작성할 수 있습니다.

 

따라서 컨텍스트의 API가 다음과 같이 설계되었다고 가정해 보겠습니다.
const ctx = createCtx();

const numberSlice = createSlice(0);

// inject a ctx.
ctx.inject(numberSlice);

const number = ctx.get(numberSlice); // -> 0

// set value of numberSlice to 1.
ctx.set(numberSlice, number + 1);

 

새로운 데이터 구조인 슬라이스를 도입했습니다.

이 디자인으로 전체 ctx를 여러 조각으로 나눴습니다.

 

이제 ctx 및 slice의 구조를 정의할 수 있습니다.

type Ctx = Map<symbol, Slice>;

type Slice<T = unknown> = {
    id: symbol;
    set: (value: T) => void;
    get: () => T;
}

Slice


이제 슬라이스를 구현해 보겠습니다.
type Metadata<T> = {
    id: symbol;
    (ctx: Ctx): Slice<T>;
};

const createSlice = <T>(defaultValue: T): Metadata<T> => {
    const id = Symbol('Slice');

    const metadata = (ctx: Ctx) => {
        let inner = defaultValue;
        const slice: Slice<T> = {
            id,
            set: (next) => {
                inner = next;
            },
            get: () => inner
        }
        ctx.set(id, slice as Slice);
        return slice;
    }
    metadata.id = id;

    return metadata;
}
슬라이스와 슬라이스의 id를 가져오는 메타데이터(슬라이스의) 타입을 정의했습니다. (슬라이스와 슬라이스의 id(심벌)을 포함)
createSlice 팩터리 함수를 통해 슬라이스를 컨텍스트에 주입할 수 있습니다.
(컨텍스트를 입력으로 받아 주입, 후 슬라이스를 리턴합니다.)

Ctx


ctx의 구현은 훨씬 간단합니다.
const createCtx = () => {
    const map: Ctx = new Map();

    const getSlice = <T>(metadata: Metadata<T>): Slice<T> => {
        const value = map.get(metadata.id);
        if (!value) {
            throw new Error('Slice not injected');
        }
        return value as Slice<T>;
    }

    return {
        inject: <T>(metadata: Metadata<T>) => metadata(map), // inject slice (id, slice)
        get: <T>(metadata: Metadata<T>): T => getSlice(metadata).get(), // T - default value
        set: <T>(metadata: Metadata<T>, value: T): void => {
            getSlice(metadata).set(value); // void (inner = next)
        }
    }
}
슬라이스가 서로 충돌하지 않도록 Symbol을 키로 사용하며, 간단한 맵을 슬라이스 컨테이너로 사용합니다.
 

Testing


 

메타데이터 (자체 심벌 ID와 내부에 슬라이스 존재) 생성

메타데이터 안의 슬라이스는 get, set 메서드로 클로저를 업데이트, 및 리턴함.

메타데이터 안의 슬라이스는 주입할 때마다 새로 생김 (컨텍스트 끼리 공유 안됨)

메타데이터 자체도 get 및 set 할 수 있음.

ctx에 inject하면 createSlice가 리턴한 함수(메타데이터)가 ctx 내부에 (id,slice)형태로 주입해줌.

ctx.get하면, ctx에서 메타데이터 아이디로 해당 슬라이스를 찾아 값을 꺼내옴. 

ctx.set하면 해당 메타데이터 내부의 값을 바꿈.

 

이제 몇 가지 테스트를 수행해 보겠습니다.
// 메타데이터를 만듭니다.
const num = createSlice(0);

// 컨텍스트를 만듭니다.
const ctx1 = createCtx();
const ctx2 = createCtx();

// 컨텍스트에 메타데이터를 주입합니다.
ctx1.inject(num); // (id,slice)
ctx2.inject(num); // (id,slice)

// 컨텍스트에서 해당 Default 값을 얻습니다.
const x = ctx1.get(num); // editor will know x is number

// 슬라이스 내부 값 업데이트
ctx1.set(num, x + 1);

// this line will have an error since num slice only accept number
// 타입 오류
ctx.set(num, 'string')


ctx1.get(num); // => 1
ctx2.get(num); // => still 0
이제 우리는  IoC를 사용하여, 컨텍스트 간에 공유할 수 있지만, 값은 컨텍스트 별로 격리되는, 슬라이스가 있는 타입 친화적 컨텍스트를 구축했습니다.

Full Code (전체 코드 다시보기)


type Ctx = Map<symbol, Slice>;

type Slice<T = unknown> = {
  id: symbol;
  set: (value: T) => void;
  get: () => T;
};

type Metadata<T> = {
  id: symbol;
  (ctx: Ctx): Slice<T>;
};

const createSlice = <T>(defaultValue: T): Metadata<T> => {
  const id = Symbol("Slice");

  const metadata = (ctx: Ctx) => {
    let inner = defaultValue;
    const slice: Slice<T> = {
      id,
      set: (next) => {
        inner = next;
      },
      get: () => inner
    };
    ctx.set(id, slice as Slice);
    return slice;
  };
  metadata.id = id;

  return metadata;
};

const createCtx = () => {
  const map: Ctx = new Map();

  const getSlice = <T>(metadata: Metadata<T>): Slice<T> => {
    const value = map.get(metadata.id);
    if (!value) {
      throw new Error("Slice not injected");
    }
    return value as Slice<T>;
  };

  return {
    inject: <T>(metadata: Metadata<T>) => metadata(map),
    get: <T>(metadata: Metadata<T>): T => getSlice(metadata).get(),
    set: <T>(metadata: Metadata<T>, value: T): void => {
      getSlice(metadata).set(value);
    }
  };
};

여담


Slice는 recoil/jotai과 매우 유사한 자료구조이다.

메타데이터와 컨텍스트를 하나로 합치면 아톰이 된다.

반응형