2026년 3월 15일

"선언형 오버레이"라는 말은 얼마나 정확할까

🤖이 글의 주제와 흐름은 작성자의 생각이며, 본문 작성에 AI의 도움을 받았습니다.

실무에서 모달이나 다이얼로그를 다루다 보면 이런 코드를 자주 보게 됩니다.

const { showAlert } = useAlert();
 
const handleClickDelete = () => {
  showAlert({
    title: '등록된 문의 삭제',
    confirmText: '문의 삭제',
    cancelText: '아니오',
    onConfirm: () => {
      deleteMutation.mutate(id);
    },
    content: "문의를 삭제하시겠습니까?\n삭제된 문의는 복구할 수 없습니다.",
    width: '420px',
    variant: 'emergency',
  });
};

이 패턴은 편리합니다. 함수 하나만 호출하면 모달이 열리고, 제목이나 버튼 문구, 콜백 같은 설정도 함께 넘길 수 있습니다.

그런데 쓰다 보면 묘한 불편함이 있습니다.
width: '420px', variant: 'emergency', content: (...)처럼 UI를 설명하는 값들이 이벤트 핸들러 안에 들어가 있기 때문입니다.

보통 React 컴포넌트를 읽을 때는 다음과 같은 흐름을 기대합니다.

state / hook 선언 → 이벤트 핸들러 → return 안의 UI

하지만 이런 명령형 모달 훅을 쓰면, 핸들러 안에 비즈니스 로직과 UI 명세가 함께 들어옵니다.
삭제 버튼을 눌렀을 때 어떤 일이 일어나는지 보려다가, 모달의 모양과 문구까지 같이 읽어야 합니다. 로직과 UI의 경계가 흐려지는 셈입니다.

이 문제의식은 꽤 보편적입니다. 토스도 비슷한 맥락에서 overlay-kit을 만들었고, 이를 “선언형 오버레이”라는 이름으로 소개합니다.

토스의 useOverlay

사용 예시는 대략 이런 형태입니다.

const overlay = useOverlay();
 
const handleClick = () => {
  overlay.open(({ isOpen, close }) => (
    <Dialog open={isOpen} onClose={close}>
      <DialogTitle>문의를 삭제하시겠습니까?</DialogTitle>
      <DialogActions>
        <Button onClick={close}>취소</Button>
        <Button
          onClick={() => {
            deleteMutation.mutate(id);
            close();
          }}
        >
          삭제
        </Button>
      </DialogActions>
    </Dialog>
  ));
};

이 방식은 앞선 showAlert({...})보다 확실히 유연합니다.
정해진 config 객체 안에서만 UI를 구성해야 하는 것이 아니라, 필요한 JSX를 직접 넘길 수 있기 때문입니다.

토스 문서에서는 이런 패턴을 “선언형”이라고 부르고, 반대로 useState로 모달을 여닫는 전통적인 방식은 “명령형”에 가깝게 설명합니다.

그런데 여기서 한 가지 질문이 생깁니다.

정말 그럴까요?

useState 패턴은 정말 명령형일까

토스가 비교 대상으로 두는 방식은 보통 이런 코드입니다.

function DeleteButton() {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <>
      <Button onClick={() => setIsOpen(true)}>삭제</Button>
      <Dialog open={isOpen} onClose={() => setIsOpen(false)}>
        <DialogTitle>문의를 삭제하시겠습니까?</DialogTitle>
        <DialogActions>
          <Button onClick={() => setIsOpen(false)}>취소</Button>
        </DialogActions>
      </Dialog>
    </>
  );
}

이 코드를 React 관점에서 보면, 오히려 꽤 전형적인 선언형 코드입니다.

Dialog는 JSX 안에 직접 선언되어 있고, open={isOpen}이라는 형태로 “상태가 이렇게 생기면 UI는 이렇게 보여야 한다”는 관계를 기술합니다.
setIsOpen(true)는 UI를 직접 조작하는 명령이라기보다, 상태를 바꾸고 그 결과를 React가 다시 렌더링하게 만드는 트리거에 가깝습니다.

즉, 이 패턴의 핵심은 “지금 다이얼로그를 열어라”가 아니라, “isOpen이 true일 때 다이얼로그는 열린 상태여야 한다”는 선언에 있습니다.

React가 오랫동안 강조해온 “UI는 상태의 함수”라는 관점에서 보면, 이 방식은 충분히 선언적입니다.

그래서 useState 기반 패턴을 곧바로 “명령형”이라고 부르면 조금 어색해집니다.
그 논리를 그대로 밀고 가면, React의 대부분의 상태 변경도 전부 명령형이라고 봐야 하기 때문입니다.

그런데도 토스는 왜 이를 “선언형”이라고 부를까

여기서 중요한 건, 토스가 “선언형”이라는 단어를 조금 다른 뜻으로 쓰고 있다는 점입니다.

토스 프론트엔드 글에서는 선언적인 코드를 대체로 추상화 수준이 더 높은 코드로 설명합니다.
즉, 선언형과 명령형을 딱 잘라 나누기보다, 얼마나 많은 제어 흐름을 감추고 있는가의 문제로 보는 셈입니다.

이 기준으로 보면 두 패턴은 이렇게 비교할 수 있습니다.

패턴특징
useState + isOpen열고 닫는 상태와 핸들러를 직접 관리해야 함
overlay.open(...)열림/닫힘 제어를 라이브러리가 감추고, 개발자는 오버레이 내용을 넘기는 데 집중함

이 관점에서는 useOverlay가 더 높은 수준의 추상화를 제공합니다.
개발자는 isOpen 상태를 만들고 setIsOpen(true/false)를 직접 쓰지 않아도 되고, “이 UI를 오버레이로 띄운다”는 의도만 표현하면 됩니다.

이런 의미에서라면 토스가 useOverlay를 더 “선언적”이라고 부르는 이유는 이해할 수 있습니다.

문제는 용어가 아니다. 기준이 섞일 때다

다만 여기서 한 가지를 구분할 필요가 있습니다.

React 문맥에서 흔히 말하는 선언형/명령형 구분과, 토스가 말하는 선언형/명령형 구분은 완전히 같은 기준이 아닙니다.

보통 선언형이라고 하면 결과 상태를 기술하는 쪽에 가깝고, 명령형이라고 하면 그 결과에 도달하는 절차를 직접 적는 쪽에 가깝습니다.
그 기준으로 보면 useState + isOpen은 선언형에 가깝고, overlay.open(...)은 이벤트 핸들러 안에서 오버레이를 띄우는 호출이라는 점에서 오히려 더 명령형으로 읽힐 수도 있습니다.

반대로 토스의 기준에서는 useOverlay 쪽이 더 선언적입니다.
왜냐하면 제어 흐름이 더 많이 감춰져 있기 때문입니다.

결국 같은 코드를 두고 평가가 엇갈리는 이유는 간단합니다.
서로 “선언형”이라는 말을 다른 기준으로 쓰고 있기 때문입니다.

제가 보기에 진짜 문제는 어느 쪽이 맞느냐보다, 이 차이가 충분히 설명되지 않을 때 생깁니다.

“기존 방식은 명령형이고, overlay-kit은 선언형이다”라는 표현만 따로 떼어 전달되면, 독자는 마치 useState 기반 패턴이 React스럽지 않은 나쁜 방식인 것처럼 받아들일 수 있습니다.
하지만 그건 React의 일반적인 선언형 모델을 지나치게 좁게 해석한 결과일 수 있습니다.

그래서 무엇을 기준으로 봐야 할까

이 지점에서는 “무엇이 더 선언형인가”보다 무엇이 더 수정하기 쉬운가를 묻는 편이 낫습니다.

각 패턴은 장단점이 꽤 분명합니다.

1. useState + isOpen

const [isOpen, setIsOpen] = useState(false);
 
return (
  <>
    <Button onClick={() => setIsOpen(true)}>삭제</Button>
    <Dialog open={isOpen} onClose={() => setIsOpen(false)}>
      {/* 자유롭게 UI 구성 */}
    </Dialog>
  </>
);

이 방식의 장점은 UI 구조가 return 안에 드러난다는 점입니다.
컴포넌트를 읽는 사람이 어떤 UI가 렌더링되는지 비교적 쉽게 파악할 수 있고, 다른 상태나 컴포넌트와 함께 조합하기도 쉽습니다.

반면 오버레이가 많아질수록 상태와 핸들러가 늘어나고, 단순한 확인 모달에도 반복 코드가 붙기 쉽습니다.

2. useOverlay

const overlay = useOverlay();
 
const handleClick = () => {
  overlay.open(({ isOpen, close }) => (
    <Dialog open={isOpen} onClose={close}>
      {/* ... */}
    </Dialog>
  ));
};

이 방식은 보일러플레이트를 줄이는 데 강점이 있습니다.
특히 여러 종류의 오버레이를 공통 방식으로 열어야 하거나, Promise 기반 흐름으로 순차 제어를 하고 싶을 때 꽤 매력적입니다.

다만 JSX가 이벤트 핸들러 안으로 들어가기 때문에, return만 보고는 컴포넌트의 UI 구조를 파악하기 어려워질 수 있습니다.
결국 config 기반 훅에서 느끼던 불편함이 다른 형태로 남는 셈입니다.

3. config 기반 훅

showAlert({
  headerTitle: '삭제 확인',
  width: '420px',
  variant: 'emergency',
  onSubmit: handleDelete,
});

이 방식은 가장 빠릅니다.
디자인 시스템이 잘 정리되어 있고, 모달 형태가 충분히 표준화되어 있다면 생산성이 좋습니다.

하지만 config 객체가 허용하는 범위를 벗어나는 순간 급격히 답답해집니다.
새로운 레이아웃이나 커스텀 인터랙션이 필요해지면 훅 자체를 손봐야 할 가능성이 큽니다.

정리

토스의 overlay-kit은 분명 실용적인 도구입니다.
특히 오버레이 제어 코드를 감추고, 반복적인 상태 관리 코드를 줄인다는 점에서 충분히 매력적입니다.

다만 useState 기반 패턴을 곧바로 “명령형”이라고 부르는 표현은 맥락 없이 받아들이면 오해를 낳기 쉽습니다.
React의 기본 모델에서 보면, 그 방식 역시 여전히 선언형이기 때문입니다.

그래서 이 문제를 볼 때는 “선언형이냐 명령형이냐”라는 딱지보다, 다음 질문이 더 중요하다고 생각합니다.

  • 이 패턴은 UI 구조를 읽기 쉬운가?
  • 반복 코드를 얼마나 줄여주는가?
  • 커스텀 요구사항이 생겼을 때 얼마나 유연한가?
  • 팀이 공유하는 추상화로 자리 잡기 쉬운가?

결국 좋은 추상화는 “더 선언적이라고 불리는 추상화”가 아니라, 변경에 잘 견디는 추상화입니다.

어떤 라이브러리가 자신을 “선언형”이라고 소개할 때도, 그 표현을 그대로 받아들이기보다 먼저 기준부터 확인해볼 필요가 있습니다.
그 말이 React의 일반적인 의미에서 선언형이라는 뜻인지, 아니면 제어 흐름을 더 많이 감춘 고수준 추상화라는 뜻인지는 생각보다 큰 차이를 만들기 때문입니다.

KHLogo