라떼군 이야기
Angular ng-bootstrap 모달의 'Blocked aria-hidden' 경고 원인과 해결 방법
Problem
Angular 프로젝트에서 ng-bootstrap 라이브러리를 사용하여 모달(Modal) 다이얼로그를 띄울 때, 브라우저 콘솔에 접근성(Accessibility) 관련 경고가 발생하는 경우가 있습니다. 구체적으로는 "Blocked aria-hidden on an element because its descendant retained focus"라는 메시지가 출력됩니다. 이는 aria-hidden 속성이 적용된 요소 내부에 여전히 포커스를 가진 자식 요소가 존재하여, 화면 판독기(Screen Reader) 등 보조 기기 사용자에게 심각한 혼란을 줄 수 있다는 것을 의미합니다. 개발자는 모달이 정상적으로 열리더라도 이 경고 때문에 웹 접근성 표준을 위반하고 있는지 우려하게 됩니다.
Background
이 경고를 이해하려면 ng-bootstrap이 모달을 화면에 표시할 때 배경(Background)을 어떻게 처리하는지 알아야 합니다. 모달이 열리면, 보조 기기가 모달 뒤에 있는 기존 콘텐츠를 읽지 않도록 메인 애플리케이션 요소(일반적으로 <app-root>)에 aria-hidden="true" 속성을 자동으로 추가합니다.
하지만 여기서 타이밍 문제가 발생합니다. 모달을 열기 위해 사용자가 클릭한 ‘버튼’은 여전히 <app-root> 내부에 존재하며, 클릭 직후 브라우저의 포커스(Focus)를 유지하고 있습니다. 결과적으로 스크린 리더에게는 “숨겨진 요소(aria-hidden="true") 안에 현재 포커스된 요소가 있다"는 모순된 상태가 전달됩니다. 브라우저는 이러한 접근성 충돌을 감지하고 개발자에게 경고를 띄우는 것입니다.
Solution
이 문제를 해결하는 핵심은 모달이 열리기 직전에 트리거 요소(버튼 등)에서 포커스를 해제(blur)하는 것입니다. 프로젝트의 규모와 상황에 따라 다음 두 가지 방법을 적용할 수 있습니다.
1. 개별 컴포넌트에서 포커스 해제하기
가장 직관적인 방법은 모달을 여는 함수 내에서 현재 포커스된 요소를 찾아 강제로 포커스를 빼앗는 것입니다. 단일 컴포넌트에서만 모달을 사용할 때 적합합니다.
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Component } from '@angular/core';
import { ModalComponent } from './modal.component';
@Component({
selector: 'app-parent',
template: '<button (click)="openModal()">모달 열기</button>',
})
export class ParentComponent {
constructor(private modalService: NgbModal) {}
openModal() {
// 1. 현재 문서에서 포커스를 가진 요소를 가져옵니다.
const activeElement = document.activeElement as HTMLElement;
// 2. 해당 요소가 존재하면 포커스를 해제(blur)합니다.
if (activeElement) {
activeElement.blur();
}
// 3. 포커스 해제 후 안전하게 모달을 엽니다.
this.modalService.open(ModalComponent);
}
}
2. 커스텀 서비스를 이용한 전역 처리 (권장)
프로젝트 규모가 크고 모달을 여러 곳에서 호출한다면, 매번 blur() 코드를 작성하는 것은 비효율적입니다. 기존 NgbModal을 확장(extends)하여 모달이 열릴 때마다 자동으로 포커스를 해제하는 커스텀 서비스를 만드는 것이 좋습니다.
// custom-modal.service.ts
import { Injectable } from '@angular/core';
import { NgbModal, NgbModalOptions, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
@Injectable({
providedIn: 'root'
})
export class CustomModalService extends NgbModal {
// 기존 open 메서드를 오버라이드(재정의)합니다.
override open(content: unknown, options?: NgbModalOptions): NgbModalRef {
// 모달을 열기 전 항상 현재 활성화된 요소의 포커스를 해제합니다.
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
// 부모 클래스(NgbModal)의 원래 open 로직을 실행합니다.
return super.open(content, options);
}
}
이후 app.module.ts (또는 Standalone 컴포넌트의 providers)에서 기존 NgbModal 대신 우리가 만든 CustomModalService를 사용하도록 의존성을 주입합니다.
// app.module.ts
import { NgModule } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { CustomModalService } from './custom-modal.service';
@NgModule({
// ...
providers: [
// NgbModal을 요청하면 CustomModalService를 제공하도록 설정합니다.
{ provide: NgbModal, useClass: CustomModalService }
]
})
export class AppModule { }
3. 순수 JavaScript/Bootstrap 환경에서의 대안
만약 Angular가 아닌 일반 Vanilla JS 환경에서 Bootstrap 모달을 사용하다가 비슷한 문제를 겪는다면, 전역 이벤트 리스너를 활용할 수 있습니다.
// DOM이 모두 로드된 후 실행
document.addEventListener("DOMContentLoaded", function () {
// 모달이 숨겨질 때(또는 열릴 때 'show.bs.modal' 사용 가능) 포커스 해제
document.addEventListener('hide.bs.modal', function (event) {
if (document.activeElement) {
document.activeElement.blur();
}
});
});
Deep Dive
단순히 포커스를 해제(blur())하여 에러를 덮는 것을 넘어, 웹 접근성 지침(WCAG) 측면에서 모달의 생명주기를 완벽히 관리하는 것이 중요합니다. 이상적인 접근성은 모달이 열릴 때 포커스를 모달 내부의 첫 번째 요소로 이동시키고, 모달이 닫힐 때는 원래 모달을 열었던 트리거 버튼으로 포커스를 다시 돌려주는 것입니다. ng-bootstrap은 기본적으로 닫힐 때 포커스 복원 기능을 제공하므로, 열릴 때의 충돌만 위와 같이 제어해주면 됩니다. 또한, 경고 메시지에서 언급된 inert 속성은 최신 HTML 표준으로, 요소와 그 하위 트리를 완전히 비활성화(포커스 불가, 클릭 불가, 스크린 리더 무시)할 수 있어 향후 aria-hidden을 대체할 강력하고 근본적인 대안으로 자리 잡고 있습니다.
Conclusion
Blocked aria-hidden 경고는 모달이 열리면서 배경이 스크린 리더로부터 숨겨질 때, 모달을 호출한 버튼이 여전히 포커스를 가지고 있어 발생하는 접근성 충돌 문제입니다. 이를 해결하기 위해 모달을 열기 직전 document.activeElement.blur()를 호출하여 포커스를 해제해야 합니다. 유지보수성과 코드의 재사용성을 고려한다면, NgbModal을 상속받은 커스텀 서비스를 구현하여 전역적으로 포커스 해제 로직을 적용하는 방식을 적극 권장합니다.