리액트 디자인 패턴을 이용한 재사용성, 유지보수 향상!
EditorTextArea 씹뜯맛즐
EditorTextArea 뜯어고치기0 - Intro
현재의 EditorTextArea는 문제가 많습니다,,,(다 내잘못)
??? : 누가 코드 발로 짬? (미래의 나)
현재 구조에서는 3가지 케이스의 EditorTextArea
를 사용하는 곳에서 Props
를 넘겨주면 그 Props
를 useEditorLogicByProps
가 판별하여 어떤 로직을 사용할지 정해주는 분기처리를 해줍니다.
그 당시에 생각으로는 ‘이렇게 하면’ 사용하는 곳에서 EditorTextArea
를 호출하기만 하면 되니깐 편리하게 사용할 수 있지 않을까? 라고 생각했지만 아주 크나큰 오산이었습니다.
먼저 지금 구조의 문제점을 알아보기 위해서EditorTextArea
와 useEditorLogicByProps
의 코드를 살펴보겠습니다.
// 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개의 문제가 있었습니다.
-
Props의 타입으로 판별하여 로직을 분기처리하니 사용하는 쪽에서는 어떤 Props를 넣어야 할지 모른다.
⇒ 타입으로 알려주지 않음..
-
필요한 Props가 변경될 때 사용하는 곳,
EditorTextArea
,useEdiorLogicByProps
,실제 로직 모두를 보고 변경해야 한다⇒ 유지보수가 불편하고 확장하기 어렵다.
-
현재
SubmitArea
부분을 분기처리로 하고 있지만 달라지는 View가 또 생긴다면EditorTextArea
내에 분기처리가 늘어난다.⇒ 가독성이 떨어지고, 유지보수가 어렵다.
이러한 문제점을 해결하기 위해 뷰는 재사용하고 로직을 외부에서 주입하도록 Container Presenter
패턴을 적용해보았습니다.
EditorTextArea 뜯어고치기1 - Container presenter 패턴으로 로직분리하여 뷰 재사용하기 ⭐️⭐️⭐️
먼저 Container-Presenter
패턴을 도입하기 하는것을 가로막는 존재가 있었습니다.
바로 mentionList
였습니다.
멘션 유저들에게 알람을 보내기 위해 사용하는 커스텀 훅인 useMentionNorification
은 mentionList
를 Props로 받습니다.
// Create Thread 로직
const useCreateThread = ({ nickname, channelId, mentionedList }: Props) => {
const { mutateAsync: createThreadMutate } = usePostThread(channelId);
const { mentionNotification } = useMentionNotification({ mentionedList });
...
현재의 구조에서는 MentionList
와 TextArea
가 EditorTextArea
내에 있고 여기서 로직을 호출 하기 때문에 아무런 문제가 없었지만
Container-Presenter
패턴으로 변경해 로직을 EditorTextArea
외부에서 주입한다면 주입 시점에 MentionList
를 알 수 없습니다.
이를 위해 먼저 mentionList
를 Props로 넘겨 받아서 useMentionNotification
을 선언하는 시점에 정해지는 것이 아닌 반환 로직인 mentionNotification
를 사용할때 mentionList
를 주입하는 식으로 변경해야 했습니다.
먼저 useMentnionNotifictaion
에서 Props로 받아오던 mentionList
를 mutate
의 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번 문제가 해결 되었습니다.
-
Props의 타입으로 판별하여 로직을 분기처리하니 사용하는 쪽에서는 어떤 Props를 넣어야 할지 모른다.
⇒ 타입으로 알려주지 않음..
⇒ 사용하는 곳에서 각각 다른 컴포넌트를 호출함으로써 해결
-
필요한 Props가 변경될 때
사용하는 곳
,EditorTextArea
,useEdiorLogicByProps
,실제 로직 커스텀 훅
모두를 보고 변경해야 한다⇒ 유지보수가 불편하고 확장하기 어렵다.
⇒ Props가 변경될 경우
Container
와실제 로직 커스텀 훅
두곳에서만의 수정으로 유지보수, 확장이 용이해짐
하지만 아직 3번 문제가 해결되지 않았으니 Compound-Component패턴
과 Render-Props패턴
을 이용해서 조금 더 고쳐보겠습니다.
EditorTextArea 뜯어고치기2 - Compound Component패턴으로 재사용성 향상 시키기 ⭐️⭐️⭐️⭐️
EditorTextArea
를 3개의 container
로 분리하면서 로직과 뷰를 분리하였고 뷰만 재사용하게 되었습니다. 이를 통해 좀 더 깔끔하고 유지보수가 편리해졌지만 아직 만족스럽지 않습니다.
EditorTextArea
에 뷰는 사용하는 곳에 따라 2가지 요소가 달라집니다.
mentionInput
의 유무textArea
의submitArea
을 뷰
이를 기존에는 EditorTextArea
에서 분기처리로 mentionInput
과 submitArea
를 처리해줘서 변경에 유연하지 않으며 재사용성이 떨어졌습니다.
이를 react의 compound components
패턴을 이용하여 리팩토링 할 것 입니다.
이를 통해 사용하는 쪽에서 mentionInput
의 유무와 TextArea
의 sumbitArea
뷰를 선언적으로 사용할 수 있습니다.
기능을 뺀 구조는 다음과 같습니다.
변경된 구조
-
EditorTextArea.tsx
- mentionInput
- TextArea
- CreateSubmit
- PatchSubmit
이때 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;
이제 Mention
과 TextArea
에서 각각 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
패턴을 사용하면 깔끔한 구조로 해결 할 수 있을 것 같습니다.
[문제점]
-
contextAPI 중첩
mentionInput과 textArea가 mentionList 상태값을 공유하기 위해 contextAPI를 사용했는데 TextArea 내에서 submitArea를 처리하기 위해 또 다시 handleSubmit, getValues를 공유해야함. contextAPI를 한 번 더 사용하는 건 좋은 방법이 아닌 것 같아서 다른 방법 찾는 중
EditorTextArea 뜯어고치기3 - Render Props 패턴으로 로직 재사용하기 ⭐️⭐️⭐️⭐️
합성 컴포넌트로 구현할 때 문제점이 되었던 이유는 textArea
내에서 submitArea
를 합성 할 때 textArea
의 handleSubmit
과 getValues
를 필요로 한다는 것이었습니다.
이를 위해 React.cloneElement
을 통해 props를 넘겨줄 수도 있지만 이는 react 공식문서에서 지양하고 있었습니다.
또한 사용하는 로직은 동일하지만 뷰만 달라진다는 것이었습니다.
수정시에는 취소, 확인
생성시에는 확인
이를 container-presenter
패턴의 반대 개념인 render-prop
패턴을 이용해서 리팩토링하였습니다.
부모에서 선언할 때 자식한테 있는거를 자손에게 넘겨주고 싶다면 render props 패턴을!
이를 통해 사용하는 쪽에서 선언적으로 사용할 수 있고 재사용이 편리해졌으며 다른 뷰에 동일한 로직을 넣는 방식이 가능해졌습니다.
완성된 구조는 다음과 같습니다.
좀 쓸만해 졌는데?
[남아있는 문제점]
- mention 유무가 단 한가지 케이스에서만 사용되기 때문에 합성컴포넌트로 만든 것이 오버엔지니어링 같다는 생각이 듭니다. (이 케이스를 위해서 결국에는 Props로 유무를 판별해줘야합니다.)
하지만 다른 곳에서 에디터를 사용시 멘션 유무가 달라지는 경우를 커버할 수 있으니 문제점인지는 앞으로의 기획에 따라 정해질 것 같습니다.
댓글남기기