개발 기술/사소하지만 놓치기 쉬운 개발 지식

[JS] 모바일 웹뷰에서 가상 키보드 감지하는 법: visualViewport·디바운스·rAF

by GicoMomg 2025. 8. 17.

1. 들어가며

모바일 웹/웹뷰에서 인풋 포커스로 키보드가 열리고 닫힐 때, 동시에 바텀시트(하단 고정 팝업)를 다루면 화면이 종종 “들썩인다”.
타이밍이 조금만 어긋나도 바텀시트가 순간적으로 튀거나 위치가 흔들려 UX의 신뢰성을 해쳐 서비스의 완성도도 낮아 보인다.

이 문제를 해결하려면 가상 키보드 상태를 감지해야 한다. 그러나 아쉽게도 모든 환경에서 통하는 표준 API는 없다.
특히 인앱 웹뷰는 브라우저마다 구현이 달라, 키보드의 표시·숨김 타이밍을 직접 알려주지 않는다.

 

그럼, 어떻게 해야 가상 키보드의 열림/닫힘을 감지할 수 있을까?
focus/blur를 보고 추정하기? 아니면 화면 리사이즈 변화를 관찰하기?

이번 글에서는 두 접근을 모두 살펴보고, 이 중 실제로 효과가 있었던 리사이즈 + 디바운스 + rAF(프레임 안정 확인) 방식을 구현했다.
이 방법으로 만든 KeyboardDetector는 다음 세 가지 커스텀 이벤트를 제공한다.

  • keyboardOpen — 키보드가 열림으로 전환된 즉시
  • keyboardClose — 키보드가 닫힘으로 전환된 즉시
  • keyboardStableClose — 닫힘 이후 연속 N 프레임(실험값: 6) 동안 뷰포트 높이가 동일해 완전히 안정되었을 때

핵심 아이디어는 간단하다. 리사이즈 변화량으로 열림을 판정하고, 디바운스로 노이즈를 줄인 뒤, rAF로 안정 구간을 확인해
자연스러운 바텀시트 전환 타이밍을 보장한다. 이제 두 접근을 순서대로 살펴보고, 최종 인터페이스의 구현을 살펴보겠다.




2. 키보드를 감지하는 방법

1) 포커스 이벤트 + 리사이즈로 감지하는 방법

입력 가능한 요소의 focus/blur를 열림/닫힘 가능성 신호로 보고, 뷰포트 리사이즈 변화를 함께 관찰해 실제 열림 여부를 보정하는 방식이다.

(1) 아이디어

모바일 환경에서는 사용자가 input, textarea, 혹은 contenteditable 요소에 포커스를 주면 대체로 가상 키보드가 열린다.
반대로 포커스를 잃으면(blur) 가상 키보드가 닫힌다추정할 수 있다.

  • focus 발생 → 키보드 열림으로 추정
  • blur 발생 → 키보드 닫힘으로 추정

 

또한 다음과 같은 보정 규칙을 덧붙이면 감지 시점이 더 명확해진다.

  • 대상 필터링: 포커스된 요소가 실제로 키보드를 유도하는 입력기인지 확인한다.
    • 허용: input[type=text|email|tel|number|search|url|password], textarea, contenteditable=true
    • 제외: readonly, disabled, input[type=button|checkbox|radio]
function isTextInput(el: EventTarget | null): boolean {
  if (!(el instanceof HTMLElement)) return false;
  if (el instanceof HTMLTextAreaElement) return !el.readOnly && !el.disabled;
  if (el instanceof HTMLInputElement) {
    if (el.readOnly || el.disabled) return false;
    return ['text','email','tel','url','search','number','password'].includes(el.type);
  }
  return el.hasAttribute('contenteditable');
}

 

  • 다음 포커스 대기: blur 직후 짧은 타임아웃(예: 100~200ms) 동안 다음 focus가 이어지면 여전히 키보드 열림 상태로 본다(인풋 간 포커스 이동 케이스).
let blurTimer: number | null = null;

document.addEventListener('focusin', (e) => {
  if (!isTextInput(e.target)) return;
  if (blurTimer) { clearTimeout(blurTimer); blurTimer = null; }
});

document.addEventListener('focusout', (e) => {
  if (!isTextInput(e.target)) return;
  if (blurTimer) clearTimeout(blurTimer);
  blurTimer = window.setTimeout(() => { blurTimer = null; }, 100);
});

const resizeTarget = window.visualViewport ?? window;
resizeTarget.addEventListener('resize', onResize, { passive: true });

 

(2) 문제점

포커스 기반 감지는 구현은 간단하지만 여러 상황을 커버하기 어렵다.

  1. 한 화면에 여러 필드가 있을 때
    • 사용자가 한 화면 안에서 여러 필드를 탭하며 이동하면 focus/blur가 연쇄적으로 발생한다.
      이때 매번 “열림/닫힘”으로 해석하면 상태가 요동친다.
    • 해결을 위해 blur 후 짧은 대기를 두고 다음 focus가 오면 닫힘으로 확정하지 않기 같은 보정이 필요하다.
      그러나 대기 시간이 짧으면 여전히 튀고, 길면 반응성이 나빠진다.
  2. 자동 포커스/프로그램적 포커스
    • 화면 진입과 동시에 autofocus나 스크립트 focus()로 포커스가 이동하면,
      사용자 조작 없이 포커스가 발생한다. 일부 웹뷰는 이 순간 키보드를 즉시 띄우지 않거나 지연시킨다.
    • 결과적으로 포커스가 발생했는데 키보드는 아직 안 뜬 상태가 생기며,
      반대로 키보드가 떴지만 포커스 이벤트를 놓치는 타이밍 불일치도 발생한다.
  3. 브라우저/웹뷰별 순서·지연 차이
    • iOS 인앱 웹뷰, Android WebView, Chrome Custom Tabs, 삼성 인터넷 등에서
      포커스·블러·뷰포트 업데이트 타이밍이 제각각이다.
    • 같은 코드라도 디바이스/웹뷰에 따라 열림 감지 누락, 닫힘 감지 지연 등이 발생한다.
  4. contenteditable와 가상 포커스
    • 리치 텍스트 에디터나 커스텀 입력기에서는 내부 포커스 이동, Shadow DOM, 가상 포커스 관리가 사용된다.
      DOM 이벤트만 보고는 “진짜 입력기 활성화”를 정확히 식별하기 어렵다.
  5. 닫힘 타이밍의 불확실성
    • blur가 발생했다고 해서 곧바로 키보드가 닫히는 것이 아니다.
      OS/브라우저가 주소창/툴바 애니메이션을 먼저 수행하고, 잠시 뒤에 키보드를 끄기도 한다.
    • 이 때문에 바텀시트 최종 배치를 blur 직후에 실행하면 화면이 들썩이는 문제가 재현된다.

결국, 포커스 기반 감지는 “키보드가 열릴 수도 있는 신호” 수준으로는 쓸 만하지만, “지금 완전히 닫혔다/안정됐다” 같은 정밀 타이밍을 보장하긴 어렵다.




2) 리사이즈 + rAF로 감지하는 방법

화면 리사이즈 변화량을 기준 높이와 비교해 threshold를 넘는 감소가 있으면 키보드 열림으로 판정하고, 디바운스로 노이즈를 줄인다.
닫힘 전환 시 rAF로 연속 N프레임 동일 높이를 확인해 키보드가 완전히 닫혔는지 판별하는 방법이다.

(1) 아이디어

  • 기준 높이(baseline) 대비 threshold(기본 100px) 이상으로 줄어들면 열림으로 본다.
  • resize는 디바운스(기본 150ms)로 묶어 중복 처리를 줄인다.
  • 닫힘 전환이 감지되면 즉시 keyboardClose이벤트를 발행하고, rAF로 연속 6프레임 동안 높이가 동일하다면 keyboardStableClose를 발행한다.
  • 닫힘 사실만 필요하면 keyboardClose, 정밀 후처리(애니메이션)가 필요하면 keyboardStableClose를 사용하면 된다.

 

(2) 구현 방법

그럼 실제로 어떻게 구현했는지 살펴보자! (전체 코드)

클래스 필드(상태)

class KeyboardDetector {
  #listeners: { [key: string]: Function[] } = {};
  #isOpen = false;
  #stableViewportHeight = 0;
  #threshold;
  #debouncedResizeHandler;
  #rAF_id: number | null = null;
  ...
}
필드 의미
#listeners keyboardOpen / keyboardClose / keyboardStableClose 콜백 목록.
이 이벤트는 사용처에서 호출하여 사용할 수 있다.
#isOpen 현재 키보드가 열렸는지 유무를 관리한다.
#stableViewportHeight baseline(기준 높이). 닫힘 상태일 때만 현재 높이값으로 갱신한다.
#threshold 이 값 이상으로 높이가 변하면 열림으로 간주한다. (기본 100)
#debouncedResizeHandler resize 이벤트의 디바운스 핸들러이다.(기본 150ms).
#rAF_id requestAnimationFrame 식별자.
닫힘 전환 후 매 프레임마다 현재 높이를 검사하고,
연속 6프레임 동안 높이 변화가 없으면 keyboardStableClose 이벤트를 발생시킨다.

 

생성자

constructor(options: { threshold?: number; debounceMs?: number } = {}) {
  this.#threshold = options.threshold ?? 100;
  const debounceMs = options.debounceMs ?? 150;
  this.#debouncedResizeHandler = debounce(
      () => this.#handleResize(), 
      debounceMs
  );
}
  • thresholddebounceMs를 옵션으로 받는다.
  • debounce 유틸은 반환 함수에 cancel() 메서드가 있어야 하며, destroy()에서 정리한다.

 

이벤트 구독

on(eventName: string | number, listener: any) {
  if (!this.#listeners[eventName]) { 
      this.#listeners[eventName] = []; 
  }

  this.#listeners[eventName].push(listener);
}
  • KeyboardDetector는 총 3가지 이벤트를 제공한다.('keyboardOpen' | 'keyboardClose' | 'keyboardStableClose')
  • 만약 사용처에서 특정 시점에 동작을 붙이고 싶다면 on() 으로 콜백을 등록하면 된다.

 

초기화 / 해제

init() {
  this.#stableViewportHeight = this.#getCurrentViewportHeight();
  const target = window.visualViewport ?? window;
  target.addEventListener('resize', this.#debouncedResizeHandler);
}

destroy() {
  this.#debouncedResizeHandler.cancel();
  const target = window.visualViewport ?? window;
  target.removeEventListener('resize', this.#debouncedResizeHandler);

  if (this.#rAF_id) { cancelAnimationFrame(this.#rAF_id); }
  this.#listeners = {};
}
함수 역할
init() 현재 높이를 baseline으로 저장하고,
가능하다면 visualViewport 또는 windowresize 리스너를 단다.
destroy() 디바운스 취소·리스너 제거·rAF 취소까지 정리한다.
SPA 라우팅 전환 시 호출하는 것이 좋다.

 

핵심 판정 로직: #handleResize()

  • 현재 뷰포트 높이를 기준값과 비교해 threshold를 넘는 감소가 있으면 열림, 아니면 닫힘으로 판정하고 닫힘 상태에서는 기준값을 갱신한다.
  • 상태가 바뀌면 keyboardOpen/keyboardClose를 내보내고,
  • 닫힘(keyboardClose)일 때는 keyboardStableClose를 호출한다.
#handleResize() {
  const currentHeight = this.#getCurrentViewportHeight();
  const isKeyboardNowOpen =
    this.#stableViewportHeight - currentHeight > this.#threshold;

  if (!isKeyboardNowOpen) {
    this.#stableViewportHeight = currentHeight; 
    // 닫힘으로 보는 동안 baseline 갱신
  }

  if (this.#isOpen !== isKeyboardNowOpen) {
    const oldIsOpen = this.#isOpen;
    this.#isOpen = isKeyboardNowOpen;

    if (isKeyboardNowOpen) {
      this.#emit('keyboardOpen');
    } else if (oldIsOpen) {
      this.#emit('keyboardClose');
      this.#runWhenViewportStable(() => {
        this.#emit('keyboardStableClose');
      });
    }
  }
}
동작 설명
키보드 열림 판정 baseline - current > threshold 일 때 열린 것으로 본다.
baseline 갱신 조건 닫힘으로 보일 때만 baseline을 현재로 갱신해 툴바/주소창 출렁임 같은 노이즈를 제거한다.
상태 전이 이벤트 false → true 전이: keyboardOpen
true → false 전이: keyboardClosekeyboardStableClose를 트리거한다.

 

안정성 체크(rAF)

  • requestAnimationFrame으로 매 프레임마다 뷰포트 높이를 비교하고 연속 6프레임 동안 동일하면,
  • keyboardStableClose 이벤트를 발생시킨다.
#runWhenViewportStable(callback: () => void) {
  const STABILITY_THRESHOLD = 6;
  let lastHeight = this.#getCurrentViewportHeight();
  let stableFrameCount = 0;

  const checkStability = () => {
    const currentHeight = this.#getCurrentViewportHeight();
    if (currentHeight === lastHeight) {
      stableFrameCount++;
    } else {
      lastHeight = currentHeight;
      stableFrameCount = 0;
    }

    if (stableFrameCount >= STABILITY_THRESHOLD) {
      callback();
      this.#rAF_id = null;
    } else {
      this.#rAF_id = requestAnimationFrame(checkStability);
    }
  };

  if (this.#rAF_id) { cancelAnimationFrame(this.#rAF_id); }
  this.#rAF_id = requestAnimationFrame(checkStability);
}

 

유틸

isOpen() { return this.#isOpen; }

#emit(eventName: string | number) {
  const listeners = this.#listeners[eventName];
  if (listeners) { listeners.forEach(l => l()); }
}

#getCurrentViewportHeight() {
  return window.visualViewport 
    ? window.visualViewport.height 
    : window.innerHeight;
}
함수 동작
isOpen() 열림 상태를 확인한다.
#emit() 등록 리스너를 호출한다.
#getCurrentViewportHeight() 현재 디바이스의 뷰포트 높이를 리턴한다.
만약 visualViewport를 지원하면 visualViewport.height를 그렇지 않다면 innerHeight 를 리턴한다.

 

팩토리 함수

export const keyboardDetector = (
    options?: { threshold?: number; debounceMs?: number }) =>
  new KeyboardDetector(options);
  • 간단한 생성 헬퍼다.
  • 사용처에서는 다음과 같이 사용한다.
const kd = keyboardDetector({ threshold: 100, debounceMs: 150 }) 

 

(3) 사용 예시

앞서 구현한 인터페이스로 두 가지 케이스를 비교해보았다.

  1. 문제상황: 키보드가 닫히자마자 시트를 열어 키보드와 시트가 겹치는 경우
  2. 해결상황: 키보드가 완전히 안정된 뒤 시트를 열어 부드럽게 전환되는 경우

 

문제 상황: keyboardClose 이벤트를 사용하여 테스트

const detector = keyboardDetector();
detector.init();

const nowButton = document.getElementById('open-sheet-now-button');

detector.on('keyboardOpen', () => {
  hideSheetUI(); // 키보드가 열릴 땐 입력 공간 확보를 위해 시트 닫기
});
nowButton.addEventListener('click', () => {
  showSheetUI(); // (문제) 키보드가 닫히는 애니메이션 중이어도 즉시 시트 오픈
});
  • 키보드가 닫히는 애니메이션이 끝나기 전에 시트를 올려 겹침이 생기거나, 시트가 튀는 프레임이 보인다.
  • 아래 사진을 보면, 바텀시트와 키보드가 겹치는 모습이 보인다.
  • keyboardClose는 “닫힘 전환 직후”를 알려줄 뿐, 브라우저 UI(툴바/주소창)와 스크롤의 잔여 애니메이션이 남아 있을 수 있다.

 

해결 상황: keyboardStableClose 이벤트를 사용하여 테스트

const detector = keyboardDetector();
detector.init();

const waitButton = document.getElementById('open-sheet-wait-button');
let isSheetScheduledToOpen = false;

detector.on('keyboardStableClose', () => {
  // 연속 6프레임 동안 동일 높이 → 완전 안정 시점
  if (isSheetScheduledToOpen) {
    showSheetUI();            // (해결) 안정된 뒤에만 시트 오픈
    isSheetScheduledToOpen = false;
  }
});

waitButton.addEventListener('click', () => {
  isSheetScheduledToOpen = true; // 예약만 걸어두고
  input.blur();                  // 포커스 해제로 키보드 닫힘 유도
});
  • keyboardStableClose에서만 시트를 열기 때문에, 뷰포트 높이가 완전히 고정된 뒤 부드럽게 나타난다.
  • 겹침·요동 없음, 바텀시트가 정해진 스냅 포인트에 정확히 안착한다.
  • 아래 사진을 보면, 키보드가 완전히 닫한 후 바텀시트가 등장하는 걸 볼 수 있다.
  • keyboardStableClose는 rAF 기반으로 연속 6프레임 동일 높이를 확인해 애니메이션 종료 후를 보장한다.



3. 마치며…

가상 키보드를 정밀 타이밍으로 다루는 표준 API는 없다. 그래서 뷰포트 높이 변화로 열림/닫힘을 판정하고,
debounce + rAF로 완전 안정 시점을 분리해 keyboardOpen → keyboardClose → keyboardStableClose의 3단계 이벤트를 만들었다.

 

핵심은 다음과 같다.

  • 열림/닫힘 판정: baseline - current > threshold이면 열림으로 본다. 닫힘으로 보이는 동안에는 baseline을 현재값으로 따라가 노이즈를 흡수한다.
  • 안정 분리: 닫힘 후 연속 N프레임(기본 6) 동안 높이가 동일하다면 keyboardStableClose를 발생시킨다. 이때 바텀시트를 열면 들썩임이 줄어든다.

 

언제 무엇을 써야할까?

  • keyboardOpen: 입력 공간 확보, 임시 숨김, 레이아웃 간소화 등 즉시 반응이 필요할 때.
  • keyboardClose: 키보드가 딛히자마자 감지해야할 때.
  • keyboardStableClose: 스냅 포지션 확정, 스크롤 복원 등 픽셀 정밀 후처리가 필요할 때.

 

유의 사항은…!

  • 가로모드/회전 시 baseline을 리셋하거나 잠시 감지를 무시한다.
  • visualViewport 미지원 환경에서는 innerHeight로 폴백하며 threshold를 약간 높게 잡는다.
  • iFrame·하드웨어 키보드·리치 에디터(contenteditable) 등 특수 케이스는 별도 검증이 필요하다.

 

결론적으로, 열림/닫힘완전 안정을 분리해 다루면 웹뷰에서도 바텀시트가 자연스럽게 동작할 수 있을 것이다.

 

반응형

댓글