EditorTextArea 씹뜯맛즐

EditorTextArea 뜯어고치기0 - Intro

현재의 EditorTextArea는 문제가 많습니다,,,(다 내잘못)

??? : 누가 코드 발로 짬? (미래의 나) ??? : 누가 코드 발로 짬? (미래의 나)


현재 구조에서는 3가지 케이스의 EditorTextArea를 사용하는 곳에서 Props를 넘겨주면 그 PropsuseEditorLogicByProps가 판별하여 어떤 로직을 사용할지 정해주는 분기처리를 해줍니다.

그 당시에 생각으로는 ‘이렇게 하면’ 사용하는 곳에서 EditorTextArea를 호출하기만 하면 되니깐 편리하게 사용할 수 있지 않을까? 라고 생각했지만 아주 크나큰 오산이었습니다.

먼저 지금 구조의 문제점을 알아보기 위해서EditorTextAreauseEditorLogicByProps의 코드를 살펴보겠습니다.

// EditorTextArea
const EditorTextArea = ({isMention, nickname, editorProps, onEditClose, authorNickName} : Props) => {
    ...
  const { upload } = useEditorLogicByProps({ ... });

  const handleUpload = (formValues: FormValues) => {
    ...
    upload(formValues);

    ...
  };

  return (
    <form className="relative">
      <Textarea
        placeholder={user ? `내용을 작성해주세요.` : "로그인이 필요합니다."}
        className="resize-none overflow-hidden pr-200pxr text-base text-content-5"
        {...register("content")}
        onKeyDown={handleKeydown}
      />
      <div className="absolute bottom-2 right-2 flex items-center gap-2">
        <label className="flex cursor-pointer items-center gap-2 rounded-xl border border-layer-5 p-3 hover:bg-layer-2">
          <input type="checkbox" {...register("anonymous")} onClick={handleClickCheckBox} />
          <p className="text-content-4">익명</p>
        </label>
        {onEditClose ? (
          // 수정에 띄우는 submitArea
          ...
        ) : (
          // 생성에 띄우는 submitArea
          ...
        )}
      </div>
    </form>
  )
}

현재 EditorTextArea는 3가지의 Editor역할을 합니다.

  • Create Thread
  • Patch Thread
  • Create Comment

각각의 역할은 비슷한 View를 가지고있지만 로직은 완전히 다릅니다.

따라서 로직을 처리하기 위해서 useEditorLogicByProps 를 만들었습니다.

// useEditorLogicByProps

// 각 로직마다 필요한 값들
interface CreateThreadProps {
  channelId: string;
}

interface PatchThreadProps {
  prevContent: string;
  postId: string;
  channelId: string;
}

interface CommentProps {
  channelId: string;
  channelName: string;
  postId: string;
  postAuthorId: string;
}

export type EditorProps = CreateThreadProps | PatchThreadProps | CommentProps;

// Props에 따라서 해당 로직을 반환해주기 위해 판별하는 커스텀 타입가드
const isPatchThreadProps = (props: EditorProps): props is PatchThreadProps => {
  return "prevContent" in props;
};

const isCommentProps = (props: EditorProps): props is CommentProps => {
  return "channelName" in props;
};

const useEditorLogicByProps = ({
  editorProps,
  nickname,
  mentionedList,
}: Props) => {
  const [upload, setUpload] = useState<UploadHooksProps>(() => () => {});

  const { uploadThread } = useCreateThread({...});

  const { changeThread } = useChangeThread({...});

  const { uploadComment } = useUploadComment({...});

  useEffect(() => {
    if (isPatchThreadProps(editorProps)) {
      setUpload(() => changeThread);
      return;
    }
    if (isCommentProps(editorProps)) {
      setUpload(() => uploadComment);
      return;
    }

    setUpload(() => uploadThread);
  }, [editorProps, mentionedList]);

  return { upload };
};

useEditorLogicByProps가 하는 일을 간략하게 설명하자면 Editor를 사용하는 곳에서 Props로 어떤 값을 넘겨주냐에 따라서 해당하는 로직을 반환해 줍니다.

  판별 기준 Props 반환 로직
Create Thread 아래 두개가 없는 경우 useCreateThread
Patch Thread prevContent useChangeThread
Create Comment channelName useUploadComment

이로 인해 사용하는 곳에서는 필요한 Props만 넣어주면 나머지 알아서 연결이 될 것이고, EditorTextArea에서는 View를 useEditorLogicByProps에서는 Logic을 담당하니 분리가 잘 된것 아닐까! 하고 뿌듯해 했었죠… (크게 잘못된 생각)

이러한 구조에서는 크게 3개의 문제가 있었습니다.

  1. Props의 타입으로 판별하여 로직을 분기처리하니 사용하는 쪽에서는 어떤 Props를 넣어야 할지 모른다.

    ⇒ 타입으로 알려주지 않음..

  2. 필요한 Props가 변경될 때 사용하는 곳, EditorTextArea, useEdiorLogicByProps,실제 로직 모두를 보고 변경해야 한다

    ⇒ 유지보수가 불편하고 확장하기 어렵다.

  3. 현재 SubmitArea부분을 분기처리로 하고 있지만 달라지는 View가 또 생긴다면 EditorTextArea 내에 분기처리가 늘어난다.

    ⇒ 가독성이 떨어지고, 유지보수가 어렵다.

이러한 문제점을 해결하기 위해 뷰는 재사용하고 로직을 외부에서 주입하도록 Container Presenter 패턴을 적용해보았습니다.

EditorTextArea 뜯어고치기1 - Container presenter 패턴으로 로직분리하여 뷰 재사용하기 ⭐️⭐️⭐️

먼저 Container-Presenter패턴을 도입하기 하는것을 가로막는 존재가 있었습니다.

바로 mentionList였습니다.

멘션 유저들에게 알람을 보내기 위해 사용하는 커스텀 훅인 useMentionNorificationmentionList를 Props로 받습니다.

// Create Thread 로직
const useCreateThread = ({ nickname, channelId, mentionedList }: Props) => {
  const { mutateAsync: createThreadMutate } = usePostThread(channelId);
  const { mentionNotification } = useMentionNotification({ mentionedList });
	...

현재의 구조에서는 MentionListTextAreaEditorTextArea내에 있고 여기서 로직을 호출 하기 때문에 아무런 문제가 없었지만

Container-Presenter패턴으로 변경해 로직을 EditorTextArea 외부에서 주입한다면 주입 시점에 MentionList를 알 수 없습니다.

이를 위해 먼저 mentionList를 Props로 넘겨 받아서 useMentionNotification을 선언하는 시점에 정해지는 것이 아닌 반환 로직인 mentionNotification 를 사용할때 mentionList를 주입하는 식으로 변경해야 했습니다.

먼저 useMentnionNotifictaion에서 Props로 받아오던 mentionListmutate의 Props로 변경했습니다.

// 이전 코드
const useMentionNotification = ({ mentionedList }: Props) => {
  const { mutateAsync: mentionMutate } = usePostMention();
  const { mutate: notificationMutate } = usePostNotification();

  const mentionNotification = ({ content, postId, channelName }: MentionNotificationProps) => {
  if (!mentionedList) return;

	mentionedList.forEach(async (mentionUser) => {...})

	}
	return {mentionNotification}
}

// 이후 코드

const useMentionNotification = () => {
  const { mutateAsync: mentionMutate } = usePostMention();
  const { mutate: notificationMutate } = usePostNotification();

  const mentionNotification = ({
    mentionedList,
    content,
    postId,
    channelName,
  }: MentionNotificationProps) => {
    if (!mentionedList) return;

    mentionedList.forEach(async (mentionUser) => {...});
  };

  return { mentionNotification };
};

덕분에 사용하는 곳에서도 선언하는 시점이 아닌 사용하는 시점에 mentionList를 넣도록 변경되었습니다.

// 이전 코드
const EditorTextArea = (...) => {
	...
	const { upload } = useEditorLogicByProps({
	    editorProps,
	    nickname: user?.nickname || nickname,
	    mentionedList: mentionedList.length ? mentionedList : undefined,
	  });
	...

	const handleUpload = (formValues: FormValues) => {
	...
	upload(formValues);
	}
	...
}

// 이후 코드
const EditorTextArea = (...) => {
	...
	const { upload } = useEditorLogicByProps({
	    editorProps,
	    nickname: user?.nickname || nickname,
	  });
	...

	const handleUpload = (formValues: FormValues) => {
	...
	upload({formValues, mentionedList});
	}
	...
}

이제 Container-Presenter패턴을 사용하기 위한 준비가 끝났습니다.

먼저 useEditorLogicByProps 로 내부에서 사용하던 upload 핸들러를 외부에서 props로 넘겨주도록 변경합니다.


// 이후 코드
const EditorTextArea = ({onSubmit, ...props}) => {
	...
	// useEditorLogicByProps 삭제

	const handleUpload = (formValues: FormValues) => {
	...
	onSubmit({formValues, mentionedList});
	}
	...
}

그리고 각각의 container를 만들어서 onSubmit을 주입해 줍니다.

// CreateThreadContainer
const CreateThreadContainer = (createThreadProps: Props) => {
  const { uploadThread } = useCreateThread({
    nickname: createThreadProps.nickname,
    channelId: createThreadProps.channelId,
  });
  const handleSubmit = (params: FormSubmitProps) => {
    uploadThread(params);
  };

  return (
    <EditorTextAreaPresentational
      {...createThreadProps}
      onSubmit={handleSubmit}
    />
  );
};

export default CreateThreadContainer;

// PatchThreadContainer
const PatchThreadContainer = (patchThreadProps: Props) => {
  const { changeThread } = useChangeThread({
    nickname: patchThreadProps.nickname,
    postId: patchThreadProps.postId,
    channelId: patchThreadProps.channelId,
  });
  const handleSubmit = (params: FormSubmitProps) => {
    changeThread(params);
  };

  return (
    <EditorTextAreaPresentational
      {...patchThreadProps}
      onSubmit={handleSubmit}
    />
  );
};

export default PatchThreadContainer;

// CreateCommentContainer
const CreateCommentContainer = (createCommentProps: Props) => {
  const { uploadComment } = useUploadComment({
    nickname: createCommentProps.nickname,
    postId: createCommentProps.postId,
    channelId: createCommentProps.channelId,
    channelName: createCommentProps.channelName,
    postAuthorId: createCommentProps.postAuthorId,
  });
  const handleSubmit = (params: FormSubmitProps) => {
    uploadComment(params);
  };

  return (
    <EditorTextAreaPresentational
      {...createCommentProps}
      onSubmit={handleSubmit}
    />
  );
};

export default CreateCommentContainer;

이렇게 로직은 container에서 주입하고, 뷰는 EditorTextAreaPresentational로 재사용하게 되었습니다.

변경 후 구조

??? : 손으로 짜긴 했지만 좀만 더 노력해보지..?

??? : 손으로 짜긴 했지만 좀만 더 노력해보지..?

이로써 Intro에 있는 1, 2번 문제가 해결 되었습니다.

  1. Props의 타입으로 판별하여 로직을 분기처리하니 사용하는 쪽에서는 어떤 Props를 넣어야 할지 모른다.

    ⇒ 타입으로 알려주지 않음..

    ⇒ 사용하는 곳에서 각각 다른 컴포넌트를 호출함으로써 해결

  2. 필요한 Props가 변경될 때 사용하는 곳, EditorTextArea, useEdiorLogicByProps,실제 로직 커스텀 훅 모두를 보고 변경해야 한다

    ⇒ 유지보수가 불편하고 확장하기 어렵다.

    ⇒ Props가 변경될 경우 Container실제 로직 커스텀 훅 두곳에서만의 수정으로 유지보수, 확장이 용이해짐

하지만 아직 3번 문제가 해결되지 않았으니 Compound-Component패턴Render-Props패턴을 이용해서 조금 더 고쳐보겠습니다.

EditorTextArea 뜯어고치기2 - Compound Component패턴으로 재사용성 향상 시키기 ⭐️⭐️⭐️⭐️

EditorTextArea를 3개의 container로 분리하면서 로직과 뷰를 분리하였고 뷰만 재사용하게 되었습니다. 이를 통해 좀 더 깔끔하고 유지보수가 편리해졌지만 아직 만족스럽지 않습니다.

EditorTextArea에 뷰는 사용하는 곳에 따라 2가지 요소가 달라집니다.

  1. mentionInput의 유무
  2. textAreasubmitArea을 뷰

이를 기존에는 EditorTextArea에서 분기처리로 mentionInputsubmitArea를 처리해줘서 변경에 유연하지 않으며 재사용성이 떨어졌습니다.

이를 react의 compound components 패턴을 이용하여 리팩토링 할 것 입니다.

이를 통해 사용하는 쪽에서 mentionInput의 유무와 TextAreasumbitArea뷰를 선언적으로 사용할 수 있습니다.

기능을 뺀 구조는 다음과 같습니다.

변경된 구조

  • EditorTextArea.tsx

    • mentionInput
    • TextArea
      • CreateSubmit
      • PatchSubmit

Untitled

이때 mentionList를 결정하는 곳은 Mention이지만 전송하는 곳은 TextArea입니다.

즉, Mention에서 변경된 mentionList에 대한 정보를 TextArea도 가져야 하기때문에 둘은 mentionList라는 공통의state를 가지고 있습니다.

공통된 state를 관리하기 위해서 EditorTextArea에서 contextAPI를 사용해 상태관리를 할 수 있는 Provider로 만들어줍니다.

// EditorTextAreaProvider
interface Props {
  children?: ReactNode;
}
export const EditorContext = createContext<{
  mentionedList: UserDBProps[];
  setMentionedList: Dispatch<SetStateAction<UserDBProps[]>>;
}>({ mentionedList: [], setMentionedList: () => {} });

const EditorContextProvider = ({ children }: Props) => {
  const [mentionedList, setMentionedList] = useState<Array<UserDBProps>>([]);
  const providerValue = { mentionedList, setMentionedList };

  return (
    <EditorContext.Provider value={providerValue}>
      {children}
    </EditorContext.Provider>
  );
};

EditorContextProvider.Mention = MentionInput;
EditorContextProvider.TextArea = ContentTextArea;

export default EditorContextProvider;

이제 MentionTextArea에서 각각 mentionList라는 state를 사용할 수 있습니다.

// MentionInput
const MentionInput = () => {
  const { mentionedList, setMentionedList } = useContext(EditorContext);
	...
}

// TextArea
const ContentTextArea = (props: Props) => {
  const { mentionedList, setMentionedList } = useContext(EditorContext);
	...
}

하지만 한가지 문제가 남았습니다.

CreateThread의 경우와 PatchThread의 경우를 보면 TextArea내에있는 동일한 로직으로 submit을 하지만 뷰가 달라집니다.

물론 contextAPI를 중첩으로 사용해서 한번 더 Provier로 만들어 sumbit을 내려줘도 해결할 수 있지만 조금 더 생각해보면 Container-Presenter 방식과는 반대로 ‘뷰’는 다르지만 ‘로직’은 공통인 상황입니다. 이러한 경우에 사용할 수 있는 react 패턴 중 Render Props 패턴을 사용하면 깔끔한 구조로 해결 할 수 있을 것 같습니다.

[문제점]

  1. contextAPI 중첩

    mentionInput과 textArea가 mentionList 상태값을 공유하기 위해 contextAPI를 사용했는데 TextArea 내에서 submitArea를 처리하기 위해 또 다시 handleSubmit, getValues를 공유해야함. contextAPI를 한 번 더 사용하는 건 좋은 방법이 아닌 것 같아서 다른 방법 찾는 중

EditorTextArea 뜯어고치기3 - Render Props 패턴으로 로직 재사용하기 ⭐️⭐️⭐️⭐️

합성 컴포넌트로 구현할 때 문제점이 되었던 이유는 textArea 내에서 submitArea를 합성 할 때 textAreahandleSubmitgetValues를 필요로 한다는 것이었습니다.

이를 위해 React.cloneElement을 통해 props를 넘겨줄 수도 있지만 이는 react 공식문서에서 지양하고 있었습니다.

또한 사용하는 로직은 동일하지만 뷰만 달라진다는 것이었습니다.

Untitled

수정시에는 취소, 확인

Untitled

생성시에는 확인

이를 container-presenter 패턴의 반대 개념인 render-prop 패턴을 이용해서 리팩토링하였습니다.

부모에서 선언할 때 자식한테 있는거를 자손에게 넘겨주고 싶다면 render props 패턴을!

부모에서 선언할 때 자식한테 있는거를 자손에게 넘겨주고 싶다면 render props 패턴을!

이를 통해 사용하는 쪽에서 선언적으로 사용할 수 있고 재사용이 편리해졌으며 다른 뷰에 동일한 로직을 넣는 방식이 가능해졌습니다.

완성된 구조는 다음과 같습니다.

좀 쓸만해 졌는데?

좀 쓸만해 졌는데?

[남아있는 문제점]

  1. mention 유무가 단 한가지 케이스에서만 사용되기 때문에 합성컴포넌트로 만든 것이 오버엔지니어링 같다는 생각이 듭니다. (이 케이스를 위해서 결국에는 Props로 유무를 판별해줘야합니다.)

하지만 다른 곳에서 에디터를 사용시 멘션 유무가 달라지는 경우를 커버할 수 있으니 문제점인지는 앞으로의 기획에 따라 정해질 것 같습니다.

댓글남기기