이전 OOP를 프론트에 적용하는 고민을 개인 프로젝트를 통해 경험하고, 여러 관련 문제들을 해결하려 했다.
제법 복잡한 상황을 맞게 되었는데, OOP적 관점만으로는 개인적으로 이를 정리하기 어려웠다.
이에 FP를 공부하고 이 관점에서 문제를 해결했기에, 이를 기록하고자 한다.
차트 라이브러리를 사용하며, 위에 리액트 컴포넌트를 띄워야 했다.
차트는 꺽은선 그래프인데, 각 데이터 지점을 클릭 시 '마크 포인트'가 생성된다.

위의 '마크 포인트'들에 대응해서 리액트 컴포넌트 Annotation을 띄우게 된다.
또한, 흰색 점선으로 둘러쌓인 마크포인트를 '포커스'라고 하는데,
현재 선택한 지점을 의미한다.
이는 해당 마크포인트나 관련 Annotation를 클릭 시, 혹은 데이터 지점을 처음 클릭할 때 활성화 된다.
상황이 복잡한 것 같으니, 정리하자면 다음과 같다.

위를 더 복잡하게 하는 조건은 아래와 같다.
덕분에 코드는 아래와 같았다.
/*각종 import*/
/* 기본 그래프 데이터 생성 */
interface MarkData {
[id: string]: MarkDataContent;
// focus: MarkDataContent;
}
/* 기본 그래프 옵션 정의 */
const RealFlowEcharts = () => {
/*아래는 hooks*/
/*차트 ref랑 이거로 저장하는 echartinstance 상태 저장*/
/*markData상태 (빈거초기화)랑, atom으로부터 focus상태*/
/* focus정보 useEffect안에서 deps없이 접근 위해 ref에 state저장 */
useEffect(() => {
/* <<포커스 정보 변화를 markData에 반영>>
(외부에서 atom으로 focus수정을 동일 대상의 markpoint의 정보에 반영위해)*/
}, [focusInfo]);
useEffect(() => {
/*<<그래프 인스턴스 처리>>*/
/*첫 렌더링에 instance를 state에 저장.*/
}, []);
useEffect(() => {
// 이건 기본. echartinstance있을 때만 동작시키도록.
if (!echartInstance) return;
/*렌더링용 마크포인트 데이터 생성*/
/*
렌더링용 마크포인트 데이터 정리
markData에 있는 데이터 렌더링용으로 정리
그 중 focus에 대응되는 것은 focus용으로 정리
*/
/*focus에 있는 것(markData에는 없는)도 렌더링용으로 편입*/
/* <<마크포인트 그래프에 반영>> */
}, [echartInstance, markData, focusInfo]);
useEffect(() => {
if (!echartInstance) return;
/*<<클릭 이벤트 핸들러 등록>>*/
/*클릭 이벤트에서 markPoint와 series는 제외.*/
/*공통 데이터 정리하기 - accumulatedValue(그래프에서 정확한 y값), markData저장용 공통 데이터 템플릿*/
/*series(데이터 포인트)클릭: 포커스에 markData정보랑 같이 추가. (tmpFocus)*/
/*
markpoint클릭:
- focus라면
- tmpFocus라면: '시리즈-날짜'를 키로, focus에서 대충 걸러서 markData로 추가 / focus 비활성화
- 원본있는 focus라면: focus비활성화
- 아니라면: focus로 데이터 반영 및 활성화 및 original 연결
*/ // 불필요한 코드가 많긴 했네..
return /*클릭 이벤트 핸들러 제거*/
}, [echartInstance, defaultDatum]); // defaultDatum위치 정리한번 해야함.
/* annotation 제한 범위 위해 */
const constraintsRef = useRef(null);
return (
<div style={{ height: '100%', width: '100%' }} ref={constraintsRef}>
<ReactECharts
ref={chartRef}
style={{ width: '100%', height: '100%' }}
option={defaultOption}
/>
{Object.entries(markData)
.map(
([key,/*마크데이터들*/,],idx) => (
<ChartAnnotation
/* 클릭 이벤트에 이거랑 관련된 markData를 focus로 편입 및 활성화 */
/*key랑 위치 및 내용 데이터 넣기*/
/>
)
)}
</div>
);
};
export default RealFlowEcharts;
구조만 표현하기 위해 내부 코드를 날리고 주석으로 대체했지만, 문제가 많다.
- 각 기능마다 useEffect가 사용되고 있다.
- 관리 포인트가 산재되어 디버깅이 어렵다.
- 추상화가 없어 가독성이 떨어진다.
...
=> 유지보수가 어렵다
물론, 충분하게 추상화와 모듈화를 하지 않아보인다.
그러나 변명하자면, 컴포넌트 구현부에 존재하는 코드들 중 마지막의 constraintsRef를 제외하곤 차트에 대한 것이었기에 크게 차이나지 않았을 것이다.
또한, 각 기능들을 커스텀 훅으로 묶어내는 것으로도 문제를 해결할 수 없었을 것이다.
hooks간 동작의 연결이 필요한 부분이 적었기 때문이다.
그저 1겹으로 된 단순한 캡슐화를 했을 것이다.
FP가 데이터와 동작을 잘 구조화 할 수 있는 힌트가 되지 않을까 싶어 공부하게 되었다.
이 내용은 추후 작성하겠지만, 제일 인상깊었던 부분은 '액션'에 대한 접근이었다.
FP에서 side-effect가 존재하는 함수를 '액션'이라고 부를 수 있다.
액션이 아닌 순수 함수라면 '계산'이라고 부를 수 있다.
- 계산은 신뢰할 수 있는 영역에 속하기 때문에, 최대한 액션에서 계산을 뽑아내야 한다.
- 이러한 액션을 참조하는 함수도 액션이 되기 때문에, 액션을 최대한 상위 계층에 위치시켜야 한다.
위는 프론트엔드에서도 적용 가능한 부분이라고 봤고, 이를 고려해 리팩토링을 진행했다.
우선 계층을 분리했다.
FP에서 배운 어니언 아키텍쳐와 액션을 최상위 계층으로 올린다는 것을 고려해 다음과 같이 분리했다.

계산으로 드러낼 수 있던 부분은 기능 로직들 뿐이었으나, 이는 복잡한 로직들을 정리한다는 의도에 맞았기에 적절한 행동이었다.
기능의 동작 추가는 차트 instance에 적용하는 방식이었기에, '기능 로직들'에서 '차트 라이브러리'를 직접 사용하게 할 순 없었다.
또한, 차트 라이브러리를 컴포넌트로 사용했기 때문에 위의 구조가 되었다.
// ReusableEchart.tsx - 차트 라이브러리
const ReusableEchart = memo(
({ cachedGetInstance, defaultOption }: ReusableEchartProps) => {
const chartRef = useRef(null);
useEffect(() => {
// 기본 차트 옵션 적용, 인스턴스 외부로 전달
chartInstance = echarts.init(chartRef.current);
chartInstance.setOption(defaultOption);
cachedGetInstance(chartInstance);
return () => {
if (chartInstance) {
chartInstance.dispose();
}
};
}, [cachedGetInstance, chartRef]);
return (
<div ref={chartRef} style={{ width: '100%', height: '100%' }}></div>
);
}
);
export default ReusableEchart;
// chartHandlers.ts - 기능 로직들
const makeActualFlowEchartOption = (defaultDate, defaultDatum) => ({
// date와 datum로 기본 옵션 만들기
});
const addEventHandlerNew = (
param: echarts.ECElementEvent,
defaultDatum,
determineFocus
): {
type: 'mark' | 'focus';
data?: MarkDataContent | null;
origin?: string;
}[] => {
if (param.componentType !== 'markPoint' && param.componentType !== 'series') {
return [];
}
if (param.componentType === 'series') {
const accumulatedValue = getYcoord(
defaultDatum,
param.seriesIndex as number,
param.dataIndex
);
const markDataTemplate: any = {
asset: param.seriesName || '이름 없음',
date: param.name,
type: 'normal',
value: Number.isInteger(Number(param.value)) ? Number(param.value) : 0,
viewPos: [param.event!.offsetX, param.event!.offsetY],
dataIndex: param.dataIndex || 0,
seriesIndex: param.seriesIndex as number,
accumulatedValue,
};
return [{ type: 'focus', data: markDataTemplate }];
} else {
const paramData = param.data as ParamData;
const isFocus = determineFocus(
paramData.yAxis,
paramData.xAxis,
paramData.name
);
if (isFocus) {
return [{ type: 'mark' }, { type: 'focus', data: null }];
} else {
return [
{ type: 'focus', origin: `${paramData.name}-${paramData.xAxis}` },
];
}
}
};
const makeMarkPoints = (markData, focusInfo) => {
// 차트 상 표시와 차트 위 컴포넌트 렌더링을 위한 데이터 가공
});
// FlowCharts.tsx
const ActualFlowEchartNew = () => {
const [chartInstance, setChartInstance] = useState(null);
const [markData, setMarkData] = useState<MarkData>({} as MarkData);
const [focusInfo, setFocusInfo] = useAtom(ActualFlowFocusInfoAtom);
const constraintsRef = useRef(null);
const getInstance = useCallback((instance) => {
if (instance) {
setChartInstance(instance);
}
}, []);
const markPoints = makeMarkPoints(markData, focusInfo);
const date = makeDefaultDate();
const datum = makeDefaultDaum();
const actualFlowDefaultOption = makeActualFlowEchartOption(date, datum);
useEffect(() => {
// 차트 상 표시 적용
}, [markPoints, chartInstance]);
useEffect(() => {
// 이벤트 핸들러 부착
if (!chartInstance) return;
(chartInstance as EChartsInstance).on(
'click',
(param: echarts.ECElementEvent) => {
const determineFocus = (yAxis, xAxis, name) => {
// 포커스 구별 결과를 반환
};
const evaluations = addEventHandlerNew(param, datum, determineFocus);
evaluations.forEach((eachEvaluation) => {
if (eachEvaluation.type === 'mark') {
// key값 만들어 markData에 focusInfo를 편입
// setMarkData와 focusInfo사용
} else if (eachEvaluation.type === 'focus') {
if (eachEvaluation.origin) {
// origin 필드가 있다면 markData[origin]을 focusInfo로 set
// markData와 setFocusInfo사용
} else {
// data 필드 값에 대해 focusInfo로 업데이트
// setFocusInfo사용
}
}
});
}
);
return () => (chartInstance as EChartsInstance).off('click');
}, [chartInstance, datum, focusInfo]);
return (
<>
<div style={{ height: '1000px', width: '100%' }} ref={constraintsRef}>
<ReusableEchart
cachedGetInstance={getInstance}
defaultOption={actualFlowDefaultOption}
/>
{/*markData를 통해 차트 위 컴포넌트 렌더링*/}
</div>
</>
);
};
장점
chartHandler에 존재하는 모든 함수를 '계산'으로 만들었고, 이 부분이 핵심 로직의 대부분을 담당하기 때문에 유지보수에 유리해졌다.
기능을 추가 및 수정한다고 해서, 다른 기능들에 영향을 미치지 않는 다는 것은 명확해지기 때문.
한계
최상위 계층에서 '어떻게'라는 정보를 담고 있다.
각 useEffect안에, '계산'을 통해 얻은 가공된 데이터를 처리하는 방법을 작성해야 했다.
이를 해결하려면, 더 계층을 나눠야 한다.
하지만 이를 위해선 markData, setMarkData, focusInfo, setFocusInfo를 모두 넘겨받는 함수로 분리해야 하기 때문에, 지저분해서 포기했다.
기능 관리하는데 있어서 확실피 수월해짐을 느꼈다.
이전에는 setMarkData안에 setFocusInfo가 들어가는 등 매우 복잡한 구조였다.
또한 계층이 분리되어있지 않아, 어디에서 문제가 발생하는지 확인하기 위해서는 모든 문맥을 읽고 하나하나 따라가며 파악해야 했다.
FP관점으로 계층을 분리하고, 로직을 순수함수로 분리하는 과정에서 신뢰할 수 있는 영역을 만들었기 때문에, 필요한 부분에만 신경 쓸 수 있다는 점이 좋았다.
나름 FP의 주요 원칙으로 고려하며 리팩토링을 진행했으나, 솔직히 완벽하지 않다고 생각한다.
react의 scheduler 관련 코드를 보면서 꽤 계층화와 추상화가 잘 이루어져 있다고 생각했는데, 내 결과물에는 여러 한계를 볼 수 있었으므로.
또한, FP를 완벽하게 프론트엔드에 적용하기 어렵다는 점도 한 몫 한다고 본다.
또한, OOP와 어떻게 조화를 이룰지도 생각하고, 자신만의 스타일을 키워나가야 할 것이다.