OSS

Promise 기반의 Headless Modal API 개발기

·

TL;DR

react-flow-modal은 모달을 상태가 아닌 비동기 흐름으로 다루기 위한 Promise 기반 headless modal API입니다.

핵심은 단순합니다. 모달을 isOpen 같은 UI 상태로 선언하는 대신 함수처럼 호출하고, 사용자 선택은 Promise의 결과값으로 돌려받습니다.
이렇게 하면 모달을 단순한 렌더링 대상이 아니라, 흐름을 잠시 멈추고 사용자 결정을 기다리는 지점으로 더 자연스럽게 표현할 수 있습니다.


모달을 상태로 다루면 왜 불편할까?

프론트엔드에서 모달은 아주 익숙한 UI입니다.
삭제 확인, 약관 동의, 입력 폼, 경고 메시지처럼 사용자에게 한 번 더 결정을 요청해야 하는 순간마다 등장합니다.

문제는 구현입니다. 보통 React에서는 isOpen 같은 상태로 모달을 열고 닫고, 모달 안에서 입력한 값은 다시 외부 상태로 끌어올리고, 닫힌 뒤 실행할 로직은 콜백이나 useEffect로 이어 붙이게 됩니다.

익숙한 패턴이지만, 흐름이 길어질수록 코드가 빠르게 복잡해집니다.

function App() {
    const [isOpen, setIsOpen] = useState(false);
    const [value, setValue] = useState("");

    useEffect(() => {
        console.log(value);
    }, [value]);

    return (
        <div>
            <button onClick={() => setIsOpen(true)}>Open modal</button>
            {isOpen && (
                <Portal>
                    <Modal>
                        <h1>Modal</h1>
                        <input
                            value={value}
                            onChange={(e) => setValue(e.target.value)}
                        />
                        <button onClick={() => setIsOpen(false)}>Close</button>
                    </Modal>
                </Portal>
            )}
        </div>
    );
}

이 방식이 특히 불편하게 느껴졌던 이유는 다음과 같습니다.

  • 모달의 열림과 닫힘을 위해 별도의 상태를 관리해야 합니다.
  • 모달 내부 값을 외부에서 쓰려면 상태를 끌어올려야 합니다.
  • 후속 로직을 위해 useEffect나 추가 콜백이 필요해집니다.
  • 플로우가 취소되면 상태 초기화 책임도 직접 가져가야 합니다.

사용자에게는 단순히 “결정을 기다리는 순간”인데, 구현은 여러 개의 상태와 사이드 이펙트로 흩어지는 셈입니다.


모달의 본질을 다시 보면

제가 생각하는 모달의 본질은 플로우의 중단에 더 가깝습니다.

  • 흐름은 특정 지점에서 멈추고
  • 사용자의 선택을 기다린 뒤
  • 그 결과에 따라 다시 진행됩니다

예를 들어 삭제 버튼을 눌렀다고 해보겠습니다.
실제로 우리가 표현하고 싶은 건 “삭제를 시도한다”가 아니라 “삭제 전에 확인을 받는다”는 흐름입니다.

즉, 모달은 단순히 보여주는 UI가 아니라 사용자 결정을 기다리는 비동기 단계에 가깝습니다.
그런데 이를 전부 렌더링 상태로 제어하면, 의미와 구현 방식 사이에 미묘한 어긋남이 생깁니다.

그래서 출발점을 이렇게 바꿔봤습니다.

모달을 선언하지 말고, 함수처럼 호출하자


함수로 호출하면 무엇이 달라질까?

가장 먼저 떠올린 건 명령형 API였습니다.

function openModal(content) {
    return createPortal(content, document.body);
}

이렇게 보면 모달은 더 이상 컴포넌트 트리 어딘가에 조건부로 매달린 UI가 아니라, 호출 시점에 열리는 하나의 동작이 됩니다.

하지만 곧바로 다음 문제가 남습니다.

모달에서 발생한 값을 어떻게 다시 호출한 쪽으로 전달할 것인가?

처음에는 아래처럼 모달을 함수로 열고, 값은 여전히 외부 상태에 저장하는 방식도 생각했습니다.

function Component() {
    const [value, setValue] = useState("");

    return (
        <button
            onClick={() =>
                openModal((close) => (
                    <Modal>
                        <input
                            value={value}
                            onChange={(e) => setValue(e.target.value)}
                        />
                        <button onClick={close}>Close</button>
                    </Modal>
                ))
            }
        >
            Open modal
        </button>
    );
}

가능한 방식이긴 하지만, 결국 원래의 문제로 되돌아갑니다.

  • 값은 여전히 state에 저장됩니다.
  • 외부 플로우에서 사용하려면 다시 상태가 필요합니다.
  • 모달 종료 시 상태 정리 책임도 남아 있습니다.

즉, 호출 방식만 명령형으로 바뀌었을 뿐, 결과를 다루는 방식은 아직 흐름 중심이 아니었습니다.


Promise로 보면 훨씬 자연스럽다

이 지점에서 가장 자연스럽게 연결된 추상화가 Promise였습니다.

모달이 “사용자 결정을 기다리는 작업”이라면, JavaScript에서 그 의미를 가장 잘 표현하는 도구가 바로 Promise이기 때문입니다.

function openModal(render) {
    return new Promise((resolve, reject) => {
        render(resolve, reject);
    });
}

이 구조에서는 각 역할이 명확해집니다.

  • render는 모달 UI를 렌더링합니다.
  • resolve는 결과값을 반환하면서 모달을 닫습니다.
  • reject는 예외나 취소 흐름을 표현하면서 모달을 닫습니다.

이렇게 바꾸면 앞서의 불편함이 상당 부분 사라집니다.

  • 모달 결과를 state가 아니라 Promise의 반환값으로 다룰 수 있습니다.
  • 결과값은 렌더링과 무관한 스코프 변수로 남습니다.
  • useEffect 없이도 async/await 흐름 안에서 다음 로직을 바로 이어갈 수 있습니다.
  • cleanup 시점도 resolvereject에 맞춰 더 명확하게 관리할 수 있습니다.

모달을 “보여주는 상태”가 아니라 “기다렸다가 값을 받는 작업”으로 보면, 코드의 표현이 실제 사용자 흐름에 훨씬 가까워집니다.


그래서 만든 API

이 아이디어를 정리해 만든 것이 Promise 기반 headless modal API입니다.
호출부에서는 아래처럼 사용할 수 있습니다.

function App() {
    const modal = useModal();

    const onClick = async () => {
        const result = await modal.open(
            "confirm",
            (resolve, reject) => (
                <ConfirmModal
                    onConfirm={() => resolve(true)}
                    onCancel={() => resolve(false)}
                />
            )
        );

        // resolve / reject 시 모달은 스택에서 자동으로 제거됩니다
        console.log(result);
    };

    return <button onClick={onClick}>Open modal</button>;
}

모달은 렌더링 대상이 아니라, 비동기 흐름의 중단점으로 취급됩니다.

이를 라이브러리 형태로 정리하면 전체 구조는 다음과 같습니다.

import { ModalProvider, ModalHost, useModal } from "react-flow-modal";

function App() {
    const modal = useModal();

    const onClick = async () => {
        const result = await modal.open(
            "confirm",
            (resolve) => (
                <ConfirmModal
                    onConfirm={() => resolve(true)}
                    onCancel={() => resolve(false)}
                />
            )
        );

        console.log(result);
    };

    return <button onClick={onClick}>Open modal</button>;
}

export default function Root() {
    return (
        <ModalProvider>
            <App />
            <ModalHost />
        </ModalProvider>
    );
}

Headless 방식으로 둔 이유

이 라이브러리는 의도적으로 headless 방식을 선택했습니다.

제가 풀고 싶었던 문제는 “모달을 어떻게 예쁘게 만들까”가 아니라,
모달이라는 UI를 어떤 API로 표현하는 것이 가장 자연스러울까”에 더 가까웠기 때문입니다.

프로젝트마다 모달의 디자인, 애니메이션, 오버레이, 버튼 배치는 전부 다릅니다.
반면 호출 방식, 결과 전달, 스택 관리 같은 흐름 제어 문제는 비교적 공통적입니다.

그래서 라이브러리가 가져야 할 책임은 UI를 강제하는 것이 아니라, 모달의 생명주기와 결과 전달을 일관되게 다루는 것이라고 봤습니다.


이 방식이 특히 잘 맞는 경우

물론 모든 모달이 Promise 기반이어야 하는 것은 아닙니다.

단순 안내성 모달이나 화면 일부에 가까운 오버레이는 여전히 선언형 상태 관리가 더 자연스러울 수 있습니다.
반대로 아래와 같은 경우에는 이 방식이 특히 잘 맞습니다.

  • 확인 후 다음 작업이 이어지는 모달
  • 입력 결과를 받아 후속 요청을 보내는 모달
  • 여러 단계의 사용자 결정을 순차적으로 연결해야 하는 플로우

특히 “확인 모달 -> 입력 모달 -> 최종 요청”처럼 단계가 이어지는 흐름에서는 차이가 더 분명합니다.
상태 기반 구현은 화면 제어 코드가 중심이 되기 쉽지만, Promise 기반 구현은 실제 사용자 흐름이 코드에 더 직접적으로 드러납니다.


마무리

이 방식으로 구현한 모달은 사용자 이벤트를 state에 종속시키지 않고, 호출 스코프에서 직접 다룰 수 있게 해줍니다.

결국 이 글에서 이야기하고 싶었던 핵심은 하나입니다.
모달을 단순히 보여주고 숨기는 UI로만 보지 말고, 흐름을 잠시 멈추고 사용자 결정을 기다리는 인터랙션으로 볼 수도 있다는 점입니다.

react-flow-modal은 그 관점을 API로 옮긴 결과물입니다.
모달 때문에 상태와 이펙트가 계속 늘어나는 경험을 자주 했다면, Promise 기반 접근도 충분히 검토해볼 만한 선택지라고 생각합니다.

앞으로도 실사용 사례와 테스트를 쌓으면서 안정성과 사용성을 더 다듬어볼 생각입니다.

읽어주셔서 감사합니다.