Contents
see List모달은 화면보다 동작 규칙이 더 중요합니다
모달 창은 단순히 가운데 뜨는 박스가 아닙니다. 사용자가 현재 작업을 잠시 멈추고 확인, 수정, 결제, 삭제 같은 집중 작업을 수행하도록 만드는 UI입니다. 그래서 모달을 만들 때는 색상이나 애니메이션보다 먼저 키보드 이동, 포커스 복귀, 배경 클릭, Escape 닫기, 스크린 리더 안내를 정해야 합니다. 이 규칙이 빠지면 마우스로는 그럴듯해 보여도 키보드 사용자에게는 빠져나오기 어려운 화면이 되고, 모바일에서는 뒤로 가기나 스크롤 충돌 때문에 운영 이슈가 생깁니다.
요즘 브라우저에서는 직접 포커스 트랩을 모두 구현하기보다 HTML의 <dialog> 요소를 먼저 검토하는 것이 좋습니다. showModal()로 열면 브라우저가 모달 계층을 만들고, 바깥 문서와의 상호작용을 제한하며, 기본적인 Escape 닫기 흐름도 제공합니다. 다만 이것만으로 제품 수준의 모달이 완성되는 것은 아닙니다. 제목 연결, 초기 포커스, 취소 가능 여부, 닫힌 뒤 원래 버튼으로 돌아가는 처리, 폼 제출 흐름은 애플리케이션 코드에서 분명하게 정리해야 합니다.
기본 구조는 제목, 본문, 명령 영역을 분리합니다
모달 안의 제목은 시각적 장식이 아니라 보조 기술이 읽어야 할 이름입니다. aria-labelledby로 제목 요소를 연결하고, 설명이 길거나 위험한 작업이면 aria-describedby로 안내 문구도 연결합니다. 버튼은 의미가 분명한 텍스트를 사용하고, 파괴적인 작업은 보조 버튼과 시각적으로 구분합니다. 닫기 아이콘만 제공하는 경우에도 접근 가능한 이름이 있어야 하며, 실제 서비스에서는 아이콘 버튼에 aria-label을 붙이는 편이 안전합니다.
<button type="button" id="openDeleteDialog">회원 탈퇴 진행</button>
<dialog id="deleteDialog" aria-labelledby="deleteTitle" aria-describedby="deleteDesc">
<form method="dialog" class="modal">
<h2 id="deleteTitle">회원 탈퇴를 진행할까요?</h2>
<p id="deleteDesc">탈퇴 후에는 계정, 주문 내역, 저장된 설정을 복구할 수 없습니다.</p>
<div class="modal-actions">
<button value="cancel">취소</button>
<button value="confirm" class="danger">탈퇴 진행</button>
</div>
</form>
</dialog>
method="dialog"를 사용하면 폼 안 버튼의 value가 다이얼로그의 returnValue로 들어갑니다. 서버 요청이 필요한 확인 버튼이라면 이 값으로 사용자의 의도를 확인한 뒤 API를 호출할 수 있습니다. 중요한 점은 버튼 클릭과 API 호출을 섞어두지 않는 것입니다. 먼저 모달에서 사용자의 선택을 확정하고, 그 다음 비동기 작업을 시작하면 실패 처리와 재시도 버튼을 더 깔끔하게 만들 수 있습니다.
열기와 닫기는 포커스 복귀까지 한 흐름으로 봅니다
모달을 열 때는 지금 어떤 요소가 포커스를 가지고 있었는지 저장해야 합니다. 모달이 닫힌 뒤 사용자를 문서 맨 위나 알 수 없는 곳으로 보내면 키보드 사용자는 작업 맥락을 잃습니다. 또한 모달 안에 자동 포커스를 줄 요소를 정해야 합니다. 단순 확인창은 취소 버튼이나 닫기 버튼으로 시작하는 것이 안전하고, 검색이나 이름 입력처럼 입력이 주목적인 창은 첫 입력 필드로 시작할 수 있습니다.
const openButton = document.querySelector('#openDeleteDialog');
const dialog = document.querySelector('#deleteDialog');
let previouslyFocused = null;
openButton.addEventListener('click', () => {
previouslyFocused = document.activeElement;
dialog.showModal();
const firstAction = dialog.querySelector('button[value="cancel"]');
firstAction?.focus();
});
dialog.addEventListener('close', async () => {
previouslyFocused?.focus();
if (dialog.returnValue !== 'confirm') return;
try {
await fetch('/api/account', { method: 'DELETE' });
location.href = '/goodbye';
} catch (error) {
alert('처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.');
}
});
위 예제는 최소 구조입니다. 실제 서비스에서는 alert() 대신 화면 안 오류 영역을 표시하고, 요청 중에는 확인 버튼을 비활성화하며, 중복 클릭을 막아야 합니다. 그래도 핵심은 같습니다. 열기 전 포커스를 저장하고, 닫힌 뒤 복귀시키며, 사용자의 선택 값에 따라 다음 동작을 분기합니다. 이 세 가지가 지켜지면 모달은 페이지 흐름을 크게 망가뜨리지 않습니다.
배경 스크롤과 바깥 클릭은 정책을 정합니다
모달이 떠 있는 동안 배경이 스크롤되면 사용자는 현재 위치를 잃기 쉽습니다. <dialog>는 바깥 상호작용을 제한하지만, 프로젝트 CSS와 모바일 브라우저 조합에 따라 배경 스크롤이 남을 수 있습니다. 모달을 열 때 body에 상태 클래스를 붙여 스크롤을 막고, 닫을 때 제거하는 방식은 여전히 실무에서 유효합니다. 이 처리는 다이얼로그 전용 이벤트에 기대기보다 실제로 여는 함수 안에서 수행하는 편이 명확합니다. 단, 스크롤 위치가 튀지 않도록 페이지 구조에서 고정 헤더와 함께 테스트해야 합니다.
function openModal() {
document.body.classList.add('modal-open');
dialog.showModal();
}
dialog.addEventListener('close', () => {
document.body.classList.remove('modal-open');
});
/* CSS */
body.modal-open {
overflow: hidden;
}
dialog::backdrop {
background: rgba(15, 23, 42, 0.56);
}
.modal {
width: min(92vw, 480px);
padding: 24px;
}
바깥 클릭으로 닫을지도 업무 성격에 따라 다릅니다. 이미지 미리보기나 간단한 필터 창은 바깥 클릭 닫기가 자연스럽지만, 결제, 삭제, 저장되지 않은 편집 내용이 있는 모달은 실수로 닫히면 안 됩니다. 바깥 클릭 닫기를 넣는다면 이벤트의 좌표가 다이얼로그 박스 바깥인지 확인하고 close('cancel')처럼 의도를 명확히 남깁니다. 반대로 중요한 작업에서는 취소 버튼과 Escape 동작만 허용하는 편이 운영 문의를 줄입니다.
운영 전 점검할 항목
- 모달 제목이
aria-labelledby로 연결되어 있고, 스크린 리더에서 목적이 바로 읽히는지 확인합니다. - Tab과 Shift+Tab만으로 모달 안의 모든 조작을 이동할 수 있는지 확인합니다.
- Escape, 취소 버튼, 확인 버튼, API 실패 후 재시도 흐름을 각각 테스트합니다.
- 닫힌 뒤 포커스가 원래 열기 버튼이나 자연스러운 다음 위치로 돌아오는지 확인합니다.
- 모바일 화면에서 배경 스크롤, 가상 키보드, 하단 버튼 가림 문제가 없는지 확인합니다.
정리하면 접근성 있는 모달은 특정 라이브러리의 문제가 아니라 상태와 의도를 분명히 다루는 설계 문제입니다. <dialog>를 기반으로 시작하되 제목 연결, 포커스 복귀, 닫기 정책, 실패 처리를 함께 묶어 구현하면 작은 확인창부터 관리자 화면의 위험 작업까지 안정적으로 재사용할 수 있습니다.