오픈소스 댓글 시스템에서 자체 댓글 시스템으로 전환하면서 댓글 컴포넌트를 만들었는데요, 어떤 기준으로 컴포넌트를 나눌 것인가에 대한 고민보다는 구현에만 집중하다 보니 재사용성을 고민한 흔적을 코드에서 찾아볼 수 없었습니다. 이번 글에서는 재사용 불가능한 코드를 재사용 가능한 컴포넌트 단위로 나누고 좀 더 읽기 편한 게 코드를 정리 한 과정에 대해 공유합니다.


최초 구현

 컴포넌트 트리는 중첩 댓글의 구조 때문에 list 컴포넌트item 컴포넌트에서 다시 순회하는 트리를 갖는데요, 우선 개별 댓글 컴포넌트 <Comment />와 댓글 작성을 담당하는 <CommentForm />부터 살펴보겠습니다.

컴포넌트 트리 보기

댓글의 댓글 같은 중첩 댓글이 있을 경우 list 컴포넌트를 다시 순회하는 구조

댓글 컴포넌트 트리

1.2 댓글 컴포넌트 트리

아래 UI를 바탕으로 작성한 최초 코드는 다음과 같습니다.


댓글 컴포넌트

1.1 블로그 댓글란


먼저 댓글 컴포넌트인데요, 다소 길어 읽기 불편하더라도 개선이 필요한 부분들을 공유하고자 코드 간소화는 최소한으로 했습니다.

댓글 인터페이스 보기
types.ts
1interface Comment {
2  id: string;
3  body: string | null;
4  userId: string;
5  postId: string;
6  user: User;
7  parentId: string | null;
8  children: Comment[];
9  depth: number;
10  isDeleted: boolean;
11  createdAt: Date;
12  updatedAt: Date;
13  deletedAt: Date | null;
14}
15
16interface User {
17  id: string;
18  name: string | null;
19  email: string | null;
20  emailVerified: Date | null;
21  image: string | null;
22}
Comment.tsx
1const Comment = ({ comments }: { comments: CommentProps }) => {
2  const [isEditMode, setIsEditMode] = useState(false);
3  const [isReplyMode, setIsReplyMode] = useState(false);
4  const [isCommentOptionsVisible, setIsCommentOptionsVisible] = useState(false);
5
6  const { onDeleteComment, isDeleting } = useDeleteComment(comments);
7
8  return (
9    <S.Container parentId={comments?.parentId} depth={comments.depth}>
10      <div>
11        <S.Avatar src={assets.avatar} />
12        <div>
13          <div>
14            <div>
15              <S.User>{comments.user.name}</S.User>
16              <S.PublishDate>{comments.createdAt}</S.PublishDate>
17            </div>
18            <S.OptionsButton onClick={}>
19              <img src={assets.options} />
20            </S.OptionsButton>
21            {isCommentOptionsVisible && <CommentOptions {...optionProps} />}
22          </div>
23          {isEditMode ? ( // 편집 모드 일때
24            <CommentForm
25              isEditMode={isEditMode}
26              setIsEditMode={setIsEditMode}
27              comments={comments}
28              type="edit"
29            />
30          ) : (
31            <S.Body isDeleted={comments.isDeleted}>
32              {comments.isDeleted ? '삭제된 댓글입니다.' : comments.body}
33            </S.Body>
34          )}
35          {isReplyMode && ( // 답글 모드 일때
36            <CommentForm
37              isReplyMode={isReplyMode}
38              setIsReplyMode={setIsReplyMode}
39              comments={comments}
40              type="new_comment"
41            />
42          )}
43        </div>
44      </div>
45      {comments.children.length > 0 && (
46        <CommentList comments={comments.children} />
47      )}
48    </S.Container>
49  );
50};

<Comment /> 컴포넌트에서는 댓글의 본문과 작성자 정보를 보여주고 mode 상태에(답글 또는 편집) 따라 다른 상태값을 <CommentForm />에 내려주고 있어요. 부모에서 전달되는 댓글의 mode 상태에 따라 버튼의 텍스트, 이벤트 처리 로직 등을 처리하고요. 리팩토링하기 전 기존 코드의 개선점을 정리해 보면,

다음은 댓글을 작성하고 편집하는 form 컴포넌트입니다.

CommentForm.tsx
1interface CommentFormProps {
2  isReplyMode?: boolean;
3  isEditMode?: boolean;
4  setIsEditMode?: Dispatch<SetStateAction<boolean>>;
5  setIsReplyMode?: Dispatch<SetStateAction<boolean>>;
6  comments?: Comment;
7  type: 'new_comment' | 'edit';
8}
9
10const CommentForm = ({
11  isReplyMode,
12  isEditMode,
13  setIsEditMode = () => {},
14  setIsReplyMode = () => {},
15  comments,
16  type,
17}: CommentFormProps) => {
18  const { modalConfig: login, showModal } = useModal();
19  const { data: session } = useSession();
20
21  // 댓글 작성 로직
22  const { comment, handleComment, onCreateComment, isSubmitting } =
23    useCreateComment(comments, setIsReplyMode, type, isReplyMode);
24  // 댓글 편집 로직
25  const { edittedComment, handleEditComment, onEditComment, isSubmittingEdit } =
26    useEditComment(comments, setIsEditMode);
27
28  return (
29    <div>
30      <form
31        // 이벤트 처리 로직
32        onSubmit={e => {
33          e.preventDefault();
34          session
35            ? type === 'new_comment'
36              ? onCreateComment()
37              : onEditComment()
38            : showModal('login');
39        }}
40      >
41        {type === 'new_comment' && ( // 댓글 작성 또는 답글 작성일 때
42          <S.Input
43            // ..
44            placeholder={
45              session
46                ? isReplyMode
47                  ? '답글 작성하기 ..'
48                  : '댓글 작성하기 ..'
49                : '로그인하고 댓글 작성하기'
50            }
51            disabled={!session}
52            autoFocus={isReplyMode}
53          />
54        )}
55        {type === 'edit' && ( // 댓글 편집일 때
56          <S.EditInput
57            // ..
58            autoFocus={isEditMode}
59          />
60        )}
61        <div>
62          {(isEditMode || isReplyMode) && <button onClick={}>취소</button>}
63          <button type="submit" disabled={isSubmitting || isSubmittingEdit}>
64            {session
65              ? isReplyMode
66                ? '답글 작성'
67                : isEditMode
68                ? '수정'
69                : '댓글 작성'
70              : '간편 로그인'}
71          </button>
72        </div>
73      </form>
74    </div>
75  );
76};

코드를 살펴보니 상위 컴포너트에서 form의 타입과 상태, 상태 업데이트 함수 등을 내려주고 form 컴포넌트에서 수행하는 것들이 많은데요, 리팩토링하기 전 기존 코드의 개선점을 정리해 보면,


리팩토링

1. 상태 관리 간소화

 복잡하진 않지만 기존 코드는 form의 모드를 나타내는 isEditMode와 isReplyMode 그리고 form의 타입을 나태는 "type" prop을 가지고 버튼의 텍스트를 표현하고 이벤트 처리 로직을 수행하고 있어요. mode 상태가 항상 한 가지 상태로만 활성화된다는 점(보기, 답글 또는 편집), 모드를 변경할 때마다 다른 상태값을 기본값으로 변경해야 하는 점, 조건부 렌더링을 직관적으로 작성할 수 있는 점 그리고 하나의 상태로 관리할 수 있는 점 등을 이유로 enum을 사용해서 리팩토링했는데요,

Comment.tsx
1enum CommentMode {
2  View = 'VIEW',
3  Edit = 'EDIT',
4  Reply = 'REPLY',
5}
6const Comment = ({ comments }: { comments: CommentProps }) => {
7  const [mode, setMode] = useState(CommentMode.View); // 기본값은 보기 모드인 'View'
8  const [isCommentOptionsVisible, setIsCommentOptionsVisible] = useState(false);
9
10  const onResetMode = () => setMode(CommentMode.View); // 상태 초기화
11  return (
12    <S.Container parentId={comments?.parentId} depth={comments.depth}>
13      // ..
14      <div>
15        {mode === CommentMode.Edit && ( // 편집 모드
16          <CommentForm
17            comments={comments}
18            isEditMode={mode === CommentMode.Edit}
19            setFormToDefaultMode={onResetMode}
20          />
21        )}
22        {mode === CommentMode.Reply && ( // 답글 모드
23          <CommentForm
24            comments={comments}
25            isReplyMode={mode === CommentMode.Reply}
26            setFormToDefaultMode={onResetMode}
27          />
28        )}
29      </div>
30      // ..
31    </S.Container>
32  );
33};

모드같은 경우 하나의 상태만 가질 수 있기 때문에 enum으로 간소화하면 상태가 충돌할 일이 없고 기존에 내려지던 "type" prop을 제거할 수 있어요. 하나의 상태만 활성화되기 때문에 mode 상태를 평가하는 조건문도 기존 코드보다 직관적으로 표현할 수 있어요.


2. 공통 form 컴포넌트 사용하기

 기존 form 컴포넌트는 댓글 작성과 편집을 모두 처리하고 있어서 관심사에 맞게 독립적으로 분리해 봤는데요, 우선 공통으로 사용할 form 컴포넌트를 정의했어요.

BaseCommentForm.tsx
1interface BaseCommentFormProps {
2  onSubmit: () => void; // submit 시 호출될 콜백 함수
3  isFormModeCancellable?: boolean; // form mode 취소 가능 여부
4  setFormToDefaultMode: () => void; // form mode 초기화 함수
5  isSubmitButtonDisabled?: boolean; // submit 버튼의 활성화/비활성화 상태
6  submitButtonLabel: string; // submit 버튼의 텍스트
7}
8
9const BaseCommentForm = ({
10  children,
11  onSubmit,
12  isFormModeCancellable,
13  setFormToDefaultMode,
14  isSubmitButtonDisabled,
15  submitButtonLabel,
16}: PropsWithChildren<BaseCommentFormProps>) => {
17  // 이벤트 처리 함수
18  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
19    e.preventDefault();
20    onSubmit();
21  };
22  return (
23    <div>
24      <form onSubmit={handleSubmit}>
25        {children} // 댓글 input
26        <div>
27          {isFormModeCancellable && (
28            <button onClick={setFormToDefaultMode}>취소</button>
29          )}
30          <button type="submit" disabled={isSubmitButtonDisabled}>
31            {submitButtonLabel}
32          </button>
33        </div>
34      </form>
35    </div>
36  );
37};

BaseCommentForm 컴포넌트는 기본적인 form의 기능과 구조에 집중하고 상위 컴포넌트에서 이벤트 처리 로직 등을 받아와 재사용할 수 있는 베이스 컴포넌트의 역할을 하고 있어요. 댓글 작성을 위한 input 요소도 상위 컴포넌트의 children prop으로 받아와 보여주게 했어요.

따라서 모드에 따라 댓글을 편집하고 작성하는 상위 form 컴포넌트에서는 공통 form 컴포넌트를 가져와 아래와 같이 사용하게 했는데요, 역할에 맞게 EditCommentForm과 NewCommentForm으로 나눴어요.

EditCommentForm.tsx
1interface EditCommentFormProps {
2  isEditMode: boolean;
3  setFormToDefaultMode: () => void;
4  comments: Comment;
5}
6
7const EditCommentForm = ({
8  isEditMode,
9  setFormToDefaultMode,
10  comments,
11}: EditCommentFormProps) => {
12  const [edittedComment, handleCommentChange] = useInput<HTMLTextAreaElement>(
13    comments?.body
14  );
15  const { mutate, isLoading } = useEditComment();
16
17  const onEditComment = () => mutate();
18
19  // BaseCommentForm에 전달될 prop 객체
20  const editCommentFormConfig = {
21    onSubmit: onEditComment,
22    isFormModeCancellable: isEditMode,
23    setFormToDefaultMode,
24    isSubmitButtonDisabled: isLoading,
25    submitButtonLabel: '수정',
26  };
27
28  return (
29    <BaseCommentForm {...editCommentFormConfig}>
30      <S.CommentEditInput // form input 요소
31        defaultValue={edittedComment}
32        onChange={handleCommentChange}
33        autoFocus={isEditMode}
34      />
35    </BaseCommentForm>
36  );
37};
NewCommentForm 보기
NewCommentForm.tsx
1interface NewCommentFormProps {
2  isReplyMode?: boolean;
3  setFormToDefaultMode?: () => void;
4  comments?: Comment;
5}
6
7const NewCommentForm = ({
8  isReplyMode,
9  setFormToDefaultMode = () => {},
10  comments,
11}: NewCommentFormProps) => {
12  const [comment, handleCommentChange, resetInput] =
13    useInput<HTMLTextAreaElement>('');
14  const { data: session } = useSession();
15  const { showModal } = useModal();
16  const { mutate, isLoading } = useCreateComment();
17  const onCreateComment = () => mutate();
18
19  // BaseCommentForm에 전달될 prop 객체
20  const newCommentFormConfig = {
21    onSubmit: session ? onCreateComment : showModal,
22    isFormModeCancellable: isReplyMode,
23    setFormToDefaultMode,
24    isSubmitButtonDisabled: isLoading,
25    submitButtonLabel: session
26      ? isReplyMode
27        ? '답글 작성'
28        : '댓글 작성'
29      : '간편 로그인',
30  };
31
32  return (
33    <BaseCommentForm {...newCommentFormConfig}>
34      <S.CommentInput // form input 요소
35        value={comment}
36        onChange={handleCommentChange}
37        placeholder={
38          session
39            ? isReplyMode
40              ? '답글 작성하기 ..'
41              : '댓글 작성하기 ..'
42            : '로그인하고 댓글 작성하기'
43        }
44        disabled={!session}
45        autoFocus={isReplyMode}
46      />
47    </BaseCommentForm>
48  );
49};

각 NewCommentForm과 EditCommentForm은 기본 레이아웃, 버튼, 로직 등을 prop으로 공통 form 컴포넌트에 내려주고 input 요소를 children prop으로 전달하고 있어요.

물론 BaseCommentForm 컴포넌트를 가져와 작은 컴포넌트를 또 만들면 파일과 컴포넌트 수가 많아진다는 점에서 불필요하다고 느꼈는데요, 하나의 컴포넌트 내에서 내려줄 모든 변수와 로직을 정의하면 해당 컴포넌트가 무거워지는 경우가 있어서 공통 prop만 객체로 전달하고 onEditComment과 onCreateComment 같은 이벤트 처리 로직은 개별 컴포넌트 단에서 정의하도록 했어요.


댓글 컴포넌트

1.3 리팩토링 후


리팩토링한 form 컴포넌트를 기존 코드에 적용하면 다음과 같은데요,

Comment.tsx
1enum CommentMode {
2  View = 'VIEW',
3  Edit = 'EDIT',
4  Reply = 'REPLY',
5}
6const Comment = ({ comments }: { comments: CommentProps }) => {
7  const [mode, setMode] = useState(CommentMode.View); // 기본값은 보기 모드인 'View'
8  const [isCommentOptionsVisible, setIsCommentOptionsVisible] = useState(false);
9
10  const onResetMode = () => setMode(CommentMode.View); // 상태값 초기화
11  return (
12    <S.Container parentId={comments?.parentId} depth={comments.depth}>
13      // ..
14      <div>
15        {mode === CommentMode.Edit && ( // 편집 모드
16          <EditCommentForm
17            comments={comments}
18            isEditMode={mode === CommentMode.Edit}
19            setFormToDefaultMode={onResetMode}
20          />
21        )}
22        {mode === CommentMode.Reply && ( // 답글 모드
23          <NewCommentForm
24            comments={comments}
25            isReplyMode={mode === CommentMode.Reply}
26            setFormToDefaultMode={onResetMode}
27          />
28        )}
29      </div>
30      // ..
31    </S.Container>
32  );
33};

 기존 코드에서는 각 mode 별로 상태를 유지하고 내려주는 prop이 많았는데요, enum을 사용해서 하나의 상태로 간소화했어요. 또한 모든 로직이 하나의 컴포넌트 내에 정의되다 보니 조건부로 UI와 이벤트 처리 로직을 처리하는 패턴이 많았는데 공통 컴포넌트를 재사용해 컴포넌트를 역할 단위로 나눠 컴포넌트스럽게 리팩토링했어요.