매일공부

포커스 도둑 — 첫 클릭만 씹히던 이상한 버그 본문

IT/작업기록

포커스 도둑 — 첫 클릭만 씹히던 이상한 버그

aram 2026. 9. 2. 13:07

새로고침 직후 첫 클릭만 씹히고, 이슈 앱만 멀쩡했던 이상한 버그. 가설을 다섯 번 갈아치우고 나서야 범인을 잡았다 — 부트스트랩 드롭다운의 상태를 손으로 흉내 낸 코드 한 줄이었다.

요약: 기각된 가설 5개, 진짜 원인은 코드 1줄, 영향 범위는 거의 전 앱(이슈 앱만 예외).

증상: 첫 클릭은 항상 씹혔다

리스트 화면의 검색 필터, 등록 폼의 텍스트·숫자·날짜 입력창, 드롭다운 선택창까지 — 페이지에 처음 진입했을 때(새로고침 직후, 또는 다른 페이지에서 넘어온 직후) 첫 번째 클릭은 아무 반응이 없었다. 입력창을 클릭해도 커서가 안 들어가고, 드롭다운을 클릭해도 안 열렸다. 두 번째 클릭부터는 멀쩡했다. 매번, 어느 브라우저에서나, 운영 서버에서도 항상 재현됐다.

단서는 하나 있었다 — 이슈 관리 앱만 이 증상이 없었다. 같은 사이트, 같은 레이아웃, 같은 공통 스크립트를 쓰는데 왜 한 앱만 멀쩡한지가 이 버그를 풀 수 있는 유일한 실마리였다.

증상 있음: 고객·라이선스·견적서·상품 관리 화면의 텍스트 입력창, 날짜 입력창, Select2 검색 콤보박스, 리스트 화면 검색 필터

증상 없음: 이슈 관리(전 화면) — 키워드를 입력하면 바로 반응하는 검색창

가설 다섯 번, 전부 틀렸다

콘솔에 에러 하나 없는 상태에서 "뭔가 클릭을 가로챈다"는 감만으로 원인을 좁혀야 했다. 시도했던 순서대로 남겨둔다 — 틀린 가설도 왜 틀렸는지 알아야 다음에 같은 삽질을 반복하지 않는다.

1. 사이드바 드롭다운이 강제로 열려있다가 첫 클릭에 닫힌다 — 부분 확인

navbar.js가 현재 섹션 하위 메뉴에 부트스트랩의 show 클래스를 직접 붙여서 항상 펼쳐진 것처럼 보이게 하고 있었다. 이걸 부트스트랩 API 없이 클래스만 붙이면, 부트스트랩이 전역으로 듣고 있는 "바깥 클릭 시 열린 드롭다운 닫기" 로직이 반응해서 첫 클릭에 메뉴를 닫아버리고, 사이드바 높이가 줄어드는 리플로우가 발생한다. 이 부분은 맞는 관찰이었지만, 고쳐도 입력창이 안 먹히는 증상 자체는 그대로였다 — 진짜 원인이 아니라, 같은 뿌리에서 나온 곁가지 증상이었다.

2. Django Debug Toolbar가 비동기로 로딩되며 레이아웃을 흔든다 — 기각

클릭 전후 스크린샷에서 페이지 폭이 미세하게 달라진 게 보여서, 개발 툴바 패널이 늦게 로딩되며 스크롤바가 생기고 그 순간 클릭 좌표가 어긋난 게 아닐까 의심했다. 하지만 콘솔에는 진짜 JS 에러가 하나도 없었고, 결정적으로 이 증상은 운영 서버(개발 툴바가 없는 환경)에서도 똑같이 재현됐다.

3. 브라우저 확장 프로그램(비밀번호 관리자 등)의 간섭 — 기각

이름·이메일·연락처 필드에 자동완성 아이콘을 주입하는 확장 프로그램이 첫 클릭을 가로챈다는 가설도 그럴듯해 보였다. 시크릿 모드, 그리고 아예 다른 브라우저의 시크릿 모드에서도 똑같이 재현되면서 기각됐다 — 확장 프로그램이 개입할 여지가 없는 환경에서도 버그는 그대로였다.

4. 부트스트랩 모달의 FocusTrap이 활성화돼 있다 — 기각

클릭 한 번 안에서 포커스가 입력창 → 사이드바 링크로 튀는 게 관찰되자, 모달/오프캔버스 전용 기능인 FocusTrap(포커스가 지정 영역 밖으로 나가면 강제로 되돌리는 접근성 기능)을 의심했다. 콘솔에서 document.querySelectorAll('.modal.show, .offcanvas.show')를 찍어보니 열려있는 모달이 하나도 없었다. 깔끔하게 기각.

5. 부트스트랩 드롭다운의 "바깥 클릭 시 닫기"가 포커스를 되돌린다 — 확정

클릭 이벤트에 console.trace()를 심어서 포커스가 엉뚱한 곳으로 튀는 순간의 호출 스택을 직접 찍었다.

document.addEventListener('focusin', e => {
  if (e.target.matches && e.target.matches('a.dropdown-toggle')) {
    console.trace('nav-link로 포커스 이동!', e.target);
  }
});

이걸 콘솔에 심어두고 확인한 두 가지 케이스가 결정적이었다.

  • 가만히 있을 때: 아무것도 안 찍힘 — setInterval 같은 주기적 실행이나 전역에서 알아서 도는 코드는 아니라는 뜻
  • 클릭할 때만: 클릭 시점에 정확히 트레이스가 찍힘 — "무엇이 이 refocus를 유발했는지"가 콜스택에 그대로 남았다

찍힌 콜스택은 _completeHide → clearMenus — 부트스트랩 드롭다운 컴포넌트 내부 코드였다. 여기서부터 진짜 원인을 역추적할 수 있었다.

진짜 원인: 가짜로 흉내 낸 "열림" 상태

부트스트랩 드롭다운은 "지금 열려있는 드롭다운이 있는지"를 판단할 때, 메뉴가 아니라 토글 버튼 자신의 [data-bs-toggle="dropdown"].show 클래스를 기준으로 삼는다. 그런데 navbar.js는 현재 섹션을 항상 펼쳐 보이게 하려고 토글 버튼에도 show 클래스를 직접 붙이고 있었다.

그 결과 부트스트랩은 이 드롭다운을 "사용자가 열어놓은 진짜 드롭다운"으로 착각했다. 그래서 페이지 어디를 클릭하든 — 그 클릭이 드롭다운과 아무 상관 없는 입력창이라도 — 전역으로 등록된 clearMenus가 실행되면서 "열려있는" 이 드롭다운을 강제로 닫았다. 그리고 부트스트랩은 드롭다운을 닫을 때 접근성 규칙에 따라 포커스를 토글 버튼으로 되돌려준다 — 방금 사용자가 클릭한 입력창의 포커스를 그 자리에서 빼앗아 가는 것이다.

1차 수정의 실수: 처음엔 드롭다운 메뉴(.dropdown-menu) 쪽 클래스만 show에서 커스텀 클래스로 바꾸고, 토글 버튼(<a class="nav-link dropdown-toggle">) 쪽에는 classList.add('show')를 그대로 남겨뒀다. 판단 기준이 메뉴 쪽인 줄 알았는데, 실제로는 토글 버튼 쪽이 기준이었다 — 위 콘솔 스크립트로 클릭할 때마다 여전히 트레이스가 찍히는 걸 보고서야 이 사실을 확인했다. 그래서 사이드바가 접히는 증상은 사라졌지만 진짜 원인(포커스 탈취)은 그대로 남아있었다.

왜 하필 "첫 클릭"만 씹혔나

한 번의 클릭 동작 안에서 브라우저와 스크립트가 처리하는 순서를 그대로 옮기면 이렇다.

  1. mousedown — 입력창
  2. focusin — 입력창 (정상적으로 포커스 이동)
  3. click 이벤트 발생 → 부트스트랩의 clearMenus가 (우리 코드보다 먼저 등록돼 있어서) 가장 먼저 실행됨 → "열려있는" 드롭다운을 발견 → hide() → 토글 버튼에 .focus()
  4. focusin — 사이드바 토글 링크 (포커스 탈취 완료)

클릭의 대상(target)은 여전히 입력창이지만, 이벤트가 처리되는 도중에 포커스가 다른 곳으로 옮겨져 버려서 결과적으로 입력창은 아무 반응도 없는 것처럼 보인다. 두 번째 클릭이 먹히는 이유는 단순하다 — 그 시점엔 더 이상 "열려있다고 착각된" 드롭다운이 없어서, clearMenus가 할 일이 없기 때문이다.

이슈 앱만 멀쩡했던 이유

원인과는 무관한 우연이었다. 이슈 메뉴의 <li>에만 다른 메뉴와 달리 dropdown 클래스가 빠져 있어서, 애초에 하위 메뉴가 시각적으로 펼쳐지지 않았다. 펼쳐진 적이 없으니 부트스트랩이 착각할 "열린 드롭다운"도 없었던 것 — 버그를 피해간 게 아니라, 다른 마크업 실수 덕분에 우연히 안전했던 것뿐이다.

이 버그, 더 일찍 잡을 수 있었을까

이 버그는 사실 앱이 하나뿐이던 아주 초기부터 있었다. 그때도 첫 클릭이 가끔 안 먹혔는데, "원래 이런가 보다" 하고 대수롭지 않게 넘겼었다. 나중에 돌아보면 아이러니한 지점이 있다.

  • 코드는 그때가 더 고치기 쉬웠을 것: navbar.js가 지금보다 훨씬 짧았을 시점이라, console.trace()까지 갈 것도 없이 classList.add('show') 같은 코드를 눈으로 훑다가 발견했을 가능성이 높다
  • 하지만 그때는 "이게 버그다"라고 확신하기가 오히려 더 어려웠을 것: 이번에 결정적 단서가 됐던 "이슈 앱만 멀쩡하다"는 대조군 자체가, 이슈 앱이 생기고 검색창(JS가 필요한 컴포넌트)이 붙으면서야 만들어졌다. 비교 대상이 없던 시절엔 "그냥 좀 불편하네" 정도로 지나가기 쉬웠다

코드는 단순할 때 고치기 쉬웠겠지만, 정작 그때는 이게 고쳐야 할 대상인지조차 알아채기 어려웠을 거라는 게 이번 케이스에서 얻은 교훈이다. 버그를 더 빨리 잡으려면 코드가 단순한 것보다, "이건 정상이 아니다"라고 판단할 수 있는 비교 대상(다른 화면, 다른 사용자 경험)이 있는 게 오히려 더 중요할 수 있다.

수정

static/js/navbar.js

수정 전 → 후

if (toggleButton && toggleButton.classList.contains('dropdown-toggle')) {
    menu.classList.add('show');
    toggleButton.classList.add('show');
    toggleButton.setAttribute('aria-expanded', 'true');
    menu.classList.add('sidebar-menu-open');
    toggleButton.setAttribute('aria-expanded', 'true');
}

메뉴는 부트스트랩이 추적하지 않는 별도 클래스(sidebar-menu-open)로 펼치고, CSS 한 줄로 display: block을 줘서 시각적으로는 기존과 동일하게 유지했다. 토글 버튼에는 show를 아예 붙이지 않아서 clearMenus가 이 드롭다운을 더 이상 "열려있다"고 오인하지 않는다.

UI 라이브러리의 상태 클래스를 공식 API 없이 흉내 낼 때는, 그 라이브러리가 "열림"을 정확히 어느 요소의 어느 클래스로 판단하는지부터 확인해야 한다. 반대로 짐작하면, 증상 하나를 없애고 진짜 원인은 그대로 남기는 절반짜리 수정이 나온다.

다음에 이런 증상을 보면

비슷한 유형의 버그를 더 빨리 알아보기 위한 체크리스트. 아래 조건이 겹치면 "가짜로 흉내 낸 컴포넌트 상태"를 제일 먼저 의심할 것.

  • 콘솔 에러 없이 UI만 이상하게 동작한다 — 진짜 JS 예외가 없다는 건, 코드가 "의도한 대로" 동작하고 있다는 뜻이다. 문제는 그 의도 자체가 라이브러리의 실제 판단 기준과 어긋나 있는 것.
  • 특정 화면·특정 앱만 멀쩡하다 — 마크업/클래스 구성이 미묘하게 다른 곳이 있는지 diff해볼 것. 우연히 버그를 피해간 쪽이 오히려 원인을 알려주는 대조군이 된다.
  • 부트스트랩류 라이브러리의 상태 클래스(show, active, open 등)를 직접 classList로 조작하는 코드가 있다 — grep -rn "classList.add('show'" static/js 같은 걸로 먼저 찾아볼 것. 공식 JS API(.show(), .hide())를 거치지 않은 상태 조작은 전부 용의선상에 둔다.
  • "첫 번째만" 실패하고 그다음부터는 정상이다 — 한 번 실행되면 스스로 정리되는 일회성 상태(강제로 열어둔 것, 잘못 설정된 플래그 등)를 의심할 것. 두 번째 시도부터 멀쩡한 건 "이미 정리가 끝나서"인 경우가 많다.
  • 재현되면, 로그보다 이벤트 순서와 콜스택을 직접 찍는다 — mousedown/focusin/click에 콘솔 리스너를 걸어 순서를 보고, 의심 지점엔 console.trace()를 심는다. Event Listener Breakpoint는 "모든 클릭이 지나가는 공용 디스패처"에 걸리는 라이브러리에서는 노이즈가 많아 오히려 덜 유용했다.

부록 — 이번에 콘솔에 심었던 코드, 하나씩 뜯어보기

큰 흐름만이 아니라 실제로 뭘 쳤고 각 줄이 무슨 뜻이었는지 기록해둔다.

1. 이벤트 순서 확인

document.addEventListener('mousedown', e => console.log('①mousedown', e.target));
document.addEventListener('focusin', e => console.log('②focusin', e.target));
document.addEventListener('click', e => console.log('③click', e.target));
  • document.addEventListener('이벤트이름', 콜백함수) — "이 이벤트가 페이지 어디서든 발생하면 이 함수를 실행해라"를 등록하는 브라우저 표준 API. document에 걸면 페이지 전체(모든 자식 요소)에서 일어나는 이벤트를 다 잡을 수 있다 (이벤트가 아래에서 위로 "버블링"되기 때문)
  • e => console.log(...) — 화살표 함수. "이벤트가 발생하면 이벤트 객체(e)를 받아서 콘솔에 출력해라"
  • e.target — 실제로 그 이벤트가 발생한 DOM 요소. 입력창을 클릭했다면 e.target은 그 <input> 태그
  • 세 이벤트의 의미:
    • mousedown — 마우스 버튼을 누르는 순간 (아직 떼기 전)
    • focusin — 어떤 요소가 포커스를 받는 순간 (커서가 그 안으로 들어가는 것)
    • click — 마우스를 눌렀다 뗀 뒤 브라우저가 "클릭이 완료됐다"고 판단하는 순간 (mousedown → mouseup 다음에 발생)

세 개를 동시에 건 이유: 한 번의 클릭 안에서 이 셋이 어떤 순서로 찍히는지 보려는 것. 정상이면 mousedown → focusin(입력창) → click 순서로만 찍혀야 하는데, 실제론 그 사이에 focusin(엉뚱한 nav 링크)이 끼어드는 게 보였다 — 그게 "포커스가 중간에 도둑맞았다"는 증거였다.

2. defaultPrevented 확인 (초반 시도)

document.addEventListener('mousedown', e => console.log('mousedown', e.target, 'prevented:', e.defaultPrevented));

e.defaultPrevented는 "누군가 이 이벤트에 대해 preventDefault()를 호출했는지"를 알려주는 값(true/false)이다. 어떤 코드가 e.preventDefault()를 부르면 브라우저의 기본 동작(포커스 이동, 체크박스 토글 등)이 막힌다. "혹시 누가 일부러 포커스를 막고 있나?"를 의심해서 확인했다. (결과는 false — 이 경로는 아니었다.)

3. 모달/포커스 상태 확인

document.querySelectorAll('.modal.show, .offcanvas.show')
document.activeElement
  • document.querySelectorAll('CSS선택자') — 그 CSS 선택자에 맞는 요소를 전부 찾아서 목록으로 돌려준다. .modal.show, .offcanvas.show는 "modal이면서 동시에 show 클래스를 가진 요소, 또는 offcanvas이면서 show 클래스를 가진 요소"라는 뜻 (콤마는 "또는"). 부트스트랩 팝업창(모달)이 열려있으면 이 클래스 조합이 붙는다 — "지금 몰래 열려있는 팝업창이 있는지"를 확인한 것
  • document.activeElement — 지금 이 순간 포커스를 갖고 있는 요소가 뭔지 바로 알려주는 브라우저 내장 속성. 함수 호출이 아니라 값을 읽는 것이라 괄호가 없다

4. 콜스택 추적 (console.trace)

document.addEventListener('focusin', e => {
  if (e.target.matches && e.target.matches('a.dropdown-toggle')) {
    console.trace('nav-link로 포커스 이동!', e.target);
  }
});
  • e.target.matches('CSS선택자') — "이 요소가 이 CSS 선택자에 해당하는지"를 true/false로 알려준다. 즉 "포커스를 받은 요소가 하필 사이드바의 드롭다운 토글 링크인가?"를 걸러낸 것
  • e.target.matches && — 혹시 e.target이 document 같은 걸로 와서 .matches 함수 자체가 없는 예외 케이스를 방지하는 안전장치
  • console.trace()가 핵심이었다. console.log와 달리 "지금 이 코드가 실행되기까지 어떤 함수들이 차례로 호출됐는지" 그 호출 사슬(콜스택)을 통째로 출력해준다. 그래서 결과에 (익명) → _completeHide → clearMenus → i 같은 게 찍혔고, 이게 "누가 이 포커스 이동을 시켰는지" 범인을 특정해준 결정적 단서였다

한 줄 요약

1~3번은 "언제, 무슨 일이 일어나는지"를 눈으로 관찰하는 도구였고, 4번(console.trace)이 "누가 그 일을 시켰는지" 진짜 범인의 이름(호출 스택)을 알려준 결정타였다.

Comments