Angular에서 Keyboard Events 연결 중단 — 대신 Model Focus

발행: (2026년 2월 26일 오후 07:31 GMT+9)
15 분 소요
원문: Dev.to

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 앱이 같은 접근법 변형을 채택해 일부를 해결하지만 전체는 해결하지 못함.
  • 포커스에 대한 일관된, 애플리케이션‑전체 모델이 없으므로 스트레스가 드러남.

전형적인 증상

  1. 컴포넌트‑레벨 키 처리

    • 간단함: keydown을 듣고, 키를 확인하고, 포커스를 이동.
    • 네비게이션이 컴포넌트 경계를 넘을 때 깨짐 → 로직 중복, 동작 차이, 전역 네비게이션 테스트 어려움.
  2. 문서‑레벨 처리 (중복을 줄이기 위해)

    • 처음엔 깔끔해 보이지만 포커스 컨텍스트가 암묵적으로 되고, 경계 규칙이 복잡해지며 숨겨진 결합이 나타남.
    • 이벤트를 중앙화한다고 해서 상태가 중앙화되는 것은 아님.
  3. Angular CDK FocusKeyManager

    • 단일 컴포넌트 안에서는 훌륭함.
    • 여러 컴포넌트 트리, 혼합 레이아웃, 전역 단축키 범위 등을 조정하도록 설계되지 않음.
  4. UI‑라이브러리 키보드 동작

    • 하나의 툴킷만 사용할 때는 작동함.
    • 툴킷을 섞거나 커스텀 컴포넌트를 추가하면 파편화됨.

이 접근법들 자체가 근본적으로 잘못된 것은 아니지만, 포커스를 로컬하게 관리할 수 있다고 가정합니다. 실제로는 그 가정이 균열을 만들게 됩니다.

Source:

포커스 재고: 이벤트에서 상태로

문제는 더 나은 이벤트 핸들러가 부족한 것이 아니라, 우리가 잘못된 대상을 모델링하고 있다는 점입니다.

  • 키보드 네비게이션은 보통 키 입력에 대한 반응(일련의 조건문)으로 다룹니다.
  • 포커스이벤트가 아니라 상태입니다.

아키텍처 전환

다음과 같이 묻는 대신:

이 컴포넌트 안에서 ArrowDown이 눌렸을 때 무엇을 해야 할까?

다음과 같이 물어보세요:

현재 어느 요소에 포커스가 잡혀 있는가—그리고 다음 유효한 포커스 대상은 무엇인가?

이 미묘한 변화는 네비게이션을 이벤트 핸들러들의 집합이 아니라 상태 전이 시스템으로 바꾸어 줍니다. 이 시스템은 다음과 같이 할 수 있습니다:

  • 결정될 수 있음
  • 제한될 수 있음
  • 구성될 수 있음
  • 조합될 수 있음

컴포넌트는 스스로 포커스를 조정하는 일을 멈추고, 더 넓은 모델에 참여하게 됩니다.

예시: 탭 전환 및 포커스

  1. 사용자가 탭을 전환한다.
  2. 코드가 새로 활성화된 패널 안의 필드에 포커스를 주려고 시도한다.

대부분은 잘 동작합니다—하지만 그렇지 않을 때도 있습니다.
포커스 호출이 요소가 존재하기 전에 실행되면 포커스가 사라집니다. 이 실패는 버그가 아니라 즉시성을 전제로 한 모델 때문입니다.

현대 애플리케이션에서는 element.focus()가 즉시 실행된다고 보장할 수 없습니다(예: 비동기 렌더링, 지연 로드된 콘텐츠).

Intent → Confirmation 패턴

  1. 특정 대상에 포커스를 잡겠다는 의도를 표현 → 명시적인 상태 전이가 된다.
  2. 요소가 실제로 존재하고 준비되었을 때 확인한다.

이점

  • 비동기 렌더링에서도 살아남는다.
  • 지연 로드된 콘텐츠를 처리한다.
  • 레이스 컨디션과 포커스 손실을 방지한다.
  • 컴포넌트 경계를 넘어 동작한다.

포커스를 부수 효과가 아니라 상태처럼 다룹니다.

Source:

Focusly 소개

이 모델을 반복해서 구현해 보면서 누락된 레이어가 필요하다는 것이 명확해졌습니다.

Focusly는 Angular 라이브러리로서:

  • 포커스를 명시적으로 모델링하여 공유 애플리케이션 상태로 관리합니다.
  • 네비게이션을 방향성 상태 전환으로 취급합니다.
  • 구성 기반 키 처리를 제공하며(컴포넌트마다 리스너를 달 필요 없음)
  • 컴포넌트가 네비게이션 컨텍스트 내에서 자신의 위치를 선언할 수 있게 합니다.

포커스 오케스트레이션은 애플리케이션 레이어에 존재합니다.

더 이상 수동 리스너가 필요 없습니다

// With Focusly you typically won’t need @HostListener in each component.

이러한 포커스 사고 방식이 마음에 든다면, 프로젝트를 살펴보세요:

  • 데모 & 문서
  • GitHub 저장소
  • 실시간 데모

Focusly는 파편화된 이벤트 중심 키보드 처리에서 견고하고 상태 기반의 포커스 아키텍처로 전환하도록 도와줍니다.

Angular 애플리케이션에서 현재 키보드 네비게이션을 어떻게 처리하고 있는지, 그리고 이 아키텍처 변화가 여러분의 상황에 유용한지 알려 주세요.

0 조회
Back to Blog

관련 글

더 보기 »

작은 것, 큰 영향

!Trailing comma 삽화 https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s...

파이썬의 비밀스러운 삶: Default 함정

왜 빈 리스트를 기본 인수로 절대 사용하면 안 되는지. 🎧 Audio Edition: 듣는 것을 선호하시나요? 이 심층 탐구의 확장된 AI podcast 버전을 확인해 보세요.