Featured image of post window.confirm처럼 Modal을 만들 수 없을까?

window.confirm처럼 Modal을 만들 수 없을까?

Modal 컴포넌트 관련 상태가 너무 많아요. 꼭 필요한게 맞을까요.

window.confirm()

프론트엔드 개발을 하다보면, 의외로 간단하게 사용했던 Web API인 window.confirm()가 그리울 때가 있다. 사용자에게 yes/no만 간단하게 요구하는 경우가 그러하다. 아래 그림처럼 어떤 리소스를 삭제하기 전에 사용자에게 한번 더 확인한다든지 그런 UI이다.

window-confirm

사용하기도 간단하고 UI 컴포넌트를 추가할 필요도 없으며, 추가로 저 confirm창에 대한 상태 또한 관리하지 않아도 된다. 단지 이 함수의 시그니처에서도 알 수 있듯이 사용자가 선택하여 반환된 boolean 값에 의존하여 이후 로직을 설계하기만 하면 된다.

window.confirm(message?: string): boolean

하지만 사용자가 적은 관리자 UI가 아니고선 사실 사용하기 쉽지 않다. 대부분의 웹은 자신들만의 특정한 UI 컨셉을 가지고 있으며 그것에 맞게 디자인, 위치 등을 수정할 수 없는 window.confirm 같은 Web API를 사용할 수가 없다. 또한 confirm이나 alert 등의 Web API는 스크립트 실행을 일시 중지하고 그 창이 닫힐 때까지 사용자가 페이지의 다른 부분과 상호 작용하는 것을 허용하지 않기 때문에 때로는 사용자 경험이 좋지 못하다.

위에서 보듯 window.confirm은 정말 단순하지만 실제로 사용하기에는 그 댓가가 너무 크다.

에이, 그러면 Modal을 직접 구현해서 만들면 되지

맞다. 틀린 말이 절대 아니다. 직접 구현해서 사용하면 되고 그렇게 만들었다고 틀린거나 동작이 이상한건 없다. 단지 관리 포인트(상태)가 늘어날 뿐이다. 이를 개선하기 위해 커스텀 훅으로 상태를 관리하는 부분을 뺀다든지 할 수 있겠지만, 여전히 window.confirm만큼 심플하지 않다.

간단함을 포기하는게 답인가

그런건 아니다. 예를들어 react-toastify 같은 라이브러리에서 볼 수 있듯이 충분히 개선할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

function App() {
  const notify = () => toast("Wow so easy!");

  return (
    <div>
      <button onClick={notify}>Notify!</button>
      <ToastContainer />
    </div>
  );
}

잠깐 다른 라이브러리를 언급한 이유는 이 문제를 개선할 수 있는 방법은 여러 가지가 존재하고 그중 한가지를 보여주려 하기 때문이다.

처음에는 이런 문제를 해결할때 종종 사용했던 context API를 사용하려 했다. 하지만 우리의 앱은 이미 여러 사람들이 잠깐잠깐 거쳐가면서 추가해 놓은 Provider Hell을 겪고 있었다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function App() {
  return (
    <AProvider>
      <BProvider>
        <CProvider>
          <DProvider>...</DProvider>
        </CProvider>
      </BProvider>
    </AProvider>
  );
}

그래서 이번엔 조금 다른 방식으로 문제를 해결해 보자는 생각을 했고 그 방식을 적어 보려고 한다.

간단하면서도 유연하게 만들자

Modal 컴포넌트의 경우 요구 사항에 따라 미세하게 변경되는 경우가 많았다. 그래서 window.confirm과 같이 yes/no에 대해서 사용할 수 있는 컴포넌트만 추상화하기로 결정했고, 그 외의 부분은 미리 추상화 놓은 Modal 컴포넌트를 통해 선언적으로 만드는 것이 더 낫다고 판단했다.

이렇게 범위로 정하고 나니깐 내가 만들어야할 window.confirm와 유사한 함수의 시그니처를 정할 수 있었다.

modalHook(args: ModalProps): Promise<boolean>

이 함수는 기존에 사용해왔던 Modal의 인터페이스를 그대로 가져와 매개변수로 사용하고 Modal를 렌더링해주며, 확인/취소에 따라 Promise를 통해 boolean 값을 반환하는 간단한 함수이다. 여기서 기존에 추상해 놓은 Modal의 인터페이스를 그대로 가져온 이유는 기존 추상화된 Modal의 유연함을 그대로 가져가면서도 기존 개발자들이 사용하던 인터페이스를 훼손하지 않음으로서 기존 인터페이스에 대한 그대로 유지함을 목적으로 했다.

modalHook demo code

실제 코드를 적을 수는 없어서, 스펙을 최대한 간소화한 데모 코드의 시그니처는 아래와 같다.

modalHook(message?: string): Promise<boolean>

데모 코드는 간단히 window.confirm과 동일하게 message만 받도록 수정했다. 그외의 구현체는 대부분 동일하기 때문에 실제 프로젝트에 적용할 때도 상황에 맞게 약간만 변경해 준다면 문제 없다.

 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
import ReactDOM, { unmountComponentAtNode } from "react-dom";

const bodyNode = document.querySelector("body");

interface ModalProps {
  open?: boolean;
  message?: string;
  onConfirm?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
  onCancel?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}

export function Modal({
  open = false,
  message = "",
  onCancel,
  onConfirm,
}: ModalProps) {
  return (
    <dialog open={open}>
      <p>{message}</p>
      <button onClick={onCancel}>취소</button>
      <button onClick={onConfirm}>확인</button>
    </dialog>
  );
}

export function modalHook(message?: string): Promise<boolean> {
  const modalNode = document.createElement("div");
  modalNode.id = "kyuhyun-modal";
  bodyNode?.append(modalNode);

  return new Promise((resolve) => {
    const handleReolve = (res: boolean) => {
      unmountComponentAtNode(modalNode);
      resolve(res);
      bodyNode?.removeChild(modalNode);
    };

    ReactDOM.render(
      <Modal
        open
        onConfirm={() => handleReolve(true)}
        onCancel={() => handleReolve(false)}
        message={message}
      />,
      modalNode
    );
  });
}

코드를 보면 특별히 이해가 어렵거나 특이한 로직은 없어 설명은 생략한다. 다만 react 18 버전 이상을 쓴다면 ReactDOM.render부분만 변경해 줘도 좋을거 같다. (참고: ReactDOMClient)

실제 코드에서 테스트 해보면 위에서 만든 modalHookwindonw.confirm과 유사한 방식으로 잘 동작하는 것을 확인할 수 있다. 만약 별도의 Modal 라이브러리를 사용한다면 위의 모달 구현체 부분과 타입만 약간 수정하면 그대로 적용이 가능하다.

 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 { modalHook } from "./Modal";

export default function App() {
  return (
    <div className="btn-container">
      <button
        onClick={() => {
          const agree = window.confirm("정말 삭제하시겠습니까?");
          if (agree) {
            alert("삭제 좋아!");
          } else {
            alert("삭제 안돼!");
          }
        }}
      >
        삭제하기(window.confirm)
      </button>
      <button
        onClick={async () => {
          const agree = await modalHook("정말 삭제하시겠습니까?");
          if (agree) {
            modalHook("삭제 좋아!");
          } else {
            modalHook("삭제 안돼!");
          }
        }}
      >
        삭제하기(modalHook)
      </button>
    </div>
  );
}

데모 영상

2022-04-20_04-40-14 (1)


감사합니다.

티스토리에서 블로그 이사중..