• About

상태에서 관계로: 선언적 오버레이 패턴(Declarative Overlay Pattern)

overlay-kit으로 배우는 선형적 정보 흐름과 인지 부하 재분배


상태에서 관계로: 선언적 오버레이 패턴(Declarative Overlay Pattern)

이번 포스팅에서는 지난 포스팅에 이어 선언적 프로그래밍이 현실에 어떤 형태로 구현되는지에 대해서 조금 더 자세한 이야기를 해보려고 한다.

그냥 이론적인 설명만 하면 너무 재미가 없으니 리액트에서 오버레이 요소들을 쉽게 다룰 수 있도록 도와주는 overlay-kit이라는 라이브러리를 통해 선언적 프로그래밍에 대해 조금 더 자세히 알아보겠다.

선언적 프로그래밍의 본질

필자는 이전에 선언적 프로그래밍의 본질에 대해 이야기한 바 있다. map이나 filter 같은 배열 메소드를 쓴다고 해서 무조건 선언적인 것이 아니며, 진정한 선언적 사고는 “어떻게(How)“가 아닌 “무엇을(What)”, 절차가 아닌 관계에 집중하는 것이라고 말했다.

하지만 React를 사용하는 대부분의 개발자들은 여전히 모달이나 토스트와 같은 요소를 다룰 때 만큼은 10년 전의 절차적 사고에서 벗어나지 못하고 있다.

우리는 여전히 useState를 사용해 상태를 만들고, 이벤트 핸들러를 연결하고, 상태 변화의 순서를 관리한다. 이것은 “먼저 다이얼로그를 열고, 그 다음 확인을 기다리고, 마지막에 API를 호출한다”는 시간적 순서에 집중하는 절차적 사고라고 볼 수 있다.

마치 map을 사용하면서도 절차적으로 사고할 수 있는 것처럼, useState를 사용한다고 해서 선언적인 코드가 되는 것은 아니다.

상태 공간과 인지적 부하

이전 글에서 다뤘던 비동기 데이터 상태를 기억하는가? loading, data, error를 독립적인 boolean 상태로 관리하면 논리적으로 불가능한 상태 조합이 발생한다는 이야기였다. 오버레이 관리에서도 똑같은 문제가 발생한다.

const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<'confirmed' | null>(null);

// 논리적으로 불가능한 상태 조합들
// { isOpen: false, isLoading: true, result: null }
// → 닫혀있는데 로딩 중

// { isOpen: false, isLoading: false, result: 'confirmed' }
// → 닫혀있는데 결과가 있음

// { isOpen: true, isLoading: true, result: 'confirmed' }
// → 로딩 중인데 이미 결과가 있음

세 개의 독립적인 상태 변수는 이론적으로 23=82³ = 8가지 조합을 만들지만, 실제로 논리적으로 유효한 조합은 그보다 훨씬 적다. 개발자는 코드를 작성할 때마다 “이 조합이 가능한가?”를 머릿속으로 검증해야 한다.

이러한 상황이 문제가 되는 근본적인 이유는 바로 인지적 부하의 증가다. 심리학자 존 스웰러(John Sweller)의 인지 부하 이론에 따르면, 인간의 작업 기억은 제한적이며, 동시에 처리할 수 있는 정보의 양에는 한계가 있다고 한다. 조지 밀러(George Miller)가 제시한 “매직 넘버 7±2”도 이와 동일한 맥락에서 나온 이야기이다.

그래서 위 예시의 오버레이 관리 패턴을 보면 isOpen, isLoading, result, 그리고 이들 사이의 유효한 조합까지 추적해야만 오버레이의 동작을 유추할 수 있다는 점이 문제인 것이다. 그나마 지금은 8가지 경우의 수 밖에 되지 않지만, 필요한 상태가 늘어날 수록 개발자가 기억해야할 정보의 양도 기하급수적으로 늘어날 것이다.

하지만 이 상황은 문제 자체의 복잡도가 아니라, 표현 방식이 만들어낸 외재적 인지 부하이기 때문에 충분히 해결해볼 수 있는 문제이다. 수학에서 복잡한 식을 적절한 표기법으로 바꾸면 이해가 쉬워지는 것처럼, 약간의 접근 방법만 바꾸면 오버레이도 더 간단한 방식으로 표현할 수 있다.

입력과 출력의 관계

필자가 이전 글에서 강조했던 핵심은 선언적 코드는 시간의 흐름이 아닌 관계를 표현한다는 것이었다.

한번 일차 함수를 나타내는 y=2x+1y = 2x + 1을 떠올려보자. 이것은 xx에 2를 곱하고 1을 더하라는 계산 절차를 의미하는 것이 아니라 xxyy 사이의 관계를 선언하는 것이다. 이 관계는 시간과 무관한 영원한 진리다. 언제 어디서나 xx가 3이라면 yy는 7이기 때문이다.

오버레이의 본질도 마찬가지다. 브라우저 네이티브 API인 window.confirm을 보자.

const result = window.confirm("정말 삭제하시겠습니까?");

물론 window.confirm은 UI를 띄우는 부수 효과를 가지므로 순수 함수는 아니다. 하지만 중요한 것은 이 함수가 상태 관리를 호출자에게 노출하지 않는다는 점이다. isOpen 같은 상태도, handleConfirm 같은 핸들러도 필요하지 않다. 내부적으로 어떻게 구현했는지, 어떤 순서로 렌더링하는지는 추상화 뒤로 숨겨져 있다. 개발자는 오직 “무엇을 넣으면 무엇이 나오는가”라는 관계에만 집중할 수 있다.

수학에서 함수는 집합 간의 대응 관계를 나타낸다. f:ABf:A→B라고 쓸 때, 우리는 집합 AA의 각 원소가 집합 BB의 원소와 어떻게 대응되는지를 선언한다. 이것은 계산 과정이 아니라 구조 간의 관계다.

window.confirm도 마찬가지다. 메시지 문자열이라는 집합에서 boolean 값이라는 집합으로의 대응 관계를 나타낸다.

선언적 오버레이 패턴(Declarative Overlay Pattern)

그렇다면 어떻게 해야 할까? 답은 의외로 간단하다. 오버레이를 상태로 다루지 말고 함수로 다루면 된다.

선언적 오버레이 패턴은 window.confirm이 보여준 함수적 본질을 React 생태계로 가져온다.

const result = await overlay.openAsync(({ isOpen, close }) => (
  <ConfirmDialog
    isOpen={isOpen}
    onConfirm={() => close(true)}
    onCancel={() => close(false)}
  />
));

openAsyncPromise<T>를 반환하는 함수다.

Promise를 써본 개발자라면 이미 이 패턴을 알고 있다. API를 호출하면 Promise가 반환되고, await로 결과를 기다린다. openAsync도 동일하다. 오버레이를 열면 Promise가 반환되고, 사용자가 응답할 때까지 기다린다. 다만 API 서버 대신 사용자에게서 응답을 받을 뿐이다.

즉, “오버레이를 보여주고 사용자 응답을 받는다”는 관계를 선언하는 것이다.

이번 섹션에서는 선언적 오버레이 패턴이 왜 전통적인 상태 관리보다 나은지 세 가지 측면에서 살펴보려고 한다.

상태에서 관계로

이전 글에서 불가능한 상태 조합을 막는 방법을 다뤘다. 가능한 상태들을 명시적으로 열거하고, 타입 시스템이 불가능한 조합을 차단하도록 하는 것이다.

하지만 선언적 오버레이 패턴은 다른 접근을 취한다. 상태 조합을 제한하는 대신, 상태 자체를 추상화한다.

전통적인 상태 관리는 스냅샷을 다룬다. “지금 다이얼로그가 열려있는가?”, “지금 로딩 중인가?”, “지금 결과가 있는가?” 같은 현재 시점의 상태를 추적한다. 그리고 이 상태들을 하나씩 바꿔나가며 원하는 동작을 만든다. 이는 마치 사진 여러 장을 이어붙여 애니메이션을 만드는 것과 비슷하다.

하지만 잘 생각해보면 우리는 사용자가 다이얼로그에서 확인을 눌렀는지 아닌지를 알고 싶은 것이지 다이얼로그가 현재 열려있는지 닫혀있는지를 알고 싶은게 아니다.

즉, 중간 과정의 상태들은 사실 그 결과를 얻기 위한 수단일 뿐, 현재 상태가 아니라 최종 결과를 알고 싶은 것이다.

선언적 오버레이 패턴은 이 중간 과정을 제거한다. 기존에는 복잡하게 다루었던 여러 상태들을 오버레이를 연다는 하나의 동작으로 표현하고, 그 결과를 받는 것에만 집중한다.

// 다이얼로그를 열고
setIsOpen(true);

// 나중에 어딘가에서 다이얼로그를 닫으며 결과를 입력한다.
setIsOpen(false);
setResult('confirmed');
// 동작과 결과와의 관계만 표현해서 깔끔해졌다
const result = await overlay.openAsync(...);

왜 이것이 더 나을까?

첫째, 코드가 의도를 직접 표현한다. “사용자 확인을 받고 싶다”는 의도가 코드에 그대로 드러난다. 개발자가 머릿속으로 상태 변수 세 개를 조합해서 “아, 이게 확인을 받으려는 동작이구만”이라고 유추할 필요가 없다.

둘째, 에러를 만들 여지가 줄어든다. 첫 번째 방법은 isOpen을 true로 바꿨는데 나중에 false로 바꾸는 걸 깜빡한다거나, result를 설정했는데 다이얼로그를 닫지 않는다거나 하는 휴먼 에러의 구멍이 많다. 이러한 동작들을 추상화해서 함수로 표현하면 이런 실수들이 구조적으로 불가능해진다.

셋째, 변화의 과정이 아니라 변화의 결과에 집중할 수 있다. 상태가 어떻게 바뀌는지가 아니라 무엇이 일어나는지를 표현한다. 즉, 선언적이다.

다시 말해, 선언적 오버레이 패턴은 개별 상태 변수들이 어떤 값을 가지고 있는지(상태의 스냅샷)를 다루는 대신, 어떤 동작을 하면 어떤 결과가 나오는지(입출력의 관계)를 다루는 것이다.

인지적 부하의 재분배

코드의 가독성은 종종 주관적인 취향의 문제로 여겨진다. 하지만 심리학자 존 스웰러(John Sweller)의 인지 부하 이론은 이것이 단순한 취향이 아니라 인지 과학의 문제임을 보여준다.

전통적인 상태 관리 방식을 다시 보자. 개발자는 isOpen, isLoading, result 같은 여러 상태 변수를 동시에 추적해야 한다. 개발자가 모듈의 동작을 이해하기 위해 이 변수들이 어떤 순서로 바뀌는지, 어떤 조합이 유효한지를 머릿속으로 시뮬레이션해야 한다는 의미이다.

이것을 인지 부하 이론에서는 외재적 부하(Extraneous Load)라고 부른다. 코드에서 발생하는 외재적 부하는 문제 자체의 복잡도가 아니라 표현 방식이 만들어낸 불필요한 복잡도라고 볼 수 있다. 오버레이를 띄워 사용자 확인을 받는다는 본질적인 문제는 단순하지만, 이 행위를 여러 개의 상태 변수로 표현하면서 복잡도가 인위적으로 증가한 것이다.

조지 밀러(George Miller)가 제시한 “매직 넘버 7±2”를 떠올려보자. 인간은 동시에 약 7개 정도의 정보만 작업 기억에 유지할 수 있다. 하지만 상태 변수가 세 개만 되어도 가능한 조합은 8가지가 되고, 이 중 유효한 조합이 무엇인지까지 추적하면 이미 인지적 한계에 다다른다.

선언적 오버레이 패턴은 이 외재적 부하를 제거한다. 개발자는 더 이상 여러 개의 상태 변수 간의 조합을 기억할 필요가 없고, 하나의 함수 호출, 즉 오직 입력과 출력의 관계만 기억하면 되기 때문에 부하가 줄어든다.

필자는 이렇게 같은 문제를 더 적은 인지 부하로 표현하는 것, 개발자가 본질적인 문제에 집중할 수 있도록 불필요한 복잡도를 제거하는 것이 가독성 높은 코드를 만드는 본질이라고 생각한다.

하지만 오해하지 말아야 할 점이 있다. 복잡도가 사라진 것이 아니라 그저 재분배되었을 뿐이다.

overlay-kit 라이브러리 구현자는 여전히 Promise 관리, 상태 동기화와 같은 복잡한 로직을 다뤄야 하지만, 이런 짜치는 구현은 한 번만 하면 된다.

소수의 기여를 통해 수많은 개발자들이 낮은 인지 부하로 오버레이라는 동작을 다룰 수 있는 것, 이것이 추상화가 우리에게 선물해주는 가치이다.

조합과 제어 흐름

여러 오버레이를 통해 사용자 입력을 받아야 하는 상황을 생각해보자. 전통적인 방법에서는 아래와 같은 코드로 해당 로직을 구현하게 된다.

function CreateUserFlow() {
  const [step, setStep] = useState(1);
  const [info, setInfo] = useState(null);
  const [preference, setPreference] = useState(null);

  const handleInfoSubmit = (data) => {
    setInfo(data);
    setStep(2);
  };

  const handlePreferenceSubmit = (data) => {
    setPreference(data);
    setStep(3);
  };

  const handleConfirm = async () => {
    await api.createUser({ ...info, ...preference });
    setStep(1);
    setInfo(null);
    setPreference(null);
  };

  return (
    <>
      {step === 1 && <UserInfoForm onSubmit={handleInfoSubmit} />}
      {step === 2 && <PreferenceForm onSubmit={handlePreferenceSubmit} />}
      {step === 3 && <ConfirmDialog onConfirm={handleConfirm} />}
    </>
  );
}

위 코드는 사용자 정보, 선호도, 최종 확인을 받은 후 API를 호출하는 간단한 플로우다. 하지만 정보의 흐름이 코드 곳곳에 흩어져 있기 때문에 개발자는 step 상태가 어떻게 바뀌는지, 각 핸들러가 무엇을 하는지, JSX에서 어떤 조건으로 렌더링되는지를 모두 따라가야 전체 흐름을 이해할 수 있다.

이제 같은 로직을 overlay-kit의 openAsync로 구현한 모습을 보자.

async function createUser() {
  const info = await overlay.openAsync(({ isOpen, close }) => (
    <UserInfoForm isOpen={isOpen} onSubmit={close} />
  ));

  const preference = await overlay.openAsync(({ isOpen, close }) => (
    <PreferenceForm isOpen={isOpen} onSubmit={close} />
  ));

  if (await confirmCreation(info, preference)) {
    await api.createUser({ ...info, ...preference });
  }
}

가장 큰 차이점은 정보의 흐름이 선형적이라는 점이다. 첫 번째 줄에서 사용자 정보를 입력받고, 두 번째 줄에서 선호도를 입력받고, 세 번째 줄에서 확인을 거쳐 API를 호출한다. 코드를 위에서 아래로 읽는 순서가 곧 실행 순서다.

openAsync가 Promise를 반환하기 때문에 이것이 가능하다. Promise는 비동기 작업의 완료를 기다릴 수 있게 해준다. 첫 번째 오버레이가 닫힐 때까지 기다렸다가 두 번째 오버레이를 연다. 두 번째가 닫히면 세 번째로 넘어간다. 각 단계가 순차적으로 실행되면서 정보가 위에서 아래로 흐른다.

또한 각 단계는 독립적이기 때문에 각 관심사의 결합도가 낮고 응집도가 높다. 사용자 정보를 받는 폼은 사용자 정보만 받고 선호도를 받는 폼은 선호도만 받으며, 최종적으로 이들을 어떻게 이어붙일지는 createUser 함수가 결정한다. 각 컴포넌트는 그저 close 함수를 호출할 뿐이고, 이 결과가 어디로 가는지는 알 필요가 없다.

게다가 openAsync는 그저 값을 반환하는 함수이기에 일반적인 제어 흐름에 자연스럽게 통합된다. 조건부 로직이 필요하면 if 문을 쓰면 되고, 에러 처리가 필요하면 try/catch를 쓰면 되며, 반복이 필요하면 for 문을 쓰면 된다. 프로그래밍 101에서 배우는 제어 흐름이 그대로 작동하며, 오버레이를 위한 특별한 패턴을 배울 필요가 없다.

이러한 함수의 특성 덕에 특정 UX 흐름에 대한 재사용도 간단한 편이다.

function confirmAction(message: string) {
  return overlay.openAsync(({ isOpen, close }) => (
    <ConfirmDialog 
      message={message}
      isOpen={isOpen} 
      onConfirm={() => close(true)}
      onCancel={() => close(false)}
    />
  ));
}

if (await confirmAction('정말 삭제하시겠습니까?')) {
  await api.deleteUser();
}

if (await confirmAction('설정을 초기화하시겠습니까?')) {
  await api.resetSettings();
}

이것이 오버레이를 함수로 다루는 힘이다. 정보의 흐름을 선형적으로 표현할 수 있고, iftry/catchfor 같은 일반적인 제어 흐름에 자연스럽게 통합되며, 재사용은 별도 함수로 빼는 것만으로 충분하다.

선언적 사고의 확장

2013년, 우리는 React를 통해 직접적인 DOM 조작에서 벗어나 JSX로 데이터와 UI의 구조적 관계를 표현하며 선언적으로 UI를 다루는 세상을 접하게 되었다.

하지만 슬프게도 우리는 같은 React 코드베이스에서 컴포넌트는 선언적으로 작성하면서 오버레이만큼은 절차적으로 다루고 있다.

// 컴포넌트는 선언적으로
<UserProfile user={user} />

// 오버레이는 절차적으로
const [isOpen, setIsOpen] = useState(false);
const handleOpen = () => setIsOpen(true);
const handleClose = () => setIsOpen(false);

왜 이런 분열이 생긴 걸까? 그 이유는 오버레이가 마치 시간에 종속된 것처럼 보이기 때문이다. 사용자가 버튼을 누르면 다이얼로그가 열리고, 그 이후 확인 버튼을 누르면 API를 호출하고, 응답이 오면 다시 다이얼로그를 닫는 것처럼 말이다. 그래서 우리는 상태로 이 순서를 관리해야 한다고 생각했다.

하지만 window.confirm을 다시 보자.

if (window.confirm('정말 삭제하시겠습니까?')) {
  deleteItem();
}

물론 이 코드가 발생시키는 이펙트에도 사용자가 다이얼로그 내에서 확인을 누르기 까지 기다려야한다는 시간적 순서는 존재한다. 하지만 우리는 이것을 코드 상에서 시간의 흐름으로 표현하지 않으며, 문자열을 인자로 받아 boolean 값을 반환하는 함수, 즉 문자열과 boolean의 관계로만 표현한다.

선언적 오버레이 패턴이 하는 일이 바로 이것이다. 시간적 순서를 조건과 결과의 관계로 바꾼다.

// 절차적: "먼저 열고, 기다리고, 닫고, 그 다음..."
setIsOpen(true);
// ... 어딘가에서
setIsOpen(false);
// ... 그 다음에
deleteItem();
// 선언적: "확인받으면 삭제한다"
if (await overlay.openAsync(...)) {
  deleteItem();
}

이제 우리는 React의 선언적 철학을 오버레이에도 적용할 수 있다. 컴포넌트가 데이터와 UI의 관계를 선언하듯, 오버레이도 사용자 응답과 다음 동작의 관계를 선언한다.

그리고 이 통일성이 중요하다. 컴포넌트를 읽을 때는 선언적으로 사고하다가, 오버레이를 읽을 때는 절차적으로 사고해야 한다면 인지적 분열이 발생한다. 하지만 선언적 오버레이 패턴을 사용하면 전체 코드베이스가 하나의 사고방식으로 통일된다.

마치며

overlay-kit은 이 패턴을 구현한 라이브러리다.

이 라이브러리의 가치는 코드 줄 수를 줄이는 것이 아니라, window.confirm이 보여준 단순함을 React 생태계로 가져오는 것이다. 10년 전에는 간단했지만 복잡해진 무언가를 다시 간단하게 만드는 것 뿐이다.

필자는 이전 글에서 선언적 프로그래밍이 도구가 아니라 사고방식이라고 말했다. map이나 filter를 쓴다고 선언적인 것이 아니듯, useState를 쓴다고 절차적인 것도 아니다. 중요한 것은 무엇을 표현하는가였다.

이 글에서 다룬 선언적 오버레이 패턴도 마찬가지다. 중요한 것은 openAsync라는 API가 아니라 “오버레이를 상태로 볼 것인가, 관계로 볼 것인가”이다.

상태로 보면 isOpen, isLoading, result 같은 변수들을 추적해야 한다. 관계로 보면 입력과 출력만 생각하면 된다. 같은 문제를 다르게 표현하는 것만으로 인지 부하가 달라진다.

그리고 필자는 이렇게 개발자가 본질적인 문제에 집중할 수 있도록 불필요한 복잡도를 제거하는 것이 좋은 추상화의 본질이라고 생각한다.

관련 포스팅 보러가기

Sep 07, 2025

선언적 프로그래밍에 대한 착각과 오해

프로그래밍
Dec 25, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 11. 선언적 프로그래밍

프로그래밍
Jun 01, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 10. 자연 변환

프로그래밍
Apr 18, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 9. 함수 타입

프로그래밍
Apr 02, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 8. 펑터의 특성

프로그래밍