반응형

개발하다보면 로직을 재사용하기 위해 Provider를 사용하는 경우가 있는데,
동일한 Provider가 여러번 재사용 되는 경우 (테이블 안에 테이블이 있다던가.. 모달 에서 모달을 연다던가)
상태 핸들링에 어려움을 겪었습니다. 
 
그래서 Radix-Ui에서 사용하는 스코프 개념을 본따 라이브러리도 만들었는데,
홍보 겸 블로그 글을 작성하려고 합니다
 

스코프란?

import { Dialog } from "radix-ui";


// Radix Ui 예시 코드, Root (Provider)를 기반으로 내부에서 상태를 공유해 사용합니다.

export default () => (
	<Dialog.Root>
		<Dialog.Trigger />
		<Dialog.Portal>
			<Dialog.Overlay />
			<Dialog.Content>
				<Dialog.Title />
				<Dialog.Description />
				<Dialog.Close />
			</Dialog.Content>
		</Dialog.Portal>
	</Dialog.Root>
);




Radix UI는 React 기반의 UI 컴포넌트 라이브러리로, Compound Components 패턴을 적극 활용합니다.
Compound Components는 여러 개의 하위 컴포넌트를 조합하여 하나의 큰 구성 요소를 만드는 패턴이에요. 예를 들어 Radix UI의 Dialog 컴포넌트는 Root, Trigger, Content 등 여러 부분으로 쪼개져 있습니다. 이렇게 하면 필요한 부분만 선택해서 조합할 수
있어 유연성이 높아지죠.
 
 
 
하지만 이런 구성 요소들을 여러 겹으로 중첩해서 사용할 때 문제가 생길 수 있습니다. 
예를 들어, Dialog 컴포넌트를 기반으로 만든 AlertDialog가 있다고 상상해봅시다.  AlertDialog 내부에서는 실제로 Dialog의 기능을 활용하지만, AlertDialog만의 추가 기능과 요구사항이 있어요. 
만약 Dialog와 AlertDialog를 한 화면에 함께 쓰거나, 서로 포함하는 구조로 쓴다면 어떻게 될까요? 
잘못하면 두 컴포넌트의 내부 상태나 컨텍스트가 섞여서 엉뚱한 동작이 일어날 수 있습니다.
 

Alert Dialog 코드 예시

 
Radix UI 팀은 이 문제를 해결하기 위해 스코프(scope)라는 개념을 도입했습니다. 
간단히 말해, 컴포넌트 인스턴스마다 독립적인 컨텍스트를 부여하는 것입니다. 
 
앞의 AlertDialog 예에서, AlertDialog는 자체적인 컨텍스트를 만들고 이를 내부에서 사용하는 Dialog에 넘겨줍니다. 
그렇게 하면 Dialog와 AlertDialog 각각 자기만의 상태 공간을 가지므로 충돌이 발생하지 않아요.
 
 실제로 Radix UI 개발자는 "AlertDialog에는 자체 컨텍스트가 있어서, 내부에 렌더링하는 Dialog에 그 컨텍스트를 전달하고 Dialog는 그것을 사용하게 만든다. 이렇게 하지 않으면 Dialog는 자기 자신의 DialogContext를 사용하게 되어 문제가 발생한다고 설명했습니다.
 
즉, 스코프를 이용해 컨텍스트의 범위를 한정함으로써 비슷한 구조의 컴포넌트들이 서로의 상태를 잘못 공유하지 않도록 막아주는 것이죠
 
참조) https://github.com/radix-ui/primitives/discussions/1091
 
쉽게 비유하자면, 여러 개의 라디오 채널이 있다고 생각해봅시다. 각 채널은 자기 주파수가 있어서 다른 채널 신호와 섞이지 않고 독립적으로 송출되죠. Radix의 스코프 개념도 이와 비슷하게, 컴포넌트별로 **자기만의 신호(컨텍스트)**를 주파수처럼 분리해준다고 볼 수 있습니다. 따라서 여러 인스턴스를 동시에 사용하더라도 각자 독립적인 상태를 유지하게 됩니다.
 

React와 Zustand로 Scope 개념 구현하기

 
이제 Radix UI의 스코프 아이디어를 우리만의 코드로 한 번 구현해보겠습니다. React에서는 보통 Context를 사용해서 컴포넌트 트리 전체에 걸쳐 상태를 공유할 수 있는데요. 여기서는 이 Context를 응용해서 컴포넌트마다 개별적인 상태 저장소(Zustand 스토어)를 제공하는 방법을 만들어볼 거예요.
Zustand는 원래 전역 상태를 관리하기 좋지만, 약간의 창의력을 더해 인스턴스별로 분리된 상태 관리도 할 수 있습니다.
전체적인 구현 전략은 다음과 같습니다:
 

  1. createScope 함수 – 새로운 스코프(컨텍스트와 Zustand 스토어)를 생성하는 유틸리티 함수를 만듭니다. 이 함수는 React Context를 만들고, 그 안에 독립적인 Zustand 스토어(상태)를 생성해 줄 거예요.
  2. ScopeProvider 컴포넌트 – createScope 함수가 반환하는 Provider 컴포넌트입니다. 이 컴포넌트를 사용하면 자식 컴포넌트들이 특정 스코프에 접근할 수 있도록 zustand 스토어를 컨텍스트로 공급해줍니다. 이 Provider를 매 인스턴스마다 사용하면 각 인스턴스마다 별도의 상태 저장소를 갖게 되겠죠.
  3. useScope 훅(Hook) – 해당 컨텍스트에서 Zustand 상태를 읽고 조작할 수 있는 커스텀 훅입니다. 이 훅을 사용하면 현재 스코프(컨텍스트)에 연결된 상태 값들과 업데이트 함수를 손쉽게 가져올 수 있어요.

이제 실제 코드로 확인해보겠습니다. 먼저 createScope 함수를 구현해보고, 그를 이용해 컴포넌트별 독립 상태를 가지는 예제를 만들어볼게요.
 
글쓰기에 앞서, zustand에 대한 정보가 필요하면 아래 공식 사이트 링크를 참고하세요.
https://zustand-demo.pmnd.rs/
 
zustand를 몰라도 "전역 상태"를 사용한다"는 개념만 이해하면 이해하기 쉽습니다.
provider를 사용해보기 위해 zustand store를 provider에 넣었는데, 어떻게 넣었는지는 아래 링크를 참고해주세요.
 
https://zustand.docs.pmnd.rs/guides/nextjs
 

// createZustandContextWithScope.ts
"use client";

import React, { createContext, ReactNode, useContext, useRef } from "react";
import type { StoreApi, UseBoundStore } from "zustand";
import { useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";

/**
 * createZustandContextWithScope
 *
 * zustand 스토어를 context Provider와 연계하는 유틸 함수에 스코프 개념을 추가한 버전입니다.
 * Provider와 hook 모두 선택적으로 scope 값을 받을 수 있으며, 동일한 scope를 사용한 컴포넌트끼리 별도의 상태를 공유합니다.
 */
export function createZustandContextWithScope<TStore extends object>(
  createStore: (initialState?: Partial<TStore>) => UseBoundStore<StoreApi<TStore>>
) {
  // scope 값을 key로 하는 React Context들을 저장할 WeakMap (key는 객체여야 함)
  const contexts = new WeakMap<object, React.Context<UseBoundStore<StoreApi<TStore>> | null>>();
  // scope가 없는 경우에 사용될 기본 컨텍스트
  let defaultContext: React.Context<UseBoundStore<StoreApi<TStore>> | null> | undefined;

  // scope 값(객체)이 있으면 해당 scope의 컨텍스트, 없으면 기본 컨텍스트를 반환
  function getContext(scope?: object) {
    if (!scope) {
      if (!defaultContext) {
        defaultContext = createContext<UseBoundStore<StoreApi<TStore>> | null>(null);
      }
      return defaultContext;
    } else {
      if (!contexts.has(scope)) {
        contexts.set(scope, createContext<UseBoundStore<StoreApi<TStore>> | null>(null));
      }

      return contexts.get(scope)!;
    }
  }

  // Provider는 선택적 scope prop(객체)을 받고, 해당 scope에 맞는 컨텍스트 Provider로 감쌉니다.
  const Provider = ({
    children,
    initialState,
    scope,
  }: {
    children: ReactNode;
    initialState?: Partial<TStore>;
    scope?: object;
  }) => {
    const ContextToUse = getContext(scope);
    const storeRef = useRef<UseBoundStore<StoreApi<TStore>>>();
    if (!storeRef.current) {
      storeRef.current = createStore(initialState);
    }
    return <ContextToUse.Provider value={storeRef.current}>{children}</ContextToUse.Provider>;
  };

  // hook도 선택적으로 scope 값을 받고, 동일한 scope에 대응하는 컨텍스트에서 zustand 스토어를 읽어옵니다.
  const useStoreFromContext = <U,>(selector: (state: TStore) => U, scope?: object): U => {
    const ContextToUse = getContext(scope);
    const store = useContext(ContextToUse);
    if (!store) throw new Error(`Zustand store is missing the Provider; required scope: ${scope}`);
    return useStore(store, useShallow(selector));
  };

  return {
    Provider,
    useStore: useStoreFromContext,
  };
}

 
 
코드가 이것저것 많은데요, 핵심 로직만 이해하면 쉽습니다.
 
위 코드에서 createZustandWithScope 함수는 새로운 Context와 Zustand 스토어 훅을 묶어서 제공하고 있습니다.
ScopeProvider가 마운트될 때 한 번만 create 함수가 실행되고, (Provider의 storeRef.current = createStore(initalState) 코드 부분)
 
컴포넌트가 언마운트되기 전까지 동일한 Zustand 스토어 인스턴스를 유지합니다. 이렇게 해야 Provider 컴포넌트가 리렌더되더라도 매번 새로운 스토어를 만들지 않고, 해당 스코프에 안정적인 상태 저장소가 할당되죠.
 
그리고 Scope를 외부에서 할당받아 주입해줍니다. 
createScope함수는 다음과 같습니다.

import { useRef } from "react";

export function useScope<T extends object = {}>(): T {
  const scopeRef = useRef<T>();
  if (!scopeRef.current) {
    scopeRef.current = {} as T;
  }
  return scopeRef.current;
}

 
객체를 사용하는 이유는, WeakMap이 unique한 키 값을 받아 set/get 및 가비지 콜렉팅을 관리하는데
참조하는 scope가 없으면 자동으로 가비지 콜렉팅 되도록 구현하려고 사용했습니다.
 

컴포넌트 예시 

이제 createScope를 활용해서 실제로 컴포넌트별 독립적인 상태가 잘 동작하는지 확인해보겠습니다.
 
예시로 Counter(카운터) 컴포넌트를 만들어 볼게요. 이 Counter는 내부에 자신의 상태로 숫자를 하나 들고 있고, 버튼을 누르면 그 숫자가 증가합니다. 중요한 점은, 이런 Counter 컴포넌트를 여러 개 렌더링해도 서로 다른 숫자를 가지도록 하는 것입니다. 전역적으로 상태를 공유했다면 하나 올릴 때 모두 올라가겠지만, 우리는 각 인스턴스가 분리되어야 하니까요!
 

// App.tsx
"use client";

import React from "react";
import { create, type StoreApi, type UseBoundStore } from "zustand";

import { createZustandContextWithScope, useScope } from "@/shared/libs/zustand";

// 1. 스토어 타입 정의
type CounterStore = {
  count: number;
  increment: () => void;
};

// 2. zustand 스토어 생성 함수 정의
const createCounterStore = (initialState?: Partial<CounterStore>): UseBoundStore<StoreApi<CounterStore>> =>
  create<CounterStore>((set, get) => ({
    count: initialState?.count ?? 0,
    increment: () => set({ count: get().count + 1 }),
  }));

// 3. 스코프를 지원하는 zustand context 생성 (WeakMap 사용)
const { Provider: CounterProvider, useStore: useCounterStore } = createZustandContextWithScope(createCounterStore);

// 4. 카운터 컴포넌트 (선택적 scope prop을 받음)
function Counter({ scope }: { scope?: object }) {
  const { count, increment } = useCounterStore((state) => ({ count: state.count, increment: state.increment }), scope);
  return (
    <div
      style={{
        border: "1px solid #ccc",
        padding: "1rem",
        margin: "1rem",
      }}
    >
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

// 5. App 컴포넌트에서 useScope를 통해 scope 객체를 생성
function App() {
  // 이제 useScope 훅을 통해 scope 객체를 생성하므로, useMemo를 직접 쓸 필요가 없습니다.
  const scope1 = useScope();
  const scope2 = useScope();

  return (
    <div>
      <h1>Scoped Zustand Example (useScope 활용)</h1>

      <CounterProvider scope={scope2} initialState={{ count: 100 }}>
        <CounterProvider scope={scope1} initialState={{ count: 10 }}>
          {/* 첫 번째 인스턴스 - scope1 */}
          <Counter scope={scope1} />

          {/* 두 번째 인스턴스 - scope2 */}
          <Counter scope={scope2} />
        </CounterProvider>
      </CounterProvider>

      {/* scope prop을 전달하지 않은 경우 (기본 컨텍스트 사용) */}
      <CounterProvider initialState={{ count: 0 }}>
        <Counter />
      </CounterProvider>
    </div>
  );
}

export default App;

 
위 예제에서 <CounterScopeProvider>는 각각 독립된 스토어를 만들어서 그 자식인 Counter에게 제공합니다. 따라서 화면에 Counter를 두 개 렌더링했지만, 각각 자기만의 count 상태를 가지게 됩니다. 첫 번째 버튼을 몇 번 눌러도 두 번째 Counter의 숫자에는 영향을 주지 않고, 반대의 경우도 마찬가지예요. 🎉
 

scope 로 상태를 격리한 코드 결과 예시 gif

 
만약 우리가 이렇게 스코프를 분리하지 않고 하나의 전역 Zustand 스토어를 두 Counter가 공유했다면 어떤 일이 벌어질까요? 아마도 하나의 버튼을 클릭할 때 두 Counter 컴포넌트가 모두 같은 상태를 참조하고 있기 때문에 같이 증가했을 거예요. 하지만 createScope로 인해 Provider마다 별도의 스토어 인스턴스를 쓰고 있으니 이러한 충돌이 사라집니다.
 
정리하면, 우리는 Radix UI의 "한 컴포넌트 그룹만을 위한 독립적인 컨텍스트"라는 개념을 React+Zustand 조합으로 구현했습니다. 이렇게 컨텍스트와 상태 저장소를 컴포넌트 단위로 캡슐화하면 재사용성과 상태 격리가 훨씬 쉬워집니다. 실제 사례로도, Zustand를 전역으로 쓰는 대신 각 컴포넌트 subtree별로 컨텍스트를 통해 스토어를 주입하면, 각 컴포넌트가 자신의 상태를 갖게 되어 테스트나 초기화가 간편해집니다. 여러 곳에서 동일한 컴포넌트를 렌더링할 때도 모두 독립적인 동작을 하게 되는 것이죠.
 
(zustand가 아니고 단순한 context api등 다른 전역 상태 라이브러리/방식을 사용해도 동일합니다.)
 

마무리: Scope로 인한 독립성의 가치

Radix UI의 scope 개념과 그것을 활용한 상태 격리 방법을 살펴보았습니다. 핵심 아이디어는 **"컨텍스트를 복제하여 인스턴스별로 분리한다"**라고 요약할 수 있겠네요. 이번에 만든 createScope와 useScope 패턴은 작은 예제이지만, 이러한 원리를 이해하면 더 복잡한 UI를 만들 때도 유사한 접근법을 활용할 수 있어요. 🙂
 
React와 Zustand를 사용한 이 방법으로 컴포넌트를 구성하면, 필요한 곳에만 국한된 상태를 가질 수 있어서 애플리케이션의 유지보수성이 높아집니다. Radix UI가 멋진 점은 이러한 문제들을 미리 고민하고 API에 녹여냈다는 것이고, 우리는 그 아이디어를 배우고 응용해 본 것이죠.
처음엔 조금 헷갈릴 수 있지만, 천천히 코드를 살펴보고 직접 따라 해보세요. 궁금한 점이 생기면 공식 문서나 레퍼런스도 참고하면 큰 도움이 됩니다. 부디 이번 튜토리얼로 스코프 개념에 대한 이해와 독립 상태 관리 기법을 얻어가시길 바랍니다. 🚀
 
 
아 참고로, 저는 이 scope 개념을 활용해 하나의 라이브러리를 만들었습니다.
https://github.com/lodado/react-namespace
 
zustand를 사용하지 않고 직접 전역 상태 관리 방법을 구현한것 이외에는 위 예시 코드와 거의 동일합니다.
그런데 전역 라이브러리를 만들어서 직접 배포해보니 경쟁자(zustand, redux 등)들이 너무 쟁쟁해서 사람들이 별로 사용하진 않는거 같네요.. ㅋㅋ  
 
 
참고 자료: Radix UI 공식 논의에서 컨텍스트 스코프 설명 github.comgithub.com,
Zustand와 React Context 활용에 대한 블로그 포스트 tkdodo.eu.
 
 
직접 구현한 코드 예시)
https://github.com/lodado/OXVoter/tree/main/src/shared/libs/zustand
 
 
 
 

반응형

'Front-end > React' 카테고리의 다른 글

Next 14 tree shaking 관련 조사  (0) 2024.02.27
jotai 내부 구조 훑어보기  (0) 2023.11.25
유연한 컴포넌트 만들기 - 모듈화와 추상화  (0) 2023.06.10
react HOC와 decorator  (0) 2023.05.14
react Dialog tab trap 만들기  (1) 2023.05.01
반응형

next14로 사이드 프로젝트를 진행중인데, next14에서 빠른 로딩 및 UX를 위해서 treeshaking을 어떻게 하면 잘 할수 있는지 찾아봤고 요약해 공유한다.

 

1. next는 페이지 기반 code split을 자동으로 해준다.
production 모드에서 확인 가능

 

2. next는 webpack 기반이다. next에서 babel 대신 swc 기반으로 동작한다.

 

3.  전역 변수 등도 쓰이지 않는다면 webpack이 자동으로 트리쉐이킹 해준다. next 및 webpack version이 높아지면서 성능이 좋아졌다.

 

4.  package.json 에서 sideEffects: false 옵션을 키면 코드들이 side Effect 가 없다고 가정하고, 휴리스틱하게 트리쉐이킹 해준다. 혹은 아래와 같이 특정 폴더만 side Effect 가 있다고 지정할 수도 있다.

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

 

혹은 PURE 옵션을 이용 가능하다.

const Button$1 = /*#__PURE__*/ withAppProvider()(Button);​

 

공식 문서에서 위와 같이 pure 옵션을 사용해서 side-effect-free인 코드라고 명시 가능하다고 설명한다만 라이브러리에서 실제로 쓰는건 별로 못본듯..?

 

테스트 결과 전역 클로저, 클래스 등도 모두 트리쉐이킹 된다.

다만, 아래와 같은 케이스를 조심해야한다.

 

1. 함수 내부에서 외부의 값에 영향을 준다든지(side Effect) 예측 불가능한 동작을 한다면 트리쉐이킹이 정상적으로 동작하지 않을 수도 있다.

순수함수를 쓰는게 코드 관리에도 좋고 성능에도 좋으니 순수함수적으로 짜도록 더욱 노력하면 좋을것 같다. 
 

2. ES6 문법(imprt, export)등을 사용해야한다. module.exports 등 es5 문법을 사용하면 트리쉐이킹이 안될 수도 있다.
특히, babel을 쓴다면 주의

{
  "presets": [
    ["env", {
      "modules": false
    }]
  ]
}​

 
babel-preset-env를 쓴다면 .babelrc에서 ES6 모듈만 남도록 옵션을 설정한다.

 

3. 라이브러리 등을 import시

import * as lodash from  'lodash-es'

 

위 방법을 쓴다면 무엇을 원하는지 알기 쉽지 않아서 트리쉐이킹 최적화가 힘듬

 

import { flow } from 'lodash-es'
import { Box } from '@mui/material'
 

필요한 부분만 import해서 사용!

 

export * from '~~' (barrel pattern)등도 과거에는 트리쉐이킹이 안되었으나 최신 버전은 지원한다.

 

참고)

 

https://webpack.js.org/guides/tree-shaking/

 

vercel/next.js#49742

 

^ 과거에 올라온 이슈인데 아직도 글이 써지는걸 보니까 관련 이슈가 핫한 모양..

반응형
반응형

회사에서 복잡한 react 컴포넌트(테이블)를 만들 일이 생겨서

c++의 namespace 기능처럼 provider 내부에서만 전역이고 밖에서는 사용 불가능한 context 기반의 라이브러리를 찾고 있었다.

 

그래서 찾은것중 하나가 jotai였고, 용량이 작고 꾸준히 업데이트 되는 점이 마음에 들어서 jotai를 사내 라이브러리 용으로 

쓰다보니 다음과 같은 여러가지 의문이 생겨서 코드를 훑어보기로 했다.

 

바로, 

 

1. context api는 상태가 하나 바뀌면 provider 하위 컴포넌트들이 전부 rerendering 되는데 jotai는 해당 문제를 해결하기 위해서 어떻게 구현했는지?

2. react에서 리렌더링이 되지 않고 현 상태값을 어떻게 알고 있는지??

 

2번 의문이 뭔지 구체적으로 설명하기 위해서 우선 jotai에 대해 설명해보자면,

jotai 상태를 재정의할때 쓰는 write 전용 useSetAtom이라는 hook 혹은 wrtieOnlyAtom이라는 atom이 있다.

const [state, setState] = useState의 setState의 jotai 버젼이라고 보면 된다. 

 

App.js
import { atom, useAtom } from "jotai";

const dotsAtom = atom([]);
const drawingAtom = atom(false);

const handleMouseDownAtom = atom(
  null,
  (get, set) => { // get(drawingAtom) 을 쓰면 현 상태 값을 불러올 수 있다! 
    set(drawingAtom, true);
  }
);

const handleMouseUpAtom = atom(null, (get, set) => {
  set(drawingAtom, false);
});

(https://tutorial.jotai.org/quick-start/write-only-atoms에서 발췌)

 

이 기능들을 쓰기 위해선 "현 상태"를 알아야할 때도 있을텐데 (주석 부분 참고)

writeOnlyAtom만을 쓰면 그 atom을 쓰는 컴포넌트는 상태가 바뀌어도 리렌더링이 되지 않는다.

react에서 리렌더링이 되지 않고 현 상태값을 어떻게 알고 있는지??

 

예를 들어 숫자를 +1, -1 하는 counter 컴포넌트를 만든다고 했을때

+1을 하기 위해 지금 상태 값을 알고, 그 값에 +1 혹은 -1를 해줘야할텐데

react에서 리렌더링을 유발하지 않고 어떻게 지금 상태값을 알고 있는건지???

 

두가지 의문점이 생겨서 내부 코드를 뜯어보기로 했다. (특히 2번)

깊게 보지는 않고, 위 2가지 의문점을 해결하는 것 중심으로 살펴볼 것이다.

 

 

우선 파일구조부터 보자.

https://github.com/pmndrs/jotai/tree/main/src

 

구조를 보니 vanila 폴더에 기본 로직이 있고 react 폴더에 react와 vanila를 이어주는 부분이 있을 것 같다.

 우선 react 부분부터 보자..

 

https://github.com/pmndrs/jotai/tree/main/src/react

 

생각보다 심플한 구조다. 

우선 provider를 봐보자 

 

provider.ts

export const useStore = (options?: Options): Store => {
  const store = useContext(StoreContext)
  return options?.store || store || getDefaultStore()
}

export const Provider = ({
  children,
  store,
}: {
  children?: ReactNode
  store?: Store
}): FunctionComponentElement<{ value: Store | undefined }> => {
  const storeRef = useRef<Store>()
  if (!store && !storeRef.current) {
    storeRef.current = createStore()
  }
  return createElement(
    StoreContext.Provider,
    {
      value: store || storeRef.current,
    },
    children,
  )
}

https://github.com/pmndrs/jotai/blob/main/src/react/Provider.ts

 

react 폴더 안에 provider.ts란 파일이 있다. 

 

useRef 안에 아까 본 "vanila 로직이 든 객체"를 넣고, 그 ref를 provider에 넣어주고 있다.

이럼 ref값이 바뀌어도, 상태가 아니니 react에서 리렌더링이 발생하지 않게 되는데

어디선가 리렌더링을 강제로 발생시키는 로직을 쓰고 있을것 같다.

 

추가적으로 useStore에서 getDefaultStore라는 값을 쓰고 있는데

provider가 명시가 안되면 전역에 한 createStore 객체를 만들고,

그 객체를 계속 참조하는것 같다.

 

useState의 jotai판인 useAtom을 봐보자.

 

useAtom.ts

export function useAtom<Value, Args extends any[], Result>(
  atom: Atom<Value> | WritableAtom<Value, Args, Result>,
  options?: Options,
) {
  return [
    useAtomValue(atom, options),
    // We do wrong type assertion here, which results in throwing an error.
    useSetAtom(atom as WritableAtom<Value, Args, Result>, options),
  ]
}

https://github.com/pmndrs/jotai/blob/main/src/react/useAtom.ts

 

useAtom이 뭔지 간단히 설명하자면, 말 그대로 useState인데 jotai atom을 참조해서 사용하는

useState의 전역 버젼이다. 

const [state, setState] = useAtom(jotaiAtom)

 

이런식으로, react를 알고 있다면 러닝커브가 낮아 쉽게 사용 가능해서 jotai를 고른 이유도 있다.

useAtom은 useAtomValue와 useSetAtom을 반환하는데 각각, read/write 기능이다. 

useAtomValue에 우리가 궁금했던 1번 로직이 담겨있을 것 같다.

 

보고 싶은 부분만 추려보자..

 

useAtomValue.ts

export function useAtomValue<Value>(atom: Atom<Value>, options?: Options) {
  const store = useStore(options)

  const [[valueFromReducer, storeFromReducer, atomFromReducer], rerender] =
    useReducer<
      ReducerWithoutAction<readonly [Value, Store, typeof atom]>,
      undefined
    >(
      (prev) => {
        const nextValue = store.get(atom)
        if (
          Object.is(prev[0], nextValue) &&
          prev[1] === store &&
          prev[2] === atom
        ) {
          return prev
        }
        return [nextValue, store, atom]
      },
      undefined,
      () => [store.get(atom), store, atom],
    )

  let value = valueFromReducer
  if (storeFromReducer !== store || atomFromReducer !== atom) {
    rerender()
    value = store.get(atom)
  }

  const delay = options?.delay
  useEffect(() => {
    const unsub = store.sub(atom, () => {
      if (typeof delay === 'number') {
        // delay rerendering to wait a promise possibly to resolve
        setTimeout(rerender, delay)
        return
      }
      rerender()
    })
    rerender()
    return unsub
  }, [store, atom, delay])

  useDebugValue(value)
  // TS doesn't allow using `use` always.
  // The use of isPromiseLike is to be consistent with `use` type.
  // `instanceof Promise` actually works fine in this case.
  return isPromiseLike(value) ? use(value) : (value as Awaited<Value>)
}

 

 

핵심 부분은 useEffect 부분의, 

  useEffect(() => {
    const unsub = store.sub(atom, () => {
      if (typeof delay === 'number') {
        // delay rerendering to wait a promise possibly to resolve
        setTimeout(rerender, delay)
        return
      }
      rerender()
    })
    rerender()
    return unsub
  }, [store, atom, delay])

 

sub/pub 부분인것 같다. 이 코드를 보고 나니 아까 품었던 의문 1,2가 풀린것 같다. 

jotai는 부분 렌더링을 지원하기 위해서 Publish-subscribe pattern(https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)을 사용하고 있었다.

 

어떤 "atom"을 component에서 사용한다면 해당 컴포넌트를 subscribe를 해주고 

atom의 값이 바뀌면 이벤트를 발행해서 해당 컴포넌트를 모두 rerendering 시키고 있는 간단한 형식이었다.

 

그리고, atom의 값은 완전한 상태가 아니고 store라는 객체가 가지고 있는 값이여서 아까 품었던 2번 의문(writeOnlyAtom에서 리렌더링 하지 않고도 최신 값을 가져올 수 있었던 방법)도 해결 되었다.

 

useAtom, 정확히는 useAtomValue를 쓰지 않는 컴포넌트는 리렌더링 되지 않았던 것..

 

생각보다 코드가 단순했던것 같다.

react에서 외부 라이브러리와 리엑트 상태를 동기화시키고 tearing을 방지 시키기 위해서 usesyncexternalstore? 를 쓰는 경우도 있다고 들었는데 jotai는 useEffect로 동기화 시키고 있었다. 

 

그외에 바닐라 폴더에서 비동기/동기 처리를 위해

여러 흥미로운 로직을 쓰고 있었는데 이건 다음 시간에 살펴봐야겠다.

반응형
반응형

소프트웨어는 탄생과 동시에 생명 주기를 갖는다.

소프트웨어 개발자는 소프트웨어가 변경될 가능성에 더 신경써야 한다.

 

소프트웨어 개발 수명 주기 (Software Development Life Cycle,&nbsp; SDLC ), 출처 - 위키피디아

 

소프트웨어는 시간의 흐름에 따라 언젠가 죽음을 맞이하게 될텐데 초기 설계 단계에서부터 변경과 폐기에 유연한 소프트웨어를 개발해야한다. 

 

특히 프론트엔드 개발은 개발 패러다임이 자주 바뀌거나

사용하던 라이브러리의 버전업 & 유지보수가 끝나는 일이 잦은 편이라

코드 폐기 & 수정에 유연한 프로그램을 만들어야한다. 

 

그럼 프론트엔드에서 어떻게 유연한 구조를 가질 수 있을까?

 

개발자로 일하면서 기존 코드의 수정이 너무 힘들어서 좀 더 좋은 아키텍처를 만들수는 없을지, 

어떻게 변경에 유연한 소프트웨어를 만들지 궁금해서 몇주간 찾아봤고,

해답을 모듈화와 추상화에서 찾았다.

 

모듈화

모듈화 예시

 

모듈화란 큰 시스템을 작은 부분으로 나누는 프로그래밍 설계 기법이다.

작은 부분들을 모듈이라고 부르며, 각 모듈은 독립적으로 기능을 수행하고 다른 모듈과는 추상화된 인터페이스를 통해 상호작용한다. 

 

사실 리엑트에서 컴포넌트를 사용하고 컴포넌트들을 조합해서 개발하고 있다면 모듈화를 잘 활용하다고 볼 수 있지만

인터페이스로 상호작용하라는 말이 잘 안와닿을수도 있다.

 

예를 들어 아래 그림 같이 MUI 라이브러리의 버튼을 사용한다고 해보자.

 

import React from 'react';
import Button from '@mui/material/Button';

const ExampleList = () => {
  ... // 생략

  return (
  ....
    <Button variant={'primary'} color={'blue'}>
      confirm
    </Button>
    <Button variant={'primary'} color={'red'}>
      exit
    </Button>
   ....
  );
};

 

그런데 MUI의 라이브러리의 지원이 끝났고 Ant.d 라이브러리나 다른 라이브러리 버튼으로 교체한다고 생각해보자.

 모든 페이지의 MUI button을 찾아서 수정해야할텐데 상상만 해도 끔찍하다...

 

해당 방식을 피하기 위해선 아래처럼 "Button" 을 모듈화 하고 Prop으로 필요한 값, 함수들을 내려보내는 식으로 해결하면 된다.

 

// MyButton.js
import React from 'react';
import Button from '@mui/material/Button';

const MyButton = ({ variant, color, children, ...props }) => {
  return (
    <Button variant={variant} color={color} {...props}>
      {children}
    </Button>
  );
};

export default MyButton;

 

상당히 간단한 일이지 않을까? 아니, 사실 개발하면서 우리가 계속 해오고 있던 일이다.

 

버튼의 상세 구현은 Mybutton 컴포넌트 내부 로직에 격리되어 있고, 외부에서 prop을 통해 필요한 값을 주입하면서 서로 통신하고 있다. 

prop을 사용하는 컴포넌트와 실제 구현된 컴포넌트를 연결하는 인터페이스로 쓰고, List는 모듈화 된 Button을 내부 구현을 몰라도 가져다 쓸 수 있는 셈이다.

 

모듈화는 레고 조립과 같다. 자동차에서 타이어를 교체하기 위해서는 타이어만 바꿔 끼우면 되지 자동차 엔진 내부 구조까지  알아야할 필요가 없을 것이다. 

 

이를 컴포넌트 주도 개발 - CDD (컴포넌트를 모듈 단위로 개발하는 및 설계 방법론)라 부른다. 

 

모듈화를 잘 만들기 위해선 객체 지향 방법론의 SOLID를 참고하면 좋을 듯 싶다.

특히, 의존 역전 원칙(DIP) - "고수준(추상화된 인터페이스)은 저수준(상세 구현)에 의존하지 말아야 한다."

을 지키면 좋은 설계 구조를 가질 수 있다.

 

테스트

각 로직을 모듈화했다면 테스트 코드도 작성하면 좋을것 같다.

테스트는 변경에 유연한 컴포넌트 구조를 만들기 위한 핵심이다.

각 모듈은 유닛 테스트로 안정성을 보장하고,

통합 테스트로 연결된 모듈끼리 안정적인지 보장하면 소프트웨어가 견고해질 것 같다. 

 

테스트는 항상 동일한 input에서는 동일한 output이 보장되어야한다.

 

만약 특정 요일에만 세일하는 함수을 테스트한다고 해보자.

 

const isSale = () => {
    const date = new Date();
    const day = date.getDay(); // 0이 일요일, 1이 월요일
	
    return day === 1;
};

 

해당 함수를 테스트하면 월요일에만 성공할 것이다. 또한, 이 함수를 사용하는 모든 모듈들은 월요일에만 성공할 것이다.

그렇다고 이 함수만을 mocking하면 통합 테스트를 하는 의미가 없을것이다.

이 함수가 통합 테스트에서 정상 작동하는 보장이 없으므로 mocking은 최후의 대안이다.

 

왜 이 함수는 테스트가 힘들까?

왜냐하면 함수가 순수하지 않은 부분(Date 객체 - side Effect가 있는 부분)이 있기 때문이다.

순수하지 않은 함수는 오염된다. 오염된 함수는 다른 모듈도 오염시킨다. 

 

비순수함수는 최대한 회피하거나 격리시켜야 한다.

 

회피

 

회피의 예시로는 parameter를 사용하는 방식이 있다.

const isSale = ({ date: new Date() }) => {
    const day = date.getDay(); // 0이 일요일, 1이 월요일
	
    return day === 1;
};

순수하지 않은 부분을 외부(parameter)로 밀어내고 default Parameter로 변경시키면 

순수함수인척 눈속임이 가능하다.

 

격리

 

격리의 예시로는 네트워크 콜이 있을 것이다. API call은 서버 상태에 따라 결과가 달라지므로 어쩔 수 없이 격리 시키고 mocking시켜야 한다.

 

결론

비순수함수의 격리

모듈끼리 통신하는 부분은 순수함수로 작성하는것이 최선이다만 sideEffect가 발생하는 부분은 외부로 밀어내서 격리 시키거나 회피하는것이 좋다.

 

로직의 모듈화 

 

그렇다면 "컴포넌트 내부"는 어떨까?

컴포넌트를 개발하면서 api나 여러 함수, 조건문들이 엮여서 유지보수에서 어려움을 겪은적이 있을것이다.

 

컴포넌트 내부 로직들도 모듈화를 시키고 마치 레고처럼 조합해서

필요한 부분만 교체하고 폐기시키면 더 좋지 않을까?

 

그럼 무엇을 나눌 수 있을까?

나누기 전에 무엇이 있는지 정의부터 해야할 것 같다.

 

예시 그림들은 https://martinfowler.com/articles/modularizing-react-apps.html 에서 가져왔다.

해당 글은 코드 예시를 들어서 상세히 설명해주고 있으니 읽어보는것이 좋을것 같다.

 

컴포넌트 내부 

 

1. 모든게 섞여있는 컴포넌트

모든게 섞여있는 single Component

리엑트 컴포넌트 내부에는 크게 아래의 4가지로 묶을 수 있을것 같다.

 

1. 네트워크 (API 콜)

 

네트워크를 통해 가져오는 데이터들이다. 

순수하지 않은 특성(side Effect)를 가지고 있다. 

 

2. 상태

 

지역 상태 - useState나

전역 상태 - redux, justand, context 등 view를 제어하는 로직들을 말한다.

state를 변경하는 action과 observing되서 변경되는 state들로 이루어져 있다.

 

3. 도메인 로직 

여기서 3번 도메인 로직이 좀 생소한 개념일수도 있는데

예를 들어서 쇼핑 결제 파트를 만들때 원으로 결제할지 혹은 달러나 엔으로 결제할지등의 오프라인의 문제를 프로그래밍으로 해결하기 위한 방법을 말한다. 

 

도메인 로직은 프로그래밍이 아닌, 외부 이벤트에 따라 변경될 가능성이 높고 따라서

모듈화를 통하여 저수준 (상세 구현)을 격리하고 인터페이스를 통해 통신하는 방식이 유지보수에 도움이 된다.

 

4. UI 로직 (render)

 

최종적으로 브라우저에 보여지는 부분을 말한다.

리엑트의 JSX는 babel를 통해 React.CreateElement로 변환되고 이는 최종적으로 브라우저의 HTML로 반영된다

 

여기서 4번 UI 부분은 디자인에 의해 자주 변경되고 1,2,3 번(logic)들에 의해 최종적으로 브라우저에 반영되는 결과물이다.

따라서 view와 logic을 일단 분리시켜야 유지보수가 편해질것 같다.

 

2. view와 logic이 분리된 컴포넌트 

logic은 hook으로 분리시키고, view 부분은 logic에 따라서 반영되는 순수한 UI 컴포넌트가 되었다.

또한 logic을 분리시켰으니 다양한 UI에 hook를 재사용 가능할 것 같다.

 

그런데 아까 보았던 1,2,3번 logic이 아직 모듈로 분리가 되지는 않았다. (예시 사진에 state가 분리되있긴 하지만 전역 상태는 hook에 있다고 치자.)

 

도메인 로직을 처리할때 엔화로 결제할지, 아님 원으로 결제할지가 중요하지

실제 프론트엔드가 redux를 쓰는지 context를 쓰는지 뭘 쓰는지는 중요하지 않고 알 필요도 없는게 이상적이다.

따라서, state logic과 도메인 로직은 분리되어야 나중 라이브러리 교체(redux->justand 등등)도 수월해진다.

 

마찬가지로 네트워크 API를 사용할때 상세 구현인

웹소캣을 사용하는지, api 콜을 기억해두었다 localStorage에서 꺼내와서 쓰는지가

다른 로직 입장에선 주 관심사가 아니다. 그냥 내가 필요한 데이터만 요청해서 꺼내와서 쓰면 된다.

 

또한, 테스트 측면에서도 네트워크 부분은 비순수함수이므로 격리되어야 한다.

 

따라서 각각 logic은 분리되고 인터페이스로 통신하는것이 이상적이다.

 

3. logic이 모듈화된 컴포넌트 

 

이제 각 로직이 모듈화되었고 서로 인터페이스로 따라 통신하면서 레고처럼

로직을 교체하기 쉬운 구조가 된 것 같다.

 

분리를 통해 유연해진 컴포넌트를 작성한 것 같다.

 

logic을 교체할때 좋은 방식은 무엇이 있을까?

 

예를 들어서, useHook에서 도메인 변경 - "결제 방식을 엔화에서 원화로 바꾸는 예시"를 들어보자.

의존성 주입에서 영감을 받아서 prop으로 도메인 "전략"을 바꾸면 좋을 것 같다.

 

객체 지향을 사용해도 되고 아니면 함수를 고차함수로 사용해도 되고 취향에 따라 사용하면 된다.

아래 예시는 객체 지향의 전략 패턴이다.

전략 패턴 예시

 

코드로 보면 다음과 같다.

 

export interface PaymentStrategy {
  getRoundUpAmount(amount: number): number;

  getTip(amount: number): number;
}

export class PaymentStrategyAU implements PaymentStrategy {
  get currencySign(): string {
    return "원";
  }

  getRoundUpAmount(amount: number): number {
    return Math.floor(amount + 1);
  }

  getTip(amount: number): number {
    return parseFloat((this.getRoundUpAmount(amount) - amount).toPrecision(10));
  }
}

 

interface를 사용해서  고수준에서 클래스의 "동작"를 정의했다.

그리고 interface를 상속한 클래스에서 실제 클래스의 "작업"을 구현했다.

 

  export const useRoundUp = (amount: number, strategy: PaymentStrategy) => {
    const [agreeToDonate, setAgreeToDonate] = useState<boolean>(false);
    ....
  
    const { total, tip } = useMemo(
      () => ({
        total: strategy.getRoundUpAmount(amount),
        tip: strategy.getTip(amount),
      }),
      [agreeToDonate, amount, strategy]
    );
    
    ...
  
    return {
      .....
    };
  };

 

위 코드는 hook에서 domain을 위에서 주입받은 예시이다.

strategy는 상황에 따라 바꿔끼면 다양한 domain을 상황에 따라 갈아끼울 수 있다.

 

여담으로

특별 할인 이벤트할때마다 react를 다시 빌드하고 싶지 않으면

프론트엔드에서 도메인 로직을 관리하기 보다는 백엔드에서 주 정책을 관리하는편이 좋다. (api call을 통해 주입받는게 좋음)

 

백엔드에서 data로 what을, 프론트에서 UI/UX로 how를 풀어나가는 편이 좋은것이라 생각한다.

 

 

reference

 

구글 엔지니어는 이렇게 일한다 - 일부 부분에서 영감을 받음 

https://product.kyobobook.co.kr/detail/S000061352347?utm_source=google&utm_medium=cpc&utm_campaign=googleSearch&gclid=CjwKCAjwm4ukBhAuEiwA0zQxkwPDg9tDbJxxFQn99zEAQXUJzUseeGWdT7iUhjtNc5yC5GNZl10M8BoCvCgQAvD_BwE 

 

구글 엔지니어는 이렇게 일한다 | 타이터스 윈터스 - 교보문고

구글 엔지니어는 이렇게 일한다 | 구글은 어떻게 개발하고 코드를 관리하는가지난 50년의 세월과 이 책이 입증한 사실이 한 가지 있습니다. 바로 '소프트웨어 엔지니어링의 발전은 결코 정체되

product.kyobobook.co.kr

 

https://martinfowler.com/articles/modularizing-react-apps.html

 

Modularizing React Applications with Established UI Patterns

Learn how to apply established UI patterns for a more organized and maintainable codebase and discover the benefits of layering architecture in React development

martinfowler.com

 

반응형

'Front-end > React' 카테고리의 다른 글

Next 14 tree shaking 관련 조사  (0) 2024.02.27
jotai 내부 구조 훑어보기  (0) 2023.11.25
react HOC와 decorator  (0) 2023.05.14
react Dialog tab trap 만들기  (1) 2023.05.01
프론트엔드 테스트 정리 요약  (1) 2022.11.30
반응형

주의: 이 글은 삽질기입니다...

HOC(Higher-Order Component)란?

컴포넌트를 구현하면서 공통된 로직을 재사용해야할 때가 있다. 

보통 공통 로직은 hook으로 처리하면 되지만, error boundary같은 wrapper component나 로깅을 구현해야할때가 있는데

 

js의 고차함수 개념을 활용한 HOC 개념을 사용하면 wrapper component를 쉽게 구현할 수 있다.

(보통 HOC는 class component에서 많이 사용하는데 함수 컴포넌트에서도 사용 가능하다.)

 

import { Component } from "react";

function withLogging(WrappedComponent) {
  return class extends Component {
    componentDidMount() {
      console.log(`Component ${WrappedComponent.name} is mounted.`);
    }

    componentDidUpdate() {
      console.log(`Component ${WrappedComponent.name} is updated.`);
    }

    componentWillUnmount() {
      console.log(`Component ${WrappedComponent.name} is unmounted.`);
    }

    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}

const App = () => {
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
    </div>
  );
};

export default withLogging(App);

(위 코드는 hoc component 예시)

 

고차함수는 React.memo나 mobx의 observer등 여러 라이브러리에서 유용하게 사용되고 있다.

HOC의 개념을 몰라도 리엑트를 사용하다보면 몇번은 사용해봤을 것이다.

 

decorator?

 

그런데 어디서 유사한걸 많이 봤지 않았나?

java의 spring이나 nest, typeORM 등에도 리엑트의 HOC와 비슷한 역할을 하는 함수가 있다. 

@Get('users')
@Auth('admin')
findAllUsers() {}

바로 decorator이다. 즉, HOC는 디자인패턴의 데코레이터 패턴과 유사한 기능을 한다고 생각하면 될듯하다.

 

나는 HOC를 js(혹은 ts)의 decorator로 대체할 수 있는 방법을 찾고 싶었고, 

@withLogging
const app = () => {
  ...
}

 

이 글의 주제를 HOC를 함수 컴포넌트의 decorator로 변환하는 법으로 쓰고 싶었는데

현재 2023년 05월 기준으론 js(ts)에서는 decorator는 클래스형 컴포넌트(object)에만 사용이 가능한것으로 판명났다.

 

즉, 내가 원했던것의 반대로 decorator가 HOC로 대체 된듯하다.. 원하는걸 하고 싶으면 고차함수를 쓰라는 것 같다.

 

ㅠㅜ

 

비록 함수 컴포넌트에는 쓰지 못하지만 만약 일급 컬렉션 객체나 repository pattern for api를 class형으로 사용할 경우 decorator는 사용해볼만 한것 같다..! 

 

ref 

 

https://ko.legacy.reactjs.org/docs/higher-order-components.html

 

고차 컴포넌트 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

https://www.typescriptlang.org/ko/docs/handbook/decorators.html

 

Documentation - Decorators

TypeScript Decorators overview

www.typescriptlang.org

 

반응형
반응형

요즘 키보드만으로 브라우저를 조작할 수 있도록 공부를 해보고 있다.

이 글은 키보드 유저를 위한 tabtrap이라는 기술에 대해 다룬다. 

 

Tab Trap

모달은 사라질 때까지 사용자는 그 영역에서만 집중할 수 있어야하고, 모달을 닫기 전까지는 벗어나서는 안된다. 

해당 기법을 위한 tab trap을 구현하는 방식은 간단한데

 

모달의 첫 엘레먼트와 마지막 엘레먼트를 체크한 이후, 

마지막 엘레멘트에서 tab keyboard 이벤트가 들어올 경우 처음 엘레먼트로 이동시키고

반대로 첫번째 엘레먼트에서 shift + tab keyboard 이벤트가 들어올 경우 마지막 엘레멘트로 이동시킨다. 

 

dialog 코드 예시로 보자.

// getPortal 함수, Portal 컴포넌트 등은 react의 createPortal를 사용하기 위한 추가적인 함수 
function getPortal() {
  if(typeof window !== 'object') return null;
  let portalRoot = document.getElementById("portal");

  if (!portalRoot) {
    portalRoot = document.createElement("div");
    portalRoot.setAttribute("id", "portal");
    document.body.appendChild(portalRoot);
  }

  return portalRoot;
}

const Portal = ({ children }) => {
  const element =
    typeof window !== "undefined" && getPortal();
  return element && children ? createPortal(children, element) : null;
};


const DIALOG_HEAD = ':dialog-head:'
const DIALOG_PARAGRAPH = ':dialog_paragraph:'

const Dialog = ({
}) => {
  return <Portal>
      <DialogWrapper ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby={DIALOG_HEAD} aria-describedby={DIALOG_PARAGRAPH}>
        <DialogHead>
          <h2 id={DIALOG_HEAD}>dialog head</h2> 
          <IconButton aria-label="close button">X</IconButton> {/* tag를 icon or svg로 해야하는데 귀차니즘으로 생략 */}
        </DialogHead>
         
        <DialogBody>
        <p id={DIALOG_PARAGRAPH}>this is dialog body description
        </p>

        </DialogBody>

        <ButtonWrapper>
        <button type="button">OK</button>
        <button type="button">Cancel</button>
        </ButtonWrapper>
      </DialogWrapper>
  </Portal>
};

 

구현된 dialog 예시

 

copound pattern까지 넣어서 설명할까 하다가 배보다 배꼽이 커지는거 같아서

직관적인 마크업으로 작성했다.

 

위 구현 예시는 아직 tab Trap이 구현되지 않은 예시이다.

여기서 tab으로 화면을 순회한다면?

 

마지막 엘레먼트(Cancel)에서 tab을 누르는 순간 포커스가 화면 밖으로 나가게된다.

 

만약 dialog가 열린 경우 다른 엘레먼트의 작동을 비활성화 해놓았다면

tab을 사용하는 저시력 사용자의 경우 브라우저를 이용하는데 애로사항이 생길 수 있다.

그래서 tab trap을 적용하여 dialog를 다시 만들어보자.

 

const DIALOG_HEAD = ':dialog-head:'
const DIALOG_PARAGRAPH = ':dialog_paragraph:'

const Dialog = ({
}) => {

  const ref = useRef(null);

  useEffect(() => {
    let focusableElements = ref.current.querySelectorAll(
      'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex="0"]'
    );

    let firstFocusableElement = focusableElements[0];
    let lastFocusableElement = focusableElements[focusableElements.length - 1];

    const handleKeyDown = (event) => {
      if (event.key === 'Tab') {
        focusableElements = ref.current.querySelectorAll(
          'a[href], button, textarea, input[type="text"], input[type="radio"], 
          input[type="checkbox"], select, [tabindex="0"]'
        );

        firstFocusableElement = focusableElements[0];
        lastFocusableElement = focusableElements[focusableElements.length - 1];

        if (event.shiftKey && document.activeElement === firstFocusableElement) {
          lastFocusableElement.focus();
          event.preventDefault();
        } else if (!event.shiftKey && document.activeElement === lastFocusableElement) {
          firstFocusableElement.focus();
          event.preventDefault();
        }
      }
    };

    firstFocusableElement.focus();
    ref.current.addEventListener('keydown', handleKeyDown);
    return () => {
      ref.current.removeEventListener('keydown', handleKeyDown);
    };
  }, []);


  return <Portal>
      <DialogWrapper ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby={DIALOG_HEAD} aria-describedby={DIALOG_PARAGRAPH}>
        <DialogHead>
          <h2 id={DIALOG_HEAD}>dialog head</h2> 
          <IconButton aria-label="close button">X</IconButton> {/* tag를 icon or svg로 해야하는데 귀차니즘으로 생략 */}
        </DialogHead>
         
        <DialogBody>
        <p id={DIALOG_PARAGRAPH}>this is dialog body description
        </p>

        </DialogBody>

        <ButtonWrapper>
        <button type="button">OK</button>
        <button type="button">Cancel</button>
        </ButtonWrapper>
      </DialogWrapper>
  </Portal>
};

 

해당 코드의 경우 dialog이 열릴 경우 맨 처음 focus 가능한 element에 focus가 잡히고, 그 후 tab을 이용해 이동시

밖으로 못 빠져나가게 자바스크립트로 구현되었다.

 

tab trap은 react 라이브러리인 MUI 라이브러리에도 구현되어 있다.

 

mui dialog 예시

 

 

여담으로 찾아본 결과 웹 접근성 === 텝 접근은 아닌거 같다.

웹 접근성을 도와주는 항목중 하나가 탭 인터렉션인듯 싶은데 (교집합이 있는 집합 관계 느낌?)

이 부분은 좀 더 공부를 해봐야할듯..? 

 

 

혹은 HTML tag인 inert를 외부 tag들에 넣어주면 되는데 inert는 아직 브라우징 지원이 완벽하진 않을듯하다..? 

 

https://ui.toast.com/posts/ko_20220603

반응형
반응형

프론트엔드 코드의 특성

  1. 코드의 잦은 변경(디자인)
  2. 짧은 생명주기
  3. 정량적 평가(디자인, UI/UX)의 어려움

이는 테스트의 어려움으로 이어지게 된다.
프론트엔드의 특성을 잘 살린

  1. 잦은 변경에 대응 가능한 (react-testing library)
  2. 생명 주기에 구애받지 않는 (모듈화후 통째로 교체)
  3. 시각적 요소 평가가 편하게 (storybook & chromatic)

코드, 테스트 코드를 작성하는게 좋다.

 

프론트엔드 테스트의 종류

크게 두가지 요소로 나눌 수 있다.

  1. 기능적 테스트(소프트웨어 공학적 테스트)
  2. 시각적 테스트(디자인, UI/UX 등)

 

기능적 테스트

Static Test
정적 테스트는 코드를 실행시키지 않고 테스트를 하는 것
오타나 type에러 같은 개발자의 실수로 인해 발생하는 에러를 미연에 방지
대표적 도구 : typescript, eslint, prettier

 

unit
각 모듈을 단독 실행 환경에서 독립적으로 테스트
특정 컴포넌트를 렌더링해서 깨지지 않는지 확인하는 것을 예

Button, Dialog같은 작은 단위

 

intergration
통합 테스트는 두 개 이상의 모듈이 실제로 연결된 상태를 테스트

UI와 API 간의 상호작용이 올바르게 일어나는지
state에 따른 UI의 변경이 올바르게 동작하는지

GNU, 하나의 페이지 같은 큰 단위

 

e2e
실제 사용자의 입장 및 환경에서 테스트하는 것

 

위로 갈수록 테스트 비용이 비싸지고 느려진다!

권장 테스트 비율은

unit 70%
intergration 20%
e2e 10%

 

프론트엔드 관점의 테스트

 

visual test(Visual Regression Test)

 

시각적으로 무엇이 달라졌나 테스트
정량적 요소(디자인 등)로 평가하기 어렵기 때문에 자동화가 어렵다

 

대표적 도구 : storybook, chromatic

 

chromatic - Visual Regression Test

 

chromatic을 사용하면 화면의 bitmap 단위로 비교가 가능하다
원본(snapshot)과 변경 코드가 시각적으로 무엇이 달라졌는지 분석

 

테스트 환경

 

브라우저 환경

 

장점 : 크로스 브라우징 & 기기 호환성 테스트 가능
단점 : 느리다
ex) cypress, playwright

 

Node.js 환경


장점: 빠르다
단점: 크로스 브라우징 & 기기 호환성 테스트 가능 불가
ex) jest

 

프론트엔드 테스트 대상

(시각적 요소, 사용자 이벤트 처리, API 통신)
테스트의 대상은 다음과 같이 크게 3가지

 

1. 시각적 요소

시각적 요소 - Visual Regresstion testing(chromatic 참고)

컴포넌트가 깨졌는지, side Effect가 없는지 확인 용도로 쓰고
왜 깨졌는지는 기능적 테스트에서 확인하는게 빠르다.

 

2. 사용자 이벤트 처리

프론트엔드 대부분의 로직은 다음과 같은 로직을 가진다.

  1. 1. Event가 발생한다 (click, enter event 등)
  2. 2. Handler가 event를 처리하고 Effect를 발생시킨다. <-
  3. 3. Effect는 View를 변경시킨다.

테스트에서 중점으로 처리해야 할것은 2번


이벤트가 발생하여 어떠한 효과(effect)를 가져오는가를 테스트

view, style보다는 비즈니스 로직에 중점을 둔다.
시각적 요소는 storybook, Chromatic에게 위임

 

1번은 브라우저, react library에서 알아서 처리하니 믿음을 가져라
2번이 주 로직
3번은 2번이 제대로 처리되었다면 정상적으로 동작하게됨(의존성을 가짐)

 

하지마라

  1. 컴포넌트들이 제대로 합성되는가(view?)
  2. 컴포넌트들이 화면에 제대로 렌더링 되는가(view)
  3. 컴포넌트에 어떠한 동작을 했을 때, 무엇인가 트리거가 되는가(event<->handler 연결)

 

Testing library(jest)

 

Testing-library는 이름 그대로 UI를 사용자 관점에서 테스트할 수
있도록 도와주는 라이브러리

 

내부 상태 변경과 같은 상세한 구현(implementation details)을 테스트하는
기존의 프론트엔드 테스트의 문제점을 개선하기 위해 등장

사용자는 컴포넌트 내부의 상태가 어떻게 바뀌고,
컴포넌트 내부 메서드가 어떻게 호출되는지에는 전혀 관심이 없기 때문에,
테스트 코드를 작성할 때도 이에 대해선 테스트하지 말고
사용자 관점에서 테스트하자.

 

2, 3번을 잘 처리하면 테스트가 잘 되었다는 믿음
리펙토링을 해도 잘 동작하는지 확인 가능

 

3. API 통신

 

백엔드는 정상적으로 동작한다고 가정

주 목표는 API 통신으로 받아온 데이터를 넣으면 컴포넌트가
'정상적으로 동작 하는가' 중점

크게 2가지 방식

 

1. 테스트용 서버 or 라이브러리

테스트용 서버를 가지는건 공수가 많이 들기 때문에
"MSW"라는 라이브러리 이용

서비스워커가 API를 가로채고 가짜 데이터를 응답해준다.

 

2. api mocking

api 자체를 mocking
axios를 호출하면 데이터를 전해주는 가짜 함수로 변경

취향에 따라 사용

 

최근엔 MSW를 많이 쓰는 느낌
(storybook + jest + msw 연동해서 환경설정을 구축해놓으면
시각적 & 기능적 테스트에 한꺼번에 편리하게 사용가능)

 

실제 api 테스트를 작성하다보니 느낀점 (+ 2023.3월 추가)

 

보통 리엑트 컴포넌트 코드를 나누자면

 

1. style(UI, styled component) 

2. business logic(hook, 전역 state, api 등)

 

두가지로 세분화할 수 있는데

 

간단한 api + logic이면 그냥 storybook + jest + msw 연동시켜서 테스팅 시키는 방식으로 테스트 하면 편한거 같고

(이때도 대다수의 테스트 대상은 2번이다)

 

복잡한 인터렉션 + api는 위 방식으로 테스트하기 너무 복잡한거 같다. 

(실시간 모니터링 툴을 개발중이다보니 한 컴포넌트에 연동되는 api가 3~6개가 되는 케이스도 있었다;;)

2번만 따로 jest + mocking api로 business logic를 테스트 후  

style 부분에서 연동 & 호출이 잘되는지 연동 테스트 하는 방식이 좋은거 같다.

 

예를 들어 

 

const Example = ({onClick}) => {
  return <button type="button" onClick={onClick} /> 
}

위와 같은 컴포넌트는 style만 정의되어 있고 business logic(onClick)은 다른곳에서 주입받는다고 가정해보자. (hook이나 전역 state를 사용할수도 있고 방법은 여러가지)

 

onClick이라는 함수를 jest에서 jest.fn()로 mocking하고 

tobeCalled(), tobeCalledWith()등 호출을 확인하는 함수를 사용하면 

테스트 상황이 주어졌을때 실제로 business logic을 올바르게 호출하는지 확인 가능하다. 

 

너무 내부로직이 비대해서 쪼갠 케이스인데 이런 케이스는 잘 없지 싶다

 

잘하는법? (나도 몰라..)

 

테스트는 비용이다
-> 불필요한 테스트 최소화
-> 동어반복적 테스트 방지

 

부작용을 최소화하자

로직을 순수함수로 작성한다면 항상 input이 동일하다면

마지막 output이 동일할꺼라 예측 가능하다.

 

즉, 부작용(side Effect)가 없는 순수함수의 조합으로 코드를 작성하다면

테스트시 end-to-end로 테스트가 가능하니 테스트 작성 & 유지보수가 쉬워짐

 

다만 부작용이 없는 코드는 나오기 어려우므로

UI <-> business logic <-> data(접근) 이라고 나눠본다면

 

business logic를 순수함수로 작성하고 테스트하려고 노력하고,

UI, data 부분에 부작용 함수 부분을 넘겨주자..?

 

FIRST 법칙

F (Fast 빠르게)
I (Independat 독립적으로 - sideEffect 영향 X)
R (Repeatable 반복가능하게 - 결과가 매번 동일)
S (Self-Validating 자가검증하는 - 결과가 True or False)
T (Timely 적시에 - 필요할때)

 

 

코드(테스트 대상) 짤때 SOLID 법칙 응용

약간 다르긴 하지만 본질은 비슷하다

핵심 -

  1. 함수는 하나의 기능만
  2. 모듈화(ex - 라이브러리 교체시 인터페이스는 놔두고 통째로 교체)
  3. 컴포넌트 간의 의존성 제거
  4. 로직과 스타일의 분리 (container-presenter패턴, hook 등등)

 

Given, When, Then 패턴 사용

 

test('button click event', () => {
// Given
const data = $4
const spyOn = jest.fn();
render(<button type="button" onClick={spyOn}  />)

// When
const button = screen.getByRole('button');
button.click();

// Then
expect(spyOn).tohaveBeenCalled();
})

가독성 좋은 테스트는 기능 명세서 역할도 가능하다 

유저의 행동에 중점을 두고 작성

 

Given -> 주어진 환경

When -> Event Trigger

Then -> Effect Result

 

BDD에서 파생된 개념인거 같은데 깔끔하게 시나리오를 짤 수 있어서 좋은거 같다

 

라이브러리 테스트(optional)

 

필수는 아니지만 라이브러리 버전업(or 교체)시 무엇이 변경되었는지(or 무엇이 라이브러리 종속적인지) 

빠르게 알기 쉽도록 라이브러리 관련 코드에 테스트 작성

 

또한 라이브러리(ex-버튼, 테이블 라이브러리 등등)을 사용한다면 모듈화하고 테스트를 작성해놓으면 

나중 교체할때 어떤 기능이 부족하고 무엇이 문제인지 빠르게 인지 가능하다.

 

--추가 

일하다 yarn.lock이 꼬여서 라이브러리가 터지는 정신나가는 케이스를 봐서

필수는 아니지만 틈틈히 라이브러리 테스트를 작성해놓으면 좋을꺼같다..?

 

 

 

reference

 

1. 좋은 함수 만들기 - 부작용과 거리두기 (tistory.com)

https://velog.io/@teo/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-MV-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80%EC%9A%94
https://kooku0.github.io/blog/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90-solid-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0/
https://jbee.io/react/testing-1-react-testing/
https://blog.mathpresso.com/%EB%AA%A8%EB%8D%98-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-1%ED%8E%B8-841e87a613b2

반응형
반응형

이전에 유저의 명령을 undo, redo 하기 위해서 유저가 실행한 커맨드 하나하나를 기록하여 

스택에 쌓는 작업을 했었다.

 

그런데 다양한 인터렉션을 처리하려고 하다보니 여러 원자적인 연산(create, delete, update)들을 조합해 하나의 커맨드로 기록할 필요성이 생기게 되었다.

 

예를 들어 다음 예시 화면과 같이

부모 노드안에 자식 노드를 추가하려면 한번 추가할때마다 각각 두번의 연산이 필요하다. 

 

1. update 연산 -> 부모 노드(시작 스탭)의 width, height가 커져야 한다. 

2. add 연산 ->  자식 노드를 nodes 배열에 추가해야한다.

 

(undo, redo할땐 두 연산을 하나로 합쳐서 작동해야한다.)

 

이것을 간단한 코드 예시로 보자면 다음과 같다.

	const [nodes, setNodes] = useRecoilState(flowNodesState); // 배열 형태
	const [edges, setEdges] = useRecoilState(flowEdgesState); 
	

	const addChildNode = () => {
		....

		// this should be updateNodes
		setNodes((nodes: Node[]) => 부모 노드 크기 변경)

		// this should be addNodes
		setNodes((nodes: Node[]) => 새로운 자식 추가)
	}

 

setState 함수에서 인자를 이용하면 비록 setState가 비동기라도

기존 state가 아닌 새로 갱신된 state를 인자로 받아 마치 차례대로 실행하는것처럼 사용할 수 있다.

 

식으로 비유하자면

A : 처음 node State

F(x) : update 연산

G(x) : add 연산

 

새로운 상태 : G(F(A))

 

 

문제점 

 

그래서 setState를 히스토리를 기록하기 위해서 command 함수들로 바꾸면

내부적으로는 setState를 커맨드 하나마다 각각 실행하니 기존과 동일하게 실행되지 않을까? 하는 가설을 세웠지만 실제로 실행해보니 기대처럼 작동하지는 않았다.

 

코드 예시

더보기

 

	const [nodes, setNodes] = useRecoilState(flowNodesState);
	const [edges, setEdges] = useRecoilState(flowEdgesState);
	

	const addChildNode = () => {
		....

		updateNodes(부모 노드 크기 변경) 
        
        	addNodes(새로운 자식 노드 추가)
        
	}

 

즉, 내가 원하던건

 

A -> B -> C -> D 연산처럼 A에서 시작해 상태를 순서대로 업데이트 하는것이였다면

 

command들 함수를 이용하면 각각 command 내부에 들어있는 setState들이 서로 연관이 없다고 리엑트가 판단하여 인자값으로 갱신되지 않은 기존의 state를 반환해 A -> B, A -> C,  A->D 처럼 서로 독립적으로 실행이 되어버리는 것이였다.

 

그래서 한가지 아이디어를 냈는데

 

우리가 원하는건 A -> B -> C -> D 연산중에서 최종적으로 히스토리에 필요한것은 

undo했을때 기존의 값으로 되돌릴 A 상태와

redo했을때 되돌릴 D 상태다. 

 

그럼 커맨드들이 node를 인자값으로 받아 node를 return값으로 반환하는 여러가지 함수들을 만들고,

그 함수들을 순서대로 실행하게 이으면 A->B->C->D 연산을 할 수 있지 않을까?

 

그리고 history stack에는 A와 D만 저장하는 식으로 구현하면 될것이다.

 

이를 위해서 함수형 패러다임을 끼워넣어 구현해보기로 해보았따.

 

각 add, remove, update 등 상태를 변경하는 atomic한 함수를 만들고, 

아래의 pipe 함수를 이용해 순서대로 실행하는 식으로 구조를 변경하고,

마지막 갱신된 state를 setState해주는 식으로 구현하면 될것이다 

const pipe = (...fns) => { 
  return (...args) => {
    return fns.reduce( // 입력 받은 fns의 순서를 뒤집는다
      (res, fn) => [fn.call(null, ...res)], // 순서가 뒤집어진 fns 를 오른쪽부터 실행
      args // 초기값으로 받은 파라미터
    )[0];
  }
};

p.s) 위 함수 동작원리에 대한건 https://velog.io/@nakta/FP-in-JS-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%A1%9C-%EC%A0%91%ED%95%B4%EB%B3%B4%EB%8A%94-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%95%A8%EC%88%98-%EC%BB%B4%ED%8F%AC%EC%A7%80%EC%85%98-%EC%BB%A4%EB%A7%81-s7k2z039vb

를 참고해주세요. 

 

 

이를 간단하게 요약한 코드 예시로 보면 다음과 같다.

const NodesPipeExecution =
		(...fns) => {
			const element: Node[] = nodes.slice();

			return (option?: OptionType) => {
				const options = { ...option, isAtomicCommand: false, doesHistoryBeRecorded: false };

				const { newElement } = pipe(fns)({newElement : element, option : options}); // 함수를 순서대로 실행

				triggerUpdate(newElement) // 실제 history를 기록하고 setState를 반영하는 함수
			};
		};

 

실제 사용 예시

 

NodesPipeExecution(
		updateNodesPipe(부모 노드 크기 업데이트), 
		addNodesPipe(새로운 노드),
)()

 

그런데 이와 같이 함수들을 직접 넘기는 식으로 구현하면 

모든 함수와 파라미터를 알아야 되니 불편하지 않을까? 라는 피드백을 받아서 

 

정해진 인터페이스에 알맞게 넘기면 내부적으로 처리하는 식으로 다시 리펙토링 했다.

 

export type QueryType<T> = ((element: T) => boolean) | { [key: string]: string }[];

export interface AddExecutionPipeType<T> {
	type: "add";
	item: (() => T[]) | T[];
}

export interface RemoveExecutionPipeType<T> {
	type: "remove";
	query: QueryType<T>;
}

export interface UpdateExecutionPipeType<T> {
	type: "update";
	query: QueryType<T>;
	onChange: (element: T) => T;
}

export type ExecutionPipeType<T> = AddExecutionPipeType<T> | RemoveExecutionPipeType<T> | UpdateExecutionPipeType<T>;


const onNodesChange = (functionArray: ExecutionPipeType<Node>[]) => {
		NodesPipeExecution(
			...functionArray.map((func: ExecutionPipeType<Node>) => {
				const { type, item, query, onChange } = func as AddExecutionPipeType<Node> &
					RemoveExecutionPipeType<Node> &
					UpdateExecutionPipeType<Node>;
				switch (type) {
					case "add":
						return addNodesPipe(item);
					case "remove":
						return removeNodesPipe(query);
					case "update":
						return updateNodesPipe(query, onChange);
					default:
						return new Error("developer type error");
				}
			})
		)();
	};

 

실제 사용 예시



onNodesChange([
			{
				type: "update",
				query: 부모 노드 선택,
				onChange: (element: Node) => {
					부모 노드 크기 변경
				},
			},
			{
				type: "add",
				item: 새로운 자식 노드 생성,
			},
		]);

 

함수형 패러다임을 맛만 본거긴 하지만 실제로 해보니 재밌었다.

 

 

reference

 

https://velog.io/@nakta/FP-in-JS-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%A1%9C-%EC%A0%91%ED%95%B4%EB%B3%B4%EB%8A%94-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%95%A8%EC%88%98-%EC%BB%B4%ED%8F%AC%EC%A7%80%EC%85%98-%EC%BB%A4%EB%A7%81-s7k2z039vb

 

FP in JS (자바스크립트로 접해보는 함수형 프로그래밍) - 함수 컴포지션, 커링

두 번째 글입니다. 함수형 프로그래밍에서는 함수의 조합으로 원하는 값을 만들어 냅니다. 함수의 조합인 함수 컴포지션에 대해서 살펴보도록 하겠습니다. 그리고 커링 기법을 이용해 함수 컴

velog.io

 

반응형

'Front-end > React' 카테고리의 다른 글

react Dialog tab trap 만들기  (1) 2023.05.01
프론트엔드 테스트 정리 요약  (1) 2022.11.30
react-flow에 undo, redo 구현  (0) 2022.09.03
Suspense를 위한 wrapper 만들기  (0) 2022.07.28
react 17(개인 공부용)  (1) 2022.06.09
반응형




일하면서 react-flow(https://github.com/wbkd/react-flow)를 써보던 중

Node와 edge를 기반으로 한 react 시각화 라이브러리



해당 오픈소스 라이브러리에는 유저의 실행 명령을 기록해서 undo, redo하는 기능이 없다는것을 알게 되었다.
그래서 기획 상 필요한 undo, Redo 기능을 어떻게 넣을까 고심하게 되었는데...

배경지식 설명


해당 라이브러리는 node라는 배열과 Edge라는 배열을 가지고

	const [nodes, setNodes] = useRecoilState(flowNodesState); // 배열 형태
	const [edges, setEdges] = useRecoilState(flowEdgesState); 
	

	const addChildNode = () => {
		setNodes((nodes: Node[]) => 새로운 자식 추가)
	}


새로운 UI를 그려줄때마다 리엑트의 setState를 부르는 식으로 구현이 되어있다.

아이디어


setState를 실행할때마다 한 프레임이라고 정의한다면
undo를 하면 이전 프레임으로 상태를 복귀시키고,
Redo하면 다시 undo 했던 명령을 취소해 다시 기존 프레임으로 돌아와야 할것이다.

각 프레임이 변경될때마다 전체 state(node, edge)를 프레임 별로 저장해놓는 것은 메모리 용량 이슈가 발생하는것 같아서
유저가 실행한 커맨드를 순서대로 기억해두고(stack) undo시 해당 실행 커맨드를 역산하고, redo시 다시 실행하자 라는 가설을 세우고 코드를 작성해보았다.

즉, command 단위로 명령어를 저장했다.

모든 명령어는 event라는 단위로 추상화가 가능하다.

이를 위해 유저의 명령을 event라는 하나의 단위로 추상화하고, 이벤트가 실행할때마다 커맨드를 기록하는 식으로 구현하했다.

예를 들어 create, create, create라는 명령이 3번 온다면

우선 undo stack에 [create, create, create] 라는 형식으로 3번 쌓이게 된다.

그리고 undo 명령을 실행하면 undo stack에서 순서대로 명령어를 하나씩 꺼내어 그 명령의 역 커맨드를(역산) 실행하는데

undo시 (역산)
- create 명령어는 delete,
- delete 명령어는 create,
- update 명령어는 rollback

명령어를 실행하고 redo stack에 해당 명령어를 쌓게 된다.

redo 명령시 redo stack에서 명령어를 꺼내서 역으로 실행된 커맨드를 역 실행하기 위해서(부정의 부정은 긍정!)
즉.. 기존 커맨드를 그냥 재실행 하게되는데

redo 시 (재실행)
- create 명령어는 create,
- delete 명령어는 delete
- update 명령어는 update

하게 된다.

매우 간단히 요약된 코드 예시로 보자면

undoStack = [];
redoStack = [];


// 커맨드 기록 
function record(command){
    undoStack.push(command);
}


//undo 실행 
function undo(){
    redoStack.push(undoStack.pop());
}


//redo 실행
function redo(){
    undoStack.push(redoStack.pop());
}


위 두가지 스택이 맞물리는 형태가 된다.

undo, redo 예시


직접 구현하기 까다로웠던 문제였는데 잘 작동하는것 같다.

반응형
반응형

 

 

복잡한 비동기 로직을 사용할때 Suspense를 사용하면 데이터를 사용하는 컴포넌트와 로딩 & 에러 상태를 핸들링하는 방식을 분리시켜 선언적으로 프로그래밍이 가능하다. 

 

react-query나 recoil에서 suspense를 사용하는 방식을 지원해주는데  라이브러리의 지원이 없이 직접 처리하는 방식이 궁금해서 해보았다. 

 

우선 suspense에 대해 인터넷에 찾아보니 나온 코드는 다음과 같다.

 

function wrapPromise(promise: AxiosPromise) {
	let status = "pending";
	let result = {};
	const suspender = promise.then(
		(successResult) => {
			status = "success";
			result = successResult;
		},
		(errorResult) => {
			status = "error";
			result = errorResult;
		}
	);

	return function read() {
		switch (status) {
			case "pending":
				throw suspender;
			case "error":
				throw result;
			case "success":
			default:
				return result;
		}
	};
}

function fetchDataVersion1(requestAPI: Promise) {
	return wrapPromise(requestAPI);
}

const Main = () => {
	return (
			<Suspense fallback={<div>loading...</div>}>
				<Dddd />
			</Suspense>
	);
};

let count = 0;

setInterval(() => {
	count += 1;
}, 1000);

const axiosRequestExample = new Promise((res) => {
	setTimeout(() => {
		res(`${count} is shown`);
	}, 6000);
});

const requestResource = fetchDataVersion1(axiosRequestExample);

const Dddd = () => {
	const data = requestResource();

	return <div>{JSON.stringify(data)}</div>;
};

export default Main;

 

로직을 간단히 설명하자면, 

 

function wrapPromise(promise: AxiosPromise) {
	let status = "pending";
	let result = {};
	const suspender = promise.then(
		(successResult) => {
			status = "success";
			result = successResult;
		},
		(errorResult) => {
			status = "error";
			result = errorResult;
		}
	);

	return function read() {
		switch (status) {
			case "pending":
				throw suspender;
			case "error":
				throw result;
			case "success":
			default:
				return result;
		}
	};
}

비동기 데이터를 처리중인 상태에는 상위 컴포넌트로 promise를 넘겨서 Suspense가 처리하게 만들고,
(동일하게 error도 상위 컴포넌트로 넘겨서 ErrorBoundary(직접 구현해야함)가 처리하게 함)

데이터가 도착하면 렌더링 하는식으로 

 

Suspense를 이용하면 데이터를 불러오는 event(what)과 어떻게 처리하는 로직(how)을 분리시켜 선언적으로 처리하기 쉽게 만들어주는 강력한 도구가 될 수 있다.

 

그런데 위 코드는 실제로 사용하려고 하니 다음과 같은 기능이 부족했다.

 

1. 캐싱처리로 동일한 데이터는 캐싱처리해서 불러오는 시간을 줄임

2. 필요시 캐싱 처리를 무효화하고 새로운 데이터를 받아올 수 있어야함.

3. 여러 컴포넌트에서 재사용할 수 있어야함. 

 

그래서 다음의 블로그를 참고해서 위 조건을 만족하게 새로 만들어보았다.

 

https://coffeeandcakeandnewjeong.tistory.com/56

 

최종 구현형태는 다음과 같다.

 

let count = 1;

const TEST_URL = () => {
	console.log(`https://jsonplaceholder.typicode.com/todos/${count}`);

	return `https://jsonplaceholder.typicode.com/todos/${count}`;
};

setInterval(() => {
	count += 1;
}, 1000);

const Main = () => {
	return (
		<ErrorBoundary fallback={<div>error</div>}>
			<Suspense fallback={<div>213</div>}>
				<Dddd />
			</Suspense>
		</ErrorBoundary>
	);
};

const Dddd = () => {
	const { data, refresh } = useSuspense("useSuspense", () => axios(TEST_URL()), [123, 456, 789, 1011]);

	return (
		<div>
			<button type="button" onClick={() => refresh()}>
				refresh
			</button>
			<Link to="/login">to login</Link>
			{JSON.stringify(data)}
		</div>
	);
};

 

그런데 실제 구현물을 보니 react-query와 비슷하게 만든거 같다;;

이렇게 되면 위 코드를 실제로 사용하기는 애매해진거 같은 기분이다.

 

top-down 방식으로 위 코드를 파해쳐보자. 

type depsType = number[] | string[];
type axiosType = () => Promise<any>;

function useForceUpdate(key: string) {
	const [value, setValue] = useState(0);
	return () => {
		asyncManager.delete(key);
		setValue(Date.now());
	};
}

const useSuspense = (key: string, getPromiseCandidate: axiosType, deps: depsType = []) => {
	const resetFunction = useForceUpdate(key);

	const refresh = () => {
		resetFunction();
	};

	useEffect(() => {
		return () => asyncManager.delete(key);
	}, []);

	const { nextValue, force } = depsMemorization(key, getPromiseCandidate, deps); //deps 비교
	const { status, suspender, result } = getPromiseData(key, nextValue, force); // Promise 처리 

	if (status === "pending") {
		throw suspender;
	}

	if (status === "error") {
		throw result;
	}

	return { data: result, refresh };
};

 

가장 먼저 useSuspense hook부터 살펴보자.

 

맨 처음 소개되었던 코드에 다음과 같은 기능이 추가됐다.

 

1. depsMemorization - 캐싱처리(key와 deps 비교를 통해)

2. getPromiseData - Promise 처리 및 key값에 따라 메모라이징 

3. refresh - 캐싱 무효화 & refetch 후 re-rendering

 

status에 따라 throw & return 하는 방식은 동일하다.

다만 asyncManager라는 새로운 객체를 추가했고 해당 객체로 캐싱처리 관련을 해줄 예정이고(crud 가능),

key가 동일하고 deps가 동일하다면 한 컴포넌트에서 fetch를 받아왔으면 캐싱처리해 여러 컴포넌트에서 동일하게 사용할 수 있도록 구현했다.

 

다음은 depsMemorization 코드이다.

 

const depsMemorization = (key: string, axiosRequest: axiosType, deps: depsType) => {
	if (!deps || !Array.isArray(deps) || deps.length === 0) return { nextValue: axiosRequest(), force: true };

	if (asyncManager.has(key)) {
		return asyncManager.update(axiosRequest, deps, key);
	}

	return asyncManager.mount(axiosRequest, deps, key);
};

사실 deps란 기능이 필요없다면 key의 유무만 판단해서 구현해줘도 된다.

그게 구현하기 더 심플한거 같기도 하다.

 

useEffect등의 deps 배열과 비슷한 역할을 하게 구현이 되어있다.

deps를 비교해 동일하다면 캐싱 값을 리턴하고 아니라면 key값을 기준으로 새로 캐싱해주는 형태로 구현이 되어있다.

 

다음은 Promise를 핸들링하는 코드인 getPromise이다.

 

const getPromiseDataClosure = () => {
	const promiseMem = new Map();

	return (key: string, axiosRequest: Promise<any>, force: boolean) => {
		if (promiseMem.has(key) && !force) {
			return promiseMem.get(key);
		}
		const promiseData = {
			promise: axiosRequest,
			status: "pending",
			result: null,
			suspender: axiosRequest.then(
				(response) => {
					promiseData.status = "success";
					promiseData.result = response;
				},
				(error) => {
					promiseData.status = "error";
					promiseData.result = error;
				}
			),
		};

		promiseMem.set(key, promiseData);
		return promiseData;
	};
};

const getPromiseData = getPromiseDataClosure();

기능은 심플한데 아까 맨 처음 나온 코드에 key값을 통한 메모리제이션 기능이 추가되었다.

 

1초마다 count가 올라가고, refresh 누를때마다 testurl/{count} 주소 로 request를 보내는 방식으로 구현해봤는데 실험 결과 잘 작동하는것 같다.

 

최종 코드는 다음 주소에서 볼 수 있다.

 

https://codesandbox.io/s/zealous-hugle-ozz7j0?file=/src/App.tsx

 

 

반응형

'Front-end > React' 카테고리의 다른 글

react-flow에 undo, redo 구현 (2) - 함수형 패러다임  (0) 2022.09.17
react-flow에 undo, redo 구현  (0) 2022.09.03
react 17(개인 공부용)  (1) 2022.06.09
React18(개인 공부 메모용)  (0) 2022.06.09
react TMI  (0) 2022.04.13

+ Recent posts