Web Frontend Developer

react-hook-form & zod 로 복잡한 오브젝트 관리하기 (feat. immerjs)

DevOwen 2023. 4. 21. 15:49

출처 : https://articles.wesionary.team/react-hook-form-schema-validation-using-zod-80d406e22cd8

이 글의 목적

  • 여러 뎁스로 중첩된 오브젝트 형태로 구성된 상태를 불변하게 유지하는 방법에 대해 고민이 있는 웹 프론트엔드 개발자가 참고할 수 있는 사례를 공유하기 위함
  • 로직이 복잡하고 사이드 이펙트가 많이 발생할 수 있는 코드를 적절한 도구로 간결하고 명확하게 작성하는 방법에 고민이 많은 웹 프론트엔드 개발자에게 인사이트를 주기 위함

이 글의 예상 독자

  • react와 typescript로 웹 프론트엔드 개발을 하는 엔지니어
  • 기본적인 react, typescript에 대한 선수 지식이 있다고 가정하고 설명

인트로

내가 속한 팀에서 담당하고 있는 웹 프로덕트는 현재 context로 상태를 관리하고 있으며, form 형태의 페이지는 react-hook-form 이라는 라이브러리를 사용하고 있다. 그리고 유효성 검사의 경우 zod라는 라이브러리가 궁합이 좋아서 함께 사용을 하고 있는 편이다.

당면한 문제

최근에 팀에서 중첩된 오브젝트 기반의 상태를 관리할 일이 있었다. 최대 3번까지 오브젝트 안에 오브젝트가 들어가고, 그것들을 CRUD 해주는 기능이 들어가는 작업이었다.

const exampleState = [
    {
        name: "firstObjectName",
        depth : 1,
        property: "firstObjectProperty"
        children: [
            {
                name: "secondObjectName",
                depth : 2,
                property: "secondObjectProperty",
                children: [
                    {
                        name: "thirdObjectName",
                        depth : 3,
                        property: "thirdObjectProperty"
                    }
                ]
            }
        ]
    }
];

const [state, setState] = useState(exampleState);

이와 같은 식의 상태를 관리해야 하는 미션이 주어졌고, 여기서 배열 형태로 되어 있는 오브젝트들이 있는데 여기에 값을 넣고 빼고 이 안의 프로퍼티를 수정하는 기능까지 요구사항으로 주어졌다.

오브젝트를 추가 하는 건 어렵지가 않았다. 문제는 삭제를 하는 부분이었다. 나는 처음에 삭제를 하고 싶은 인덱스를 기반으로 앞 뒤를 잘라서 붙이는 식으로 처리하려고 했다. 참고로 실제 코드를 적을 수는 없어서, 추상화한 개념만 유지한 채로 불필요한 부분을 없앤 뒤 재작성한 코드이다.

const removeFirstDepthObjectHandler = (index: number) => {
  const newState = [
        ...state.slice(0, index),
        ...state.slice(index + 1)
    ];
    setState({...newState});
}

첫 번째 뎁스의 오브젝트를 삭제하는 것까지는 문제가 없었다. 하지만 문제는 그 다음 부터 시작되었다. 두 번째 뎁스의 오브젝트를 삭제하는 코드는 다음과 같이 작성을 해야 했다.

const removeSecondDepthObjectHandler = (firstIndex: number, secondIndex: number) => {
    const targetFirstObject = state[firstIndex];
    const newTargetFirstObject = {
        ...targetFirstObject,
        children: [
            ...targetFirstObject.children.slice(0, secondIndex),
            ...targetFirstObject.children.slice(secondIndex + 1)
        ]
    };
    const newState = [
        ...state.slice(0, index),
        newTargetFirstObject,
        ...state.slice(index + 1)
    ];
    setState({...newState});
}

벌써 조금씩 아찔하다. 여기서 만약 세 번째 뎁스를 바뀌는 요구사항이 생긴다면..? 코드는 아마 두 배 이상 복잡해질 것이다. 단순히 값을 하나 빼는 처리를 해주는데 말이다. 그리고 setState는 비동기로 처리가 되는데 삭제가 여러 곳에서 마구잡이로 일어난다면 state의 불변성이 지켜진다는 보장도 할 수 없다.

값을 수정해 주는 부분도 직접 해보면 알겠지만, 값 하나 바꿔주는 건데 그 역할에 비해 코드가 정말 복잡해 지는 것을 알 수 있다. 두 번째 뎁스의 property 를 바꿔주는 로직을 일단 생각나는 첫 번째 방법으로 써 보면 다음과 같다.

const updateSecondDepthObjectHandler = (firstIndex: number, secondIndex: number, value: string) => {
    const targetFirstObject = state[firstIndex];
        const newTargetFirstObject = {
            ...targetFirstObject,
            children: [
                ...targetFirstObject.children.slice(0, secondIndex),
                {
                    ...targetFirstObject.children[secondIndex],
                    property: value
                }
                ...targetFirstObject.children.slice(secondIndex + 1)
            ]
        };
        const newState = [
            ...state.slice(0, index),
            newTargetFirstObject,
            ...state.slice(index + 1)
        ];
        setState({...newState});
    }

해결 1 : react-hook-form 의 useFieldArray

팀에서 나보다 react-hook-form을 더 잘 다루는 동료가 있었다. 이 문제를 해결하면서 그 동료에게 도움을 많이 받았다. (감사합니다 H) 그 동료는 내 코드를 리뷰하더니, react-hook-form의 useFieldArray() 훅을 한 번 읽어보라고 조언해 주었다.

react-hook-form에서 기본적으로 form을 관리할 때 사용하는 훅은 useForm() 이다. 나도 처음에는 당연히 이걸 사용하고 있었다. 그런데 공식 문서를 살펴보니 이 아래에 useFieldArray() 라는 녀석이 있었다.

공식 문서에서는 react-hook-form을 Custom hook for working with Field Arrays (dynamic form). The motivation is to provide better user experience and performance. 이라고 소개하고 있다.

배열에 특화된 훅인 것을 인지하고, 한 번 적용해 보기로 했다.

const { getValues, setValue, control, handleSubmit } =
    useForm<T>({
      defaultValues: { arrayState: exampleState }
    });

const { fields, append, remove, update } = useFieldArray<T>({
    control,
    name: 'arrayState'
  });

useFieldArray를 useForm과 함께 사용하면, useForm의 값 중에 배열 형태(예제 코드에서는 arrayState 라는 프로퍼티)의 값을 따로 관리할 수 있다. 프로퍼티명을 name에 넣어주면 된다. 그러면 fields라는 값이 이 arrayState 배열의 값이고 이 배열에 값을 넣고 싶다면 append 메서드, 값을 빼고 싶다면 remove 메서드, 값을 수정하고 싶다면 update 메서드를 쓰면 된다.

이 메서드를 위에 짠 코드에 적용해보면 다음과 같다.

const removeFirstDepthObjectHandler = (index: number) => {
  remove(index);
}
const removeSecondDepthObjectHandler = (firstIndex: number, secondIndex: number) => {
    const targetFirstObject = state[firstIndex];
    update(firstIndex, {
        ...targetFirstObject,
        children: [
            ...targetFirstObject.children.slice(0, secondIndex),
            ...targetFirstObject.children.slice(secondIndex + 1)
        ]
    });
}
const updateSecondDepthObjectHandler = (firstIndex: number, secondIndex: number, value: string) => {
    const targetFirstObject = state[firstIndex];
    update(firstIndex, {
        ...targetFirstObject,
            children: [
                ...targetFirstObject.children.slice(0, secondIndex),
                {
                    ...targetFirstObject.children[secondIndex],
                    property: value
                }
                ...targetFirstObject.children.slice(secondIndex + 1)
            ]
        })
    }

다행히도 조금 코드가 간결해 졌다.

해결 2 : 배열 상태를 분리해서 관리

잠시 기분이 좋았다가, 나는 금방 또 다른 문제를 마주하게 되었다. form에서 관리하는 요소중에 input 으로 관리하는 상태들도 있었다. onChange 이벤트는 문제가 없이 동작했지만, 중첩된 오브젝트의 상태를 업데이트해주는 못하는 이슈가 있었다.

기존에 useFieldArray를 사용하던 방식은 context에서 state를 관리하면서 control 속성을 통해 하위 컴포넌트들에 props로 타고타고 전달해 주면서 이 전체 state를 관리를 해 주고 있었다. 이 방식으로 하다 보니 각 중첩된 오브젝트 내의 배열 상태들을 올바르게 업데이트 해주지 못하는 이슈를 발견했고 다음과 같이 해결을 하게 되었다.

기존에는 state와 control을 전부 받아서 컴포넌트에 뿌려주었다면, 새로운 방법은 각각의 컴포넌트에서 필요할 경우 useFieldArray로 새로운 fields를 만들어 주고 그 때 control은 context에서 관리하던 걸 가져와서 사용한다. 그러면 이 fields를 control로 관리할 수 있어서 우리가 원하는 대로 상태를 업데이트 해줄 수가 있게 된다.

AS-IS

const Depth2Component = ({childrenArray}) => { 

    return (
        <>
            {fields.map((field) => <Depth3Component />)}
        </>
    )
}

TO-BE

const Depth2Component = ({control, index}) => {
    const { fields: depth2Fields } = useFieldArray({
        control,
        name: `children.${index}.children`
    })
    return (
        <>
            {depth2Fields.map((field) => <Depth3Component />)}
        </>
    )
}

(디테일한 부분은 생략하였다.)

해결 3 : zod

react-hook-form과 zod를 같이 사용하면 form 형태의 UI에서 값들에 대한 유효성 검사를 쉽게 할 수가 있다. 자세한 사용 방법은 react-hook-form 문서 를 참고해 보면 좋을 것 같다.

일반적인 오브젝트의 경우 validation 처리를 문서를 보면서 하면 어렵지 않게 할 수 있지만, 중첩이 되기 시작하면서 이런 저런 삽질을 여러 차례 했던 것 같다.

Object 형태의 배열을 zod 스키마로 만들어 주기 위해서는 다음과 같이 array() 로 한 번 감싼 다음 그 안에 object를 감싸서 그 안에서 오브젝트의 프로퍼티 별 유효성 검사 체크를 해 주어야 한다.

const exampleFormSchema = zod.object({
    name: zod.string().trim(),
    depth: zod.number(),
    property: zod.string().trim(),
    children: zod.array(
        zod.object({
            name: zod.string().trim(),
            depth: zod.number(),
            property: zod.string().trim(),
            children: zod.array(
                zod.object({
                    name: zod.string().trim(),
                    depth: zod.number(),
                    property: zod.string().trim()
                })
            )
        })
    )
});

이런 식으로 해 주어야 하며, 조건을 넣거나 에러메시지도 넣을 수 있으므로 자세한 사용 방법은 위에 첨부한 문서를 보고 하면 어렵지 않게 할 수 있을 것이다.

해결 4 : immer로 불변성을 유지하기

상태를 바꿔주다 보면 불변성을 유지하기가 생각보다 쉽지가 않음을 느낀다. 이 부분에 대한 문제를 해결하기 위해 동료가 알려준 immer 라는 라이브러리를 도입해 보았다.

immer 라이브러리는 이뷰터블 자료구조를 핸들링하기 위해 나온 경량 라이브러리이다. 필요한 컨텍스트에 어디에서든지 사용이 가능하며 오브젝트에서 필요한 부분만 바꿔주면서 나머지 값들에 대해서는 불변성을 보장해 준다. 자세한 라이브러리에 대한 설명은 공식 문서 를 참고하기 바란다.

이 라이브러리를 도입해서 상태를 업데이트 해 주는 부분의 로직을 다음과 같이 개선해 보았다.

AS-IS

const updateSecondDepthObjectHandler = (firstIndex: number, secondIndex: number, value: string) => {
    const targetFirstObject = state[firstIndex];
    update(firstIndex, {
        ...targetFirstObject,
            children: [
                ...targetFirstObject.children.slice(0, secondIndex),
                {
                    ...targetFirstObject.children[secondIndex],
                    property: value
                }
                ...targetFirstObject.children.slice(secondIndex + 1)
            ]
        })
    }

TO-BE

const updateSecondDepthObjectHandler = (firstIndex: number, secondIndex: number, value: string) => {
    const newTargetSecondObject = produce(
        state[firstIndex],
        (draft) => {
            draft.children[secondIndex].property = value;
        }
    );

    update(firstIndex, newTargetSecondObject);
}

코드가 훨씬 더 간결해 졌다.

마무리

글로 적어서 별거 아니네… 라고 생각하시는 분들도 있겠지만 ㅎㅎ 이 작업을 하면서 많이 스트레스를 받았다. 일정도 타이트 했고, 잘 모르는 도구들을 갑자기 여러 개 쓰려 하니 배우고 적용하기가 마음처럼 잘 되지 않았던 적이 많았던 것 같다. 그럴 때 마다 동료들이 옆에서 많이 도와주고, 페어 프로그래밍 요청하면 항상 흔쾌히 도와주고 그런 부분들이 있어서 다행히 잘 마무리가 되었다. 이 자리를 빌어 동료들에게 감사를 표하고 싶다.

프론트엔드 개발자라면 Form은 아마 다룰 일이 많을 것 같고, 이렇게 중첩된 오브젝트 형태로 복잡한 상태를 관리할 일이 흔치는 않겠지만 ㅎㅎ 언제든지 마주할 수 있다고 생각한다. 예를 들어 구글 폼만 봐도 이중, 삼중으로 상태를 관리해 주어야 하는 부분이 보인다. 이 글이 그러한 분들에게 조금이나마 도움이 되기를 바라는 마음에서 글을 마무리 해 본다.