폼 리렌더링 최적화 (shadcn ui)폼 리렌더링 최적화 (shadcn ui)

폼 리렌더링 최적화 (shadcn ui)

2024-11-30

0

0

오늘의 개발 - 2024년 11월 30일

FEAT: 댓글에 아바타, 닉네임 변경 가능하도록 반영

🚀 오늘의 목표

댓글 프로필 이미지 변경 기능 구현
댓글 닉네임 변경 기능 구현
리렌더링 최적화

📝 개발 작업 내역

구현한 기능

  • 댓글 프로필 이미지 변경 기능 구현
  • 댓글 닉네임 변경 기능 구현
notion image

코드 변경 사항

/* 전체적인 코드를 shadcn 의 FormField 를 적용하여 리렌더링 최적화 */ <Form {...form}> <form onSubmit={handleSubmit(handleSubmitForm)} className="space-y-4 border-t pt-4" aria-label="댓글 등록"> <FormField control={form.control} name="text" render={({ field }) => ( <Textarea required disabled={isPending} placeholder={parentId ? '답글을 남겨주세요 👋🏻' : '댓글은 큰 도움이 됩니다 🙏'} {...field} /> )} /> </Form>
/* ImageElement 의 onLoad 이벤트를 사용한 인터렉션 구현 */ const handleAvatarChange = useCallback((setIsLoading: (isLoading: boolean) => void) => () => { const newAvatarUrl = getAvatarUrl(); setIsLoading(true); const img = new Image(); img.src = newAvatarUrl; img.onload = () => { setCurrentAvatar(newAvatarUrl); setIsLoading(false); }; }, []);
 

🐞 문제 및 해결 과정

1. 문제 상황

  • 문제 설명:
    • 폼 컴포넌트의 불필요한 리렌더링 문제와 부가적으로 발생하는 프로필 이미지 무한 변경 문제
      setState 를 사용한 Controlled 패턴으로 폼의 상태를 변경하도록 구현되니 값이 변경될 때 마다 해당 컴포넌트에 포함된 모든 컴포넌트가 리렌더링 되는 문제 발생
      랜덤 아바타의 URL 만 변경하면 다음 이미지가 로드되기까지 렌더링이 되지 않아 UX 저하 문제 발생
  • 발생 위치: CommentForm 컴포넌트
  • 에러 메시지/스크린샷: 이미 다 고쳐서 없다.. 흑흑

2. 문제 해결 과정

아바타 URL 에 useMemo 를 통한 메모이제이션 적용
단순히 URL 을 메모이제이션 한다면 컴포넌트가 리렌더링 되어도 아바타가 무작위로 변경되는 일은 발생하지 않을 것이라 생각하였지만 이미지를 포함한 컴포넌트가 꾸준히 리렌더링 되니 결국 오래봤을 때 타이핑으로 인한 성능 저하가 발생할거라 예상했다. 🤔
shadcn ui 의 FormField 를 사용하여 단순 아바타 이미지뿐만 아닌 다른 컴포넌트의 리렌더링까지 최적화
애초에 닉네임 값만 변경하는데 모든 컴포넌트가 리렌더링 된다는게 굉장히 말이 안되긴 했다. 따라서 shadcn 에서 제공하는 Form 컴포넌트와 zod, react-hook-form 을 사용하여 스키마까지 적용해서 조금이라도 선언적 프로그래밍을 해보았다.
Image 객체의 onload 를 이용해 브라우저에 이미지를 미리 로드해 교체하는 방식을 적용
  • 최종 해결 방법:
zod 스키마 적용으로 인한 선언형 프로그래밍
const form = useForm<InferZodType<typeof commentFormSchema>>({ resolver: zodResolver(commentFormSchema), defaultValues: { text: '', author: '', }, }); type InferZodType<T extends z.ZodType> = z.infer<T>;
모든 컴포넌트가 입력값이 변경될 때 마다 리렌더링 되는 상태 →
상태가 변경되는 컴포넌트만 리렌더링 될 수 있게 최적화
/* FormField 의 메모이제이션과 컨텍스트를 사용한 리렌더링 최적화 */ <FormField control={form.control} name="text" render={({ field }) => ( <Textarea required disabled={isPending} placeholder={parentId ? '답글을 남겨주세요 👋🏻' : '댓글은 큰 도움이 됩니다 🙏'} {...field} /> )} />
하지만 해당 방법으로 인하여 코드가 굉장히 비대해졌다. 이러한 부분이 가독성 측면에서 폼 값이 열개가 넘어가는 상황에서 FormField 를 사용하면 어떻게 관리할 수 있을지.. 고민을 해봐야 할 것 같다.
프로필 이미지를 변경하면 마찬가지로 setCurrentAvataUrl 이 실행되면서 리렌더링 되기 때문에 그 외의 컴포넌트가 모두 리렌더링 된다. 이러한 부분을 고려하기 위해서 폼부분을 또 useMemo 를 통해 랩핑하여 삽입해야 하나 했지만 이부분은 가독성 측면에서 어떻게 핸들링 해야하는지 고민이 되어서 일단 보류하는쪽으로 결정했다.
 

3. 학습한 내용

shadcn 의 폼 컴포넌트는 react-hook-form 의 Controller 를 랩핑해서 사용하는데, 내부적으로 이런식의 코드로 메모이제이션과, 필드 하나당 하나의 프로바이더를 사용하여 컨텍스트 범위를 최소화 하여 리렌더링을 최소화한다.
const FormControl = React.forwardRef(({ ...props }, ref) => { return React.useMemo( () => <Slot {...props} ref={ref} />, [props, ref] ) }) const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> >({ ...props }: ControllerProps<TFieldValues, TName>) => { return ( <FormFieldContext.Provider value={{ name: props.name }}> <Controller {...props} /> </FormFieldContext.Provider> ) }
폼 값이 변경될 때마다 모든 내부 컴포넌트가 리렌더링 되는것이 당연하다 생각했는데, 너무 놀라운 구현방식이다.
헤드리스 UI 라이브러리를 공부해보면 알 수 있는 것들이 많을 것 같다.
 
그리고 지금까지 이미지를 단순히 출력하는쪽으로만 시도를 많이 했었는데, 애니메이션을 적용해보면서 이벤트를 활용한 요구사항을 구현 할 수 있다는것도 깨달았다. 정말 그냥 라이브러리 사용하는 정도로만 개발을 해왔구나 싶기도 하지만.. 이제라도 알았으니 🫠
// 1. 메모리에 이미지 객체 생성 const img = new Image(); img.src = newAvatarUrl; // 2. 브라우저가 이 URL의 이미지를 다운로드하고 캐시에 저장 // 3. 다운로드 완료되면 onload 이벤트 발생 img.onload = () => { // 4. 실제 DOM의 이미지 src 업데이트 setCurrentAvatar(newAvatarUrl); // 이미 캐시된 이미지를 사용하므로 즉시 표시 }

🌟 오늘의 성과

  1. 단순히 프로필 이미지 변경 기능만 만들었다면 모바일로 접근하는 사용자는 해당 기능의 출시를 모를 수 있을거라 생각해서 안내 문구를 추가했다. 조금이라도 UX 를 챙기지 않았나? 싶다.
  1. 이미지를 계속 변경하는 상황에 있어서 쓰로틀을 적용해서 빠른 클릭을 방지하도록 구현했다. 지금은 단순히 UX 개선을 위한 고민이었지만 이러한 영향범위를 꾸준히 챙긴다면 장애를 사전에 예방할 수 있지 않을까 하는 생각이 있기도 하다.
  1. 각각의 역할에 맞도록 코드를 배치하려고 노력한 것 같다. 이전에는 무작정 Props 로 내리거나 해서 최상단에 로직이 뭉쳐져 있어서 발생하는 가독성문제나 리렌더링 문제가 많았었다.
이번엔 커링을 이용하여 아바타 이미지의 다음 랜덤 아바타를 가져오는 로딩상태를 상단에 두지 않고 하위 컴포넌트 (아바타 컴포넌트)에 둬서 상위 컴포넌트의 상태관리를 좀 고민해보았다. 폼을 제출하기 위한 상태값만 상단에 두고 부가적인 상태는 필요한 곳에만 둬서 PropsDrilling 을 줄이는쪽으로 핸들링 해봤는데, 이것도 로딩 상태가 폼 제출에 필요하다면 고민해볼 문제이긴 한 것 같다. (정해진 답이 없다 ㅜ.ㅜ.)
// 상위 컴포넌트 const handleAvatarChange = useCallback((setIsLoading: (isLoading: boolean) => void) => () => { const newAvatarUrl = getAvatarUrl(); setIsLoading(true); const img = new Image(); img.src = newAvatarUrl; img.onload = () => { setCurrentAvatar(newAvatarUrl); setIsLoading(false); }; }, []); // 하위 컴포넌트 const CommentAvatar = memo(({ handleAvatarChange, currentAvatar }: CommentAvatarProps) => { const [isLoading, setIsLoading] = useState(false); const throttledHandleAvatarChange = useThrottle(handleAvatarChange(setIsLoading), 1500);
 

🔨기술부채 정리

노션 타입 정의
댓글 API 성능 개선

📅 다음 작업 계획

게시물 시리즈 기능 구현
DOM API 동작

💡 추가 메모

 
 

BRIN</>E

© 2025