본문으로 바로가기
윤창원uiwwsw · 작은 우주

개발과 기술

확인 모달을 콜백으로 연결하기 — 대기·실패·중복 요청까지

2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.

삭제처럼 되돌리기 어려운 작업에는 실행 전 확인이 필요할 수 있다. 매 이벤트 핸들러마다 모달을 열고 응답을 기다리는 코드를 반복하지 않도록, 실행할 함수를 확인 모달과 연결해봤다.

모든 저장이나 이동에 확인창을 넣어야 하는 것은 아니다. 되돌리기, 자동 저장, 실행 취소가 더 적절한 상황도 있다. 이 글은 확인 과정이 필요한 작업을 대상으로 한다.

먼저 정한 동작

확인창에는 실행할 작업 하나를 보관한다. 확인 후 비동기 작업이 끝날 때까지 기다리고, 실패하면 모달에 오류를 표시한다. 처리 중인 요청은 중복 실행하지 않는다.

아래는 이전 예시를 보완한 구조다. Dialog는 프로젝트에서 제공하는 컴포넌트로, open, onClose, children을 받는다고 가정한다. 키보드 닫기, 초점 이동·복귀, 접근 가능한 제목과 배경 상호작용 차단은 이 Dialog 구현에서 제공해야 한다.

import {
  createContext, useCallback, useContext, useMemo, useRef, useState,
  type ReactNode,
} from 'react';
import { Dialog } from './Dialog';

type ConfirmAPI = {
  openConfirm: <A extends unknown[]>(
    fn: (...args: A) => unknown,
    content?: ReactNode,
  ) => (...args: A) => boolean;
};

const ConfirmContext = createContext<ConfirmAPI | null>(null);

export function ConfirmProvider({
  children,
  defaultContent = '정말 실행할까요?',
}: {
  children: ReactNode;
  defaultContent?: ReactNode;
}) {
  const task = useRef<(() => unknown) | null>(null);
  const busy = useRef(false);
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);
  const [content, setContent] = useState<ReactNode>(defaultContent);
  const [error, setError] = useState<string | null>(null);

  const close = useCallback(() => {
    if (busy.current) return;
    task.current = null;
    setOpen(false);
    setError(null);
  }, []);

  const apply = useCallback(async () => {
    if (busy.current || !task.current) return;
    const run = task.current;
    busy.current = true;
    setPending(true);
    setError(null);
    try {
      await run();
      task.current = null;
      setOpen(false);
    } catch {
      setError('처리 결과를 확인하지 못했습니다. 상태를 확인한 뒤 다시 시도해주세요.');
    } finally {
      busy.current = false;
      setPending(false);
    }
  }, []);

  const openConfirm = useCallback(
    <A extends unknown[],>(
      fn: (...args: A) => unknown,
      customContent?: ReactNode,
    ) => (...args: A): boolean => {
      if (task.current !== null || busy.current) return false;
      task.current = () => fn(...args);
      setContent(customContent === undefined ? defaultContent : customContent);
      setError(null);
      setOpen(true);
      return true;
    },
    [defaultContent],
  );

  const value = useMemo(() => ({ openConfirm }), [openConfirm]);
  return (
    <ConfirmContext.Provider value={value}>
      {children}
      <Dialog open={open} onClose={close}>
        <div aria-busy={pending}>
          {content}
          {error && <p role="alert">{error}</p>}
          <button type="button" disabled={pending} onClick={close}>취소</button>
          <button type="button" disabled={pending} onClick={apply}>
            {pending ? '처리 중…' : '확인'}
          </button>
        </div>
      </Dialog>
    </ConfirmContext.Provider>
  );
}

export function useConfirm<A extends unknown[]>(
  fn: (...args: A) => unknown,
  content?: ReactNode,
) {
  const context = useContext(ConfirmContext);
  if (context === null) {
    throw new Error('useConfirm은 ConfirmProvider 안에서 사용해야 합니다.');
  }
  return useMemo(
    () => context.openConfirm(fn, content),
    [context, fn, content],
  );
}

useConfirm으로 받은 함수를 호출하면 확인창을 열 수 있다.

// ConfirmProvider 아래에서 사용한다. deleteApi는 프로젝트의 API 함수다.
function DangerZone({ id }: { id: number }) {
  const confirmDelete = useConfirm(
    (targetId: number) => deleteApi(targetId),
    <p>{id}번 항목을 삭제할까요?</p>,
  );
  return <button type="button" onClick={() => confirmDelete(id)}>삭제</button>;
}

이 코드가 정한 정책과 남은 일

모달이 이미 열려 있으면 새 요청을 덮어쓰지 않고 false를 반환한다. 큐에 쌓지는 않는다. 새 모달을 열 때 본문을 다시 설정하므로 이전 요청의 본문이 남지 않는다. Provider를 빠뜨리면 조용히 무시하는 대신 오류로 알려준다.

콜백의 Promise가 성공하면 닫고, 실패하면 내용을 유지한다. API 래퍼가 실패를 정상 반환값으로 바꾼다면 콜백에서 실패를 판별해 예외를 던져야 한다. 네트워크 오류가 서버 작업의 실패를 보장하지도 않으므로, 중복 실행에 민감한 작업에는 서버의 멱등성 처리나 상태 확인도 필요하다.

이 예시는 요청 중 닫기를 막는다. 취소 가능한 작업이라면 AbortSignal과 취소 후 화면 정책을 별도로 설계해야 한다. Provider는 요청을 처리하는 동안 유지되는 위치에 두어야 한다.

한 줄의 호출 뒤에는 이런 동작 규칙이 있다. 공통 컴포넌트의 가치는 사용처의 코드가 짧아지는 데 더해, 그 규칙을 일관되게 적용하고 검토할 수 있다는 데 있다고 생각한다.

Assisted by AI

윤창원이 벨로그에 남긴 글을 이 작은 우주에도 모았습니다. 사진은 누르면 원본 크기로 볼 수 있습니다. 원문의 전체 서식 보기 ↗

모든 글 둘러보기 →