최근 진행하고 있는 사이드 프로젝트에서 절차 지향적으로 작성된 코드들을 함수형으로 리팩토링 하며 느꼈던 점들을 기존 지식들을 회상하며 다시 작성한다.
함수형 패러다임이란
함수형 패러다임이란 모든 프로그램을 하나의 함수로 보고 그 안에서 다양한 순수 함수들을 합성하여 함수를 만들어내는 프로그래밍 패러다임을 의미한다.
순수함수란 동일한 입력에 대해 항상 동일한 출력값을 반환하는 함수를 의미한다. 순수 함수라는 점이 함수형 패러다임에서 중요한 이유 는 함수 합성 과정에서 부수효과가 존재하는 함수의 경우 매번 동일한 입출력을 보장 할 수 없어 안정성이 떨어지기 때문이다.
함수형 패러다임에서 가장 큰 목적으로 두는 부분들은 다양하지만 그 중 이번 리팩토링 단계에서 느꼈던 점은 함수 합성과 지연평가이다.
코드스멜이 났던 기존 구조
우선 백그라운드를 좀 살펴보자
현재 나는 블록 기반 텍스트 에디터를 만들며 블록 기반 에디터의 input
요소들을 관리하는 스토어를 생성하고 있다.
인풋 요소들을 Segment라 정의했다.
현재 zustand
를 이용하고 있는 상태 관리 스토어에서 거대한 상태의 인터페이스는 이렇게 생겼다.
interface Segment {
/* ... 생략 */
clientSegmentId : string;
segmentText : string;
segmentId : null | string;
}
export type ParagraphOrder = number;
export type SegmentOrder = number;
export type SegmentMap = Map<SegmentOrder, Segment>;
// 📌 실제 Store에서 사용하는 State, 중첩된 Map 형태
export type ParagraphMap = Map<ParagraphOrder, SegmentMap>;
이런 중첩되어있는 Map
형태에서 가장 말단에 존재하는 특정 Segment
를 수정하기 위한 상태 변경 메소드는 changeSegment
메소드는 이런 타입으로 구현되어 있었다.
changeSegment: (segmentChange: {clientSegmentId : string; newSegment : Segment}) => void;
이후 스토어 내부에서 인수로 받은 식별자인 clientSegmentId
를 이용해 중첩되어 있는 Map
내부에서 변경하고자 하는 Segment
를 찾아내고 두 번째 인수로 준 newSegment
로 교체하는 과정을 거친다.
기존 구조의 코드 스멜을 알아보자
함수에서 인수를 원시값이나 객체로 받는 행위 자체는 꽤나 익숙한 구조일 것이다.
그럼 저 changeSegment
들을 사용하여 input
요소에 붙을 핸들러 중 segmentText
값을 수정하는 핸들러의 모습을 살펴보자
const useCorrectionSegmentInput = ({
paragraphOrder,
segmentOrder,
}: DiaryWriteSegmentInputProps) => {
// 1. 기존의 Segment 를 구독
const segment = useDiaryEditorStore(
(state) => state.paragraphs.get(paragraphOrder).get(segmentOrder));
const changeSegment = useDiaryEditorStore((state) => state.changeSegment);
// 2. 기존 Segment의 식별자와 새로운 값을 이용해 업데이트
const handleChangeSegmentText = (text: string) => {
changeSegment({
clientSegmentId: segment.clientSegmentId,
nextSegment: {
...segmentState,
segmentText: text,
},
});
};
...
개인적으로 이 핸들러를 보며 느껴졌던 코드 스멜들을 회상해본다.
1. 상태 변경 자체는 사실 테스트 하기가 어렵다
우선 가장 크게 느껴졌던 부분은 리액트의 상태 관리 업데이트는 테스트 하기가 어렵다.
예를 들어 호출 될 때 마다 인수의 값을 하나씩 증가시키는 순수 함수가 있다고 가정해보자
이런 순수함수는 단순 호출 시 출력값이 입력값보다 1 큰지만 확인하면 된다.
하지만 useState
와 같은 상태 값으로 정의되어 있는 로직이 잘 동작하는지 확인하기 위해선 상태 관리가 영향을 미치는 Virtual DOM
-> Actual DOM
의 변화를 테스트 해야 한다.
2. 상태 변경 로직이 인수의 인터페이스에 영향을 받는다.
현재의 changeSegment
함수 자체는 변경하고자 하는 상태의 식별자와 변경 할 새로운 상태를 인수로 받는다.
하지만 이런 경우를 생각해보자.
만약 changeSegment
함수를 호출하는 부분에서 기존 정의된 식별자가 아닌 다른 식별자요소를 통해 식별하고 싶다면 어떻게 해야 할까?
아마 다른 요소를 받는 핸들러를 또 생성해줘야 할 것이다.
changeSegmentBySomething1 = ({something1 : ... , segment : Segment})=> void;
changeSegmentBySomething2 = ({something2 : ... , segment : Segment})=> void;
changeSegmentBySomething3 = ({something3 : ... , segment : Segment})=> void;
이러한 일이 발생하는 이유는 changeSegment
라는 상태 변경 메소드가 인수 자체의 인터페이스에 크게 종속 되어 있는 구조이기 때문일것이다.
그렇기에 받고자 하는 인수가 변경된다면 변경된 인수에 종속되는 다른 상태 변경 메소드를 또 만들어야 한다.
즉, 상태 변경을 유발하는 컴포넌트들에선 다양한 요구 사항이 존재 할 수 있는데 상태변경 메소드 자체는 여러 요구사항을 만족 할 수 없는 구조이다.
3. 현재 구조는 추상화 수준이 낮아 이해하기 어렵다.
다시 상태 변경 메소드인 changeSegment
를 호출하는 부분을 다시 살펴보자
const handleChangeSegmentText = (text: string) => {
changeSegment({
clientSegmentId: segmentState.clientSegmentId,
nextSegment: {
...segmentState,
segmentText: text,
},
});
};
추상화 수준이 가장 낮은 단계는 어떠한 유명 함수도 호출하지 않는방식이다.
현재 changeSegment
의 인수에 들어가는 부분들은 단순 객체와 스프레드 문법으로만 작성 되어 있어 가장 낮은 추상화 수준으로 작성되어 있음을 알 수 있다.
처음에는 이 문제를 해결하기 위해 changeSegment
의 인수를 만드는 메소드를 만들어 해결하고자 했었으나 이는 올바른 접근 방식이 아녔음을 깨달았다.
changeSegment(createChangeSegment(...))
추상화 하고자 하는 요소는 어떤 요소를 찾아 어떤 형태로 수정 할 것인지 인데 현재의 방식은 단순 changeSegment
함수에서 기대하는 인수를 만드는 방식밖에 되지 않기 때문이다.
함수형으로 바꿔보자
구구절절 설명하기 전 변경된 형태를 먼저 보여주도록 한다.
type Finder = (segment: Segment) => boolean;
type Updater = (segment: Segment) => Segment;
changeSegment: (finder: Finder,updater: Updater)
이전과 다르게 인수를 객체가 아닌 함수들로 받도록 상태 변경 메소드가 수정했다.
이를 통해 인수로 받을 인터페이스 구조에 종속되지 않고 해당 상태 변경 메소드를 호출하는 부분에서 제어 가능하게 되었다.
import { hasSameClientId, updateSegmentId, updateSegmentText } from '../../lib';
...
const useCorrectionSegmentInput = ({
paragraphOrder,
segmentOrder,
}: DiaryWriteSegmentInputProps) => {
const segment = useDiaryEditorStore(
(state) => state.paragraphs.get(paragraphOrder).get(segmentOrder),
);
const changeSegment = useDiaryEditorStore((state) => state.changeSegment);
const handleChangeSegmentText = (segmentText: string) =>
changeSegment(hasSameClientId(segment), updateSegmentText(segmentText));
이런식으로 말이다.
이 리팩토링을 통해 무엇을 얻을 수 있었을까?
- 상태 변경 메소드를 호출하는 부분에서 상태 변경 로직을 제어 함으로서 더욱 유연한 구조를 가질 수 있다.
- 상태 변경 메소드를 호출하는 부분에서 인수를 유명 함수로 제공함으로서 추상화 수준을 높혀 어떤 로직들이 일어나는지 이해하기 쉬워졌다. (객체와 ... 로 범벅된 인수때와 비교해보자)
- 인수로 제공되는 함수들은 순수 함수로 구성되어 테스트 하기 매우 쉽다.
import { Segment } from '@/shared/types/diary/shared/Editor';
export const hasSameClientId = (segmentA: Segment) => (segmentB: Segment) =>
segmentA.clientSegmentId === segmentB.clientSegmentId;
import { Segment } from '@/shared/types/diary/shared/Editor';
const updateSegmentWithPartial =
(partialSegment: Partial<Segment>) =>
(prevSegment: Segment): Segment => ({
...prevSegment,
...partialSegment,
});
export const updateSegmentText = (segmentText: string) =>
updateSegmentWithPartial({ segmentText });
export const updateSegmentId = (segmentId: string) =>
updateSegmentWithPartial({ segmentId });
이 때 인수로 제공 될 함수들은 고계 함수 형태로 정의되어 있다.
함수를 값처럼 이용하는 함수형 패러다임 자체에서 커링을 통한 고계함수는 일반적인 패턴이다.
변경된 상태 변경 메소드의 구현체
타입만으로 직관적이지 않을 수 있어 zustand
내부에서 구현된 코드를 함께 첨부한다.
완전 완전 슈퍼 함수형으로 생각한다면 반복문을 재귀로 변경하고, mutable
한 isUpdate
변수 자체도 재귀 함수의 인수로 집어넣고 그래야겠지만 적절히 섞어 쓰도록 했다.
changeSegment: (finder, updater) => {
set(({ paragraphs }) => {
const newParagraphs = new Map(paragraphs);
let isUpdated = false;
for (const [paragraphOrder, segmentMap] of paragraphs) {
if (isUpdated) {
break;
}
for (const [segmentOrder, segment] of segmentMap) {
if (isUpdated) {
break;
}
if (finder(segment)) {
const updatedSegmentMap = new Map(
newParagraphs.get(paragraphOrder),
);
updatedSegmentMap.set(segmentOrder, updater(segment));
newParagraphs.set(paragraphOrder, updatedSegmentMap);
isUpdated = true;
}
}
}
return { paragraphs: newParagraphs };
});
},