반응형

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
반응형

react 17은 큰 변화는 없고 점진적인 upgrade를 위한 단계라고 한다.

 

 

이벤트 위임의 변경점

 

기존에는 document에 이벤트 위임을 붙여 이벤트를 처리했다면

17부터는 root에 이벤트를 붙여서 처리

 

https://ko.reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation

ex) 

<button onClick={handleClick}>

 

 

위 코드같은경우엔 내부적으로 

 

const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);

 

root에 이벤트 위임을 넣어 처리

기존에는 (예시로) 리엑트 내부 e.stopPropagation() 코드가 혼용되서 쓰여지고 있는 JQuery 이벤트를 prevent하는 일이 있었는데

이런일을 방지하게 변경

또한 React17에서 event propagation이 보다 regular dom에 가깝게 변경

 

 

브라우저 최적화 (잡다한 변경점)

 

1. onScroll이 네이티브에선 버블링되지 않는데 React onScroll은 버블링되서 이벤트 버블링 제거 

 

2.

onFocus = focusin

onBlur = focusout 이벤트 사용하도록 변경?

(focusin focusout은 버블링 발생) 

 

3. Capture phase events (e.g. onClickCapture) 가 이제 진짜 캡처 페이즈 리스너 사용 (이전엔 어케했누?)

 

이벤트 풀링 최적화 제거 (No Event Pooling)

 

“event pooling” optimization이 별 의미없어서 삭제

비동기적으로 이벤트 객체에 접근 불가능했던게 삭제됨

 

function handleChange(e) {
  setData(data => ({
    ...data,
    // This crashes in React 16 and earlier:
    text: e.target.value <- before v17 : null
  }));
}

 

난 2021 입문자라(react 17 이후 사용) event pooling이 무슨 역할이었는지 정확히는 모르겠다

SyntheticEvent 객체는 이벤트 핸들러가 호출된 후 초기화되기에, 비동기적으로 이벤트 객체에 접근할 수 없었던 것이 수정되었다고 한다.

 

아마 setData 실행시기와 e의 생명주기가 일치하지 않았던것(초기화되버려서)으로 추론된다.

 

Effect Cleanup Timing

useEffect(() => {
  // This is the effect itself.
  return () => {    // This is its cleanup.  };});

 

useEffect가 보다 효율적으로 변경 

 

기본 컨셉 : 

 

Most effects don’t need to delay screen updates, so React runs them asynchronously soon after the update has been reflected on the screen.

 

(For the rare cases where you need an effect to block paint, e.g. to measure and position a tooltip, prefer useLayoutEffect.)

 

그러나 보통은 unmounting 될때 cleanup function은 보통 동기적으로 실행되게 사용되어 왔다.

(similar to componentWillUnmount being synchronous in classes)

이것은 탭 변경과 같은 큰 페이지 변경시 속도 저하를 초래했다. 

 

그래서 React 17에서는 항상 cleanup function은 비동기적으로 실행되게 변경되었다.

 

(In React 17, the effect cleanup function always runs asynchronously — for example, if the component is unmounting, the cleanup runs after the screen has been updated.)

 

동기적 상황을 원하면 useLayoutEffect를 쓰시라

 

(you might want to rely on the synchronous execution, you can switch to useLayoutEffect instead)

 

추가적으로 cleanup function은 언제나 new Effect가 실행하기 전에 실행된다. 

useEffect(() => {
  const instance = someRef.current;
  instance.someSetupMethod();
  return () => {
    instance.someCleanupMethod();
  };
});

 

Consistent Errors for Returning Undefined

let Button = forwardRef(() => {
  // We forgot to write return, so this component returns undefined.
  // React 17 surfaces this as an error instead of ignoring it.
  <button />;
});

let Button = memo(() => {
  // We forgot to write return, so this component returns undefined.
  // React 17 surfaces this as an error instead of ignoring it.
  <button />;
});

return undefined하면 에러

(제대로 return 안하면 에러) 

 

만약 아무것도 return 하지 않는 것을 명시적으로 나타내고 싶다면 return null 사용

 

ref

https://han7096.medium.com/react-v17-release-candidate-톺아보기-6a4b091965c4

 

React v17 Release Candidate 톺아보기

변경사항과 유의할점을 React 공식 문서와, 개인적인 지식을 덧붙여 정리한 글

han7096.medium.com

https://reactjs.org/blog/2020/08/10/react-v17-rc.html

 

React v17.0 Release Candidate: No New Features – React Blog

Today, we are publishing the first Release Candidate for React 17. It has been two and a half years since the previous major release of React, which is a long time even by our standards! In this blog post, we will describe the role of this major release, w

reactjs.org

 

반응형

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

react-flow에 undo, redo 구현  (0) 2022.09.03
Suspense를 위한 wrapper 만들기  (0) 2022.07.28
React18(개인 공부 메모용)  (0) 2022.06.09
react TMI  (0) 2022.04.13
drag and drop으로 창 크기 조절  (0) 2022.03.18

+ Recent posts