안녕하세요 후야호에서 풀스택 개발자로 일하고있는 최봉수라고 합니다.
이번에 저희 서비스가 1주년일 맞이해서 1주년 이벤트를 진행하게 됐습니다!
새로운 이벤트는 새로운 뷰에서 그리기 마련이고,,
새로운 뷰를 그린다면 새로운 도전을 못참기 때문에,,
이번에 기존에 유지되던 코딩 기조를 벗어나 새로운 도전을 해봤습니다!
그래서 이런 도전을 허락해준 저희 회사 대표님과 개발자 선배님들께 굉장히 감사합니다..
먼저 핵심적으로 저희는 ContextApi로 전역상태를 관리하다가 Recoil을 사용하게 됐습니다!
혹시 이 이야기가 궁금하시다면 자세한 이야기는 이 글에서 읽어보시면 될 것 같습니다!
우선 기존 폴더 구조에서 recoil을 만들 공간을 따로 만들었습니다.
// 기존 구조
src
-- screen
[ScreenName]
-- index.tsx
-- components (종속적 컴포넌트)
-- ***.tsx
-- ...
-- screen (스크린 내부의 스크린의 경우)
-- ***.tsx
-- ...
-- components
-- apis
-- assets
...
// 기존 구조
src
-- screen
[ScreenName]
-- index.tsx
-- components (종속적 컴포넌트)
-- ***.tsx
-- ...
-- screen (스크린 내부의 스크린의 경우)
-- ***.tsx
-- ...
-- *state*
-- index.ts
-- atoms.ts
-- selectors.ts
-- refresh.atoms.ts
-- types.ts
-- components
-- apis
-- assets
-- *state*
-- index.ts
-- atoms.ts
-- selectors.ts
-- refreshs.atoms.ts
-- types.ts
...
우선 src(root)에 state를 저장할 수 있는 디렉토리를 생성했습니다.
이 곳에 recoil에 대한 사용 후기를 보면 너무 편하게 가져와서 상태를 변경할 수 있기 때문에 디버깅할 때 언제 어디서 버그가 났는지 확인하기 어렵다는 이야기가 있었습니다!
물론 src/state 에서 선언된 atom이나 src/screen/[ScreenName]/state에서 선언된 atom이나 전역에서 사용할 수 있는 상태로 만들어지는건 동일합니다.
그래서 일단 흔히 Redux, MobX를 사용하는 것 처럼 전역과 각 스크린에 종속된 store만들었습니다.
일단 한 개 이상의 스크린에서 사용되면 반드시 전역에서 관리해주는 것으로 하고, 한 스크린 내부에서 사용되면 스크린에 종속된 상태처럼 생각하자고 이야기 했습니다.
store를 만들어 주듯 state라는 폴더를 만들어주고,
그 내부에 atom, selector를 저장할 공간을 만들어줬습니다.
types.ts는 타입선언을 외부에서 해주기 위해 정리했고
index.ts는 import깔끔하게 해오기 위해 아래와 같이 정의해 줬습니다.
// index.ts
export * from './atoms';
export * from './types';
export * from './selectors';
export * from './refresh.atoms';
그리고 특이한게 refresh.atoms.ts라는것이 있는데 Recoil의 특성 상 selecotr와 atom을 refresh해주는 것이 조금 복잡한 것이 있습니다!
selector를 사용하면 초기 선언시 api를 호출해서 데이터를 불러오고 그 데이터가 캐싱됩니다.
캐싱은 좋지만 새로운 값을 유저에게 보여주고 싶을 때 해당 selector를 불러와도 캐싱된 값을 갱신하지 않고 그대로 보여주기 때문에 애로사항이 생깁니다.
해결할 수 있는 방법이 여럿 있지만 selector에서는 get으로 구독하고 있는 RecoilState가 있다면 (아래의 get(_exampleRefreshTrigger)) 해당 RecoilState가 업데이트 될 때 selector는 다시 정보를 불러옵니다.
그렇기 때문에 selector를 업데이트할 때만 사용해줄 atom을 refreshTrgger라고 명명했습니다.
// 리프레쉬 트리거 용 Atom
// from refresh.atoms.ts
export const _exampleRefreshTrigger = atom({
key: '_exampleRefreshTrigger',
default: 0,
});
// 사용할 Selector
// from selectors.ts
export const exampleSelector = selector<any>({
key: 'exampleSelector',
get: async ({get}) => {
get(_exampleRefreshTrigger);
// ...비즈니스 로직
return ...;
},
set: ({set}) => {
set(_exampleRefreshTrigger, v => v + 1);
},
});
// 사용하는 컴포넌트
const ExampleComponent = () => {
const [example, setExample] = useRecoilState(exampleSelector);
useEffect(() => {
setExample(v => v);
}, []);
return (
<>
// View
</>
);
};
export default ExampleComponent;
이런식으로 구조하도록 구조화 했습니다!
물론 refresher를 지원하는 기능이 나왔지만 아직 정식 지원이 아니라 UNSTABLE이 붙은 상황에서 사용하긴 두려웠습니다!
https://recoiljs.org/ko/docs/api-reference/core/useRecoilRefresher/
Recoil을 사용하면서 refresh trigger를 사용하는 방식이 사이드 이펙트가 더 적다고 판단했습니다.
atom을 사용하여 setRecoilState를 해주는 방식으로 코드를 짜다보니 다른 경험을 갖고 있는 분들처럼 어디서 왜 atom이 변경된지 쫓아갈 수 없었습니다.
atom을 setRecoilState 통해 외부에서 값을 변경시키도록 하기보단
selector를 setRecoilState해주어 (refreshTrigger를 사용해서 refresh시켜서) 사이드 이펙트 없이 selector 내부 로직에서 문제를 해결하도록 해야겠다 느꼈습니다.
뒤에서 한번 더 이야기하겠지만 이렇게 사용하면서
atom 을 모아서 selector로 만드는 패턴과
selector를 분해해서 atom으로 나누는 패턴
두 가지가 가능해지는데 아직 좋은 답을 찾지 못했습니다.
(물론 이 부분은 서버에서 어떻게 보내주는 지에 따라 달라질 듯 합니다. 아.. 내가 서버도 해야되자나..?)
폴더 구조를 완성했으니 이제 기존 상태관리를 해주던 ContextApi의 상태들을 Recoil로 전환해야 했습니다.
우선 이번에 사용해보고 점진적으로 상태를 전환해가기로 했습니다.
위의 원칙을 바탕으로 코드를 짜보면 이러한 일이 생깁니다.
전역으로 관리하고 있는 유저 정보에서 유저의 username을 가져와서 특정 로직을 동작시켜야한다고 가정해보겠습니다.
export const exampleSelector2 = selector<any>({
key: 'exampleSelector2',
get: async ({get}) => {
const {myInfo} = useContext(GlobalContext)
const example = get(exampleSelector)
return exampleFunction(myInfo.username, example)
},
});
정말 극단적이긴 하지만 exampleFunction이 username과 example을 인자로 받고 그 결과를 리턴해주는 함수라고 생각해보겠습니다.
위 selector는 동작할까요? 당연히 동작하지 않습니다.
왜냐면 useContext라는 훅을 Function 컴포넌트 내부에서 사용하지 않았기 때문입니다.
그렇기 때문에 우리는 이렇게 사용해줘야 합니다.
// 그 어딘가 Context...
// 그 어딘가 selector...
const ExampleComponent2 = () => {
const {myInfo} = useContext(GlobalContext)
const example = useRecoilValue(exampleSelector)
const [example2, setExample2] = useState<any>()
useEffect(() => {
const result = exampleFunction(myInfo.username, example)
setExcample2(result)
}, [])
return (
<>
// 위쪽뷰 ...
{example2} // 그 사이 어딘가..
// 아래뷰 ..
</>)
}
결국 screen에서 상태들을 다시 조합해주고 결국 스크린에 비즈니스 로직과 상태가 하게 되면 최대한 비즈니스 로직과 상태를 스크린에서 제거하고 싶었던 제 목적과 어긋나게 됩니다.
다시 스크린은 방대해지고 목적성을 잃게 됩니다.
위의 문제를 마주치고 열심히 멘붕이 올 때 다른 개발자님께서 리팩토링할 때, 특히 점진적으로 무언가를 수정해야할 때는 중복이 있을 수도 있다는 것을 받아들여야 한다고 말씀해주셨습니다.
수정하기로 결정했는데 기존의 문제에 얽혀서 다시 같은 문제를 반복할거면 애초에 시작하지 않는 것이 맞고, 시작했으면 조금의 불편함을 감수하더라도 좋은 방향으로 나아가야 하는게 좋다는 조언을 해주셨습니다.
감동해버린 나는 그 즉시 전역 state에 유저정보를 이번 기능에 필요한 전역으로 관리하면 좋을 정보들을 하나 둘 담았습니다...
그렇게 코드를 정리해보면 이렇게 나오게 됐습니다.
// 그 어딘가 Context... 는 없어도 돼..
export const exampleSelector2 = selector<any>({
key: 'exampleSelector2',
get: async ({get}) => {
const myInfo = get(myInfoSelector)
const example = get(exampleSelector)
return exampleFunction(myInfo.username, example)
},
});
const ExampleComponent2 = () => {
const example2 = useRecoilValue(exampleSelector2)
return (
<>
// 위쪽뷰 ...
{example2} // 그 사이 어딘가..
// 아래뷰 ..
</>)
}
상태가 어떻고, 어떻게 조합해야 나오는지는 selector의 관심사로 모아두고,
Component는 View를 보여준다는 그 자체의 관심사만 갖을 수 있게 됐습니다.
또한 이렇게 Recoil을 사용하면서 컴포넌트 내부에 불필요하게 사용되는 useState와 useEffect를 줄였습니다.
조금 더 정확히 말하면 Recoil에서 상태들과 비즈니스 로직을 처리해주기 위해 스크린 내부에 useState, useEffect를 지우고 recoil에서 처리해주도록 노력했습니다.
그 덕분에 위에 코드에서 봤던 것 처럼 새로운 데이터를 만들고, 억지로 state에 끼워넣고 했던 코드를 제거할 수 있었고, 또한 Suspense를 사용하게 되면서
const [isLoading, setIsLoading] = useState(false)
위와 같이 사용하던 상태들도 지울 수 있게 됐습니다.
이제 스크린은 데이터를 받아와서 데이터를 보여주는 공간으로 바뀌게 됐습니다.
이후에 리팩토링 하면 더 효과를 알 수 있겠지만 이번에 만드는 스크린의 뷰는 대부분 100줄이 넘지 않았고 특히 screen, component선언문부터 return 까지의 거리가 10줄 안쪽인 코드들이 많아서 특히나 기분이 좋았습니다.
Recoil에 대한 사용 후기는 이 글 에서 더 자세히 확인하실 수 있습니다!
위에도 한번 언급됐지만 스크린에서 대부분의 로직을 처리하다가, 스크린은 뷰에 관한 로직만 그리고 비즈니스로직은 Recoil에서 해결하는 것을 시작하니 관심사라는 것에 관심이 생겼습니다.
그리고 문제가 생겼을 때도 해당 부분에만 접근해서 해결하다 보니 디버깅 시간도 단축되는 것을 느꼈습니다.
결국 내가 만든 이 코드는 관심사가 어디에 있는 지 고민하고, 어디까지 영향을 미치는쳐야하는 지에 대한 고민을 시작할 수 있었던 것 같습니다.
기깔나게 좋은 아키텍처를 그리긴 어려웠던 것 같습니다. 그래도 이런 저런 자료들 많이 찾아보고, 사용하고 이야기하면서 이런 저런 수정을 겪으면서 만족스러운 경험이 된 것 같습니다!
좋은 아키텍처는 뭘까, 적합한 패턴은 뭘까, 또 좋은 상태관리란 뭘까 은퇴할 때 까지 고민해야할 문제겠지만 이 문제를 고민하기 시작한 것에 만족스럽다는 마무리를 남기겠습니다!