일전 팀 내에서 패턴에 관한 이야기를 하던 중, 컴파운드 패턴에 대한 이야기가 나온 적이 있었다.
컴파운드 패턴에 대해서는 React Hooks: Compound Components 에 상세하게 잘 설명해주고 있지만 간단하게 이야기하자면 같은 UI적인 관심사를 갖는 컴포넌트들을 하나의 부모 컴파운드 컴포넌트의 함수 객체 프로퍼티로 포함하여 모듈처럼 사용하는 패턴이다.
function Toggle(props) {
... toggle component body
}
Toggle.On = On
Toggle.Off = Off
Toggle.Button = Button
export default Toggle
함수 역시 컴파일 시점에서는 평가되면 객체 가 되기 때문에 위와 같은 패턴처럼 같은 UI적 관심사의 컴포넌트들을 객체 맴버로 등록하여 모듈로 export하는 것은 새삼 신기한 것은 아니긴 하다.
위의 방식처럼 사용하면 보통 일반적으로 사용하는 공용 컴포넌트들을 조금 더 특정된 목표를 위한 구조로 병합하여 손쉽게 재사용할 수 있다는 장점이 있다.
하지만 위의 내용을 학습한 후에도 현재 프로젝트에는 도입을 하지 않고 있었는데, 그 이유는 이미 Barrel Pattern 을 통한 모듈 시스템 만으로도 컴포넌트들의 조직화가 충분히 가능했기 때문이었다. 즉, 큰 차이점을 느끼지 못했었다.
그렇게 해당 개발 패턴에 대해서 잊어가고 있던 즈음, 이 패턴을 적극적으로 활용하여 프로젝트의 문제점을 개선했던 경험을 하게 되어 기록으로 남겨보려고 한다.
현재 제작중인 프로젝트의 B2B 대시보드 형태의 서비스 특성상 필터링과 관련된 컴포넌트들이 참 많이 만들고, 또 사용되어야 하는데 그것을 일일이 하나씩 제작하기에는 일정이 터없이 부족하였다.
이를 위해서 팀에서 논의한 결과, UI적인 요소들을 하나하나 만드는 과정은 바퀴를 다시 만드는 시간과 동일하다는 의견의 합치가 이루어져, 이를 줄이기 위해 "Ant Design" 를 적극 활용하여 서비스에 맞게 확장하여 사용하자는 의견이 나오게 되었다.
UI 공용 컴포넌트에 대한 개발 공수를 라이브러리로 해소하는 것 까지는 참 좋았는데, 문제는 서비스 내에서 동일하게 사용되는 필터들의 데이터 플로우에 대한 로직들을 공용화 하지 않다 보니 각 팀원들이 자신들이 맡은 페이지 내에서 매번 동일하거나 유사한 형태의 필터들을 반복적으로 정의하는 상황이 벌어지게 되었다.
<다 같거나, 사실상 같아져도 무관한 유사 컴포넌트들이 곳곳에 정의>
위와 같은 구조는 현재 UI적인 관심사만 집중하게 되었을 때 발생하기 쉬운 mishap이다.
얼핏 보기에 괜찮아 보이는 해당 구조는 추후 유지보수에 문제를 야기할 가능성이 높다.
1. 불필요하게 동일한 Property들이 반복되고 있다.
서비스적인 개념을 고려한 공용 컴포넌트의 정의가 이루어지지 않아서 발생하는 문제이다. 특정 Property가 반복된다는 의미는 이미 그 자체가 해당 서비스에서 사용되는 하나의 일반화된 요소라고 바라보고 이를 한단계 더 공통화한 컴포넌트를 작성하는 것이 옳다. 그렇지 않을 경우, 위처럼 매번 동일한 Property를 정의하기 위해 "복사,붙여넣기"를 하는 상황이 벌어지며 가독성도 떨어지고, 최악의 경우 기획수정에 따라 수정이 필요하게 되었을 때 모든 개발자가 다 같이 Property를 수정해야 하는 상황이 벌어진다.
2. 상태에 대한 응집도가 제각각이라 데이터 흐름을 파악하기 어려워진다.
더 큰 문제는, Viewing에 대한 관심사만 가지게 될 경우 사용자의 입력에 대한 이벤트 핸들링 결과를 개발자마다 제각각 다르게 정의하는 가능성을 야기한다는 점이다.
위는 현재 프로젝트 내에서 정의된 공용 컴포넌트의 사용 양상을 diagram으로 일반화 시켜본 케이스 중 하나를 들고온 것이다.
(실제로는 저것보다 더 많은 방식들이 존재했다)
사용자 입력에 대해서 A 개발자는 하나의 함수 컴포넌트 안에서 처리하는 것을 선호하는 반면, B 개발자는 하나의 훅으로 추출하여 사용하는 것을 선호하는 상황이었다. 이에 따라 상태관리의 일관성이 없어짐에 따라 유지보수를 위해 코드를 제각각 파악해야 하는 상황이 벌어지게 된다. 상태관리가 특정한
장소, 혹은 개념적 정의에서 이루어진다는 공통적인 규칙이 존재한다면 코드파악을 위한 불필요한 파일탐색을 최대한 줄일 수 있다.
개발 컨벤션으로 정하고 서로 리뷰를 열심히 해주는 것도 방편이 될 수 있지만, 컨벤션이라는 것은 언제든지 휴먼 에러, 휴먼 미싱으로 이루어지기 쉽기 때문에 조금 더 코드적으로 정형화된 방식을 사용하는 것이 좋다.
즉, 컴포넌트를 한단계 더 서비스적인 측면에서 공통화함과 동시에 데이터 흐름에 대한 응집도를 같이 가져가는 구조가 필요해지는 상황이었다.
문제에 대한 파악을 명확하게 하고 나니 다시금 이전에 고민을 하였던 Compound pattern
이 떠오르게 되었다.
아까 언급했었던, 처음 Compound 패턴의 논의 당시에는 내용 자체가 익숙하지 않아서 해당 패턴을 Named Export와 별반 다를 바가 없다고 생각했었다. 그냥 컴포넌트들을 하나의 Named 구조에 저장해서 export해서 사용하는 것과 동일하게 생각했기 때문이었다. 하지만 그것은 큰 착각이었다.
사실 Compound 패턴에서 가장 핵심이 되어야 하는 부분은 각 Compound의 맴버들이 user event를 처리할 때 발생하는 데이터의 흐름을 중앙 관리하는 하나의 Container Component가 존재해야 한다는 점이었다.
// Compound 맴버들의 상태를 관리할 컨택스트를 만든다.
const ToggleContext = React.createContext()
// Context 소비 함수를 정의한다
function useToggleContext() {
return React.useContext(ToggleContext)
}
// Container Component는 맴버들에게 context 값을 전달해줄 수 있어야 한다.
function Toggle(props) {
<ToggleContext.Provider value={value}>
{props.children}
</ToggleContext.Provider>
}
// Compound 맴버들은 Container Component에서 중앙 관리하는 Context의 state와 method를 활용하여 State를 Viewing으로 소비하거나, 업데이트한다.
function ToggleOn({ children }) {
const { on } = useToggleContext()
...
}
function ToggleOff({ children }) {
const { on } = useToggleContext()
...
}
function ToggleButton(props) {
const { on, toggle } = useToggleContext()
...
}
Toggle.On = On
Toggle.Off = Off
Toggle.Button = Button
export default Toggle
Container Component는 단순하게 State만 전달하는 것이 아니라, Method도 전달해주고 맴버들은 이 context를 소비하여 데이터 플로우를 하나의 추상화된 컴포넌트가 담당하도록 역할을 위임하게 하는 것이다.
사실상 ContextAPI를 핵심적으로 사용하여 객체 지향적으로 구조화하는 패턴이라고 할 수 있다.
이 패턴을 사용하게 되면 아까 위에서 언급하였던 두번째 문제인 상태관리의 응집도가 떨어지는 문제를 해결할 수 있다. 즉, 개발자별로 무분별하게 정의하던 상태코드의 정의를 하나의 Container Component가 관리하도록 만들 수 있다.
현재 프로젝트에서는 Filter의 Validation과 관련된 유틸리티 기능들도 같이 필요했기 때문에, 이를 한번에 처리해줄 수 있는 좋은 Form state controller react-hook-form
을 위에서 언급했던 Container Component의 State 객체로 활용하여 context로 전달하는 방식을 사용하였다.
react-hook-form이 제공하는 form 인스턴스의 경우, validation 뿐만 아니라 submit, state watching등과 같은 form state와 관련된 최적화 유틸리티 메서드들을 함께 제공해주기 때문에 꼭 사용해보는 것을 추천한다. 개인적으로 form state는 react-hook-form과 Zod의 조합을 활용하는 것이 정말 너무너무너무 좋다고 생각한다(강조 x 100).
아래는 현재 사용중인 Filter Compound Pattern 코드이다.
import { Dispatch, ReactNode, SetStateAction, useState } from 'react';
import { FieldValues, UseFormReturn } from 'react-hook-form';
import { createContext } from '@/app/_contexts/createContext';
import useSubscription from '@/hooks/use-subscription';
import ApplyButton from './components/ApplyButton';
import BlockCascaderFilter from './components/BlockCascaderFilter';
import DateRangeFilter from './components/DateRangeFilter';
import EnergyCascaderFilter from './components/EnergyCascaderFilter';
import EnergyDateRangeFilter from './components/EnergyDateRangeFilter';
import EnergySelectFilter from './components/EnergySelectFilter';
import EnvironmentalFilter from './components/EnvironmentalFilter';
import FilterCount from './components/FilterCount';
import FilterPopover from './components/FilterPopover';
import PeriodFilter from './components/PeriodFilter';
import ResetButton from './components/ResetButton';
import ResponsibleBlockCascaderFilter from './components/ResponsibleBlockCascaderFilter';
import SelectedTagList from './components/SelectedTagList';
type FormType<F extends FieldValues = FieldValues> = UseFormReturn<F>;
/********************************************
*
* @Compound_Context
*
********************************************/
const useFilterContextState = <F extends FormType>(filterProps: FilterProps<F>) => {
// 추가적인 컨텍스트 상태, 및 핸들러가 필요할 경우 정의하여 return 에 포함
const { form, onSubmit } = filterProps;
// summited values
const [submittedValues, setSubmittedValues] = useState<any>(null);
const resetSubmittedValues = () => setSubmittedValues(null);
useSubscription([submittedValues]).subscribe(() => {
form.reset(submittedValues);
onSubmit?.(submittedValues);
});
// clear
const handleClear = () => {
form.reset();
setSubmittedValues(null);
};
return {
form,
submittedValues,
setSubmittedValues,
resetSubmittedValues,
handleClear,
};
};
const { Provider, useContext } = createCustomContext<
ReturnType<typeof useFilterContextState> & { additionalState?: Common.DefaultObject }
>();
const useFilterContext = <S extends Common.DefaultObject>() => {
const { form, submittedValues, setSubmittedValues, ...rest } = useContext();
return {
form: form as UseFormReturn<S>,
submittedValues: submittedValues as S | null,
setSubmittedValues: setSubmittedValues as Dispatch<SetStateAction<S>>,
...rest,
};
};
/********************************************
*
* @Container_Component
*
********************************************/
interface FilterProps<F extends FormType> {
children?:
| ReactNode
| ((filterContextState: ReturnType<typeof useFilterContextState>) => ReactNode);
form: F;
onSubmit?: (submittedValues: any | null) => unknown;
additionalState?: Common.DefaultObject;
}
const Filter = <F extends FormType>(filterProps: FilterProps<F>) => {
const filterContextState = useFilterContextState(filterProps);
const children =
typeof filterProps.children === 'function'
? filterProps.children(filterContextState)
: filterProps.children ?? null;
return (
<Provider value={{ ...filterContextState, additionalState: filterProps.additionalState }}>
{children}
</Provider>
);
};
/********************************************
*
* @Compound-Members
*
********************************************/
Filter.useFilterContext = useFilterContext;
Filter.BlockCascaderFilter = BlockCascaderFilter;
Filter.ResponseingCascaderFilter = ResponsibleBlockCascaderFilter;
Filter.EnvironmentalFilter = EnvironmentalFilter;
Filter.PeriodFilter = PeriodFilter;
Filter.DateRangeFilter = DateRangeFilter;
Filter.EnergyDateRangeFilter = EnergyDateRangeFilter;
Filter.EnergySelectFilter = EnergySelectFilter;
Filter.EnergyCascaderFilter = EnergyCascaderFilter;
Filter.ResetButton = ResetButton;
Filter.ApplyButton = ApplyButton;
Filter.FilterPopover = FilterPopover;
Filter.SelectedTagList = SelectedTagList;
Filter.FilterCount = FilterCount;
export { type FilterProps, Filter, useFilterContext };
위와 같이 반복되는 서비스 컴포넌트들을 공용화하여 Compound pattern으로 묶는 것의 효용은 아래와 같다.
아래는 Compound 패턴을 도입하기 전과 후에 코드의 변경에 대한 모습을 간략하게 나타낸 것이다.
// 아래 ... very long 시리즈들은 실제 코드로 작성되어 있으면 굉장히 보기 어려운 형태가 된다.
export default function EnvironmentChart (){
const FormState = {... very long states}
... very long state control methods
const onFilterChange_1 = () => {... very long function body}
const onFilterChange_2 = () => {...... very long function body}
const onFilterChange_3 = () => {......... very long function body}
const onCancel = () => {...}
const onApply = () => {...}
return (
<EnvrionmentChartContainer>
<Header>
<Button
property_1
property_2
property_3
... very long properties
>
Reset
</Button>
<Button
property_1
property_2
property_3
... very long properties
>
Apply
</Button>
</Header>
<Body>
<Filter_A
property_1
property_2
property_3
... very long properties
/>
<Filter_B
property_1
property_2
property_3
... very long properties
/>
<Filter_C
property_1
property_2
property_3
... very long properties
/>
</Body>
</EnvrionmentChartContainer>
)
}
// 필요한 내용들은 모두 Compound 내에 정의가 완료된 상태이다.
import { form } from 'react-hook-form'
export default function EnvironmentChart (){
const { form } = useForm<FormSchemaType>();
return (
<EnvrionmentChartContainer>
<Filter form={form}> // Container Component는 자식들에게 Context를 전달하는 역할을 담당한다.
<Header>
<Filter.ApplyButton/>
<Filter.ResetButton/>
</Header>
<Body>
<Filter.Filter_A/>
<Filter.Filter_B/>
<Filter.Filter_C/>
</Body>
</Filter>
</EnvrionmentChartContainer>
)
}
위에서 볼 수 있듯, 코드가 매우 간략해지면서 추상화를 통해 가독성이 높아지는 것을 확인할 수 있다.
컴포넌트가 제각각 이름을 통해 어떤 역할을 하게 되는지 표현되며 Filter
Container Component의 맴버가 됨으로써, 해당 맴버들이
어떤 추상화 그룹으로부터 나온 것인지 더욱 명확하게 확인할 수 있어진다.
게다가 Container Component의 장점은 그 Context를 전달할 Boundary를 얼마든지 축소, 확장이 가능하다는 부분이다.
...
// 만약 context가 필요로하는 외부 컴포넌트가 존재할 경우, 그 영역까지 해당 Boundary를 유동적으로 변경할 수 있다.
<Filter form={form}>
<SomethingOuterComponent/> <-- 이제 해당컴포넌트도 Filter Context를 소비할 수 있게 된다.
<EnvrionmentChartContainer>
...children
</EnvrionmentChartContainer>
</Filter>
위와 같이 Context를 조회할 수 있는 영역을 자유롭게 변경함을 통해서 해당 state를 활용하여 UI를 작성해야 하는 요청이 들어오더라도
불필요한 props drilling 없이 간편하게 처리할 수 있는 유연성을 얻을 수 있다.
Filter들은 다 각각 자신의 특정 선택지를 제공하고 이를 사용자가 입력을 통해 선택할 수 있도록 하는 것이 일반적이다.
이 때에, 선택지에 대한 리소스의 원천이 어디에서부터 오는지에 대해서 고민해볼 필요가 있다.
예를 들어, Dropdown의 경우 selection을 위한 데이터를 option으로 주게 된다.
const options={[
{ value: '1', label: 'Jack' },
{ value: '2', label: 'Lucy' },
{ value: '3', label: 'Tom' },
]} // 이 데이터에 해당하는 옵션들을 보여줄 것이다.
이 옵션들이 정적인 값이라면 코드 상에서 정적 값으로 설정하여 모듈처럼 가져와 사용할 수 있지만, 서비스에 따라서는 필터링에 사용되는 데이터가 네트워크 요청을 통해 받아오는 외래 데이터가 될 수 있다.
외래 데이터라 함은, 결국 비동기 요청을 통해 서버에 요청하여 받아와야 한다는 소리이고,
우리는 비동기 요청을 할 때에 늘 성공하지 않는다는 사실을 알고 있다.
즉, 프론트엔드 개발자는 try~catch등을 통해 예외적인 처리를 하고, 이에 부합하는 UI를 사용자에게 보여줘야 할 의무가 있다.
이 때, 필터 컴포넌트에게 옵션 데이터를 제공하는 방식은 크게 두가지로 나뉜다
부모 컴포넌트와 오너 컴포넌트의 차이는 이 내용 참고
첫번째 케이스인 상위 컴포넌트에서 네트워크 요청을 하고 자식에게 매개변수를 통해 해당 패치 데이터를 주입해주는 것은 상위 컴포넌트가 비동기 처리에 대한 실패 처리 역시 모두 해주어야 한다는 의미를 뜻한다.
const OwnerComponent = () => {
const {data1, isLoading1, isError1} = queryLibrary();
if(isLoading1 || isLoading2 || isLoading3 ...) return <Loading/>
if(isError1 || isError2 || isError3 ...) return <Error/>
return <Filter {data1, data2, data3 ...} />
}
Promise 처리는 query library에서 대신 처리하여 react state로 제공한다고 가정하였다(reactQuery와 같이).
이 자체는 일반적인 리엑트 컴포넌트 개발에서 많이 보이는 패턴이지만 해당 네트워크 요청에 따른 데이터 바인딩이 특정 필터 컴포넌트의 관심사와 일치하여 반복적으로 작성될 수 있다면, 이를 굳이 상위 컴포넌트에서 처리하기보다 공통화된 서비스적 컴포넌트 내에서 요청하고, 응답 결과를 바인딩하여 처리하는 것이 더 좋을 것이다.
const OwnerComponent = () => {
return (
<Filter1/> // 안에서 비동기에 대한 UI 처리
<Filter2/> // 안에서 비동기에 대한 UI 처리
<Filter3/> // 안에서 비동기에 대한 UI 처리
...
)
}
이 자체만으로 훌륭하지만, 사실 여기에서는 하나 UI 확장성에 대한 함정이 존재한다.
그것은 바로, 예외 처리가 필터 컴포넌트의 영역에 귀속된다 라는 부분이었다.
컴파운드 패턴을 잘 활용해서 구조를 만들었다고 생각했을 때, 기획측에서 요청이 들어온 후의 필자의 모습이다.
그림에서 볼 수 있는 것처럼, Container 영역은 Filter가 가진 영역보다 훨씬 더 넓은 섹션을 차지하고 있는데 정작 Loading에 대한 예외처리 UI 컴포넌트는 Filter 내부에서 처리되고 있기 때문에 Filter Component 크기에 귀속된다는 문제가 발생한다.
이를 해결하기 위해서는 첫번째 선택지였던 오너 컴포넌트가 비동기 및 예외처리를 담당한다
로 돌아가야 하지만, 그렇게 될 경우 애초에 컴파운드 패턴의 특징인 재사용성의 장점을 훼손시키는 행위이며, 네트워크 패칭 데이터 기반 필터들이 많아질 경우 Owner 컴포넌트의 네트워크 패칭에 대한 책임이 점점 누적된다는 문제점이 추가적으로 야기된다.
const FilterWithNetWorkData_A = ()=>{}
const FilterWithNetworkData_B = ()=>{}
const FilterWithNetworkData_C = () =>{}
const OwnerComponent = () => {
// 한눈에 보더라도 오너 컴포넌트가 모든 비동기 요청과 예외처리를 담당하게 되면서 코드 가독성이 떨어져가는 것을 알 수 있다.
const {fetcingDataA, loadingA, errorA} = queryLibrary();
const {fetcingDataB, loadingB, errorB} = queryLibrary();
const {fetcingDataC, loadingC, errorC} = queryLibrary();
if(loadingA || loadingB || loadingC) return <Loading/>
if(errorA || errorB || errorC) return <Error/>
return <Container>
<FilterWithNetWorkData_A data={fetcingDataA}/>
<FilterWithNetWorkData_B data={fetcingDataB}/>
<FilterWithNetWorkData_C data={fetcingDataC}/>
</Container/>
}
즉, 네트워크 요청 데이터가 상태로서 활용되어야 하는 필터 컴포넌트들이 많아질수록, 오너 컴포넌트가 관리하는 것은 피해야한다는 결론을 내릴 수 있다.
그러면 우리는 두번째 선택지였던 Filter 컴포넌트 안에서 비동기 요청과 예외처리를 진행한다
로 돌아갈 수밖에 없는데, 이렇게 되면 위에서 보았던 UI적인 요청사항을 해결할 수 없다.
이를 해소하기 위해서 가장 쉬우면서도, 선언적이면서 효율적인 방법은 바로 Suspense를 활용하는 것이다.
(다만, 명백하게 드러나는 trade off가 있으므로, 잘 판단해서 사용해야 한다. 아래에서 설명)
React에서 제공하는 Suspense 기능은 특정 컴포넌트에서 발생하는 예외 상태를 대신 처리할 수 있도록 추상화된 컴포넌트이다.
소스코드 동작 원리는 이곳에서 자세하게 분석해주고 계시니, 감사하게 정독해본다.
Suspense의 핵심 원리는 아래 코드와 같다.
outer: do {
try {
if (
// 현재 작업중인 Fiber가 있어요. 현재 Suspense 되었는데 그 이유가 종료조건이 아니었어요(Not Suspensed)
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
const unitOfWork = workInProgress; // 현재 작업 단위에요.
const thrownValue = workInProgressThrownValue; // 컴포넌트에서 Thrown 된 값이에요.
resumeOrUnwind: switch (workInProgressSuspendedReason) {
// 그럼 케이스별로 확인해봐야할거같아요.
case SuspendedOnError: {
...
// 현 작업 처리 결과는 에러네요. 작업을 마무리짓고 다른 fiber 처리로 돌아가요.
}
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// 컴포넌트에서 thrown 되었던 값이 resolve된 프로미스네요
// 루프를 종료하도 다른 fiber 처리로 넘어가요
}
const onResolution = () => {
// 그 외에는, resolution에 대한 상태를 마킹해요.
};
thenable.then(onResolution, onResolution);
break outer;
}
.
.
.
// 그 외에 여러가지 작업 케이스에 대해서 처리를 해요.
default: {
throw new Error(
'Unexpected SuspendedReason. This is a bug in React.',
);
}
}
}
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
workLoopSync();
} else {
workLoopConcurrent();
}
break;
} catch (thrownValue) { // 하위 컴포넌트 호출 결과에서 Thrown되는 값을 캐치하면,
handleThrow(root, thrownValue); // 해당 값을 모듈 내 변수에 등록해요.
}
} while (true); // 해당 thenable 값에 대한 처리가 끝나지 않아, break가 이루어지지 않으므로 루프가 반복되어요.
위의 내용을 정리해보면 아래와 같다.
React의 기존 스택단위 재조정의 한계를 해결하기 위한 Fiber 단위 재조정의 자세한 프로세스, 심도깊은 분석은 React 파이버 아키텍처 분석 에서 확인할 수 있으니 감사한 마음으로 읽어보도록 하자. (정말 읽으면 읽을수록 많은 깨우침을 주는 글이다)
즉, 우리가 Suspense를 활용하려면 해당 네트워크 패칭 라이브러리가 패칭을 진행중일 때에는 "Promise Throw"를 하면 된다는 것을 알 수 있다.
대중적으로 사용되는 react-query의 useSuspenseQuery
내부 코드를 확인해봐도, Suspense 지원을 위해서 throw를 활용하는 것을 알 수 있다.
useSuspenseQuery.ts
useBaseQuery.ts
fetchOptimistic
// useSuspenseQuery.ts
// useSuspenseQuery는 구현체인 useBaseQuery의 인자로 suspense값을 true로 전달하고 있음을 알 수 있다.
export function useSuspenseQuery(
options: UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseSuspenseQueryResult<TData, TError> {
...
return useBaseQuery(
{
...options,
enabled: true, <---
suspense: true,
throwOnError: defaultThrowOnError,
placeholderData: undefined,
},
QueryObserver,
queryClient,
) as UseSuspenseQueryResult<TData, TError>
}
-----------------------------------------------------------------------------------
// useBaseQuery.ts
// useBaseQuery 내에서는, suspense일 경우 throw를 한다.
if (shouldSuspend(defaultedOptions, result)) {
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}
-----------------------------------------------------------------------------------
// function fetchOptimistic
// fetchOptimistic은 Promise를 리턴한다.
export const fetchOptimistic = (
defaultedOptions,
observer,
errorResetBoundary,
) =>
observer.fetchOptimistic(defaultedOptions).catch(() => {
errorResetBoundary.clearReset()
})
🤔 여기서 잠깐, 그러면 자식 컴포넌트에서 Promise 객체를 throw하는 것을 알았으니, 상위 컴포넌트에서 try~catch로 이를 잡아내게 하면 Suspense처럼 처리할 수 있을까? 정답은 No 이다. (되었으면 여러가지를 해보고 싶었는데...)
위에서 언급했던 Suspense의 내부 동작 코드는 전체적으로 보았을 때 React Fiber Reconciler의 과정 중 하나이다. 다시 말하면, 자식 컴포넌트 함수가 호출될 경우, 해당 Throw된 Promise 객체는 Fiber 재조정 처리 내에 있는 catch에서 잡혀서 처리되는 구조이기 때문에 상위 컴포넌트에 있는 try~catch 블록에 도달하지 않는다.
Suspense를 활용하면 비동기 요청에 대한 UI적 처리 관심사를 효과적으로 분리할 수 있다.
Suspense의 동작 원리에 따라서 맴버 Component 내부에서 thrown되는 Promise 객체는 가장 가까운 Suspense에 도달하여 처리될 것이므로, 간단하게 아래와 같은 형태를 만들어주면 위에서 요청하였던 로딩 및 에러에 관한 에러 처리의 UI적 영역을 확장하는 것이 가능해진다.
현재 팀 내 프로젝트에서는 Error Boundary와 Suspense를 결합하여 하나의 Exceptional한 UI를 표현하는 관심사를 가진 컴포넌트를 사용중이다. 또한 무조건 스켈레톤을 보여줘야 할까요? from kakao 기술블로그 에 영감을 받아, UX 최적화를 위하여 일정 로딩 지연시간을 넘지 않을 경우 로딩을 보여주지 않는 Deffered Component
를 사용중이다.
// ExceptionBoundary
export function ExceptionBoundary({
suspenseFallback,
errorFallback,
children,
}: PropsWithChildren<{
suspenseFallback?: SuspenseFallback;
errorFallback?: ErrorFallback;
}>) {
const _suspenseFallback = getFallback(suspenseFallback) ?? (
<DefaultExceptionComponentContainer>
<Spin />
</DefaultExceptionComponentContainer>
);
const _errorFallback = getFallback(errorFallback);
return (
<ErrorBoundary fallback={_errorFallback}>
<Suspense fallback={_suspenseFallback}>{children}</Suspense>
</ErrorBoundary>
);
}
// DeferredComponent
export default function DeferredComponent({
children,
deferredTime = 200,
}: PropsWithChildren<{
deferredTime?: number;
}>) {
const [isDeferred, setIsDeferred] = useState(false);
useEffect(() => {
const timeOut = setTimeout(() => {
setIsDeferred(true);
}, deferredTime);
return () => clearTimeout(timeOut);
}, []);
const childrenWithStyles = Children.map(children, child => {
if (isValidElement(child)) {
return cloneElement(child as ReactElement, {
style: {
...((child as ReactElement).props.style || {}),
visibility: isDeferred ? 'visible' : 'hidden',
},
});
}
return child;
});
------------------------------------------------------------------------------------------------
// 맴버 컴포넌트 내부에서는 suspense인지 아닌지의 유무를 props로 전달받고, 이에 따라 네트워크 패칭을 위한 함수를 무엇으로 할 것인지를 선택하게 만들어둔다.
// Filter.Member1
function Member1({suspense}:{suspense: boolean}){
...
const queryMethod = suspense ? useSuspenseQuery : useQuery;
const {data} = queryMethod();
}
------------------------------------------------------------------------------------------------
// 맴버 컴포넌트 외부를 ExceptionBoundary 컴포넌트로 감싸주고, fallback UI를 상황에 따라 표출하게 만들어준다.
const fallbacks = {
suspense: (
<DeferredComponent>
<StatusBox type="loading" style={commonExepctionStyle} />
</DeferredComponent>
),
error: <StatusBox type="error" style={commonExepctionStyle} />,
};
<ExceptionBoundary suspenseFallback={fallbacks.suspense} errorFallback={fallbacks.error}>
...
<Filter.Member1 suspense/> // suspense 옵션을 제공해 주어서, fallback에 대한 관심사를 ExceptionBoundary로 위임한다.
<Filter.Member2 suspense/> // 다른 맴버 컴포넌트를 추가적으로 호출시킬 수도 있다.
>
명시적인 "suspense" property 하나를 통해 효과적으로 비동기 처리에 대한 fallback을 통합관리할 수 있음을 알 수 있다.
여기까지만 봐서는 완벽하다고 할 수 있지만, 이 방법에 대해서는 한가지 고려해야하는 trade-off가 존재한다.
Network Waterfall이란 네트워크 요청이 순차적으로 요청되는 현상을 뜻한다.
다시금 아까 전에 보았던 Suspense에 의한 Fiber의 처리구조의 다이어그램을 살펴보자.
Fiber의 Processing은 특정 컴포넌트 호출이 이루어졌을 때, Thrown되는 값이 Promise 객체일 경우 이를 Resolve할 때까지 루프 처리가 이루어진다고 했었다.
이로 인해, 만약 3개의 Suspense query가 존재한다면 앞의 Promise가 처리되기 전까지 뒤의 컴포넌트들이 처리를 기다리게 되는 상황이 이루어진다. 즉, 네트워크 요청에 대한 비동기적인 동시성을 요구되는 상황이라면 Suspense를 통해 fallback을 보여주는 것은 적절하지 않을 수 있다.
모든 trade-off에는 한쪽을 선택했을 때의 cost를 고려해야 한다.
보통 네트워크가 혼잡하지 않을 경우 다수의 Filter가 네트워크 요청을 동해 데이터 소스를 받아와야 한다고 하더라도 크게 지연시간이 느껴지지 않을 가능성이 높다. 또한 이렇게 네트워크 요청 형태의 Filter 맴버가 한 컴포넌트에서 사용될 갯수가 최대 10개를 넘어가기가 어렵다.
(한 요청에 대해서 64.20 ms가 걸렸다. 약 10개의 Filter 맴버가 있고, 요청을 Waterfall 형태로 받아왔다고 가정했을 때 약 650ms, 0.65초 정도로 UX을 크게 해치지 않을 범위 내에서 요청이 완료된다.)
이런 단점에 대해서 감안할 수 있다고 판단이 된다면 Suspense를 통한 에러처리는 훌륭한 선택지일 수 있다.
하지만, Waterfall에 대해서 최대한 줄일 수 있으면 좋다고 판단이 된다면 결국 Suspense를 통해서 Fiber reconcilation을 막아서는 안된다는 결론에 도달한다.
그렇다고, 우리는 아까 전에 언급했던 상위 컴포넌트가 과도하게 네트워크 요청과 UI를 담당하는 것을 하고싶지는 않을 것이다.
// 😭 상위 컴포넌트에 점점 늘어나는 네트워크 패칭 관심사...
const FilterWithNetWorkData_A = ()=>{}
const FilterWithNetworkData_B = ()=>{}
...
const OwnerComponent = () => {
const {fetcingDataA, loadingA, errorA} = queryLibrary();
const {fetcingDataB, loadingB, errorB} = queryLibrary();
...
if(loadingA || loadingB ...) return <Loading/>
if(errorA || errorB ...) return <Error/>
return <Container>
<FilterWithNetWorkData_A data={fetcingDataA}/>
<FilterWithNetWorkData_B data={fetcingDataB}/>
</Container/>
}
이에 대해서 대안적인 방안으로는, 자식들의 랜더링을 막지 않게 하여 비동기적인 처리는 동시에 일어나도록 하되, 처리들에 대한 상태를 종합하여 fallback UI를 보여주는 overlay UI를 구현해볼 수 있겠다.
코드가 조금 더 늘어나고, re-rendering에 대한 빈도수는 필터의 갯수만큼 높아지는 대신 극단적인 waterfall로 인한 딜레이를 최대한 감소시킬 수 있다는 장점이 있다.
다이어그램으로 나타내면 아래와 같다.
위처럼 fallback에 대한 ui layer을 upper layer로 덮도록 하고, 네트워크 패칭에 대한 컴포넌트의 랜더링은 under layer에서 동작하게 하여 블로킹을 하지 않는다는 전략이다. 이를 구현한 코어 코드는 아래와 같다.
// fetch store
// 하위 맴버 컴포넌트의 비동기 요청에 대한 status를 통합 관리한다.
type QueryStatus = 'loading' | 'error' | 'idle';
const [queryStatusStore, setQueryStatusStore] = useState<Map<string, QueryStatus>>(new Map());
const addQueryStatus = (name: string, queryStatus: QueryStatus) => {
setQueryStatusStore(prev => {
const newMap = new Map(prev);
newMap.set(name, queryStatus);
return newMap;
});
};
const removeQueryStatus = (name: string) => {
setQueryStatusStore(prev => {
const newMap = new Map(prev);
newMap.delete(name);
return newMap;
});
};
const resetQueryStatusStore = () => {
setQueryStatusStore(new Map());
};
// derived value인 currentQueryStatus는 fallback ui를 결정하는 상태값으로 활용한다.
const currentQueryStatus = useMemo(() => {
let hasError = false;
let hasLoading = false;
for (const [_, value] of queryStatusStore) {
if (value === 'error') {
hasError = true;
} else if (value === 'loading') {
hasLoading = true;
}
}
if (hasError) return 'error'; // error을 1순위로 보여준다 ( 전체 status에서 에러가 하나라도 있을 경우 우선적으로 선택 )
if (hasLoading) return 'loading'; // loading을 2순위로 보여준다
return 'idle' as QueryStatus;
}, [queryStatusStore]);
-------------------------------------------------------------------------------------------------
// ConcurrentExceptionBoundary
// 상태에 알맞는 Fallback UI를 상위 레이어로 덮어주면서 리턴하는 역할을 담당한다.
export type FilterErrorFallbackProps = { onReset: Common.DefaultFunction };
export type FilterErrorFallbackComponent = ComponentType<FilterErrorFallbackProps>;
const ConcurrentExceptionBoundary = ({
SuspenseFallback,
ErrorFallback,
children,
}: PropsWithChildren<{
SuspenseFallback: ReactNode;
ErrorFallback: ReactNode | ComponentType<FilterErrorFallbackProps>;
}>) => {
// currentQueryStatus에 따라 fallback을 보여준다.
const { currentQueryStatus } = useFilterContext();
const statusFallback = useMemo(() => {
// getFallback 함수는 인자로 들어오는 값의 타입에 따라 적절한 랜더링 타겟을 리턴해주는 역할을 한다.
if (currentQueryStatus === 'loading') return getFallback(SuspenseFallback);
if (currentQueryStatus === 'error') return getFallback(ErrorFallback, filterErrorFallbackProps);
return null;
}, [currentQueryStatus]);
// error fallback 내 reset function을 위한 mount key
// key 변경이 일어나게 하여 맴버 컴포넌트들의 리랜더링을 일으켜 네트워크 요청을 새롭게 발생시킨다. (Error boundary처럼)
const [mountKey, setMountKey] = useState(0);
const onReset = useCallback(() => setMountKey(prev => prev + 1), []);
const filterErrorFallbackProps: FilterErrorFallbackProps = { onReset };
return (
<ExceptionContainer key={mountKey}>
{statusFallback && <ExceptionFallbackWrapper>{statusFallback}</ExceptionFallbackWrapper>}
{children}
</ExceptionContainer>
);
};
-----------------------------------------------------------------------------------------------
// Member Component
// 네트워크 패칭이 내장되어 있는 맴버 컴포넌트들은 fetching callback을 활용하거나 useEffect를 이용해서 Context의 status store을 업데이트한다.
// 현재 프로젝트에는 react-query의 기본 함수를 callback을 받을 수 있는 구조로 확장해놓았기에 이를 활용해 구현하였다.
// name은 React-hook-form에서 사용되는 form state identifier이다. 프로젝트에 따라 고유한 특정 값이어도 무관하다.
function Member1 ({ name }: { name:string }) {
const {addQueryStatus, removeQueryStatus} = useFilterContext();
const {data, isLoading, isError} = useQuery({
onLoading() { addQueryStatus(name, 'loading') },
onError() { addQueryStatus(name, 'error') },
onSuccess() { removeQueryStatus(name) }
});
}
---------------------------------------------------------------------------------------------
// 실제 사용되는 장소
// 아까 Suspense의 Error boundary가 처리했던 역할을 ConcurrentExceptionBoundary가 담당하게 되었다.
// 해당 바운더리는 맴버 컴포넌트의 네트워크 요청을 블로킹 하지 않고 fallback UI를 표출해줄 수 있게 되었다.
<Filter.ConcurrentExceptionBoundary
SuspenseFallback={<StatusBox type="loading" />}
ErrorFallback={fallbackProps => (
<StatusBox type="error" onClick={fallbackProps.onReset} />
)}
>
<Filter.Member1 concurrentFetching /> // 뒤에서 요청 중
<Filter.Member2 concurrentFetching /> // 뒤에서 요청 중
<Filter.Member3 concurrentFetching /> // 뒤에서 요청 중
....
</Filter.ConcurrentExceptionBoundary>
실제로 이 구조를 활용해서 네트워크 요청을 진행해 보았다.
이렇게 될 경우, Query Status Store은 두 요청 중 한쪽의 에러를 확인하게 되기 떄문에 error fallback ui를 나타내게 된다.
(아래와 같이 말이다)
하지만 기존 suspense떄와는 다르게, 실제 네트워크 요청은 동시에 진행된 것을 알 수 있다. fallback ui는 upper layer을 덮어서 보여주고 있을 뿐, children인 맴버 컴포넌트들의 랜더링은 블로킹되지 않았기 때문이다.
이렇게 Compound 패턴과 비동기 처리에 대한 fallback ui의 시스템 구조의 연구 결과를 마치게 되었다. 아직 보완점이 많고 최적화할 것이 많지만, 해당 패턴의 도입 후 전체적인 코드 복잡도가 감소하고, 재사용성 및 추후 유지보수에 있어서 요구사항에 대해 유연하게 변경할 수 있다는 장점이 너무 많았던 경험이었다.
(끗)