Post

모달 컴포넌트 리팩토링

내돈내산 프로젝트를 FSD 아키텍처로 리팩토링하면서 모달 컴포넌트도 리팩토링하며 느낀점을 기록한 글 입니다.

기존 모달 컴포넌트

특징

  • 다양한 요소를 포함하여 재사용가능성이 높음
  • 여러 개의 useState를 사용하여 모달 상태를 관리하며, 상태 관리가 복잡해질 수 있음
  • createPortal을 사용하여 모달을 document.body에 추가하는 방식

코드 설명

1. Props 인터페이스

모달 컴포넌트에서 사용할 다양한 속성을 정의

1
2
3
4
5
6
7
8
9
10
11
interface Props {
  buttonText: string;
  isOpen: boolean;
  onClose: () => void;
  onConfirm?: (selectedOption: string) => void;
  onCancel?: () => void;
  title?: string;
  imageSrc?: string;
  summary?: string;
  report?: boolean;
}

2. 모달 컴포넌트

title, imageSrc, summary, report을 받으며 조건부로 렌더링함,
여러 개의 useState를 사용하여 모달의 상태를 관리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
function Modal({
  buttonText,
  isOpen,
  onClose,
  onConfirm,
  onCancel,
  title,
  imageSrc,
  summary,
  report,
}: Props) {
  const [isFadingOut, setIsFadingOut] = useState(false);
  const modalRef = useRef<HTMLDivElement | null>(null);
  const [selectedOption, setSelectedOption] = useState<string | null>(null);

  // 모달 상태 관리 로직
  const handleToggleOption = (option: string) => {
    if (selectedOption === option) {
      setSelectedOption(null);
    } else {
      setSelectedOption(option);
    }
  };

  const handleClose = () => {
    setIsFadingOut(true);
  };

  const handleConfirm = () => {
    onConfirm?.(selectedOption ? selectedOption : '신고 사유가 없음');
    setIsFadingOut(true);
  };

  const handleCancel = () => {
    onCancel?.();
    setIsFadingOut(true);
  };

  const handleOverlayClick = (e: React.MouseEvent) => {
    if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
      handleClose();
    }
  };

  const handleAnimationEnd = () => {
    if (isFadingOut) {
      setIsFadingOut(false);
      setSelectedOption(null);
      onClose();
    }
  };

  if (!isOpen) return null;

  return createPortal(
    <ModalStyle
      className={isFadingOut ? 'fade-out' : 'fade-in'}
      onClick={handleOverlayClick}
      onAnimationEnd={handleAnimationEnd}
    >
      <div className="modal-body" ref={modalRef}>
        <div className="modal-contents">
          {title && <div className="title">{title}</div>}
          {imageSrc && (
            <div className="image">
              <img src={imageSrc} alt={'사진'} />
            </div>
          )}
          {summary && <div className="summary">{summary}</div>}
          {report && (
            <div className="reportButtons">
              <ToggleButton
                label="욕설"
                isActive={selectedOption === '욕설'}
                onClick={() => handleToggleOption('욕설')}
              />
              <ToggleButton
                label="광고"
                isActive={selectedOption === '광고'}
                onClick={() => handleToggleOption('광고')}
              />
              <ToggleButton
                label="법률위반"
                isActive={selectedOption === '법률위반'}
                onClick={() => handleToggleOption('법률위반')}
              />
            </div>
          )}
          <div className="buttons">
            <Button size={'small'} scheme={'border'} onClick={handleCancel}>
              취소
            </Button>
            <Button size={'small'} scheme={'primary'} onClick={handleConfirm}>
              {buttonText}
            </Button>
          </div>
        </div>
      </div>
    </ModalStyle>,
    document.body,
  );
}

전체 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import styled from 'styled-components';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Button from './Button';

interface Props {
  buttonText: string;
  isOpen: boolean;
  onClose: () => void;
  onConfirm?: (selectedOption: string) => void;
  onCancel?: () => void;
  title?: string;
  imageSrc?: string;
  summary?: string;
  report?: boolean;
}

function Modal({
  buttonText,
  isOpen,
  onClose,
  onConfirm,
  onCancel,
  title,
  imageSrc,
  summary,
  report,
}: Props) {
  const [isFadingOut, setIsFadingOut] = useState(false);

  const modalRef = useRef<HTMLDivElement | null>(null);

  const [selectedOption, setSelectedOption] = useState<string | null>(null);

  const handleToggleOption = (option: string) => {
    if (selectedOption === option) {
      setSelectedOption(null);
    } else {
      setSelectedOption(option);
    }
  };

  const handleClose = () => {
    setIsFadingOut(true);
  };

  const handleConfirm = () => {
    onConfirm?.(selectedOption ? selectedOption : '신고 사유가 없음');
    setIsFadingOut(true);
  };

  const handleCancel = () => {
    onCancel?.();
    setIsFadingOut(true);
  };

  const handleOverlayClick = (e: React.MouseEvent) => {
    if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
      handleClose();
    }
  };

  const handleAnimationEnd = () => {
    if (isFadingOut) {
      setIsFadingOut(false);
      setSelectedOption(null);
      onClose();
    }
  };

  if (!isOpen) return null;

  return createPortal(
    <ModalStyle
      className={isFadingOut ? 'fade-out' : 'fade-in'}
      onClick={handleOverlayClick}
      onAnimationEnd={handleAnimationEnd}
    >
      <div className="modal-body" ref={modalRef}>
        <div className="modal-contents">
          {title && <div className="title">{title}</div>}
          {imageSrc && (
            <div className="image">
              <img src={imageSrc} alt={'사진'} />
            </div>
          )}
          {summary && <div className="summary">{summary}</div>}
          {report && (
            <div className="reportButtons">
              <ToggleButton
                label="욕설"
                isActive={selectedOption === '욕설'}
                onClick={() => handleToggleOption('욕설')}
              />
              <ToggleButton
                label="광고"
                isActive={selectedOption === '광고'}
                onClick={() => handleToggleOption('광고')}
              />
              <ToggleButton
                label="법률위반"
                isActive={selectedOption === '법률위반'}
                onClick={() => handleToggleOption('법률위반')}
              />
            </div>
          )}
          <div className="buttons">
            <Button size={'small'} scheme={'border'} onClick={handleCancel}>
              취소
            </Button>
            <Button size={'small'} scheme={'primary'} onClick={handleConfirm}>
              {buttonText}
            </Button>
          </div>
        </div>
      </div>
    </ModalStyle>,
    document.body,
  );
}

interface ToggleButtonProps {
  label: string;
  isActive: boolean;
  onClick: () => void;
}
function ToggleButton({ label, isActive, onClick }: ToggleButtonProps) {
  return (
    <Button
      size="small"
      scheme={isActive ? 'primary' : 'border'}
      onClick={onClick}
    >
      {label}
    </Button>
  );
}

export default Modal;

사용 (예시 코드)

모달의 열림 상태를 관리할 useState, 신고하기 옵션을 관리할 useState, 모달의 유형(타입)을 관리할 useState를 작성
openModal, closeModal, handleConfirm 함수 등을 정의하여 모달의 동작을 제어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
function ReviewContent() {
  // ...중략

  const [modalType, setModalType] = useState("");
  const [selectedOption, setSelectedOption] = useState < string > "";
  const [isModalOpen, setIsModalOpen] = useState(false);

  const openModal = () => {
    setIsModalOpen(true);
    setSelectedOption("");
  };

  const closeModal = () => {
    setIsModalOpen(false);
    setSelectedOption("");
  };

  const handleUpdate = () => {
    if (!isLoggedIn) {
      setModalType(MODAL_TYPES.LOGIN);
      setIsModalOpen(true);
      return;
    }
    navigate(`/review/${id}`);
  };

  const handleDelete = () => {
    if (!isLoggedIn) {
      setModalType(MODAL_TYPES.LOGIN);
      setIsModalOpen(true);
      return;
    }
    setModalType(MODAL_TYPES.DELETE);
    openModal();
  };

  const handleReport = () => {
    if (!isLoggedIn) {
      setModalType(MODAL_TYPES.LOGIN);
      setIsModalOpen(true);
      return;
    }
    setModalType(MODAL_TYPES.REPORT);
    openModal();
  };

  // 모달창 확인버튼 눌렀을 때 다음 동작
  const handleConfirm = (option: string) => {
    if (modalType === MODAL_TYPES.DELETE) {
      deleteToggle();
    }
    if (modalType === MODAL_TYPES.REPORT) {
      reportToggle({ reason: option, reportedUserId: review.userId });
    }
    if (modalType === MODAL_TYPES.LOGIN) {
      navigate(`/login`);
    }
    closeModal();
  };

  return (
    <Container>
      <AuthorContainer>
        <h3 className="nickname">{review.name}</h3>
        // ...중략
      </AuthorContainer>
      <Modal
        isOpen={isModalOpen}
        onClose={closeModal}
        title={
          modalType === MODAL_TYPES.LOGIN
            ? MODAL_TITLE.LOGIN
            : modalType === MODAL_TYPES.DELETE
            ? MODAL_TITLE.REVIEW_DELETE
            : MODAL_TITLE.REPORT
        }
        buttonText={
          modalType === MODAL_TYPES.LOGIN
            ? MODAL_BTNTEXT.LOGIN
            : modalType === MODAL_TYPES.DELETE
            ? MODAL_BTNTEXT.DELETE
            : MODAL_BTNTEXT.REPORT
        }
        report={modalType === MODAL_TYPES.REPORT}
        onConfirm={handleConfirm}
      />
      // ...중략
    </Container>
  );
}

export default ReviewContent;

리팩토링한 이유?

기존 모달 컴포넌트는 여러 개의 useState를 사용하여 복잡한 상태 관리를 하였고, 다양한 조건부 렌더링으로 인해 코드가 복잡해졌습니다. 또한, 반복적인 보일러 코드를 많이 작성하게 되어 코드 길이가 불필요하게 길어졌습니다. 이러한 문제를 해결하기 위해 리팩토링을 진행하였습니다.

리팩토링 목적

  • 상태 관리의 중앙화: 상태 관리를 중앙에서 효율적으로 처리하여, 여러 useState의 사용을 줄이고 코드의 복잡성을 감소시키려 했습니다.

  • 가독성 향상: 모달의 다양한 유형을 별도의 컴포넌트로 분리함으로써 코드의 가독성을 높이고, 유지보수를 용이하게 하였습니다.

  • 보일러 코드 감소: 반복적인 보일러 코드를 줄여 코드의 길이를 단축시키고, 코드 작성 및 유지보수를 더 쉽게 만들고자 하였습니다.

리팩토링한 모달 컴포넌트

특징:

  • 상태 관리 라이브러리를 사용하여 모달의 상태를 중앙에서 관리 (useModalStore훅을 사용하여 모달을 열고 닫을 수 있음)
  • 모달 유형을 별도의 컴포넌트로 분리하여 관리

코드 설명:

1. 상태 관리

zustand를 사용하여 모달의 상태를 중앙에서 관리,
모달의 상태를 관리하는 useModalStore를 사용하여 모달의 열림/닫힘 상태, 모달의 유형(모드), 모달에 전달한 속성을 관리함

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { create } from "zustand";

export type TModalMode =
  | "ALERT"
  | "DELETE"
  | "LOGIN"
  | "APPROVE_REVIEW"
  | "REPORT";

interface ModalState {
  $isOpen: boolean;
  mode: TModalMode | null;
  contentProps?: any;
  openModal: (mode: TModalMode, contentProps?: any) => void;
  closeModal: () => void;
}

const useModalStore =
  create <
  ModalState >
  ((set) => ({
    $isOpen: false,
    mode: null,
    contentProps: {},
    openModal: (mode, contentProps) => {
      console.log(contentProps);
      set({ $isOpen: true, mode, contentProps });
    },
    closeModal: () => set({ $isOpen: false, mode: null, contentProps: {} })
  }));

export default useModalStore;

2. 모달 컴포넌트

중앙에서 관리하는 상태를 사용하여 모달을 렌더링함,
모달의 유형(모드)에 따라 다른 내용을 표시하기 위해 switch문을 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import {
  AlertModal,
  DeleteModal,
  LoginModal,
  ApproveReviewModal,
  ReportUserModal
} from "./ModalContents";
import { ModalBody, ModalContents, ModalStyle } from "./Modal.style";
import useModalStore from "@/store/modal.store";

const Modal = () => {
  const { $isOpen, mode, contentProps, closeModal } = useModalStore();
  let content;

  switch (mode) {
    case "ALERT":
      content = (
        <AlertModal closeModal={closeModal} message={contentProps.message} />
      );
      break;
    case "DELETE":
      content = (
        <DeleteModal
          closeModal={closeModal}
          onDelete={contentProps.onDelete}
          type={contentProps.type}
        />
      );
      break;
    case "APPROVE_REVIEW":
      content = (
        <ApproveReviewModal
          closeModal={closeModal}
          approveReview={contentProps.approveReview}
          receiptImg={contentProps.receiptImg}
        />
      );
      break;
    case "REPORT":
      content = (
        <ReportUserModal
          closeModal={closeModal}
          reportedUserId={contentProps.reportedUserId}
        />
      );
      break;
    default:
      content = <LoginModal closeModal={closeModal} />;
  }

  return (
    <ModalStyle $isOpen={$isOpen}>
      <ModalBody mode={mode}>
        <ModalContents>{content}</ModalContents>
      </ModalBody>
    </ModalStyle>
  );
};

export default Modal;

Modal.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import {
  AlertModal,
  DeleteModal,
  LoginModal,
  ApproveReviewModal,
  ReportUserModal
} from "./ModalContents";
import { ModalBody, ModalContents, ModalStyle } from "./Modal.style";
import useModalStore from "@/store/modal.store";

const Modal = () => {
  const { $isOpen, mode, contentProps, closeModal } = useModalStore();
  let content;

  switch (mode) {
    case "ALERT":
      content = (
        <AlertModal closeModal={closeModal} message={contentProps.message} />
      );
      break;
    case "DELETE":
      content = (
        <DeleteModal
          closeModal={closeModal}
          onDelete={contentProps.onDelete}
          type={contentProps.type}
        />
      );
      break;
    case "APPROVE_REVIEW":
      content = (
        <ApproveReviewModal
          closeModal={closeModal}
          approveReview={contentProps.approveReview}
          receiptImg={contentProps.receiptImg}
        />
      );
      break;
    case "REPORT":
      content = (
        <ReportUserModal
          closeModal={closeModal}
          reportedUserId={contentProps.reportedUserId}
        />
      );
      break;
    default:
      content = <LoginModal closeModal={closeModal} />;
  }

  return (
    <ModalStyle $isOpen={$isOpen}>
      <ModalBody mode={mode}>
        <ModalContents>{content}</ModalContents>
      </ModalBody>
    </ModalStyle>
  );
};

export default Modal;

ModalContents.tsx

모달의 타입별로 출력할 UI 컴포넌트 모음

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import { Link, useLocation, useNavigate } from 'react-router-dom';

import Button from '../Button';

import { TReportReason } from '@/features/report/model/report.model';
import { useState } from 'react';
import { useReport } from '@/features/report';
import { RadioButton } from './Modal.style';

interface ILoginModalProps {
  closeModal: () => void;
}

export const LoginModal = ({ closeModal }: ILoginModalProps) => {
  const navigate = useNavigate();
  const { pathname } = useLocation();
  return (
    <>
      <h4>
        로그인이 필요한 서비스입니다.
        <br />
        로그인 하시겠습니까?
      </h4>

      <div className="button-group">
        <Button
          size="medium"
          scheme="border"
          onClick={() => {
            if (pathname === '/review') {
              navigate(-1);
            }
            closeModal();
          }}
        >
          취소
        </Button>
        <Link to="/login">
          <Button size="medium" scheme="primary" onClick={closeModal}>
            
          </Button>
        </Link>
      </div>
    </>
  );
};

interface IDeleteModalProps {
  closeModal: () => void;
  onDelete: () => void;
  type: 'review' | 'comment';
}

export const DeleteModal = ({
  closeModal,
  onDelete,
  type,
}: IDeleteModalProps) => {
  return (
    <>
      <h4>{type === 'review' ? '후기를' : '댓글을'}삭제하시겠습니까?</h4>
      <div className="button-group">
        <Button size="medium" scheme="border" onClick={closeModal}>
          취소
        </Button>
        <Button size="medium" scheme="primary" onClick={onDelete}>
          확인
        </Button>
      </div>
    </>
  );
};

interface IAlertModalProps {
  closeModal: () => void;
  message: string;
}

export const AlertModal = ({ closeModal, message }: IAlertModalProps) => {
  return (
    <>
      <p>{message}</p>
      <div className="button-group">
        <Button size="medium" scheme="primary" onClick={closeModal}>
          확인
        </Button>
      </div>
    </>
  );
};

interface IReportUserModalProps {
  closeModal: () => void;
  reportedUserId: number;
}

const reportReasons: TReportReason[] = [
  '같은 내용 반복 작성(도배)',
  '선정성/음란성',
  '욕설/인신공격',
  '개인정보 노출',
  '영리목적/홍보성',
  '아이디 거래',
  '불법 정보',
];

export const ReportUserModal = ({
  reportedUserId,
  closeModal,
}: IReportUserModalProps) => {
  const { postReport } = useReport();

  const [selectedReason, setSelectedReason] = useState<TReportReason | null>(
    null,
  );

  const onSubmit = () => {
    if (!selectedReason || !reportedUserId) return;
    postReport({ reason: selectedReason, reportedUserId });
    closeModal();
  };

  return (
    <>
      <h4>신고사유</h4>
      <form>
        {reportReasons.map((reason) => (
          <RadioButton key={reason}>
            <input
              type="radio"
              id={reason}
              name="reportReason"
              value={reason}
              checked={selectedReason === reason}
              onChange={(e) =>
                setSelectedReason(e.target.value as TReportReason)
              }
            />
            <label htmlFor={reason}>{reason}</label>
          </RadioButton>
        ))}
      </form>
      <div className="button-group">
        <Button size="medium" scheme="border" onClick={closeModal}>
          취소
        </Button>
        <Button size="medium" scheme="primary" onClick={onSubmit}>
          신고
        </Button>
      </div>
    </>
  );
};

interface IApproveReviewModalProps {
  closeModal: () => void;
  approveReview: () => void;
  receiptImg: string;
}

export const ApproveReviewModal = ({
  closeModal,
  approveReview,
  receiptImg,
}: IApproveReviewModalProps) => {
  return (
    <>
      <img src="receiptImg" alt="영수증 이미지" />
      <div className="button-group">
        <Button size="medium" scheme="border" onClick={closeModal}>
          취소
        </Button>
        <Button size="medium" scheme="primary" onClick={() => approveReview}>
          승인
        </Button>
      </div>
    </>
  );
};

modal.store.ts

모달의 상태를 관리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { create } from "zustand";

export type TModalMode =
  | "ALERT"
  | "DELETE"
  | "LOGIN"
  | "APPROVE_REVIEW"
  | "REPORT";

interface ModalState {
  $isOpen: boolean;
  mode: TModalMode | null;
  contentProps?: any;
  openModal: (mode: TModalMode, contentProps?: any) => void;
  closeModal: () => void;
}

const useModalStore =
  create <
  ModalState >
  ((set) => ({
    $isOpen: false,
    mode: null,
    contentProps: {},
    openModal: (mode, contentProps) => {
      console.log(contentProps);
      set({ $isOpen: true, mode, contentProps });
    },
    closeModal: () => set({ $isOpen: false, mode: null, contentProps: {} })
  }));

export default useModalStore;

사용 (예시 코드)

useModalStore에서 openModal을 가져와 유형(모드)와 전달할 인자를 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function ReviewActions({
  isAuthor,
  reviewId,
  authorId,
  showIconOnly = false
}: Props) {
  // 중략
  const { openModal } = useModalStore();

  const handleAction = (action: string) => {
    if (!isLoggedIn) {
      openModal("LOGIN");
    }
    switch (action) {
      case "update": {
        navigate(`/review/${reviewId}`);
        break;
      }
      case "delete": {
        deleteReviewInReviews(reviewId);
        break;
      }
      case "report": {
        openModal("REPORT", { reportedUserId: authorId });
        break;
      }
    }
  };

  return (
    <ul>
      {reviewActionButtons
        .filter((button) => (isAuthor ? button.isAuthor : !button.isAuthor))
        .map((button) => (
          <ActionButtonStyle
            key={button.label}
            onClick={() => handleAction(button.action)}
          >
            <Icon width={20} icon={button.icon} />
            {!showIconOnly && button.label}
          </ActionButtonStyle>
        ))}
    </ul>
  );
}

export default ReviewActions;

리팩토링 후 코드 간결성 및 유지보수 용이성

기존의 코드를 사용

리팩토링 전에는 모달의 상태와 내용이 모두 하나의 컴포넌트 내에서 관리되었기 때문에, 다양한 상태를 처리하기 위해 여러 개의 useState를 사용하고 조건부 렌더링이 복잡하게 엮여 있었습니다. 결과적으로, 모달의 내용과 상태 관리를 수정하려면 많은 코드 변경이 필요했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
function ReviewContent() {
  // ...중략
  const [modalType, setModalType] = useState("");
  const [selectedOption, setSelectedOption] = useState < string > "";
  const [isModalOpen, setIsModalOpen] = useState(false);

  const openModal = () => {
    setIsModalOpen(true);
    setSelectedOption("");
  };

  const closeModal = () => {
    setIsModalOpen(false);
    setSelectedOption("");
  };

  const handleUpdate = () => {
    if (!isLoggedIn) {
      setModalType(MODAL_TYPES.LOGIN);
      setIsModalOpen(true);
      return;
    }
    navigate(`/review/${id}`);
  };

  const handleDelete = () => {
    if (!isLoggedIn) {
      setModalType(MODAL_TYPES.LOGIN);
      setIsModalOpen(true);
      return;
    }
    setModalType(MODAL_TYPES.DELETE);
    openModal();
  };

  const handleReport = () => {
    if (!isLoggedIn) {
      setModalType(MODAL_TYPES.LOGIN);
      setIsModalOpen(true);
      return;
    }
    setModalType(MODAL_TYPES.REPORT);
    openModal();
  };

  // 모달창 확인버튼 눌렀을 때 다음 동작
  const handleConfirm = (option: string) => {
    if (modalType === MODAL_TYPES.DELETE) {
      deleteToggle();
    }
    if (modalType === MODAL_TYPES.REPORT) {
      reportToggle({ reason: option, reportedUserId: review.userId });
    }
    if (modalType === MODAL_TYPES.LOGIN) {
      navigate(`/login`);
    }
    closeModal();
  };

  return (
    <Modal
      isOpen={isModalOpen}
      onClose={closeModal}
      title={
        modalType === MODAL_TYPES.LOGIN
          ? MODAL_TITLE.LOGIN
          : modalType === MODAL_TYPES.DELETE
          ? MODAL_TITLE.REVIEW_DELETE
          : MODAL_TITLE.REPORT
      }
      buttonText={
        modalType === MODAL_TYPES.LOGIN
          ? MODAL_BTNTEXT.LOGIN
          : modalType === MODAL_TYPES.DELETE
          ? MODAL_BTNTEXT.DELETE
          : MODAL_BTNTEXT.REPORT
      }
      report={modalType === MODAL_TYPES.REPORT}
      onConfirm={handleConfirm}
    />
  );
}

리팩토링한 코드를 사용

모달의 상태를 중앙에서 관리하기 위해 zustand를 사용하여 상태 관리가 단순화되었습니다. 모달의 열림/닫힘 상태와 유형, 그리고 전달할 속성을 useModalStore 훅을 통해 간편하게 제어할 수 있습니다. 이로 인해 모달 컴포넌트는 상태에 따라 적절한 모달 유형의 컴포넌트를 렌더링하는 역할만 하게 되어, 전체 코드가 훨씬 간결해졌습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function ReviewActions({...}: Props) {
  // ...중략
  const { openModal } = useModalStore();

  const handleAction = (action: string) => {
    if (!isLoggedIn) {
      openModal("LOGIN");
    }
    switch (action) {
      // ...중략
      case "report": {
        openModal("REPORT", { reportedUserId: authorId });
        break;
      }
    }
  };
}

결론

기존 모달 컴포넌트는 복잡한 상태 관리와 다양한 조건부 렌더링으로 인해 가독성이 떨어졌습니다. 리팩토링을 통해 상태 관리를 중앙화하고, 모달의 유형별로 컴포넌트를 분리하여 코드의 가독성과 유지보수성을 크게 향상시켰습니다. zustand를 활용한 상태 관리는 모달의 상태와 유형을 명확히 구분하고, 컴포넌트 분리를 통해 코드의 재사용성과 일관성을 높였습니다.

이러한 리팩토링을 통해 모달 컴포넌트의 관리가 용이해졌고, 코드 작성 시의 편리함을 경험할 수 있었습니다. 결과적으로, 코드의 유지보수와 확장성이 크게 개선되었으며, 명확한 구조 덕분에 협업 시에도 이해가 쉬워졌습니다.

This post is licensed under CC BY 4.0 by the author.