ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 로컬 챗봇 시리즈 #11 — Esc 한 키가 깨끗해야 한다: UI 임시 상태의 우선순위 스택 디자인
    IT 2026. 5. 9. 23:00
    로컬 챗봇 시리즈 #11 — Esc 한 키가 깨끗해야 한다: UI 임시 상태의 우선순위 스택 디자인

    들어가며 — 작은 키 하나의 디자인이 챗봇 전체의 손맛을 좌우한다

    로컬 챗봇에 다크모드, Cmd-K 명령 팔레트, 코드 하이라이트, 마크다운/수식 렌더, @ 멘션과 / 슬래시 자동완성, 봇 셀렉터 — 작은 UI 장치가 한 묶음 들어갔다. 매일 매 채팅마다 체감하는 것들이라 챗봇이 갑자기 "쓸 만한" 인상으로 변한다.

    그중에서 디자인이 가장 까다로웠고 결국 가장 만족스러웠던 게 Esc 키 한 키의 동작 정의다. Esc는 모든 사용자가 "취소" 또는 "닫기"의 직관을 가진 키다. 그런데 챗봇에는 동시에 열려 있을 수 있는 임시 상태가 4-5개나 된다 — 모달 오버레이, 명령 팔레트, 자동완성 popup, 스트리밍 응답까지 떠 있을 수 있다. Esc가 어느 것을 먼저 닫아야 사용자가 자연스럽게 느끼는가가 의외로 깊은 디자인 결정이다.


    1. "가장 최근에 열린 것부터 닫는다" — Esc 우선순위 스택

    가장 단순한 답은 LIFO 스택이다. 사용자가 마지막으로 띄운 임시 상태부터 차례로 정리한다. macOS의 modal stack, VSCode의 Esc 동작이 모두 같은 원리다. 구체적인 시나리오를 그림으로 보면:

    diagram

    이 그림이 보여주는 핵심은 "같은 Esc 키가 매번 다른 일을 하지만, 그 다른 일이 사용자 직관과 일치한다"는 점이다. 그림 위쪽이 가장 최근 열린 layer(스택의 top)이고, 아래로 갈수록 더 전에 열린 layer다. Esc를 누를 때마다 위에서부터 하나씩 닫힌다 — Layer 1(@ 멘션 popup)부터 시작해서 폼, 모달, 그리고 마지막에는 스트리밍 응답을 중지한다. 사용자 입장에서는 "내가 마지막에 띄운 게 가장 가까이 있고 가장 먼저 사라진다"가 일관된 멘탈 모델이다. macOS의 모달 스택, VSCode의 quickPick + 패널 닫기 동작이 모두 같은 원리라 사용자가 학습 비용 없이 즉시 사용할 수 있다.

    이 디자인의 가치는 "Esc가 무엇을 닫을지 사용자가 추측 안 해도 된다"는 점이다. 마지막 동작의 결과를 취소하거나 닫는다. 운영체제·VSCode·브라우저 modal에서 익숙한 멘탈 모델이 그대로 챗봇에 적용된다.


    2. 구현 — capture phase에서 가장 위 layer가 먼저 처리

    JavaScript에서는 keydown 이벤트 리스너의 처리 순서가 중요하다. 단순히 document.addEventListener('keydown', ...)를 여러 곳에서 등록하면 등록 순서·bubbling 방향에 따라 동작이 예측 안 된다. 의도적인 우선순위를 보장하려면 두 가지 도구를 쓴다.

    diagram

    이 그림이 보여주는 두 가지 메커니즘은 capture phase 등록e.stopPropagation()의 조합이다. capture phase(addEventListener의 세 번째 인자 true)는 일반적인 bubbling phase의 반대 방향이다. 보통 이벤트는 자식 element에서 발생해 부모로 올라가며 처리되지만(bubbling), capture는 부모 → 자식 방향으로 먼저 처리된다. 이걸 활용해 input element에 capture phase 핸들러를 등록하면 input이 가진 모든 자식 동작(text editor, 자동완성 등)보다 먼저 Esc를 잡을 수 있다.

    그 다음 e.stopPropagation()이 핵심이다. 한 layer가 처리하면 그 위 layer로 이벤트 전파를 막는다. 그래서 한 번의 Esc로 한 layer만 닫고, 다음 Esc로 다음 layer를 닫는 동작이 자연스럽게 나온다 — 만약 stopPropagation이 없으면 한 번의 Esc가 모든 layer를 동시에 닫아버린다.

    // chat.js — Esc 처리 (요지)
    userInput.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape') return;
      if (mentionState.open) { closeMention(); e.preventDefault(); e.stopPropagation(); return; }
      // ... 다른 layer들
    }, true);  // capture phase
    
    document.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape') return;
      if (botsOverlayOpen) { closeBotsModal(); return; }
      if (mcpOverlayOpen)  { closeMcpModal();  return; }
      if (ciOverlayOpen)   { closeCustomInstructions(); return; }
      if (cmdkOpen)        { closeCmdk();      return; }
      if (streaming)       { stopGeneration(); return; }
    });
    

    이 코드의 디자인 비결은 두 단계 핸들러 분리다. 첫 번째 핸들러는 input element에 capture phase로 등록 — input-local 상태(자동완성 popup) 처리 우선순위가 가장 높다. 두 번째 핸들러는 document에 일반 bubbling으로 등록한다 — 모달·팔레트·스트리밍 같은 글로벌 상태를 처리한다. 두 핸들러가 처리할 layer를 명확히 분리해놨고, 각 핸들러 안에서 if/return로 우선순위를 표현한다. 추가 layer가 들어와도 분기 한 줄만 더하면 된다 — 새 모달이 들어오면 두 번째 핸들러에 if newModalOpen { closeNewModal(); return; }을 한 줄 추가하면 된다.


    3. 트레이드오프 — 우선순위 스택의 함정

    3-1. capture phase keydown은 다른 핸들러와 충돌 가능 — "전역 가로채기"의 비용

    capture phase로 등록하면 모든 자식 element의 핸들러보다 먼저 발화한다. 의도된 동작이지만 부작용 — 다른 라이브러리가 자체적으로 Esc를 처리하던 경우 그 라이브러리가 Esc를 못 잡는다. 예를 들어 만약 챗봇에 markdown editor 라이브러리가 들어와서 자체 modal·proposal popup을 띄우면, 그 라이브러리의 Esc 핸들러는 우리 capture phase 핸들러 다음에 발화한다. 우리가 stopPropagation을 호출하면 라이브러리는 Esc 자체를 받지 못한다.

    구체적인 시나리오 — Monaco editor 같은 라이브러리를 textarea 대체로 도입한다고 하자. Monaco는 자체 IntelliSense popup을 가지고 있고 Esc로 popup을 닫는다. 그런데 우리 capture 핸들러가 Esc를 먼저 잡고 stopPropagation을 호출하면 Monaco가 Esc를 받지 못한다 — IntelliSense popup이 닫히지 않는다. 사용자는 "라이브러리가 깨졌다"고 느낀다.

    완화책은 "챗봇의 모든 input 핸들링을 한 곳에 모으는 것"이다. 새 라이브러리를 도입할 때 그 라이브러리의 Esc 동작이 우리 우선순위 스택과 충돌하는지 미리 확인한다. 충돌하면 라이브러리 init 시 그 라이브러리의 Esc를 disable하고 우리 핸들러에 그 라이브러리 처리를 추가한다. 핸들링이 흩어지면 디버깅이 지옥이 된다.

    3-2. "스택 top이 무엇인가" 추적이 명시적 상태에 의존 — 새 layer 추가 시 빠뜨릴 위험

    위 코드에서 mentionState.open, botsOverlayOpen, mcpOverlayOpen 같은 boolean 상태를 매 layer마다 따로 들고 있어야 한다. 새 layer가 추가될 때 이 상태를 빠뜨리면 Esc가 그 layer를 안 닫는다.

    실제 운영하면서 이 함정을 한 번 만났다. 시리즈 #10에서 Artifact 사이드 패널을 추가할 때 — 패널에 "닫기" X 버튼만 있고 Esc 핸들러를 빠뜨렸다. 사용자가 Esc를 눌러도 패널이 안 닫히는 비대칭 동작이 됐다. 사용자가 "다른 모든 모달은 Esc로 닫히는데 왜 이건 안 닫히지?"라고 느낀다. fix는 단순했다 — Esc 핸들러에 if artifactPanelOpen { closeArtifactPanel(); return; } 한 줄을 추가했다. 그러나 추가하기 전까지는 일관성이 깨져있었다.

    더 우아한 디자인은 stack 자료구조를 만드는 것이다 — uiStack.push({close: closeFn})로 layer를 등록하고 Esc 핸들러는 uiStack.pop().close()를 호출한다. 그러면 새 layer 추가 시 등록 한 줄만 빠뜨리지 않으면 자동으로 Esc 처리에 포함된다. 현재는 단순함을 우선해 boolean 분기로 진행하지만, layer 수가 7-8개를 넘기 시작하면 stack 자료구조로 옮기는 게 안전하다.

    3-3. 모바일에서는 Esc 키가 없다 — 데스크톱 우위 디자인의 한계

    이 디자인 전체가 키보드 사용자를 가정한다. 모바일 사용자는 Esc 대신 ✕ 버튼·뒤로가기 제스처에 의존한다. 모바일에서 같은 우선순위 스택을 구현하려면 안드로이드 백 버튼이나 iOS 스와이프 백 같은 OS 제스처를 가로채야 하는데, 웹 앱에서는 그게 어렵다.

    다행히 이 챗봇은 가족용 로컬 서버라 폰 접근이 드물다 — 데스크톱에서 ChatGPT처럼 쓰는 게 주된 시나리오다. 그래서 모바일 UX는 최소한으로만 지원했다. 만약 모바일이 주된 사용 환경이라면 — 예를 들어 외출 중 음성 입력으로 챗봇을 쓰는 시나리오가 늘어난다면 — 우선순위 스택 디자인 자체를 다시 생각해야 한다. 모달 닫기 ✕ 버튼을 모든 layer에 일관되게 두고, 키보드 단축키는 데스크톱 보조 기능으로 격하시키는 식이다. 사용자 패턴이 어느 쪽으로 갈지 운영 데이터를 보면서 결정한다.


    4. 마무리

    "키 하나의 동작 정의"가 사용자 손맛의 절반을 만든다는 건 데스크톱 앱 디자이너들이 잘 안다. 챗봇은 의외로 임시 상태가 많은 UI라 Esc 같은 보편 키의 동작이 흔들리면 사용자가 즉각 답답함을 느낀다. "가장 최근 layer부터 차례로 닫는다"는 한 줄 멘탈 모델이 모든 분기를 정당화한다.

    다른 부수 — 다크/라이트 토글(CSS 변수 + localStorage), Cmd-K 명령 팔레트(VSCode 스타일), 마크다운/highlight.js/KaTeX(CDN 한 줄씩), @ 멘션과 / 슬래시 통합 자동완성(같은 popup, 트리거 문자 분기), Ctrl+B 사이드바 토글 — 는 표준 작업이다.

    다음 편은 시리즈 마지막 — 인프라 통합이다. vLLM dual instance, GPU 스케줄러 우선순위 스왑, memory-store SoT를 다룬다. 챗봇이 위에 올라가 있는 시스템 레이어 이야기다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.