Back to List

관심사 분리와 Recoil

Apr 21, 2022 — 10 min read

Hyunsuk Jo

우리 팀은 지난 1년간, 매번 일회성으로 개발되고 폐기 되었던 Annotation Tool 중 하나 였던 CTL (Close The Loop) 서비스를 여러 유형의 프로젝트를 소화해낼 수 있는 지속 가능한 서비스로 변모시키기 위해 노력해 왔다.

그 중에서도 가장 많은 버그를 양산하고, 가장 많은 고통을 유발했던 Job Preloading 프로세스를 관심사 분리와 Recoil의 적용으로 개선한 과정을 간략하게 소개하고자 한다.

본 문서를 통해 다음의 과정을 간접 체험할 수 있다.

  • 하나의 역할 수행하는 복잡한 함수를 관심사 분리를 통해 유지보수성을 개선하는 과정

  • Recoil selector를 적용하여 서로 연관된 데이터의 관심사 분리

  • Recoil selectorFamily를 적용하여 순환참조를 제거하는 방법


여러 동작을 수행하는 하나의 함수

위는 사용자가 다음 작업으로 이동할 때에, 현재 작업 currentJob을 새로운 Job으로 할당하기 위해 실행하는 함수인 updateCurrentJob의 동작을 표현한 차트이다.

  • Fetch a project : project를 조회하는 API 요청

  • Fetch a job : 대기열 내의 작업(job)을 조회하는 API요청

  • currentJob : 사용자가 사용할 현재 작업(job)

  • nextJob : 사용자가 다음에 사용할 작업(job)

  • enqueue a job : 사용자의 작업 대기열에 새로운 작업을 추가하는 API 요청.

코드로 간략하게 아래처럼 표현할 수 있다.

일면 유려하고 간단해 보이고, 실제로 지속적인 관리가 필요없는 일회성 코드로서는 유용 하기도 하다. 빠르게 개발할 수 있고 데이터의 흐름을 쉽게 이해할 수 있다.

그러나 안정적이고 장기적으로 서비스를 운용 하기에는 무리가 있다. 하나의 객체 currentJob을 설정하기 위해 수 회의 API 호출과 수 회의 조건절을 포함하기 때문이다.

  • 하나의 함수가 너무 많은 동작을 수행하고 있다.

  • 실패할 가능성이 있는 여러 API 응답 결과가 서로 강력한 의존성을 가지고 있다.

  • try/catch, if 등의 많은 분기 조건을 가지고 있다.

장기적으로 운용되는 서비스는 계속해서 새로운 요구사항이 추가된다. 긴 시간 동안 여러 사람에 의해 변경 되면서도 안정성을 보장하려면, 함수는 최대한 이해하기 쉬운 상태를 유지할 수 있도록 관심사가 명확하고 간결해야 한다.

만약 다음과 같은 요구사항이 발생한다면 어떻게 이를 반영할 수 있을지 상상해 보자.

  • 첫 번째 getJob과 두 번째 getJob의 진행상황을 UI로 표현하고 싶다.

  • enqueueJob이 실패한 경우 특정 에러 코드에 대해서만 기존 에러와 다르게 UI를 표현하고 싶다.

  • currentJob을 위한 첫번째 getJob이 실패하여도, nextJob을 위한 두번째 getJob은 실행하고 싶다.

  • getJob이 실패한 경우 세 번까지 재시도하고 싶다.

이미 많은 일을 수행 중인 기존 함수 위에 무수히 많은 try/catch 구문이 깊게 반복되어 추가되어야 하고, 발생 가능한 에러의 종류만큼 필요한 조건부 에러 핸들링과 앞선 요청의 결과에 따른 if 분기, 그리고 그러한 조건과 관계없이 특정 다음 단계의 코드를 수행하기 위한 기상천외한 코딩 기법이 추가되어야 할 것이다.

기존 코드에는 실제로 이러한 유형의 요구가 구현되어 있었고, 수많은 에러 케이스와 사이드 이펙트의 원인을 파악하는 데만 많은 시간을 쏟아야 했다.



관심사의 분리


존재하는 문제점을 종합하면 “관심사의 분리가 적절히 이루어져있지 않았다.”고 말할 수 있다.

모든 동작을 한번에 처리하는 updateCurrentJob 함수는 크게 세 가지 동작에 관심이 있다.

  • Get a current job : 새로운 Job을 조회하여 currentJob에 할당한다.

  • Get a next job : 새로운 Job을 조회하여 nextJob에 할당한다.

  • Enqueue a job : 사용자의 작업 대기열에 새로운 Job을 추가한다.

서로 다른 위 세 가지 동작을 서로 의존성이 없는 완전한 별개의 로직으로 분리해 낼 수 있다면, 각 로직의 관심사는 단순해질 수 있다.

집중하고자 하는 대상이 단순해지면 코드는 읽기 쉬워지고, 코드에서 발생하는 에러도 더욱 쉽게 처리할 수 있으며, 그 때문에 기능의 추가/수정/삭제는 더욱 수월해 질 수 있다.

selector : 파생상태 도입을 통한 관심사 분리


Recoil 에는 크게 두 가지 상태, atom과 selector가 존재한다.

  • atom은 기본값을 가지는 단순 상태로, React의 기본 상태인 useState와 거의 동일하다.

  • seletor는 파생상태(derived state)로 불리우며, React의 useMemo와 유사하다. 다른 값에 의존성을 가지며, 다른 값의 변경에 따라 정의된 get 함수가 자동으로 실행되며 그 결과를 상태로 설정한다.

특히 이 selector를 사용하면, 트리 구조의 의존성 데이터 모델에서 발생하는 관심사 분리 문제를 아주 간편하게 해결할 수 있다.

다른 Recoil 상태를 구독하여 의존성은 유지하면서 오로지 자신의 상태 값을 설정하기 위해 동작을 수행하기 때문에, 다른 값을 참조할 뿐 다른 값의 설정에는 아무런 영향을 끼치지 않는다.

실제 코드에 selector를 적용하여 동작을 개선해 보자.

여러 단계를 거쳐서 마지막에 실행되는 Enqueue a job 로직


외견상 크게 관련이 있어 보이지 않으나 가장 깊은 위치에서 결합하여 있는 동작은 Enqueue a job이다.


Enqueue a job은 사용자의 작업 대기열에 새로운 작업을 할당하기 위한 API 요청이다. 대기열에 새로운 작업을 추가하기 위해서는 대기열이 비어있는지를 먼저 확인해야 했기 때문에, 대기열에 존재하는 작업(job)을 조회하는 API의 실패 여부를 의존해야 했다.


우리가 미리 사용자의 모든 작업 대기열을 알고 있을 수만 있다면 이 의존성은 해소될 수 있어 보인다.


먼저 사용자의 작업 대기열인 jobIdList 상태를 구성한다.


  1. project를 API 응답으로 설정되는 비동기 selector로 변경

  2. project에 존재하는 현재 사용자의 작업 대기열을 조회하는 비동기 selector jobIdList를 추가

추가된 작업 대기열 jobIdList를 활용하여 현재 작업이 대기열의 마지막 작업인지, 따라서 추가적인 작업을 대기열에 추가할 필요가 있는지 확인하기 위한 isLastJob 상태를 구성한다.


  1. 현재 사용자의 작업의 ID를 저장하는 currentJobId atom을 추가

  2. currentJobId가 변경될 때마다 currentJobId가 jobIdList의 마지막 값인지를 확인하여 반환하는 isLastJob selector를 추가

  3. 현재 작업이 마지막 작업일 경우 (isLastJob === true) 새로운 작업을 대기열에 추가하는 Custom hook 추가

이렇게 몇가지 Recoil 상태를 추가함으로 Enqueue a job 단계를 updateCurrentJob으로 부터 완전히 분리하였다.

사실상 새로운 API와 새로운 Recoil 상태가 추가되어 서비스 전체의 복잡도는 증가한 것으로 보인다.


그러나 앞선 예시 코드에서 보다시피 Recoil 상태는 스스로와 그를 획득하기 위한 로직을 가장 작은 단위로 분리하기 때문에 이미 완전한 관심사 분리가 이루어져 있어 기존 동작에 더 이상의 복잡성을 추가하지 않는다.


관리하기 쉬운 몇 개의 Recoil 상태를 추가하는 것으로 하나의 아주 복잡한 함수를 개선할 수 있다면 이점은 충분하다.

실제 updateCurrentJob 함수는 어떻게 변경 되었는지 확인해 보자.


getJob 함수의 에러 처리를 위한 try/catch 구문이 완전히 제거되면서 훨씬 단순한 함수가 되었다.

selector의 한계


그러나 여전히 Get a current job과 Get a next job은 그대로이다. 딱 하나 바뀐 부분은 updateCurrentJob 함수가 useEffect로 변경되었다는 것이다.

상상하기에는 currentJob과 nextJob 모두 selector로 변환한 뒤, currentJobId의 변경에 따라 각자 API 요청을 수행하면 깔끔하게 구현될 것 같았다.


그러나 두 가지 상태 currentJob과 nextJob이 완전히 서로 분리될 경우, 각자 비동기로 API 요청이 수행되기 때문에 currentJob에서 사용하게 될 nextJob이 이전 단계에서 준비된 nextJob인지, 혹은 새로이 준비된 nextJob인지 보장하기가 어려웠다.


currentJob과 nextJob이 모두 selector 로 구성될 경우의 문제점


currentJob selector의 get 함수에서 순차적으로 currentJob과 nextJob을 설정할 수 있다면 좋았겠지만, Recoil에는 구조적으로 강제하는 몇 가지 한계가 있다. selector의 get 함수는 다른 Recoil 상태를 변경할 수 없다.


이는 최초에는 불편한 요소처럼 여겨졌지만, 완전한 원자(atom) 단위의 최소 규모의 단일 상태 구성을 목표로하는 Recoil의 개발관을 생각한다면 당연해 보인다.


Writable selector 패턴을 사용하면 비슷하게 구현할 수 있지만, set 함수는 비동기 로직을 처리할 수 없기 때문에 비동기 selector를 참조하는 경우 사용이 불가능하다.


우리의 경우, nextJobId 를 설정하려면 비동기 selector인 jobIdList를 참조해야 했다. 그 때문에 currentJobId이 변경될 경우 currentJob과 nextJob을 모두 준비하기 위해서는 울며 겨자먹기로 Recoil selector가 아닌 useEffect에 의존하여 파생상태를 구성할 수 밖에 없었다.



selectorFamily : 순환참조 제거를 통한 관심사 분리

useEffect를 통한 파생상태 구성


React에서 특정 값의 변화에 의존하여 로직을 수행하려면 반드시 useEffect를 사용해야한다.

기본적으로, useEffect를 통한 파생상태 구성은 그 존재만으로도 이미 안티패턴이다.


운영시간이 길어질수록 컴포넌트의 수는 많아지고 컴포넌트의 관계는 복잡해지는데, 데이터 구조와 직관적인 관계가 없는 컴포넌트 구조의 깊은 곳 어딘가에서 변경되는 데이터는 데이터의 흐름을 이해하고 디버깅하기 어렵게 만든다.


그 때문에 최근에는 useQuery, SWR, GraphQL 등 리액트 컴포넌트 외부에서 조회되고 캐시 되는 데이터를 컴포넌트에서 참조만 하는 전략이 주가 되고 있으며, Recoil 역시도 동일하다.


무엇보다 useEffect는 그 구조상 의도치 않은 무한 재귀호출 상태에 빠지기 쉽다. 아마도 대부분의 React 개발자들이 이 문제를 겪어보았을 것으로 생각한다.


특별한 이유가 없다면 useEffect의 의존성 배열에는 모든 의존 가능한 값이 정의되어 있어야 한다. 특정 값이 추가/삭제되어도 실수로 변경하지 않을 경우 의도치 않은 Effect의 실행으로 인한 디버깅하기 어려운 사이드이펙트가 발생할 수 있기 때문이다. 이를 방지하기 위해 ESLint의 exhausted-deps 규칙을 활성화하는 것이 좋다.


문제는 useEffect는 특정 상태 뿐만 아니라 해당 상태를 설정할 수도 있는 함수에도 또한 의존성을 갖는다는 것인데, 여기에서 주로 무한 재귀호출 문제가 발생한다.


위는 기존 updateCurrentJob 함수를 useEffect를 통해 파생상태로 구현한 예시이다.

무한 재귀호출 문제를 해결하기 위해 의도적으로 ESlint 룰을 비활성 시키고, 특정 값을 useEffect의 실행조건에서 제외하여 문제를 해결했다. 이 간소화된 예제에서는 꽤 잘 짜진 코드처럼 보일 수도 있지만, 의존하고 있는 함수가 또 다른 값을 의존하거나 하는 등 깊은 의존성이 발생하기 시작하면 이는 관리가 거의 불가능한 코드가 되어버린다. 더군다나 이렇게 임의로 편집된 의존성 배열은 앞서 말했듯 안티패턴이므로 될 수 있으면 피해야 한다.


개선하고자 시작한 변경이 다른 문제점을 일으키고 말았다.


Cache 저장소 selectorFamily의 도입


위 문제상황의 핵심적 원인은 또한 currentJob과 nextJob 사이의 관심사의 분리의 실패이다. currentJob을 요청하는 동안 nextJob을 참조하면서 동시에 또 요청해야하는 강한 의존성을 제거할 수 있다면, 이 문제는 해결할 수 있어 보인다.


jobId를 key로, job을 value로하는 key-value store인 JobStore를 상상해보자.


  • set 함수를 통해 요청된 jobId에 대한 job을 조회하고 저장한다.

  • get 함수를 통해 jobId에 대한 job이 이미 존재한다면 반환, 존재하지 않는다면 set을 수행하고 반환한다.

이렇게 key-value cache store를 두게 되면,

  • 조회를 위한 key와 실제 사용하는 value가 분리되고,

  • 데이터를 조회하는 시점과 사용하는 시점이 분리되어,

하나의 함수 안에서 동시에 값을 조회/설정함으로 인해 발생하는 순환참조 문제를 해결할 수 있다. 그리고 그 함수를 사용하는 useEffect의 무한 재귀호출 문제 역시 수정될 수 있다.


Recoil에는 selectorFamily라는 간편한 key-value cache store가 내장되어 있다. 그리고 selectorFamily 역시도 Recoil 상태중 일부이므로, 다른 Recoil 상태를 구독할 수도, 다른 Recoil 상태가 selectorFamily를 구독할 수도 있다.


이렇게 구성하게 되면 currentJob과 nextJob은 각자 자신의 key인 currentJobId와 nextJobId를 사용하여 cachedJobs으로부터 필요한 값을 조회하여 사용할 수 있게 된다.


물론 필요로 하는 job이 이미 cachedJobs에 존재한다면 별도의 네트워크 요청 없이 바로 해당 값을 활용할 수 있기에, 만약 다음 job을 미리 준비하고 싶다면 다음 job의 jobId를 이용해 미리 cachedJobs에 job을 조회하고 저장해두기만 하면 된다.


  1. currentJobId (atom) : 사용자가 작업을 변경하고자 하는 시점에 변경

  2. currentJob (selector) : cachedJobs에서 currentJobId를 key로 사용하는 job을 획득/저장

  3. nextJobId (selector) : jobIdList에서 currentJobId의 다음 jobId를 획득

  4. nextJob (selector) : cachedJobs에서 nextJobId를 key로 사용하는 job을 획득/저장

  5. 다음 currentJobId의 변경 시, currentJob이 이전의 nextJob에 의해 캐쉬된 cachedJobs의 값을 사용

적용된 흐름을 도표로 표현하면 다음과 같다.

currentJob과 nextJob이 서로 아무런 의존성을 가지지 않는 것을 확인할 수 있다.


그렇다면 실제 useUpdateCurrentJob 커스텀 훅은 어떻게 변경되었을까?

✨✨ 완전히 없어졌다. ✨✨


도표에서도 볼 수 있듯이 Recoil 상태의 체인만으로 job을 획득하기 위한 별도의 로직이 완전히 필요 없어졌음을 확인할 수 있다.


무한 재귀호출을 일으킬 수도 있는 useUpdateCurrentJob 훅을 사용하는 대신, 복잡한 updateCurrentJob 함수를 유지 보수하는 대신, 완전히 별개의 작은 Recoil 상태들을 추가하고, 해당 값을 필요한 시점에 사용하는 것으로 원하는 것과 그 이상을 얻을 수 있게 되었다.



결과 비교


앞서서 작성된 변경된 코드를 한데 모아봤다.

각 데이터를 조회하는 데에 필요한 로직이 따로 나누어져 있어 데이터 간의 관계도를 상상해내기는 어려운 단점은 있다.


그러나 긴 시간 동안 새로운 기능이 추가되어가는 경우를 고려한다면, 지금의 변경사항이 어느 시점에 어떤 데이터에 변경을 가해야 할지 파악하는 데에 훨씬 적은 시간과 노력이 들 것임은 확실하다.


처음에 가정한 요구사항들을 상기해보자.


  • 첫번째 getJob과 두번째 getJob의 진행상황을 UI로 표현하고 싶다.

  • enqueueJob이 실패한 경우 특정 에러 코드에 대해서만 기존 에러와 다르게 UI를 표현하고 싶다.

  • currentJob을 위한 첫번째 getJob이 실패하여도, nextJob을 위한 두번째 getJob은 실행하고 싶다.

  • getJob이 실패한 경우 세 번까지 재시도하고 싶다.

어떤 Recoil state에 어떤 수정을 해야 할지 이전보다 빠르게 찾아낼 수 있을 것이다.


또한 잘 분리된 관심사와 각 데이터 간의 명확한 관계는 시각화하기도 쉽다.

리팩토링 전

리팩토링 후

변경된 플로 차트의 전과 후를 비교해 보면 연속된 화살표의 개수가 확연히 줄어든 것을 확인할 수 있다. 전체의 흐름을 이해하기 위해 기억해야 하는 단계가 축소되어 보다 쉬운 도표가 되었다고 말할 수 있다.


덕분에 신규로 입사한 신입 개발자에게 Job Preloading 일련의 과정을 전보다 쉽게 설명할 수 있을 것이다.

Recoil 적용을 통해 얻은 그 외의 이점들

관심사 분리를 통한 코드의 유지 보수성 개선은 Recoil을 적용으로 얻은 이점 일부에 지나지 않는다. 대표적으로 몇 가지를 소개한다.


Suspense를 통한 pending 상태관리의 외주화


Recoil은 React 개발진이 개발한 State management tool로, React v18에 앞서서 Suspense를 자체적으로 지원하는 유일한 라이브러리였다.


간결한 설명을 위해 많은 부분을 제외 하였지만, 앞서 설명했던 내용 못지않게 고통스러운 부분이 있었다. 그것은 pending 중인 API 요청의 상태를 저장하고 관리하는 것이었다. 곳곳에 특정 API 요청을 위한 isLoadingXXXX 등의 변수를 설정하고 필요할 때마다 초기화하거나 참조해야 했고, 의도치 않은 곳에서 값이 변경되거나 수정되면 원치 않는 사이드이펙트를 겪기 일쑤였다.


Recoil을 적용하고 나면, 비동기 selector를 사용하는 컴포넌트를 Suspense를 통해 감싸기만 하면 자동으로 해당 값의 pending 여부에 따라 자동으로 fallback 컴포넌트를 해당 컴포넌트 대신 렌더링한다.


즉, Suspense는 비동기 selector 내부에서 일어나는 네트워크 요청의 pending 여부를 코드로 파악할 필요가 없이 완전히 외부로 이관한다.


안정성은 물론 절대적인 코드 라인 수를 절약해 주었다. 실제로 앞서서 나열하였던 추가 요구사항 중 하나인 “첫 번째 getJob과 두 번째 getJob의 진행상황을 UI로 표현하고 싶다.”는 이 덕분에 아주 간단하게 구현할 수 있었다.


selectorFamily의 네트워크 요청 캡슐화


selectorFamily는 get 함수의 네트워크 요청 역시 key 별로 별도로 관리한다.


덕분에 미리 네트워크 요청이 진행 중인 값을 조회할 경우, 이전에 진행 중이던 네트워크 요청을 손실 없이 이어받아 사용할 수 있다.


다음 순번의 작업을 미리 요청하는 동안 사용자가 다음 작업으로 이동할 경우, 해당 작업을 다시 처음부터 요청하지 않기 때문에 네트워크 손실을 줄일 수 있고, 현재 작업과 다음 작업의 네트워크 요청 진행 상황을 UI로 표현해주고 있다면 손쉽게 서로 간의 진행상황을 이어받아 표현해 줄 수도 있다.

AIAPIComputer VisionImage RecognitionJavaScriptLunit개발딥러닝루닛스타트업연구의료산업헬스케어

More from Blog