Huny's Dev Blog
huny.dev

AI 에이전트 승인 버튼 만들기: 오래된 요청과 중복 클릭을 막는 상태 설계

물리 컨트롤러로 AI 작업을 승인할 때 요청 ID·만료·불변 스냅샷을 묶는 방법을 살펴본다. Python 로컬 시뮬레이터와 5개 검사를 통해 오래된 입력과 중복 결정을 차단한다.

Hun Jang
Hun Jang Nov 5, 2026

물리 버튼으로 여러 작업 중 하나를 선택하는 모습을 표현한 생성형 개념 이미지다. 특정 제품이나 실제 실행 화면은 아니다.

여러 AI 코딩 세션을 물리 버튼으로 제어할 때 가장 위험한 순간은 버튼을 잘못 누르는 순간만이 아니다. 화면에는 이전 요청이 보이는데, 프로그램은 이미 다음 요청을 선택한 상태일 수 있다. 이때 ‘현재 활성 창에 승인 키 보내기’로 구현하면 의도하지 않은 작업에 승인이 전달될 수 있다.

해결의 중심은 사용자가 본 요청과 프로그램이 처리할 요청을 같은 식별자로 묶는 것이다. 하드웨어 연동 전에 요청 ID, 만료, 중복 처리, 응답 확인을 갖춘 작은 시뮬레이터부터 만드는 편이 검증하기 쉽다.

매크로와 승인 제어를 분리한다

키보드 매크로는 포커스가 있는 프로그램에 입력을 보낸다. 반면 승인 제어기는 어느 세션의 어떤 요청을 처리하는지 알아야 한다. 창 전환과 입력 전달 사이에 포커스가 바뀌면 단축키 자체는 정상이어도 대상이 틀릴 수 있다.

다음 세 층으로 나누는 설계를 제안한다.

  • 장치 계층은 버튼 누름과 화면 표시를 담당한다.
  • 상태 계층은 요청의 생명주기와 현재 표시된 내용을 관리한다.
  • 에이전트 어댑터는 각 도구가 공식적으로 제공하는 승인 인터페이스와 연결한다.

특정 USB 컨트롤러에 LCD가 있다고 해서 외부 프로그램이 이미지를 실시간으로 갱신할 수 있다고 가정하지 않는다. SDK·플러그인·장치 프로토콜 지원은 별도로 확인해야 한다. 이 글은 특정 장치에서 기능을 실행했다는 사용기가 아니라 장치와 독립적인 제어 로직 예제다.

승인 요청에는 표시용 요약보다 더 많은 정보가 필요하다

상태 계층은 최소한 세션 ID, 로컬 요청 ID, 사용자에게 보여 준 요약, 만료 시각, 처리 상태를 보관한다. 실제 구현에서는 도구 이름과 변경 대상·명령·비용 등 승인 범위를 결정하는 원본 입력도 불변 스냅샷으로 묶는다. 입력이 달라지면 새 요청으로 취급한다.

JSON-RPC 2.0도 요청과 응답을 연결하는 데 id를 사용한다. 하지만 이 식별 규칙만으로 인증이나 중복 실행 방지까지 해결되지는 않는다. 컨트롤러가 사용하는 로컬 ID와 에이전트 프로토콜의 ID를 구분해 매핑해야 한다. JSON-RPC 2.0 명세

공식 인터페이스마다 필드가 다르다는 점도 중요하다. 현재 Claude Code의 PermissionRequest 훅은 tool_name과 tool_input을 받지만 tool_use_id는 받지 않는다고 문서에 명시돼 있다. 없는 ID를 있다고 가정하지 말고, 대기 중인 훅 호출과 로컬 요청 ID의 연결을 어댑터가 유지해야 한다. 반환 형식 역시 해당 훅의 decision 구조를 따라야 한다. Claude Code 훅 참조

오래된 버튼 입력을 거부하는 최소 예제

다음은 Python 표준 라이브러리만 쓰는 단일 프로세스 시뮬레이터다. 한 번에 버튼 하나에 요청 하나를 표시하는 상황을 가정한다. 실제 명령을 실행하거나 에이전트 권한을 변경하지 않는다.

핵심은 클릭 처리 시 전역 ‘최신 요청’을 무조건 승인하지 않는 것이다. 버튼이 렌더링할 때 받은 displayed 스냅샷을 함께 보내고, 현재 요청과 정확히 일치하는지 확인한다. 세션이 바뀌거나 다음 요청으로 넘어갔다면 이전 클릭은 무시한다.

Lock은 검사와 상태 변경이 같은 임계 구역에서 일어나게 한다. 두 클릭이 들어왔을 때 첫 클릭이 상태를 바꾸면 다음 클릭은 pending 조건을 통과하지 못한다. 이는 한 프로세스 안의 상태 보호이며, 여러 프로세스나 서버 사이의 분산 잠금은 아니다. Python threading 문서

만료 판단에는 시스템 시각 변경의 영향을 받지 않는 monotonic 시계를 사용한다. 반환값의 기준점은 정의돼 있지 않으므로 사람이 읽는 날짜나 재부팅을 넘긴 영속 만료값으로 저장하지 않는다. Python time.monotonic 문서

실행한 검사와 아직 검증하지 않은 부분

위 코드를 approval.py로 저장하고 다음 명령으로 실행한다.

이 글을 준비하며 로컬에서 5개 검사를 실행했고 ‘5 checks passed’를 확인했다.

  1. 새 세션 요청이 표시된 뒤 도착한 이전 요청 클릭을 거부한다.
  1. 현재 요청의 최초 승인을 처리한다.
  1. 같은 요청의 두 번째 클릭을 거부한다.
  1. 만료 시각에 도달한 요청을 거부한다.
  1. 거절 선택을 denied 상태로 기록한다.

이는 상태 전이의 순차 테스트다. 실제 장치 입력 지연, 다중 스레드 부하, 에이전트 훅 통신, 프로그램 재시작을 검증한 결과는 아니다. 위 코드의 summary도 설명용 문자열이며 실제 명령 실행에 사용하면 안 된다.

승인 버튼을 눌렀다고 작업이 실행된 것은 아니다

시뮬레이터의 approved는 로컬 결정이 내려졌다는 뜻이다. 실제 어댑터가 결정을 전달하고 상대가 수락했는지는 별도 상태로 관리해야 한다.

운영 구현에서는 pending 다음에 decision_sent, acknowledged, completed 같은 단계를 두는 방식을 고려할 수 있다. 전달 중 연결이 끊겼다면 성공으로 표시하지 않는다. 요청 결과를 조회할 수 있는 프로토콜이라면 먼저 확인하고, 조회가 불가능하면 결과 불명으로 표시한다. 중복 방지 기능을 확인하지 않은 채 승인 메시지를 재전송해서는 안 된다.

재시작 때는 세션 세대 번호를 바꾸거나 기존 대기 요청을 만료시켜 오래된 버튼 입력이 살아나지 않게 한다. 여러 작업을 동시에 표시한다면 버튼 번호와 요청 ID의 매핑도 각 화면 갱신과 함께 교체한다.

이 상태 모델은 이 글의 설계 제안이다. 모든 AI 도구가 같은 응답 확인·취소 기능을 제공한다는 뜻은 아니다.

실제 연결 전에 확인할 것

먼저 소프트웨어 버튼으로 오래된 클릭과 만료를 재현한다. 이후 장치 입력을 연결하고, 마지막에 에이전트 어댑터를 붙인다. 이 순서라면 장치 드라이버 문제와 승인 로직 문제를 따로 좁힐 수 있다.

운영에서는 다음 조건도 필요하다.

  • 버튼에 프로젝트·작업 요약·대상을 표시하고, 긴 내용은 별도 상세 화면에서 확인한다.
  • 승인할 내용이 바뀌면 기존 표시와 결정을 폐기한다.
  • 로컬 소켓이나 HTTP 제어 포트에 다른 프로세스가 임의로 승인 입력을 보낼 수 없도록 접근을 제한한다.
  • 로그에는 필요한 요청 식별자와 상태를 남기되 자격증명·민감한 명령 인수를 그대로 저장하지 않는다.
  • 하드웨어 고장이나 연결 해제 시 원래 도구의 승인 화면을 사용할 수 있게 한다.

편한 승인 버튼의 기준은 입력 횟수를 줄이는 것만이 아니다. 사용자가 확인한 한 요청에만, 정해진 시간 안에, 한 번의 결정을 전달하는 것부터 보장해야 한다.

추천 글