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
© uiwwsw · 본인 소유의 글·이미지에 대한 무단 복제·재게시를 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내