플레이스 예약 사업주향 서비스 상태관리 라이브러리 전환 후기

Seungu Lee
네이버 플레이스 개발 블로그
15 min readNov 16, 2022

“이 문서가 Recoil 전환작업을 하며 고민하는 어느 무명 개발자의 검색에 잡혀 도움이 되길 바랍니다.”

고민하는 개발자의 사진, Photo by stable diffusion AI

예약 사업주향 서비스 Recoil 전환 배경

네이버 예약 사업주향 서비스에선 상태 관리 라이브러리로 Redux를 사용하고 있었는데 워낙 오래된 코드라 class component와 결합된 Redux 코드의 learning curve가 높아 신규로 합류하게 된 분들이 어려움을 겪고 있었습니다. 그리고 오래되고 deprecated 된 코드라 이것을 hook 스타일로 리팩토링 할 지, 아예 Recoil로 전환을 해야할지 결정을 해야했고 검토 결과 아래와 같은 이유로 Recoil 전환을 결정 했습니다.

  1. Redux도 useSelector / useDispatch로 hook 스타일로 전환은 가능한데 Recoil로 전환하는 것과 비슷한 정도의 개발 공수가 소요됨.
  2. 비동기 처리를 위해 Redux saga / thunk를 추가 도입해야 하는데 Recoil은 라이브러리 하나로 이미 동일한 기능을 제공.
  3. 같은 처리를 할 때 Recoil의 코드량이 좀 더 적고 배우기 쉬움.

오래된 코드가 많았고 Redux를 전역적으로 사용하는 부분이 많아 Recoil로 그대로 전환하기에 어려운 부분이 좀 있었는데요. 어려웠던 부분과 그 해결 과정들을 정리하여 공유해 드리고자 이 문서를 작성했습니다.

Recoil을 처음 적용해 본 부분이라 미흡한 부분이 있을수도 있어 코멘트 남겨주시면 감사하겠습니다.

  • 일부 예제 코드에 typescript에서 금기시하는 any가 포함되어 있는데 내부 코드를 pseudo-code로 바꿔서 기술하다보니 너그러이 봐주시기 바랍니다.

Recoil 전환 과정중에 발생한 troubleshooting

이전 문단에서 말씀드린 것처럼 예약 사업주향 서비스의Redux가 워낙 오래된 코드라 Recoil로 리팩토링하며 애먹은 부분이 몇 가지 있었는데 recoil에서 제공하는 몇 가지 feature를 이용하여 해결한 좋은 사례들이 있어 이 부분을 공유하려 합니다. Recoil 개발 레퍼런스는 이미 잘 번역이 되어있어 학습 자체는 하기 쉬운데 응용 사례는 stackoverflow에서도 찾아보기가 쉽지 않은 부분들이 많아 향후 Redux에서 Recoil로 전환을 고려하시는 분들에게 이 예제가 많은 도움이 될 것입니다.

Class component에 대한 Recoil props 지원 사례

예약 사업주향 서비스 legacy 코드에는 class component도 상당수 있어서 대응을 해야 했습니다. Recoil은 hook을 통해서만 사용이 가능하므로 class component에서 Recoil을 사용하려면 아래 예시 코드처럼 wrapper를 구현해서 써야합니다.

Recoil atomFamily를 사용한 MiniModal, Modal 에서 atom이 공유 안되는 이슈 대응 사례

기존 코드에선 모달을 띄우고 닫는 방식으로 appendChild, removeChild을 사용하다 보니 RecoilRoot에 있는 값이 앱과 모달 사이에 공유 안되는 문제가 있었습니다. RecoilRoot 이외에도 각종 context도 공유가 안되어 모달을 appendChild 할때마다 context 복사본을 bind 해주는 형태로 구현이 되어있었는데 개선이 필요했습니다.

기존에는 Redux store를 전역에서 사용 가능하게 두고 이를 모달에 주입하는 방식으로 사용했는데 Recoil에선 이런 식으로 사용하는 방법을 찾지 못했고 혹시 가능하다고 하더라도 기본적으로 App 루트에 RecoilRoot를 둬서 사용하라고 가이드가 제공되고 있으므로 권장하지 않는 방식이라고 생각되어 모달도 Recoil을 사용하여 open / close 하도록 개선 진행했습니다.

Modal이 중첩해서 open 될 수 있었고 한 번에 하나의 모달만 하이라이트 / 리랜더링 되는 방식으로 동작하므로 array 형태의 atom은 사용 불가했고 atomFamily를 사용해야 했습니다.

export const modalAtomFamily = atomFamily<ModalBody, number>({
key: `${namespace}/modalAtom`,
default: {},
})

atomFamily를 사용하면 동일한 자료구조를 갖는 각 atom들이 별개의 reference를 가지게 되어 모달 상호간에 영향을 주지 않습니다.

아래 코드에 있는 것 처럼 열린 모달은 제어를 위해 각자 unique key값을 가지게 해서 key가 있을 경우 모달을 열고 없으면 닫습니다. (기존 appendChild / removeChild 대체)

이제 개별 컴포넌트에 코드에 붙는 모달 제어기 역할을 해주는 컴포넌트 입니다. 열린 모달에는 useRef를 사용하여 임의의 unique key를 부여합니다.

모달은 런타임에서 사용자가 open / close 를 해야 열고 닫고 뭔가 내용을 입력해야 내용을 업데이트 할 수 있으므로 useRecoilCallback을 사용하여 아래와 같은 세 가지 기능을 제공합니다.

  1. Modal open : 모달 atomFamily에서 실시간으로 현재 Recoil에 저장된 atoms를 가져오고 빈 atom에 새로운 모달을 할당합니다.
  2. Modal close : 설정된 key를 해제합니다.
  3. Modal 변경 : 사용자가 모달에 내용 입력할때 리랜더링을 위해 제공

리팩토링의 마지막 단계로 기존 모달 body에서 legacy 코드를 제거합니다. 개선된 후 HTML을 보면 아래와 같이 됩니다.

이제 앱과 모달 사이에 데이터 공유가 하나의 RecoilRoot로 자유롭게 이루어지게 되었습니다. (덤으로 React context와 provider 같은 것들도 별도의 설정 없이 상호간 데이터 공유가 자유롭게 될 수 있게 개선 되었습니다.)

useRecoilCallback을 사용한 scroll event function 리팩토링 대응 사례

아이템 리스트 페이지 안의 스크롤이 화면 하단에 위치하면 필터가 유지된 채 다음 아이템 리스트가 추가되는 구조입니다.

Recoil에 저장되어 있는 필터가 유지된 상태로 페이징 되어야 하는데 스크롤 이벤트가 발생하면 필터가 초기화 되어버리는 이슈가 있었습니다. 이럴 땐 useRecoilCallback을 사용해서 이벤트가 발생한 시점의 Recoil 필터값을 가져와서 사용해야 합니다.

해결 코드 changes는 아래와 같습니다.

페이징 issue가 발생했던 코드 (AS-IS)
TO-BE

useRecoilCallback에서 제공하는 snapshot을 사용하면 간단하게 런타임 이벤트 발생 시점의 Recoil 값을 읽어올 수 있습니다.

Recoil selector를 이용한 Redux getter / setter 마이그레이션

기존 Redux 코드에는 아래와 같이 검색 필터를 dispatch 전에 모든 코드에서 공통적으로 동작하는 전처리 과정을 거칩니다.

이런 공통 전처리 / 후처리 과정은 Recoil에서 지원하는 selector를 이용하여 아래처럼 똑같은 코드로 마이그레이션 할 수 있습니다.

Recoil selector를 이용한 인증 토큰 재발급 코드 마이그레이션

백엔드 서버 통신을 하기위한 인증용 토큰이 있는데 legacy 코드에선 이것을 재발급 할 때마다 Redux에 보관을 하는 로직이 있었습니다. 이 보관 로직을 호출하는 부분이 전역적으로 동작을 하므로 hook 사용이 불가능해서 다른 방법으로 해결을 해야 했습니다.

기존 코드는 아래와 같은 형태로 되어 있었습니다.

문제가 되는 코드는 await dispatch(getAuthToken()) 였습니다. 값을 지정된 주기마다 Redux storage에 저장을 하는데 hook 사용만 가능한 Recoil의 특성상 그대로 마이그레이션은 불가능합니다. 마이그레이션 문제를 두 가지로 나눠서 생각해 볼 수 있었습니다.

  1. 인증 토큰을 Redux에 비동기적/전역적으로 저장
  2. 저장된 인증 토큰을 Recoil에서 필요한 곳에 내려주기

우선 전역적/비동기적으로 저장하는 부분부터 해결을 해야하므로, 인증 토큰의 특성상 localStorage에 보관을 하는 것이 가장 적당하다고 생각을 해서 우선 Redux에 보관하던 것에서 localStorage에 보관하는 것으로 구현을 했습니다.

인증 토큰을 저장하는 부분은 해결이 되었습니다. 이제 Recoil에서 이 값을 사용할수 있게만 해주면 됩니다. 바로 위 문단에서 설명한 Recoil selector로 localStorage에 보관해둔 인증 토큰을 intercept 하는 방식으로 해결을 합니다.

SSR과 무관한 인증 토큰이므로 간단하기 분기를 해주고 위와 같이 getter / setter에 대해 localStorage에 보관된 값으로 intercept 해주는 식으로 해결에 성공했습니다.

Recoil selector을 이용한 코드 개선 사례

여러 개의 아이템 중 사업주가 현재 보고있는 아이템 하나를 선택해서 Redux 에서 내려주는 코드가 있었습니다. isFocused 필드가 true면 현재 사업주가 보고있는 아이템이라는 뜻인데, Redux에선 이를 표현할 방법이 마땅치 않아 아래처럼 각 컴포넌트에서 일일이 중복된 필터링 코드를 넣어줘야 했습니다.

Recoil로 마이그레이션하면서 간단하게 selector로 처리 가능하게 되었습니다.

사용할 때는 아래 코드처럼 간단하게 focusedBizItemSelector만 import해서 쓰면 됩니다.

useRecoilValue(focusedBizItemSelector)

Recoil selectorFamily의 활용 사례

기존 Redux에선 아래처럼 특정 인자로 필터링해서 props를 바인딩 해주는 부분이 있었습니다.

Recoil에선 위 코드처럼 인자를 받아 필터링하기 위해선 selectorFamily라는 것을 사용하면 됩니다. (참고 : https://recoiljs.org/docs/api-reference/utils/selectorFamily) Redux에선 매변 filter 코드를 써야 하는 반면 Recoil에선 selectorFamily를 정의해두면 인자만 바꿔가며 계속 재활용이 가능하므로 좀 더 강력합니다. 예로 든 legacy 코드와 동일한 역할을 수행하는 selectorFamily는 아래와 같이 정의해 볼 수 있겠습니다.

이제 정의해 둔 selectorFamily를 활용하여 아래처럼 마이그레이션 하면 됩니다.

const bizItems = useRecoilValue(bizItemsSelector(BIZ_ITEM_TYPE.STANDARD))

useRecoilTransaction 을 사용한 연속적인 상태 읽기 / 쓰기 기능의 대응

useRecoilCallback이 유용하긴 하나 연속적인 상태 변경이 있고 변경된 상태를 계속 사용해야 할 때 문제가 발생합니다. 예를 들어 아래와 같은 callback이 있다고 가정합니다.

아래처럼 callback을 호출할 경우 어떻게 될까요?

setSideFrameProperties({ title: '제목' }) 
setSideFrameProperties({ onClose: handleClose })

1번 라인에서 설정한 ‘제목’ 타이틀이 2번째 라인에서 호출한 onClose 때문에 삭제됩니다. useRecoilCallbacksnapshot이 이미 변경 완료된 상태의 snapshot을 제공하기 때문에 title 값이 제목 이전에 설정한 값으로 들어가게 되기 때문입니다.

이를 방지하기 위해 위와 같은 형태의 useRecoilCallbackuseRecoilTransaction 으로 대치해야 합니다.

export function useSetSideFrameProperties() {
return useRecoilTransaction_UNSTABLE(({ get, set }) => async (properties: SideFrameState) => {
const sideframe = get(sideframeAtom)
if (Object.prototype.hasOwnProperty.call(properties, 'title')) {
set(sideframeAtom, { ...sideframe, title: properties.title } as SideFrameState)
}
if (Object.prototype.hasOwnProperty.call(properties, 'onClose')) {
set(sideframeAtom, { ...sideframe, onClose: properties.onClose } as SideFrameState)
}
})
}

연속적으로 callback이 호출되더라도 set한 상태값을 pre-fetching을 통해 읽을 수 있기 때문에 의도한 대로 동작하는 콜백이 되었습니다.

Recoil 전환에 따른 성능 저하 문제 확인

Recoil로 전환하며 동일한 동작을 하는 코드지만 라이브러리의 차이에 따른 성능 저하 우려는 없는지 확인해 볼 필요가 있어, 성능 저하 문제가 있는지 측정해 보았습니다.

테스트는 스크립트 타임과 TTFB, lighthouse score를 측정했습니다. 업체별 예약자 관리 페이지를 10회 새로고침하여 가장 중간값에 가깝다고 생각되는 결과에 대해 스크린 샷을 찍는 방식으로 진행했습니다.

  • 디버깅 정보가 포함되어 무겁게 빌드된 개발 버전으로 측정한 결과라 Redux/Recoil 모두 프로덕션 빌드 대비 3배 정도 느린 점 감안하고 봐주시면 됩니다.

TTFB (Time to First Byte)

Redux 에선 universal router traversal 내에 공용 데이터를 SSR시점에서 Redux store에 바인딩 해 주는데 이게 router action 마다 흩어져 있다보니 의도치않게 중복해서 호출되는 문제가 있었습니다. Recoil 로 전환을 하며 일부 중복 호출부분을 제거하는 정도의 소소한 개선이 있어서 SSR에 영향을 받는 TTFB에 개선이 없는지 측정을 해봤습니다.

Recoil이 근소하게 앞선 것으로 생각되지만 차이가 크지는 않습니다. 그래도 SSR에서 서버 API를 중복 호출되는 부분이 몇 개 제거되어 서버쪽 부담이 약간이나마 적어질 것 같습니다.

Scripting time

Recoil 전환을 하며 이미 useRecoilCallback 적용을 해야 될 부분엔 전부 적용을 해둔 상황이고 Redux dependency 나 스크립트 일부 제거 등의 소소한 개선은 있었지만 뭔가 큰 성능적인 개선은 없었기에 스크립트 타임은 거의 비슷할 것이라고 예상을 하고 측정을 했습니다.

스크린샷에는 각각 1193ms / 978ms로 측정이 되었는데 Redux는 1000–1100ms, Recoil은 900–1000ms 로 예상대로 거의 비슷한 결과가 나왔습니다. Recoil이 평균적으로 그래도 미세하게 우세한 결과가 나오는데 불필요한 Redux dependency와 스크립트 삭제의 영향으로 보입니다.

Lighthouse score

lighthouse score는 동점이 나왔습니다. 항목별로 Redux가 앞서는 것도 있고, Recoil이 앞서는 결과도 있는데 오차범위를 감안하면 사실상 동일한 성능을 가졌다고 보여집니다. 프로덕션 기준이라면 처음에 보여드린 결과처럼 90점대가 나올 것으로 예상됩니다.

성능 측정 결론

SSR 시점에서 서버API 중복 호출 제거, Redux store / 사용되지 않는 스크립트 & 컴포넌트 제거 등의 개선이 있었지만 성능에 주는 영향은 아주 미약하다는 결론을 내렸습니다. 향후 스펙 개발을 할 때 컴포넌트를 작은 단위로 구현하고 Recoil dependency가 없다면 useRecoilCallback을 적극적으로 사용하여 re-rendering을 줄여보는 식으로 구현할 필요가 있겠습니다. 그 외 Redux -> Recoil 1:1 전환에 따른 단순 성능 차이는 전혀 없다고 봐도 무방합니다.

결론

예약 사업주향 서비스는 Boilerplate 단계부터 Redux store를 마치 전역 변수처럼 사용하는 것을 의도해서 이를 떼어내고 Recoil을 이식하는게 쉽지 않은 프로젝트 였습니다. 그럼에도 불구하고 전체 리팩토링을 완료하고 프로덕션 서비스 적용 이후 큰 이슈나 장애가 없었고 성능 역시 별다른 문제가 없음을 확인했습니다. 동일한 성능을 유지하면서 보다 강력한 기능을 가지고 배우기도 쉬워 개발자의 생산성을 올려주는 Recoil로 전환할 충분한 가치가 있었다고 생각합니다.

읽어주셔서 감사합니다.

--

--