Angular에서 Keyboard Events 연결 중단 — 대신 Model Focus
Source: Dev.to
개요
Angular 애플리케이션에서 키보드 이벤트를 직접 바인딩하는 대신, 모델 기반 포커스 접근 방식을 사용하면 코드가 더 깔끔해지고 유지보수가 쉬워집니다. 이 글에서는 기존에 keydown, keyup 등을 템플릿에 직접 연결하던 방식을 버리고, 컴포넌트 로직에서 포커스를 제어하는 방법을 단계별로 살펴봅니다.
왜 키보드 이벤트 바인딩을 피해야 할까?
- 중복 로직: 여러 컴포넌트에서 동일한 키보드 핸들러를 구현하면 중복 코드가 늘어납니다.
- 테스트 어려움: DOM 이벤트에 직접 의존하면 단위 테스트가 복잡해집니다.
- 접근성 문제: 키보드 이벤트를 직접 처리하면 스크린 리더와 같은 보조 기술과 충돌할 가능성이 있습니다.
모델 기반 포커스란?
모델 기반 포커스는 컴포넌트 상태(예: isFocused, activeItemId)를 사용해 UI 요소의 포커스를 제어하는 패턴입니다. Angular의 **@ViewChild**와 **Renderer2**를 활용해 실제 DOM 포커스를 조작하고, 템플릿에서는 상태만 바인딩합니다.
구현 단계
1️⃣ 상태 정의
export class MyComponent {
/** 현재 포커스된 아이템의 ID */
focusedItemId: number | null = null;
}
2️⃣ 템플릿에 바인딩
<ul>
<li *ngFor="let item of items"
[attr.tabindex]="0"
[class.focused]="item.id === focusedItemId"
(click)="setFocus(item.id)">
{{ item.label }}
</li>
</ul>
tabindex="0"을 지정해 키보드 탐색이 가능하도록 합니다.class.focused는 CSS에서 포커스 스타일을 적용하는 데 사용됩니다.
3️⃣ 포커스 로직 구현
import { Component, ElementRef, Renderer2, ViewChildren, QueryList } from '@angular/core';
@Component({
selector: 'app-my-component',
templateUrl: './my-component.html',
})
export class MyComponent {
@ViewChildren('listItem') listItems!: QueryList<ElementRef>;
focusedItemId: number | null = null;
constructor(private renderer: Renderer2) {}
setFocus(id: number): void {
this.focusedItemId = id;
const element = this.listItems.find(li => li.nativeElement.dataset.id === id.toString());
if (element) {
this.renderer.selectRootElement(element.nativeElement).focus();
}
}
/** 키보드 네비게이션을 위한 핸들러 */
onKeydown(event: KeyboardEvent): void {
if (!this.focusedItemId) return;
const currentIndex = this.items.findIndex(i => i.id === this.focusedItemId);
if (event.key === 'ArrowDown') {
const next = this.items[(currentIndex + 1) % this.items.length];
this.setFocus(next.id);
} else if (event.key === 'ArrowUp') {
const prev = this.items[(currentIndex - 1 + this.items.length) % this.items.length];
this.setFocus(prev.id);
}
}
}
@ViewChildren로 리스트 아이템들을 조회하고,Renderer2로 포커스를 설정합니다.onKeydown은 키보드 이벤트를 한 곳에 집중시켜, 개별 아이템에 이벤트를 붙이는 대신 컴포넌트 레벨에서 처리합니다.
4️⃣ 전역 키보드 리스너 연결
<div (keydown)="onKeydown($event)" tabindex="0">
<!-- 위의 리스트가 여기 들어갑니다 -->
</div>
- 최상위 컨테이너에
tabindex="0"을 부여해 포커스를 받을 수 있게 합니다. - 이제 모든 키보드 로직이
onKeydown메서드 하나에 모여 있어 테스트와 유지보수가 쉬워집니다.
장점 정리
| 항목 | 기존 방식 | 모델 기반 포커스 |
|---|---|---|
| 코드 중복 | 여러 컴포넌트에 동일 이벤트 바인딩 | 한 곳에서 로직 관리 |
| 테스트 용이성 | DOM 이벤트 모킹 필요 | 순수 메서드 테스트 가능 |
| 접근성 | 직접 이벤트 처리 시 실수 위험 | 표준 포커스 흐름 유지 |
| 가독성 | 템플릿에 복잡한 (keydown) 바인딩 | 템플릿은 상태 바인딩만 남음 |
마무리
키보드 이벤트를 템플릿에 직접 연결하는 대신, 모델 기반 포커스 패턴을 도입하면 Angular 애플리케이션이 더 클린하고 테스트 가능해집니다. 위 예시를 프로젝트에 적용해 보시고, 필요에 따라 Renderer2 대신 ElementRef.nativeElement.focus() 를 직접 호출해도 무방합니다. 중요한 점은 키보드 로직을 한 곳에 집중시키고, UI는 상태에만 의존하도록 설계하는 것입니다.
이 글이 도움이 되었다면 👍 버튼을 눌러 주세요! 그리고 더 많은 Angular 팁을 원한다면 팔로우해 주세요.
Angular에서 로컬 키보드 처리의 문제
Angular 컴포넌트 안에서 @HostListener를 사용해 키보드 이벤트를 연결하고 있다면, 아키텍처가 이미 새고 있는 것입니다.
전역적인 문제를 지역 로직으로 모델링하고 있는 겁니다.
깨끗해 보일 수도 있습니다:
@HostListener('keydown', ['$event'])
// …몇 가지 조건문, element.focus() 호출
이 접근 방식은 애플리케이션이 단순 폼 수준을 넘어 성장할 때까지는 동작합니다.
앱이 커질수록 깨지는 이유
- 재사용 가능한 레이아웃 → 상태가 동적으로 변함.
- 여러 UI 파트가 서로 조정해야 함.
- 포커스가 일관성 없이 동작하기 시작함.
- 키바인딩이 중복됨.
- 엣지 케이스가 늘어남.
한때 간단해 보였던 것이 조용히 부서지기 쉬운 조정 로직으로 전환됩니다. 컴포넌트 전역에 흩어지게 됩니다.
핵심 문제: 포커스는 전역 상태
한 번에 하나의 요소만 포커스를 가질 수 있습니다. 네비게이션은 현재 위치에 따라 달라지는데, 이는 지역적인 동작이 아니라 애플리케이션 전체의 상태입니다.
“포커스는 전체 애플리케이션에 걸쳐 공유되므로, 이를 로컬하게 모델링하려는 시도가 균열이 생기게 하는 시작점입니다.”
개발자들이 키보드 처리를 “개선”하려 할 때 보통 같은 잘못된 가정을 유지합니다: 포커스를 로컬하게 모델링하고 나중에 조정한다는 가정입니다.
결과
- 모든 해결책이 오르막길처럼 느껴짐.
- 대부분의 Angular 앱이 같은 접근법 변형을 채택해 일부를 해결하지만 전체는 해결하지 못함.
- 포커스에 대한 일관된, 애플리케이션‑전체 모델이 없으므로 스트레스가 드러남.
전형적인 증상
-
컴포넌트‑레벨 키 처리
- 간단함:
keydown을 듣고, 키를 확인하고, 포커스를 이동. - 네비게이션이 컴포넌트 경계를 넘을 때 깨짐 → 로직 중복, 동작 차이, 전역 네비게이션 테스트 어려움.
- 간단함:
-
문서‑레벨 처리 (중복을 줄이기 위해)
- 처음엔 깔끔해 보이지만 포커스 컨텍스트가 암묵적으로 되고, 경계 규칙이 복잡해지며 숨겨진 결합이 나타남.
- 이벤트를 중앙화한다고 해서 상태가 중앙화되는 것은 아님.
-
Angular CDK
FocusKeyManager- 단일 컴포넌트 안에서는 훌륭함.
- 여러 컴포넌트 트리, 혼합 레이아웃, 전역 단축키 범위 등을 조정하도록 설계되지 않음.
-
UI‑라이브러리 키보드 동작
- 하나의 툴킷만 사용할 때는 작동함.
- 툴킷을 섞거나 커스텀 컴포넌트를 추가하면 파편화됨.
이 접근법들 자체가 근본적으로 잘못된 것은 아니지만, 포커스를 로컬하게 관리할 수 있다고 가정합니다. 실제로는 그 가정이 균열을 만들게 됩니다.
Source: …
포커스 재고: 이벤트에서 상태로
문제는 더 나은 이벤트 핸들러가 부족한 것이 아니라, 우리가 잘못된 대상을 모델링하고 있다는 점입니다.
- 키보드 네비게이션은 보통 키 입력에 대한 반응(일련의 조건문)으로 다룹니다.
- 포커스는 이벤트가 아니라 상태입니다.
아키텍처 전환
다음과 같이 묻는 대신:
이 컴포넌트 안에서 ArrowDown이 눌렸을 때 무엇을 해야 할까?
다음과 같이 물어보세요:
현재 어느 요소에 포커스가 잡혀 있는가—그리고 다음 유효한 포커스 대상은 무엇인가?
이 미묘한 변화는 네비게이션을 이벤트 핸들러들의 집합이 아니라 상태 전이 시스템으로 바꾸어 줍니다. 이 시스템은 다음과 같이 할 수 있습니다:
- 결정될 수 있음
- 제한될 수 있음
- 구성될 수 있음
- 조합될 수 있음
컴포넌트는 스스로 포커스를 조정하는 일을 멈추고, 더 넓은 모델에 참여하게 됩니다.
예시: 탭 전환 및 포커스
- 사용자가 탭을 전환한다.
- 코드가 새로 활성화된 패널 안의 필드에 포커스를 주려고 시도한다.
대부분은 잘 동작합니다—하지만 그렇지 않을 때도 있습니다.
포커스 호출이 요소가 존재하기 전에 실행되면 포커스가 사라집니다. 이 실패는 버그가 아니라 즉시성을 전제로 한 모델 때문입니다.
현대 애플리케이션에서는 element.focus()가 즉시 실행된다고 보장할 수 없습니다(예: 비동기 렌더링, 지연 로드된 콘텐츠).
Intent → Confirmation 패턴
- 특정 대상에 포커스를 잡겠다는 의도를 표현 → 명시적인 상태 전이가 된다.
- 요소가 실제로 존재하고 준비되었을 때 확인한다.
이점
- 비동기 렌더링에서도 살아남는다.
- 지연 로드된 콘텐츠를 처리한다.
- 레이스 컨디션과 포커스 손실을 방지한다.
- 컴포넌트 경계를 넘어 동작한다.
포커스를 부수 효과가 아니라 상태처럼 다룹니다.
Source: …
Focusly 소개
이 모델을 반복해서 구현해 보면서 누락된 레이어가 필요하다는 것이 명확해졌습니다.
Focusly는 Angular 라이브러리로서:
- 포커스를 명시적으로 모델링하여 공유 애플리케이션 상태로 관리합니다.
- 네비게이션을 방향성 상태 전환으로 취급합니다.
- 구성 기반 키 처리를 제공하며(컴포넌트마다 리스너를 달 필요 없음)
- 컴포넌트가 네비게이션 컨텍스트 내에서 자신의 위치를 선언할 수 있게 합니다.
포커스 오케스트레이션은 애플리케이션 레이어에 존재합니다.
더 이상 수동 리스너가 필요 없습니다
// With Focusly you typically won’t need @HostListener in each component.
이러한 포커스 사고 방식이 마음에 든다면, 프로젝트를 살펴보세요:
- 데모 & 문서 –
- GitHub 저장소 –
- 실시간 데모 –
Focusly는 파편화된 이벤트 중심 키보드 처리에서 견고하고 상태 기반의 포커스 아키텍처로 전환하도록 도와줍니다.
Angular 애플리케이션에서 현재 키보드 네비게이션을 어떻게 처리하고 있는지, 그리고 이 아키텍처 변화가 여러분의 상황에 유용한지 알려 주세요.