반응형

몇일 전 밀리의 서재를 결제했다.

 

AI 기술의 발전으로 인류는 그 어느 시대보다도 정보에 가장 쉽게 접근할 수 있지만, 그 중에서 양질의 정보를 찾아내기 가장 어려운 시대에 도래했다고 생각된다. 그래서 디지털 리터러시를 키우기 위해 앞으로 퇴근 후 짬짬이 책을 한두 권씩 볼 예정..!

벌써 4일간 4권이나 읽었다.

 

독후감 - 부의 추월차선 후기

 

일단 이 책에 대해 찾아보니 불쏘시개에 비유할 정도로 논란이 많은데 많은 사람들의 인생 방식이 틀렸다고 하는 자극적인 내용과 "나는 이렇게 하니 되었다."라는 성공 스토리가 대다수라 좀 거부감이 들 수 있다고 생각된다. 책을 읽으며 얻을 건 얻고 필요 없는 부분은 버리는 식으로 생각하자.

 

책의 저자는 가난한 어린 시절을 보냈는데, 어느 날 람보르기니를 몰던 자수성가한 사람을 발견하고 큰 깨달음을 얻었다고 한다. 그래서 젋었을때 백만장자가 되는 방법을 연구했고, 바로 행동으로 옮겨서 큰 부를 얻었다.

 

이 과정에서 깨달은 부자가 되는 공식을 제시한다.

 

부자가 되는 공식

 

1. 지도 (나아가야 할 방향)

2. 차량 (자기 자신)

3. 속도 (생각을 행동으로 옮기는 추진력)

 

여기서 가장 중요한것은 부자까지 가는 지도이다.

사람들은 크게 3가지 방식을 이용한다고 제시한다.

 

1. 인도로 가는 지도

돈을 잘 벌든 말든 상관 없이 재무적 목적지가 존재하지 않는 사람으로, 하루 벌어 하루 쓰는 사람들을 뜻한다. 

어떤 팝스타의 한 달 소득이 40만 달러였지만 수입이 바로 끊기자 파산하는 케이스를 예시로 들었다. 

 

2. 서행차선으로 가는 지도

 

재무적 지식은 있지만 늙어서 부자가 되는 사람들을 뜻한다.

대부분의 제태크 책들은 부동산, 주식 등에 30~40년 투자해서 여유로운 은퇴생활을 즐기도록 부추기는데 저자는 늙어서 돈을 벌면 무슨 소용이 있냐고 젋은 나이에 돈을 불려야한다고 주장한다. (이 내용은 3번에 후술)

 

또한, 절약으로는 절대로 부자가 될 수 없다고 말하면서 지출보다는 소득을 늘려야 한다고 주장한다.

 

3. 추월 차선으로 가는 지도

 

직장은 시간을 팔아 돈을 얻는 방법이라고 소개하면서 저자는 부자는 시간으로부터 자유롭고, 돈으로부터 자유로운 사람을 뜻한다고 한다. 

 

이를 위해 저자는 창업을 추천하는데, 내가 일하는게 아니라 나를 위해 일하는 사람을 고용하고, 내가 일을 하지 않아도 시스템이 자동으로 돈을 벌어오는 현금 흐름을 만들어야 한다고 주장한다.

 

예시로 저자는 웹 페이지를 만들어 자동적으로 나오는 광고 수입으로 큰 소득을 올렸고, 끝내는 다른 회사에 팔아 큰 수익을 챙겼다고 한다.

혹은 창업 외에도 발명, 개발 등 현금 흐름을 기하급수적으로 발생시키는 어려가지 방법을 제시한다.

 

결론

 

보통 재테크 책이라고 하면 2번인 서행차선 방식을 추천하고, 부동산 주식 채권 등 금융 자산으로 현금 흐름을 늘리라는 식의 추천을 하는데

이 책은 하이리스크 하이리턴인 창업을 제시하다니.. 부자가 되는 방식은 쉽지 않구나 생각이 든다.

 

이 책에서 취할 수 있는 것은 다음과 같다고 생각된다.

 

- 일을 안해도 자동으로 현금 흐름이 생기는 시스템을 생성해라.

- 많은 일 => 많은 소득이 아니라 많은 일 => 더 많은 일이다.

 

또한,  저자가 부자가 된 방식인 코딩을 나는 운좋게도 전공으로 삼았고, 할 수 있다. 창업 이외에도 사이드 프로젝트등 여러 방법을 사용 할 수 있지 않을까?

반응형

'독서 > 독후감' 카테고리의 다른 글

확률적 사고의 힘 리뷰  (0) 2024.02.18
독후감 - 결국 해내는 사람들의 원칙  (0) 2024.02.18
반응형

사이드 프로젝트를 위해 chatgpt api를 사용해 보던 중 최근 신기한 기능을 찾아서 간단히 요약해본다.

 

gpt에게 어떤 '상황'을 부여하고, 문맥을 기억할 수 있게 하는 assistant api가 새로 생겼다.

이를 통해 chatgpt를 프롬프트 엔지니어링을 통해 자신이 원하는대로 커스텀 가능하게 되었는데 

국내엔 관련 자료가 많지는 않은거 같아서 기록겸 요약해본다.

 

gpt store에 올리는 custom gpt가 이 assistant와 비슷한 개념일듯?

 

사전 지식

 

chatgpt에게 api로 명령을 보낼때는 message role로 "user", "assistant", "system"을 보내어 원하는 명령을 수행시킨다.

 

'user'는 유저가 입력한 명령을 의미한다.

'system'은 chatgpt에게 입력할 제약 사항, 요구 사항 등을 의미한다.

'assistant'는 명령을 수행하기 위한 앞뒤 문맥, 사전 지식등을 뜻한다.

 

api example)

curl https://api.openai.com/v1/chat/completions \
 -H "Authorization: Bearer $OPENAI_API_KEY" \
 -H "Content-Type: application/json" \
 -d '{
 "model": "gpt-3.5-turbo",
 "messages": [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Who won the world series in 2020?"},
    {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
    {"role": "user", "content": "Where was it played?"}
    ]
 }'

 

 

기존 api는 독립적으로 작동하기 때문에 앞뒤 문맥을 파악하지 못했다.

이를 위해서 assistant role로 이전의 대화 내용을 전부 보내주거나 요약해줘야 했고, 이는 api의 과다 사용 = 비용의 상승으로 이어졌다. 

 

assistant api는 이 문제를 해결하기 위해서 미리 학습시킨 system, assistant 값을 이용해 유저의 api 명령을 처리한다.

이 api를 사용하기 위해서 몇가지의 개념 학습이 더 필요하다.

 

https://platform.openai.com/docs/assistants/how-it-works/objects

 

1. Assistant

명령을 받아 처리하는 chatgpt 자체를 뜻한다.

role system, assistant 들로 사전 입력을 할 수 있다.

이를 위해서 assistant는 text 파일 같은 file 입력도 가능하며 model 의 튜닝도 가능하다. 

 

2. Thread 

명령이 이루어지는 "context", 대화 맥락을 뜻한다.

채팅방이라고 생각하면 될 듯하다.

Thread간의 context는 독립적이다. 이 말은 즉, 카카오톡 1:1 채팅방처럼 여러 thread가 있을 수 있다.

 

3. Message

기존의 chatgpt api message와 동일하다. 

assistant api에선 thread 단위로 message간의 대화 맥락, context를 파악해서 ai가 대화를 이어나가준다.

 

4. RUN

run의 결과값

message를 입력한 이후의 결과 객체이다. 

코드 레벨에서 의미 있는 객체인데 뭔 역할인지 코드 예시에서 후술함

 

 

여기서 api를 사용하기 위해서 assistant, thread, message를 어떻게 코드 레벨에서 동작시키는지 좀 혼란을 겪었는데 사이드 프로젝트의 코드 예시로 설명하겠다.

 

개인적으로는 상당히 복잡하고, chat gpt한테 보낸 명령이 완료되었는지 확인하기 위해서

pooling을 써야하는 부분이 있어서 비동기 처리 측면에서 상당히 헷갈렸던듯..?

 

지금 next14에서 실험중이니 next 코드로 설명함 

전체 코드는 아래와 같다.

/* eslint-disable no-await-in-loop */
import { NextRequest, NextResponse } from 'next/server'
import { OpenAI } from 'openai'

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})

async function handler(req, res) {
  try {
    const body = await req.json()
    
    const assistant = await openai.beta.assistants.retrieve(' assistant api id 추가! ') // TO DO - tutor 별로 assistant 생성
	const thread = await openai.beta.threads.retrieve('thread api id 추가 !') // TO DO - 유저 대화창별로 thread 생성

    const { excelJSON, prompt } = body

    const message = await openai.beta.threads.messages.create(thread.id, {
      role: 'user',
      content: prompt,
    })

    const run = await openai.beta.threads.runs.create(thread.id, {
      assistant_id: assistant.id,
    })

    let cnt = 0

    // TO DO - polling logic 업그레이드
    while (cnt < 1000) {
      const { status } = await openai.beta.threads.runs.retrieve(thread.id, run.id)

      if (status === 'completed') break
      await new Promise((resolve) => {
        setTimeout(() => {
          resolve(1)
        }, 500)
      })
      cnt += 10
    }

    const messages = await openai.beta.threads.messages.list(thread.id)
    // @ts-ignore
    const responseText = messages.data[0].content[0].text.value
    console.log(JSON.stringify(responseText), responseText)
	
	... 생략(뒤는 중요하지 않은 파트임)
}

export { handler as POST }

 

 

1. assistant와 thread 불러오기

 

/* eslint-disable no-await-in-loop */
import { NextRequest, NextResponse } from 'next/server'
import { OpenAI } from 'openai'

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})

async function handler(req: NextRequest, res: any) {
  try {
    const body = await req.json()
    
    const assistant = await openai.beta.assistants.retrieve(' assistant api id 추가! ') // TO DO - tutor 별로 assistant 생성
	const thread = await openai.beta.threads.retrieve('thread api id 추가 !') // TO DO - 유저 대화창별로 thread 생성

    const { excelJSON, prompt } = body

 

앞 부분은 미리 생성된 assistant를 불러오고, 기존에 채팅방(thread)를 불러오는 api이다.

어시스턴트와 thread는 코드 레벨에서도 구현이 가능하고 혹은 playground에서도 생성이 가능하다.

 

숨김 처리한 부분이 각각 assistant id, thread id이다.

 

2. message 전달 후 chatgpt 실행

    const message = await openai.beta.threads.messages.create(thread.id, {
      role: 'user',
      content: prompt,
    })

    const run = await openai.beta.threads.runs.create(thread.id, {
      assistant_id: assistant.id,
    })

  // TO DO - polling logic 업그레이드
    while (cnt < 1000) {
      const { status } = await openai.beta.threads.runs.retrieve(thread.id, run.id)

      if (status === 'completed') break
      await new Promise((resolve) => {
        setTimeout(() => {
          resolve(1)
        }, 500)
      })
      cnt += 10
    }

 

message를 생성하고 chatgpt에게 실행시킨다. 

만약 message 결과값을 확인하고 싶다면 pooling api를 통해 대화가 끝났는지 체크해줘야 한다.. 

run의 결과는 아까 보았던 completed, failed, canceled 등등이 있다.

 

    const messages = await openai.beta.threads.messages.list(thread.id)
    // @ts-ignore
    const responseText = messages.data[0].content[0].text.value
    console.log(JSON.stringify(responseText), responseText)

 

message의 결과값은 커서 페이지네이션의 형태로 message들을 최신순 20개를 받아오는듯하다. 

아직 안써봤지만 message id를 통해 페이지네이션을 조작할 수 있을것이라고 추측 된다.

 

 

아직 베타 버전이라 많은 기능은 없지만

chatgpt의 발전 속도가 매우 무섭다..

 

월급 들어오면

마소 주식이나 좀 사야지 가즈아!!

 

 

reference 

 

https://platform.openai.com/docs/assistants/how-it-works/runs-and-run-steps?lang=curl

 

 

반응형

'인공지능' 카테고리의 다른 글

resnet->densenet  (0) 2022.03.08
반응형

회사에서 복잡한 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로 동기화 시키고 있었다. 

 

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

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

반응형
반응형

 

함수나 react의 props로 가끔 피치 못할 사정으로 nullable한 값을 내려보낼 때가 있다.

 

function example(a?: number, b?:number) {
	if(isNil(a) || isNil(b)) ... // null 방어 코드
	.. 
}

 

null값이면 해당 parameter를 사용 못하니 Nullish coalescing operator(??) 나 Optional chaining(?.)를 사용해서 방어 로직을 짜게 되는데,

그러다보면 nullable한 변수를 쓰는 곳마다 방어로직을 넣게 되고 다른 함수의 정보가 넘어오다보니  코드의 유연성과 안정성이 떨어지는 문제가 발생한다.

 

그래서 Null 전파를 막지는 못해도 피하는 방법에 대해 써보려고 한다.

 

실제 예시

 

지금 테이블 라이브러리를 만들고 있는데,

보통 라이브러리들에는 option을 넣어서 필요한 기능을 추가하거나 뺄 수 있다.

 

function App() {

	return <Table data={data} columns={columns} option={{ isServerSide: true }}/> // Table 예시 코드 
}

 

당연히 테이블 라이브러리를 만들때도 해당 기능을 구현하려고 했는데 option props에는 유저가 필요한 정보만 명시하고 나머지는 default 값으로 실행되야 하므로 nullable한 값이 들어올 수 밖에 없을것이다.

 

그리고 option으로 주어진 값은 테이블 내부에서 여러 곳에 전파되어 option이 지정되었다면 해당 옵션을 지원하는 로직을 실행시킬것이고, 아니라면 기본 default 로직이 실행될것이다.

 

그럼 option의 값이 nullable이니까 option이 전파된 곳마다 null check를 해야할까?

물론 그럴수도 있지만, 그럼 너무 힘들어지고 디버깅과 코드 수정이 어려워질 것이다. 

 

만약 홍수가 난다면 댐이 넘치기 전에 댐에서 조치를 취하는것이 가장 쉬운 방법일 것이다.

마찬가지로 Nullable한 값이 넘어온다면, 그 전에 Nullable한 값이 아니도록 조치를 취하는것이 가장 쉬운 방법일 것이다.

 

Nullable Object -> (전처리) -> Not Nullable Object -> 다른 모듈로 전파

 

그 해답은, props나 parameter로 넘어오는 Nullable paramter를 바로 전처리해서 Nullable하지 않는 object로 만들어 사용하면 된다.

보통 default Parameter를 주입하는 방법을 많이 쓰고, 혹은 Null object pattern(https://johngrib.github.io/wiki/pattern/null-object/)을 이용한다. 

 

Null을 넘길 수 밖에 없어서 Null을 넘긴건데 이게 뭔소리냐..? 싶을수도 있긴 하지만 default Parameter나 Null Object 같은 패턴을 이용해서 null 체크를 하는 상황 자체를 회피하는 방법이다. 

 

 

https://github.com/vercel/swr/blob/main/_internal/src/utils/resolve-args.ts#L8

 

 

테이블 라이브러리의 케이스에서는 Option이 넘어올때 중간에서 Nullable한 값이 있다면 default 값으로 overwrite하면 됐었다. 실제로 swr 라이브러리도 config로 넘긴 값을 defaultConfig과 merge해서 overwriting 하는 방법을 쓰고 있다. (17번째 줄)

 

옛날에 부스트캠프를 했을때 모든 코드에 null체크를 하는 게 안좋다는 피드백을 멘토님께 받았었는데

그 당시에는 왜 그런가 잘 몰랐었는데 오늘 날 와서 이해하게 된 것 같다..!

 

reference

 

SWR

https://github.com/vercel/swr/blob/main/_internal/src/utils/resolve-args.ts#L8

 

https://jojoldu.tistory.com/721

 

3. 좋은 함수 만들기 - Null 을 다루는 방법

여기서는 null 과 undefined 를 구분하지 않고 null 로 통일해서 표현한다. 정적 분석 서비스 rollbar 에서 1000개 이상의 JS 프로젝트에서의 소프트웨어 결함 통계를 공개했다. (출처: top-10-javascript-errors-f

jojoldu.tistory.com

 

반응형
반응형

 

전 글(https://lodado.tistory.com/83)에서 table 라이브러리를 구현해야 하는데

초기에는 headless library인 Tanstack Table을 사용하려 했으나, 대용량을 처리하지 못하는 문제로 내부 구조도 자체 구현하는 방향으로 방향을 바꿨다는 글을 썼다.

 

즉, 초기 렌더링 같은 tanstack table 자체의 성능에는 만족했지만 메모리 사용량이 너무  많아서(원본 데이터의 20배 수준) tanstack table을 사용하지 않는다는 결론을 내렸었는데

 

이 글에서는

 

1. tanstack Table의 데이터 구조

2. 메모리를 많이 사용하는 이유

 

두가지를 github를 들어가서 훑어 볼 것이다. 

 

깃허브 주소는 아래와 같다.

https://github.com/TanStack/table

 

GitHub - TanStack/table: 🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Ta

🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table - GitHub - TanStack/table: 🤖 Headless UI for building powerful tables &...

github.com

 

사용 예시 

 

우선 tanstack table에서 제공하는 예시 코드(kitchen sink)를 보고, 내부 동작을 추론해보자.

 

 

 

위 예제 코드에서 src/App.tsx를 보면 tanstack table을 사용하는 코드 예시를 볼 수 있다.

 

export const App = () => {
  const rerender = React.useReducer(() => ({}), {})[1]

  const [data, setData] = React.useState(makeData(1000))
  const refreshData = () => setData(makeData(1000))

  const [columnVisibility, setColumnVisibility] = React.useState({})
  const [grouping, setGrouping] = React.useState<GroupingState>([])
  const [isSplit, setIsSplit] = React.useState(false)
  const [rowSelection, setRowSelection] = React.useState({})
  const [columnPinning, setColumnPinning] = React.useState({})
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
  const [globalFilter, setGlobalFilter] = React.useState('')

  const [autoResetPageIndex, skipAutoResetPageIndex] = useSkipper()

  const table = useReactTable({
    data,
    columns,
    defaultColumn,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getGroupedRowModel: getGroupedRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
    getFacetedMinMaxValues: getFacetedMinMaxValues(),
... 생략

 

위 코드를 보면 useReactTable이 tanstack Table을 사용하는 방식(hook)이고, 

data와 column을 내부에 넣어서 table이란 object로 한번 감싸서 export하는것을 볼 수 있다.

 

그리고 getCoreRowModel, getFilterRowModel 등이 있는데 

해당 model들이 table에서 각 기능 (sort, filter)등에 사용하는 것이라고 추론할 수 있다.

 

그래서 일단 tanstack Table의 데이터 구조 를 github에 들어가서 어떻게 각 Model들이 어떻게 동작하고 연계되는지 찾아볼것이다.

 

힌트를 주자면 SQL에서 select문으로 데이터를 조회하는 방식과 비슷하다.

 

https://github.com/TanStack/table

tanstack table 최상단 폴더로 가보자.

lerna, packages 등의 폴더가 보이는데 이 프로젝트가 모노레포로 되어 있다는 사실을 알 수 있다.

 

그럼 packages 안에 core란 폴더가 있을거 같은데 그 폴더에 우리가 찾고 싶은 내용이 있을것 같다.

 

 

https://github.com/TanStack/table/blob/main/packages/table-core/src/utils/getCoreRowModel.ts

 

core 폴더를 찾아서 들어가다보니 아까 본 getCoreRowModel.ts 이란 파일이 있다.

보통 라이브러리들은 리엑트, 스벨트 등 다른 라이브러리에 종속성을 없에기 위해서 내부 코어 로직을 js로 구현하고,

각 라이브러리에 포팅 과정을 거치는데 아래 예시 코드를 보니 이 라이브러리도 똑같은 과정을 거치는것을 확인할 수 있다.

 

import { createRow } from '../core/row'
import { Table, Row, RowModel, RowData } from '../types'
import { memo } from '../utils'

export function getCoreRowModel<TData extends RowData>(): (
  table: Table<TData>
) => () => RowModel<TData> {
  return table =>
    memo(
      () => [table.options.data],
      (
        data
      ): {
        rows: Row<TData>[]
        flatRows: Row<TData>[]
        rowsById: Record<string, Row<TData>>
      } => {
        const rowModel: RowModel<TData> = {
          rows: [],
          flatRows: [],
          rowsById: {},
        }
			.... (생략) 

          for (let i = 0; i < originalRows.length; i++) {
            // Make the row
            const row = createRow(
              table,
              table._getRowId(originalRows[i]!, i, parentRow),
              originalRows[i]!,
              i,
              depth,
              undefined,
              parentRow?.id
            )
			
            .... (생략) 

            rowModel.flatRows.push(row)
            rowModel.rowsById[row.id] = row
            rows.push(row)

          return rows
        }

        rowModel.rows = accessRows(data)
        return rowModel
      },
      {
        key: process.env.NODE_ENV === 'development' && 'getRowModel',
        debug: () => table.options.debugAll ?? table.options.debugTable,
        onChange: () => {
          table._autoResetPageIndex()
        },
      }
    )
}

https://github.com/TanStack/table/blob/main/packages/table-core/src/utils/getCoreRowModel.ts

 

해당 model는 테이블에서 모든 row를 꺼내는 로직을 구현한 함수로 보인다. 

해당 코드에서 중요하다고 생각한 점을 추려봤는데 

 

1. memo를 사용해서 메모라이징을 통해 최적화를 시도한점 (성능 향상 but 메모리 사용)
2. flawRow를 사용해서 차원 depth가 있는 배열을 1차원 배열로 처리해 저장한 점 (성능 향상 but 메모리 사용)

3. rowById로 row의 id를 key로 해서 BigO(1)에 찾는 함수를 JSON 형태로 구현한 점 (성능 향상 but 메모리 사용)

 

음.. 위 코드를 보면 일단 성능을 위해 메모리를 희생한 코드로 보이고 해당 부분이 대용량 데이터 처리에서 약점이 될 것으로 추측될것 같다.

그래도 위 코드에서 늘어나는 메모리가 고작 3~4배일꺼 같은데..? 일단 메모리 부분은 추후에 보기로 하고 넘어가자

 

4. 독립적인 기능 (모든 row만을 꺼내는 로직을 구현한 함수)을 담당하는 하나의 함수

 

/packages/table-core/src/utils 에서 다른 model들을 살펴보면 각 model 함수들이 filter, sorting, pagination등 

독립된 로직을 동작하는 함수임을 알 수 있다. 

 

그럼 어디서 해당 함수들을 연결해줄까?

 

타고 타고 들어가다보면... table( table-core/src/core/table.ts#L125 )이란 파일에서 model끼리 연결해주는 부분을 찾을수가 있다.

 

const features = [
  Headers,
  Visibility,
  Ordering,
  Pinning,
  Filters,
  Sorting,
  Grouping,
  Expanding,
  Pagination,
  RowSelection,
  ColumnSizing,
] as const  // 중요 !!! 아까 본 model들이 converting되어 내부에 들어 있는 object임

... 생략

export function createTable<TData extends RowData>(
  options: TableOptionsResolved<TData>
): Table<TData> {
  ... 생략
  const coreInitialState: CoreTableState = {}

  let initialState = {
    ...coreInitialState,
    ...(options.initialState ?? {}),
  } as TableState

  table._features.forEach(feature => {
    initialState = feature.getInitialState?.(initialState) ?? initialState
  })

  const queued: (() => void)[] = []
  let queuedTimeout = false

  const coreInstance: CoreInstance<TData> = {
    _features: features,
    options: {
      ...defaultOptions,
      ...options,
    },
    initialState,
    _queue: cb => {
      queued.push(cb)

      if (!queuedTimeout) {
        queuedTimeout = true

        // Schedule a microtask to run the queued callbacks after
        // the current call stack (render, etc) has finished.
        Promise.resolve()
          .then(() => {
            while (queued.length) {
              queued.shift()!()
            }
            queuedTimeout = false
          })
          .catch(error =>
            setTimeout(() => {
              throw error
            })
          )
      }
    },
 
   ... 생략 
   Object.assign(table, coreInstance)

  for (let index = 0; index < table._features.length; index++) {
    const feature = table._features[index]
    feature?.createTable?.(table)
  }

  return table
}

https://github.com/TanStack/table/blob/main/packages/table-core/src/core/table.ts#L125

 

보고 싶은 부분만 추려봤다

 

  table._features.forEach(feature => {
    initialState = feature.getInitialState?.(initialState) ?? initialState
  })

위 코드가 model들이 연계되는 부분인데 함수형 프로그래밍의 pipe를 구현한 형태로 볼 수 있다

즉, input을 넣고 각 model들의 out을 바로 다음 model의 input으로 넣고... pipe 형태로 모델들을 연계한 것으로 볼 수 있다.

이 부분은 SQL의 select 문의 동작 방식과 유사하다.

 

query의 동작 순서

 

tanstack table의 핵심 데이터 구조는 생각보다 간단한 형태로 되어있다.

실제로 사내에서 만든 테이블 라이브러리의 내부 데이터 구조는 SQL의 select문을 참고하여 1~2주만에 구현했고,

tanstack table 와 유사한 성능을 낼 수 있었다.

 

 

그럼 tanstack table이 메모리를 과다 사용하는 이유는 무엇일까?

복합적인 요인이라서 꼭 찝어 말할 수는 없지만, 아까 보았듯이 row에 getRowById같은 편의 기능을 넣어놓았는데 만약 50~100만 row가 되면 row에 있는 편의기능 함수가 row 갯수만큼 추가되서 메모리를 과다 사용되는것으로 추측된다.

 

실제로 fork해서 50만개 row에 있는 "편의기능 함수" 만을 지워보는 실험을 했더니  1.6GB 정도에서 몇백MB를 감소 시킬 수 있었던 기억이 난다. 

 

사내에서 라이브러리를 구현했을때는 row를 인자로 받아서 실행하는 함수를 독립적으로 빼서 구현해서

tanstack table에 비해 메모리 사용량을 획기적으로 줄였다.

 

사실 tanstack table은 대용량 데이터 처리는 server pagination으로 구현한다고 생각해서

메모리 부분은 생각 안했을꺼라 추론 되지만..?

 

SQL Editor같은 제품을 브라우저에 구현하는 경우에는 브라우저에 수십~수백만 데이터가 있는 경우가 있고, 

해당 케이스를 서버 페이지네이션으로만 구현하려면 오히려 번거로워 SQL select문을 벤치마킹해서

테이블에서 1000만 이하의 대용량 데이터를 문제 없이 핸들링 할 수 있도록 내부 데이터 구조를 구현했다.

 

끟. 

반응형
반응형

react datatable (https://github.com/gregnb/mui-datatables)

사내에서 react-datatable를 브라우저 테이블으로 사용하고 있는데

렌더링 성능 이슈로 40~50만 건의 row 데이터 처리가 불가능해서 아예 테이블을 자체 구현하라는 지시가 떨어졌다.

이 글은 고분군투하면서 그 과정을 기록한 눈물겨운 글이다..

 

요구 스펙

 

1. 최소 column 10개, 100만 row를 브라우저에 렌더링 가능할 것 

*맨 처음 렌더링 성능 이슈를 해결하기 위해서 서버 페이지네이션을 생각하고 있었는데

이 요구사항으로 인해 최소 100만개는 클라이언트에 렌더링 되도록 구현해야 했다.

 

+ 가상화 라이브러리 (react-virtualized등) 적용

 

2. 현존하는 테이블 라이브러리보다 성능이 좋거나 대등할것 (!?)

특히, 1번 케이스에서 다른 테이블 라이브러리처럼 초기 렌더링이 3~5초 이내여야 한다.

 

3. 기존 테이블 라이브러리의 기능을 모두 갖출 것 (sorting, searching, filtering, pagination, inifinity scroll 등)

 

처음엔 tanstack table headless 라이브러리를 사용하려고 했는데 

메모리 사용량이 1번 스펙을 충족하지 못해서 할 수 없이 데이터구조도 자체구현 하는쪽으로 하기로 결정했다.

 

(tanstack table은 메모라이징 및 각종 지표를 제공하면서 메모리를 과다 사용해서

13개 column, 50만 row가 주어질때 다른 테이블이 200MB를 쓴다면,

tanstack table은 1.6 GB를 사용해서 대용량 처리엔 알맞지 않다는 결론을 내림)

 

초기 데이터 구조

sql select문 순서!

DB의 Select문에서 영감을 받아서 설계했는데 

원본 데이터 → ( execution ) → 현재 필요한 데이터만 UI에 전달하는 식으로 구현했다.

초기 데이터 구조 예시

UI 데이터의 관계를 분리했다.

 

원본 데이터 상태(originalData)가 있고,

sortingState, filteringState, paginationState 등등 여러 조건들을 상태로 observing하여

상태가 바뀔때마다 필터링해서 현재 UI에 필요한 데이터만 전달한다. 

 

즉, 각 sorting, filtering 상태는 Finite State Machine처럼 어떤 특정 상태를 가지고 있고, 그 상태에 맞추어서

originalData를 필터링해 최종적으로 UI에서 현재 원하는 데이터만 필터링해서 볼 수 있다.

 

성능 최적화 팁

* 초기 데이터 구조라고 써놨는데 

sorting, search같이 반드시 모든 데이터를 한번 순회 해야하는 경우도 있을 것이고

화면에 현재 보이는 row들에게만 적용해야하는 경우도 있을 것이다.

 

그래서 실 코드 구현 예시는

가상화 라이브러리를 이용해서 lazy하게 적용되는 파트와

위 예시처럼 한번 모든 row를 순회하며 적용하는 2가지 케이스로 나뉘어져 있다.

 

 

코드 예시

위 설계 부분을 그대로 코드로 구현한 것이다.

 

예시 코드이고 실제 구현된 코드와는 다름

*flow는 함수형 프로그래밍의 pipe 함수(관련 링크)이며, lodash에 구현되어 있다.

 

예시 코드에 있는 id를 +1 하는 의미없는 함수들은 성능을 측정하기 위해 집어넣은 sorting, filtering, pagination등의 mocking 구현체를 뜻한다. 

 

테이블 UI 예시

 

Body

블로그에 올리기 위한 예시 코드이고 실제 구현된 코드와는 다름

필터링된 최종 결과물을 화면에 렌더링한다.

sorting 되었다면 visibleRowData는 오름차순으로 정렬되어 제공될 것이다.

특정 열이 숨김 처리되었다면 visibleRowData는 특정 행들이 제거되어 제공될 것이다.

 

UI 입장에서는 데이터를 가공하는 중간 과정을 알 필요가 없이 자신이 요청한 데이터를 그대로 쓸 수 있다.

 

코드 예시에는 가상화 라이브러리가 적용되어 있지 않는데, 

120만개의 row가 테이블에 제공된다면 row 모두를 dom에 그릴 필요는 없을 것이니

보이는 부분만 렌더링 시키는 가상화 라이브러리를 적용하면 된다. 

 

테이블 header 예시 

블로그에 올리기 위한 예시 코드이고 실제 구현된 코드와는 다름

handleSort로 sortingState 상태를 변경한다. 

그럼 rowData들이 sorting되어 body에 보여지게 된다.

 

구현 결과

현존하는 tanstack table, tui-grid, mui datatable등과 비교해봤는데 직접적인 수치를 제공할 수는 없지만

초기 요구사항을 모두 만족했고 대등하거나 더 나은 성능을 보여줬다.

 

특히, 메모리 부분에 신경써서 10개의 column, 1000만 row까지는 무리없이 몇초내에 렌더링하는 성능을 보여줬다.

다만 1000만개정도 무지막지한 데이터양이라면 row를 브라우저에 가져오지 않고 서버 페이지네이션을 구현하는 절충안으로 나아갈 것이다..

 

반응형
반응형

개발을 진행하면서 가끔 Icon 추가나 변경이 이루어질때가 있는데

그럴때마다 아래 과정을 거쳤다.

1. 디자이너가 피그마에서 업데이트한 아이콘을 내려받은 후, 이를 압축합니다.
2. 사내 메신저에 업로드하고, 엔지니어에게 공유합니다.
3. 엔지니어는 이를 내려받아 압축을 풀고, 소스 코드에 적절히 추가합니다.
4. PR을 올려 코드 리뷰 후 머지합니다.
// (https://channel.io/ko/blog/figma-icon-plugin) 에서 발췌

 

그래서 피그마에서 아이콘 업데이트가 이뤄진다면 코드에서 바로 다운받을 수는 없을까? 생각했고,

찾아본 결과 피그마 plugin을 발견하여 직접 써본 후 사용 방식을 공유해본다.

 

사용방법

 

1. 피그마 access token 추가

 

 

figma setting > personal access token에서 토큰을 생성한다. 

피그마 web api를 사용하려면 access token을 같이 보내줘야 한다.

 

2. node-fetch 설치

pnpm add -D node-fetch //pnpm, yarn, npm 등 사용하는걸로 설치

https://www.npmjs.com/package/node-fetch

 

node-fetch 라이브러리를 devDependency에 추가한다.

프론트 환경이든 백엔드 환경이든 dev 환경에서만 돌릴꺼이므로(node를 사용하므로) 크게 상관은 없다.

 

설치 후 

   "icon": "node ./src/icons/generator.mjs"

 

packages.json에서 아래 3번의 파일을 실행하면 피그마 icon들이 자동생성되게 구현할 것이다.

 

3. Icon들을 다운받는 generator.mjs 파일 생성

 

여담으로  나는 import를 쓰고 싶은데 ESM 환경이 아니라서 mjs로 명시했는데

뭐 굳이 esm을 사용하고 싶지 않으면 .mjs 확장자를 쓰지 않아도 되긴 한다. 

 

코드는 https://medium.com/iadvize-engineering/using-figma-api-to-extract-illustrations-and-icons-34e0c7c230fa 에서

약간의 수정을 거친 코드이다.

 

전체 코드 보기

더보기
/*
 reference:
  https://medium.com/iadvize-engineering/using-figma-api-to-extract-illustrations-and-icons-34e0c7c230fa
*/


import dotenv from 'dotenv'
import { appendFileSync, writeFileSync } from 'fs'
import fetch from 'node-fetch'


dotenv.config()


const TOKEN = process.env.FIGMA_WEBHOOK
const FILE_KEY = 'v2LAIwRuECBSb24aIFDKwB'


const fetchFigmaFile = (key) => {
  return fetch(`https://api.figma.com/v1/files/${key}`, { headers: { 'X-Figma-Token': TOKEN } }).then((response) =>
    response.json(),
  )
}


const flatten = (acc, cur) => [...acc, ...cur]


const getComponentsFromNode = (node) => {
  if (node.type === 'COMPONENT') {
    return [node]
  }
  if ('children' in node) {
    return node.children.map(getComponentsFromNode).reduce(flatten, [])
  }
  return []
}


const formatIconsSVG = (svg) => svg.replace(/fill="(?:#[a-fA-F0-9]{6}|none)"/gm, 'fill="currentColor"')


const formatName = (name) => name?.toUpperCase().replace(/-/g, '_') // replaces '/' by '_'


const hash = (path) => path.replace(/^.*\/img\//g, '').replace(/\//g, '_')


const generateFiles = (ele) => {
  if (!ele) return ''


  const { name, fileName, svg } = ele
  const component = `
  import * as React from "react";


  const ${name} = (props: React.SVGProps<SVGSVGElement>) => {
    return (${svg.replace(/<svg /, '<svg {...props} ')});
  }


  export default ${name};
  `


  writeFileSync(`./src/icons/${name}.tsx`, component)
  return `${name}`
}


const getSVGsFromComponents = (components) => {
  const key = FILE_KEY
  const filteredComponent = components.filter(({ name }) => name?.toUpperCase().startsWith('ICON'))
  const ids = filteredComponent.map(({ id }) => id)


  return fetch(`https://api.figma.com/v1/images/${key}?ids=${ids.join()}&format=svg`, {
    headers: { 'X-Figma-Token': TOKEN },
  })
    .then((response) => response.json())
    .then(({ images }) =>
      Promise.all(
        filteredComponent.map(
          ({ id, name, type }) =>
            images[id] &&
            fetch(images[id])
              .then((response) => response.text())
              .then((svg) => ({
                name: formatName(name),
                fileName: hash(images[id]),
                svg: formatIconsSVG(svg),
              })),
        ),
      ),
    )
}


async function run() {
  if (!TOKEN) {
    console.error(
      'The Figma API token is not defined, you need to set an environment variable `FIGMA_API_TOKEN` to run the script',
    )
    return
  }
  fetchFigmaFile(FILE_KEY)
    .then((data) => getComponentsFromNode(data.document))
    .then(getSVGsFromComponents)
    .then((dataArray) => dataArray.map(generateFiles))
    .then((ele) => Array.from(new Set(ele)))
    .then((texts) => {
      writeFileSync(
        './src/icons/index.ts',
        texts.reduce((t, v) => `${t}\n import ${v} from './${v}'`, ''),
      )


      appendFileSync('./src/icons/index.ts', texts.reduce((t, v) => `${t} ${v},`, '\n\n export {').slice(0, -1))


      appendFileSync('./src/icons/index.ts', '}')
    })
}


run()

 

혹시 copy가 필요한 경우는 아래 gist에서 전체 코드를 볼 수 있다.

https://gist.github.com/lodado/24da180db5042ff1bb5b20b1527d5e33

 

icon 자동 설치

icon 자동 설치. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

전체 코드는 아래와 같은데

async function run() {
  if (!TOKEN) {
    console.error(
      'The Figma API token is not defined, you need to set an environment variable `FIGMA_API_TOKEN` to run the script',
    )
    return
  }
  fetchFigmaFile(FILE_KEY)
    .then((data) => getComponentsFromNode(data.document))
    .then(getSVGsFromComponents)
    .then((dataArray) => dataArray.map(generateFiles))
    .then((ele) => Array.from(new Set(ele)))
    .then((texts) => {
      writeFileSync(
        './src/icons/index.ts',
        texts.reduce((t, v) => `${t}\n import ${v} from './${v}'`, ''),
      )

      appendFileSync('./src/icons/index.ts', texts.reduce((t, v) => `${t} ${v},`, '\n\n export {').slice(0, -1))

      appendFileSync('./src/icons/index.ts', '}')
    })
}

run()

 

진행 과정을 한줄 한줄 차근차근 보자.

 

 

1. 피그마 api 접근

import dotenv from 'dotenv'
import { appendFileSync, writeFileSync } from 'fs'
import fetch from 'node-fetch'

dotenv.config()

const TOKEN = process.env.FIGMA_WEBHOOK
const FILE_KEY = 'v2LAIwRuECBSb24aIFDKwB'

const fetchFigmaFile = (key) => {
  return fetch(`https://api.figma.com/v1/files/${key}`, { headers: { 'X-Figma-Token': TOKEN } }).then((response) =>
    response.json(),
  )
}

 

나는 dotenv를 써서 아까 받은 피그마 access token을 .env에 저장하고, api 요청을 할때 token을 담아 보내게 작성했다.

FILE_KEY는 피그마 프로젝트에 들어가서 내가 받아오고 싶은 프로젝트의 file key를 쓰면 된다. 

 

figma.com/file/${file_key}&nbsp; <- 이부분!

 

2. 받아온 api 전처리

const flatten = (acc, cur) => [...acc, ...cur]

const getComponentsFromNode = (node) => {
  if (node.type === 'COMPONENT') {
    return [node]
  }
  if ('children' in node) {
    return node.children.map(getComponentsFromNode).reduce(flatten, [])
  }
  return []
}

피그마엔 Component와 frame?등 컴포넌트를 분류하는 기준이 있는데,

개발과는 크게 상관이 없는 부분이니 사용하기 쉽게 모두 전처리해서 다음 부분으로 넘긴다. 

 

3. SVG 파일(Icon들만) 받아오기

const formatIconsSVG = (svg) => svg.replace(/fill="(?:#[a-fA-F0-9]{6}|none)"/gm, 'fill="currentColor"')

const formatName = (name) => name?.toUpperCase().replace(/-/g, '_') // replaces '/' by '_'

const hash = (path) => path.replace(/^.*\/img\//g, '').replace(/\//g, '_')

const getSVGsFromComponents = (components) => {
  const key = FILE_KEY
  const filteredComponent = components.filter(({ name }) => name?.toUpperCase().startsWith('ICON'))
  const ids = filteredComponent.map(({ id }) => id)

  return fetch(`https://api.figma.com/v1/images/${key}?ids=${ids.join()}&format=svg`, {
    headers: { 'X-Figma-Token': TOKEN },
  })
    .then((response) => response.json())
    .then(({ images }) =>
      Promise.all(
        filteredComponent.map(
          ({ id, name, type }) =>
            images[id] &&
            fetch(images[id])
              .then((response) => response.text())
              .then((svg) => ({
                name: formatName(name),
                fileName: hash(images[id]),
                svg: formatIconsSVG(svg),
              })),
        ),
      ),
    )
}

 

피그마 프로젝트의 img 중에서 format이 svg인 이미지들만 전부 불러온다.

(당연히 필요한 icon이 다른 포멧이라면 해당 포멧도 추가해야한다.)

 

이때 모든 svg를 불러오게 되는데, Icon만 선별하기 위해서 디자이너 분에게 Icon은 모두 이름앞에 Icon prefix를 붙여달라고 요청했다.

아마 피그마에서 분류하는 다른 좋은방법이 있을거 같은데.. 

일정이 급해서 시간상 해당 방법으로 구현해달라고 요청했다.

 

여담으로 피그마에서 component 지정을 하지 않으면 위 api에서 나타나지 않는듯 싶다.

 

 

4. React Component로 변환

const generateFiles = (ele) => {
  if (!ele) return ''

  const { name, fileName, svg } = ele
  const component = `
  import * as React from "react";

  const ${name} = (props: React.SVGProps<SVGSVGElement>) => {
    return (${svg.replace(/<svg /, '<svg {...props} ')});
  }

  export default ${name};
  `

  writeFileSync(`./src/icons/${name}.tsx`, component)
  return `${name}`
}

그냥 ${name}.svg로 저장하는 방법도 있긴 한데 어차피 react component로 mapping 하는 과정이 필요해서 

아예 react component로 아이콘을 저장하게 생성한다. 

 

생성된 아이콘들 예시

5. 자동 export 

 

// run function의 마지막 부분)

.then((texts) => {
      writeFileSync(
        './src/icons/index.ts',
        texts.reduce((t, v) => `${t}\n import ${v} from './${v}'`, ''),
      )

      appendFileSync('./src/icons/index.ts', texts.reduce((t, v) => `${t} ${v},`, '\n\n export {').slice(0, -1))

      appendFileSync('./src/icons/index.ts', '}')
 })

 

import & export 하기 쉽도록 index.tsx에 import 후 export하는 파일을 작성한다.

자동생성 index.tsx 파일

해당 과정을 거치면 위와 같은 파일이 생성된다.

 

6. 끝!

 

혹시 storybook에서 보고 싶다면 추가 icon도 자동으로 import하게 

import * as Icons from 'icon 폴더 주소' 로 구현한다.

 

import type { Meta, StoryObj } from '@storybook/react'
import { FooterNav } from 'myll-ui'
import Image from 'next/image'
import * as Icons from 'shared'

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta<typeof FooterNav> = {
  title: 'Example/Icon',
  argTypes: {},
}

export default meta

const ICONS = Object.entries(Icons)
  .filter(([key, value]) => key.startsWith('ICON'))
  .map(([key, value]) => {
    return { key, IconComponent: value }
  })

export const IconExamples = () => {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
      색깔은 fill, color를 통해 맞추세요
      {ICONS.map(({ key, IconComponent }) => {
        return (
          <div style={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
            <div style={{ width: '280px' }}>{key} :</div> <IconComponent />
          </div>
        )
      })}
    </div>
  )
}

 

storybook 예시

 

위 icon들은 storybook이 연동된 사이드 프로젝트의 github-pages에서 볼 수 있다.

https://myll-github.github.io/myll-frontend/?path=/story/example-icon--icon-examples 

 

@storybook/cli - Storybook

 

myll-github.github.io

 

자동 업데이트를 통해서 끔찍한 파일 다운 & 저장 노가다에서 해방된 듯 싶다..?

webhook을 통하여 아이콘 업데이트마다 자동 PR을 날리거나 다른 설정도 더 해줄 수 있는것 같지만

토의 결과 아이콘이 한번 업데이트 된 이후에는 수정이 없을것으로 생각되어 시도하진 않았다. 

 

reference 

 

https://channel.io/ko/blog/figma-icon-plugin

 

피그마 플러그인으로 아이콘 업데이트 자동화하기

안녕하세요 👋, 채널톡 웹팀의 에드입니다. 채널톡엔 베지어(Bezier)라는 디자인 시스템이 있습니다. 저희 웹팀에서는 이 디자인 시스템의 React 구현체인 bezier-react 라는 오픈소스 라이브러리를

channel.io

위글에 영감을 받아서 제작했는데 webhook을 통한 자동 PR까진 만들지는 않았다.

 

https://medium.com/iadvize-engineering/using-figma-api-to-extract-illustrations-and-icons-34e0c7c230fa

 

반응형
반응형

사내 배포용으로 라이브러리를 개발 중인데 처음 보는 이상한 오류가 떴다.

 

module exports에 관한 오류

모노레포 storybook-docs 레포에서

빌드된 tttable(임시 작명이라 가명임;;) 레포의 index.js를 required 하면 오류가 나는것 

평소에 commonjs와 esm, amd 등이 뭔지 헷갈려서 이참에 정리겸 간단하게 요약해본다.

 

CommonJS(CJS)란?

// 모듈 정의
const circle = require('./circle.js');

const radius = 5;
console.log(`반지름 ${radius}인 원의 넓이: ${circle.area(radius)}`);
console.log(`반지름 ${radius}인 원의 둘레: ${circle.circumference(radius)}`);

 

JavaScript를 위한 모듈 로딩 시스템 표준 중 하나이다.

Node.js와 같은 서버 측 JavaScript 환경에서 사용되며, 모듈을 정의하고 로드하기 위한 표준화된 방법을 제공한다. 

node.js나 프론트엔드의 config 파일에서 많이 본 require를 사용한 방식이 commonJS 방식이다.

 

초기 Javascript는 모듈 시스템이 없었기 때문에 모듈화를 위해 commonJS,  AMD 등이 개발되어 사용되었다.

commonJS는 동기적으로 모듈을 로드하기 위해 사용되고,

비동기를 따로 다루기 위해 AMD(Asynchoronous Module Definition)이 비동기적으로 모듈을 다루는 것에 대한 표준안으로 제시되었다고 한다.

 

ECMAScript Module(ESM)이란? 

// 모듈 불러오기
import circle from './circle.mjs';

// 모듈 사용
const radius = 5;
console.log(`반지름 ${radius}인 원의 넓이: ${circle.area(radius)}`);
console.log(`반지름 ${radius}인 원의 둘레: ${circle.circumference(radius)}`);

 

 ES6부터 표준화 된 모듈 시스템으로, 클라이언트에서 흔히 사용하는 import, export를 사용하는 방식이다.

브라우저에서 사용하기 위해서는 type="module"을 script 혹은 package.json에 명시해주면 된다.

 

 

토스 블로그의 CommonJS와 ESM 에 대한 글에 따르면, CJS 모듈은 동기적으로 작동하고 ESM Module은

비동기적으로 작동한다.

 

그래서 ESM에서 CJS를 import 가능하지만 그 역은 안되는데 그 이유는 CJS는 top-level await를 지원하지 않기 때문이며

그 외에도 여러가지 다른 이유가 있어서 서로 호환하기가 쉽지 않다고 한다. (자세한건 링크 참고)

 

즉, 라이브러리를 배포하려면 CJS와 ESM 버젼을 둘다 지원해야 하는것!

 

 

지원하는 방법은 위 코드처럼 package.json에 exports를 두가지 버젼으로 명시해주면 된다.

 

주의할 점은 package.json에 type:"module"가 명시되어 있으면 ESM으로 인정되며 main이 index.js(ESM)로 인식되고, module이 index.cjs(CJS)로 인식될 수 있다.

반대로 type:"module"가  없으면 main이 index.js(CJS)이고 module이 index.mjs로 인식될 수 있기 때문에

exports를 통해 버젼별로 선언해줘야 한다.

 

캡처 찍고 보니 types 설정을 빼먹은거 같은데 내일 넣어야겠다.

(type도 index.d.mts, index.d.cts로 명명된다 함) 

 

 

옛날에 types: module이 정확히 왜 필요한건지 몰랐는데 이번에 알게되어 

의미 깊은 하루였다.

 

reference 

 

https://devblog.kakaostyle.com/ko/2022-04-09-1-esm-problem/

 

ESM 삽질기

저희는 주기적으로 Node.js 모듈을 최신 버전으로 업데이트하고 있습니다. Node.js를 10년째 사용 중인데, CoffeeScript → TypeScript, 콜백 → Async.js → Promise(& async, await) 전환 하면서 몇 번 혼란의 시기가

devblog.kakaostyle.com

 

https://toss.tech/article/commonjs-esm-exports-field

 

CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field

Node.js에는 두 가지 Module System이 존재합니다. 토스 프론트엔드 챕터에서 운영하는 100개가 넘는 라이브러리들은 그것에 어떻게 대응하고 있을까요?

toss.tech

 

https://velog.io/@yesbb/%EB%AA%A8%EB%93%88-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%98-%EC%97%AD%EC%82%AC-%EA%B7%B8%EB%A6%AC%EA%B3%A0-ESM

 

반응형
반응형

요즘 모노레포를 사용하기 위해서 turbo repo를 사용하면서 궁합이 좋은 패키지 매니저인 pnpm을 사용해보고 있다.

몇주간 사용하며 느낀 점들을 간단히 공유한다.

 

pnpm을 선택한 이유 

출처 : https://toss.tech/article/node-modules-and-yarn-berry

 

npm과 Yarn은 복잡하게 얽힌 dependency들을 단일 루트 하에 위치시키며(Hoisting) 

라이브러리의 packages.json에 명시된 dependencies들을 몰래 최상위로 호이스팅 시킨다.

그 과정에서 packages.json에 명시되지 않은 라이브러리를 사용 가능한데 이를 유령 의존성(phantom dependency)이라 부른다.

 

실제로 turborepo에서 yarn을 사용해본 결과 jest를 모노레포 A에서만 깔았는데 모노레포 B에서도 사용할 수 있었다.

 

사실 jest는 별 문제가 되지 않지만 만약 실제 사용하는 공통 라이브러리들이 유령 의존성으로 꼬이는 위험한 문제가 있을 수 있다고 판단했다. (실제 일하는곳도 yarn을 쓰는데 비슷한 경우로 가끔 고통받았다..; )

 

이를 해결하기 위한 yarn berry와 pnpm을 조사해보았는데, yarn berry는 git에 지속적인 과부하를 주고, pnpm 대비 사용하기 까다롭겠다는 생각에 결국은 pnpm을 선택했다.

 

pnpm의 장점

모노레포는 모든 라이브러리를 전역에 직접 설치하고 호이스팅하는 대신 

전역 저장소(Virtual Store)에 패키지를 공유하고 symlink로 패키징을 참조하는 방식을 사용한다.

 

 

모노레포를 사용하면서 비슷한 라이브러리를 많이 다운받을텐데  중복된 패키지를 설치하지 않아 저장 공간과 네트워크를 절약할 수 있으며, 파일 복사를 최소화하여 더 빠르게 패키지를 설치할 수 있게 되었다.

 

이 기능을 온전히 사용하기 위해서는 루트 디렉토리의 .npmrc에 

node-linker=isolated

node-linker=isolated라는 옵션을 사용해줘야한다. (기본 default가 isolated이다.)

 

가끔 turbo repo의 quickstart repo에 'node-linker: hoisted'라고 명시되어 있는데 조심해야한다.

hoisted라면 npm, yarn처럼 모든 패키지가 호이스팅되게 동작한다.

 

만약 특정 라이브러리만 호이스팅하고 싶다면? 

아까 예시로 든 jest를 생각해보자. 모든 모노레포에 jest를 깔고 버전을 맞춰준다고 생각해보면 정말 고통일 것 같다.

Jest 관련 레포를 만들고 해당 레포를 이용하여 jest 라이브러리 버전을 공유하고 싶지 않을까?

또한 eslint, prettier도 매번 깔아야할까..?

 

이를 방지하기 위해서 pnpm은 public-hoist-pattern라는 옵션을 .npmrc에 제공한다.

 

public-hoist-pattern

  • Default: ['*eslint*', '*prettier*']
  • Type: string[]
public-hoist-pattern[]=*plugin*

(https://pnpm.io/npmrc 에서 발췌)

 

기본적으로 eslint, prettier는 pnpm에서 호이스팅되게 설정되어 있다. 

만약 jest 관련 설정도 호이스팅하고 싶으면 

public-hoist-pattern[]=*jest*

.npmrc에 사용하면 된다.

 

 

reference 

 

 https://toss.tech/article/node-modules-and-yarn-berry

반응형

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

레거시의 구원자, jscodeshift 사용해보기  (2) 2024.08.19
commonjs와 ESM의 차이  (0) 2023.07.25
자바스크립트의 async/await에 대해서  (0) 2022.03.31
JS로 Single Page Application 구현  (0) 2022.03.12
Generator  (0) 2022.02.02
반응형

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

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

 

소프트웨어 개발 수명 주기 (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

+ Recent posts