까보KKABO.DEV
KOEN

에이전트 구축Google ADK Java로 사내 AI 봇 만들기3편

채팅에서 한 말이 실제 업무 화면의 동작이 되기까지

"업무 요청 목록 화면 열어줘" 한 줄이 모델의 도구 선택, 서버의 접수 확인, AG-UI 이벤트, React의 이동 함수로 이어지는 경로를 화면 식별자 하나로 따라갔다. 화면을 고르는 쪽은 모델이고 여는 쪽은 브라우저이며, 봇이 말하는 "이동했습니다"의 근거는 서버의 접수 확인까지다.

시점 2026-05 ~ 2026-09 · 기록 2026-09읽는데 16분Read in English#google-adk#ag-ui#java#react#spring#llm

TL;DR

  • 화면을 고르는 쪽은 모델이고, 실제로 여는 쪽은 브라우저다. 화면 찾기 도구가 사용자 권한으로 거른 후보를 돌려주면 모델이 목적 화면의 식별자를 고른다.
  • 화면 이동 도구는 서버에 등록된 도구인데, 서버 실행기는 이동하지 않는다. 받은 인자를 received:true와 함께 돌려줄 뿐이고, 화면을 여는 코드는 전부 브라우저에 있다. AG-UI가 제공하는 클라이언트 도구 선언 방식은 쓰지 않는다. 도구 선언을 서버에서 관리하는 것과 브라우저의 실행 결과를 돌려받는 것은 별개의 결정이다.
  • 브라우저는 도구 이름을 보고 TOOL_CALL_END에서 이동을 실행한다. 서버의 접수 응답은 기다리지 않는다. 1세대에 있던 실행 스코프 필드는 사라졌고, 프런트의 도구 이름 목록으로 실행 위치를 구분한다. 서버는 그 목록을 모른다.
  • "이동했습니다"는 화면 전환을 확인한 말이 아니다. 브라우저의 실행 결과는 모델에게 돌아가지 않으므로, 모델이 받는 근거는 서버 자신의 접수 확인뿐이다. 권한이 없어 이동에 실패해도 사용자는 권한 알림을 보고 봇은 성공으로 안다.

Google ADK Java로 옮긴 2세대 봇에서 채팅 요청이 업무 화면을 어떻게 여는지 살펴본다. 스택은 Spring Boot + React이고, 에이전트 프레임워크는 google-adk 1.7.1, 프런트의 AG-UI SDK는 @ag-ui/client 0.0.52다. 서버 쪽 AG-UI 변환은 공개 라이브러리 없이 직접 만들었다. 뒤에서는 이 변환기의 역할과 유지 비용을 도구 관리 방식과 나누어 평가한다.

화면 기능은 2026년 5월에 추가됐다. 이 글은 2026년 9월 7일의 코드를 기준으로 썼다. 예시의 업무 식별자와 자체 도구·업무 필드 이름은 설명용으로 새로 지은 가명이고, ADK·AG-UI의 공개 API와 이벤트 필드 이름은 그대로 썼다.

채팅 패널 뒤에서 목록 화면이 열린다

사이드 패널의 봇에게 "업무 요청 목록 화면 열어줘"라고 치면, 대화창은 그 자리에 남고 뒤에 있던 본문 영역이 업무 요청 목록으로 바뀐다. 새로고침은 없다. 잠시 뒤 봇이 "업무 요청 목록 화면으로 이동했습니다"라고 답한다. 사용자 요청과 봇의 답변은 실제 로그를 인용하지 않고, 같은 형태의 예시로 썼다.

이 한 줄이 화면 동작이 되기까지 세 곳을 거친다. 모델은 이동할 화면의 식별자를 고르고, 서버는 그 호출을 이벤트로 내보내고, 브라우저는 그 이벤트를 보고 앱의 이동 함수를 부른다. 이 글은 화면 식별자 하나가 모델의 출력에서 브라우저의 이동 함수까지 어떻게 전달되는지 따라간다. 업무 요청 목록 화면의 식별자를 MNU-3120, 그 목록에서 다루는 요청 하나를 R-1042라고 부르겠다. 마지막에는 봇이 말한 "이동했습니다"가 무엇을 근거로 한 말인지도 같이 본다.

화면 이름을 식별자로 바꾼다

사용자는 화면 이름을 정확히 모르는 게 보통이다. "요청 목록"이라고도 하고 "R-1042 같은 것들 보는 데"라고도 한다. 그래서 모델은 대개 두 단계를 밟는다.

  1. 화면 찾기 도구를 부른다. 서버에서 실행되는 도구다. 관리형 검색 서비스에 질의해 화면 후보를 찾고, 그 결과를 로그인한 사용자가 볼 수 있는 화면 목록으로 걸러서 돌려준다. 권한 밖 화면은 후보에도 오르지 않는다.
  2. 후보 가운데 하나의 식별자를 골라 화면 이동 도구를 부른다.

첫 단계에서 "업무 요청 목록"이라는 말이 MNU-3120으로 바뀐다. 이 식별자가 이 글의 끝까지 따라가는 값이다. 개발 환경의 실행 이력에도 이 찾기 단계의 실패가 남아 있다. 원인은 아직 특정하지 못했다. 찾기가 실패하면 모델은 이동할 화면의 식별자를 얻지 못해 이동 도구를 부르지 못한다. 다만 찾기 도구는 실패를 값으로 돌려주므로 모델은 그 사실을 알고 답변으로 이어갈 수 있다. 결과가 모델에게 돌아가지 않는 구간은 다음 단계부터다.

서버에 등록한 화면 도구가 하는 일

화면 이동 도구는 브라우저에서 실행되지만 서버에 등록된 도구다. AG-UI 표준은 브라우저가 실행 요청의 tools 필드에 자기 도구 선언을 실어 보내고, 실행 결과도 도구 메시지로 돌려주는 클라이언트 도구 실행 방식을 제공한다. 이 구현에는 실행 요청 본문의 도구 선언 필드가 없고, 프런트의 AG-UI SDK가 만들어 주는 실행 입력 객체 대신 자체 요청 본문 — 에이전트 코드, 세션 식별자, 현재 화면 문맥, 새 메시지 — 을 보낸다. 모델에게 줄 도구 목록은 전부 서버가 만든다. 이 구조의 장점과 남은 문제는 다음 절에서 나누어 본다.

선언은 DB의 도구 카탈로그에 JSON으로 저장돼 있다. 서버는 세션을 만들 때 그 JSON을 ADK의 함수 선언 객체로 복원하고, 같은 행에 적힌 실행기 키로 스프링 빈을 찾아 연결한다. 빈을 못 찾으면 경고로 넘기지 않고 즉시 예외를 던진다. 2화에서 "등록이 어긋났을 때 경고가 아니라 실패로 끝날 것"이라고 요구했던 그 약속이다. 화면 이동 도구는 그중에서도 내부 필수 도구로, 런타임 코드에 이름이 지정돼 있어 에이전트별 도구 매핑과 무관하게 항상 모델에게 보인다.

json
// 개념 예시 (실제 선언 아님). 이름·필드는 설명용으로 새로 지은 것이다.
{
  "name": "navigateToScreen",
  "description": "지정한 화면으로 이동을 요청하는 브라우저 실행 도구다. 서버는 이동하지 않고, 브라우저가 화면 식별자로 실제 이동을 수행한다. 화면을 모를 때는 먼저 화면 찾기 도구로 후보를 검색한다.",
  "parameters": {
    "type": "object",
    "required": ["screenId"],
    "properties": {
      "screenId":   { "type": "string", "description": "이동할 화면의 식별자. 이동 기준은 이 값이며 경로나 주소보다 우선한다." },
      "screenName": { "type": "string", "description": "사용자에게 보여줄 화면 이름." },
      "routeHint":  { "type": "string", "description": "화면 경로 메타데이터. 실제 이동 기준은 식별자다." },
      "orgScope":   { "type": "string", "description": "이동을 요청한 조직 경계(이용 구분)." },
      "payload":    { "type": "object", "description": "이동 대상 화면에 넘길 선택 상태 값." }
    }
  }
}

필수 인자는 screenId 하나다. 화면 경로와 주소를 받는 필드도 있지만 설명에 적혀 있듯 이동에 쓰지 않는다. 모델이 그럴듯한 경로 문자열을 지어내도 그 값으로는 이동하지 않고 반드시 식별자를 거쳐야 하므로, 이동 대상은 실제로 존재하는 화면으로 제한된다. 화면 이동 도구가 부르는 것은 브라우저의 공통 이동 함수이고 여는 방식은 고정돼 있다.

서버 실행기는 짧다. 화면을 여는 코드는 한 줄도 없다.

java
// 개념 예시 (실제 코드 아님)
@Component("screenAction_navigateToScreen")
public class NavigateToScreen implements ScreenActionExecutor {

    @Override
    public Object execute(Map<String, Object> args, UserContext user) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("received", true);          // 서버가 요청을 접수했다는 표시일 뿐이다
        result.put("requestKind", "SCREEN");
        putIfPresent(args, result, "screenId");
        putIfPresent(args, result, "screenName");
        putIfPresent(args, result, "orgScope");
        putIfPresent(args, result, "payload");
        return result;                          // 검증도, 조회도, 이동도 없다
    }
}

값이 있는 인자를 골라 담고 접수했다는 표시를 붙여 돌려주는 것이 전부다. DB 조회나 외부 호출은 하지 않는다. 식별자가 실제로 존재하는지도 확인하지 않는다. 대상 조회는 앞 단계의 찾기 도구가 했고, 화면 표시 권한은 그 찾기 도구의 필터와 뒤에 나올 브라우저 쪽 확인이 맡는다. 서버 실행기는 그 사이에서 인자를 되돌려주는 중계 역할만 한다. 2화의 표에서 "서버 경유 브리지"라고 불렀던 것 — 서버가 인자를 접수 표시와 함께 되돌려주고 동작은 화면이 하는 경로 — 이 2세대에서는 화면 도구의 유일한 방식이 됐다.

서버에 모은 것과 브라우저에 남은 것

AG-UI 문서는 서버 쪽 도구를 에이전트 설정에 두고, 클라이언트 도구는 실행 요청의 tools에 선언해 보내는 배치를 설명한다. 이 구현은 화면 도구도 서버 카탈로그에 등록하고 브라우저에는 실행할 이름 목록을 둔다. 지금 코드를 다시 보면 도구 관리, 대화 이력 관리, 실행 결과 반환이라는 세 결정을 구분해야 한다. 아래는 현재 구조에 대한 평가이며, 당시 이 대안들을 모두 비교했다는 뜻은 아니다.

도구 선언을 서버에서 관리하면 모델에게 보여 줄 정의를 한곳에서 만들 수 있다. DB 카탈로그의 선언과 스프링 실행기가 연결되는지도 서버에서 검사한다. 다만 이것이 실제 브라우저 실행기까지 검증한다는 뜻은 아니다. 브라우저의 이름 목록과 인자 처리 코드는 따로 있고, 서버와 일치하는지 검사하지 않는다. 서버 내부의 등록 오류는 잡지만 양쪽 배포가 어긋나는 문제는 남는다. 클라이언트가 보내는 도구 선언을 서버가 허용 목록과 스키마로 검증하는 설계도 가능하므로, 중앙에서 관리하려면 표준 방식을 포기할 필요는 없다.

대화 이력을 서버에서 관리하는 것은 도구 선언과 별개의 선택이다. 이 구현은 ADK 세션 서비스를 구현한 저장소에 이력을 두고, 프런트는 사용자와 세션 식별자, 허용 목록에 있는 화면 문맥 값, 새 메시지 등을 보낸다. 전체 이력을 다시 보내지 않으므로 전송량을 줄이고 화면 문맥의 범위도 좁힐 수 있다. 그렇다고 AG-UI의 messages 필드를 받으면 대화 이력을 두 곳에서 따로 관리해야 하는 것은 아니다. 서버 저장소를 기준으로 새 메시지를 식별하고 검증할 수도 있다. 자체 요청 형식은 현재 앱에는 맞지만, 표준 입력을 받는 서버를 전제로 만든 범용 프런트를 그대로 붙이기는 어렵다.

브라우저의 결과를 기다리지 않으면 서버는 접수 확인만으로 실행을 끝낼 수 있다. 대신 모델은 실제 이동 성공을 알 수 없다. 이 절충이 맞는지는 이동 결과가 다음 판단을 바꾸는지에 달렸다. 결과가 필요한 화면 도구에만 결과 반환과 실행 재개를 구현해도, 서버 도구를 처리하는 ADK 내부 루프는 그대로 둘 수 있다. 도구를 서버에서 관리하면서 브라우저 결과를 돌려받는 것도 가능하다. 서버 중심으로 도구를 관리한다고 해서 결과를 돌려받지 못하는 것은 아니다.

도구 카탈로그브라우저tools 선언서버선언 복원·실행기 연결모델서버 변환 계층ADK 이벤트 → AG-UI 이벤트브라우저 실행기이름 목록으로 판별·이동서버로 돌아가는 실행 결과없음세션 만들 때쓰지 않음함수 선언functionCallTOOL_CALL_START·ARGS·END
  • 1행: 도구 카탈로그, 브라우저 tools 선언
  • 2행: 서버 선언 복원·실행기 연결
  • 3행: 모델
  • 4행: 서버 변환 계층 ADK 이벤트 → AG-UI 이벤트
  • 5행: 브라우저 실행기 이름 목록으로 판별·이동, 서버로 돌아가는 실행 결과 없음
  • 도구 카탈로그 → 서버 선언 복원·실행기 연결: 세션 만들 때
  • 브라우저 tools 선언 → 서버 선언 복원·실행기 연결: 쓰지 않음
  • 서버 선언 복원·실행기 연결 → 모델: 함수 선언
  • 모델 → 서버 변환 계층 ADK 이벤트 → AG-UI 이벤트: functionCall
  • 서버 변환 계층 ADK 이벤트 → AG-UI 이벤트 → 브라우저 실행기 이름 목록으로 판별·이동: TOOL_CALL_START·ARGS·END
  • 브라우저 실행기 이름 목록으로 판별·이동 → 서버로 돌아가는 실행 결과 없음
그림 1. 현재 구현의 도구 선언과 실행 경로. 점선은 사용하지 않는 클라이언트 선언·결과 반환 경로다

브라우저의 이름 목록과 서버의 카탈로그가 어긋나도 이를 검사하는 곳은 없다. 2화에서 다룬 "무신호", 즉 문제가 생겨도 드러나지 않는 경우에 해당한다. 실행 위치를 서버가 알려 주면 이름 목록의 중복은 줄일 수 있지만, 해당 프런트에 실행기가 있고 인자를 처리할 수 있는지는 여전히 확인해야 한다. 프런트가 하나여도 서버와 서로 다른 시점에 배포되면 생길 수 있는 문제다.

Java 변환 계층의 역할과 유지 비용

서버 쪽에서 ADK 이벤트를 AG-UI 이벤트로 바꾸는 계층은 직접 썼다. 2026년 9월 8일 확인한 AG-UI 저장소의 Google ADK 통합 디렉터리에는 Python과 TypeScript 어댑터가 있고, Java SDK 문서는 공통 이벤트 타입과 원격 서버에 붙는 HTTP 클라이언트를 제공한다. 이 확인 범위에서는 ADK Java 이벤트를 AG-UI 이벤트로 바꾸는 서버 어댑터를 찾지 못했다. Java 변환 코드를 직접 작성할 필요성은 설명되지만, 이것이 도입 당시 라이브러리를 비교한 기록은 아니다.

다만 변환기를 직접 만드는 것과 공개 SDK의 공통 타입까지 쓰지 않는 것은 별개의 결정이다. 이 구현은 이벤트 타입도 자체 코드로 관리한다. SDK 버전을 고정하고 타입을 재사용하면서 ADK 매핑만 직접 작성하는 대안과 비교할 수 있다. 어느 쪽이든 프런트가 기대하는 이벤트 필드·순서·호출 식별자가 맞는지 검사해야 하며, 자체 열거형이 호환성 검사를 대신하지는 않는다.

변환기는 함수 호출을 시작·인자·종료 이벤트로, 함수 응답을 결과 이벤트로 옮긴다. 호출 식별자가 없으면 이벤트 식별자와 순번으로 대체하고, 응답에도 식별자가 없으면 같은 이름끼리 순서대로 짝짓는다. 이때 같은 도구의 병렬 호출 결과가 역순으로 오면 잘못 연결될 수 있으므로, 순서 보장이나 별도의 매칭 근거를 확인해야 한다. 이 경우의 실행 결과는 아직 확인하지 않았다. 공개 Python ADK 어댑터도 호출 ID 보정을 처리하므로 이런 문제가 자체 코드만의 영역은 아니다. 직접 만든 변환기의 가치는 필요한 보정을 넣을 수 있다는 데 있고, 그 보정이 맞는지 검증하고 유지하는 책임도 함께 생긴다.

도구 호출이 브라우저 동작이 되는 순간

1세대에서 서버 도구와 화면 도구를 가르던 실행 스코프 필드는 2세대 도구 정의에 남지 않았다. 백엔드와 프런트 모두에서 해당 컬럼과 필드가 사라졌다. 대신 브라우저가 직접 처리할 도구 이름을 프런트 코드의 목록에 적어 두었다. navigateToScreen이 그 목록에 들어 있어서, 그 이름의 호출 인자를 다 받으면 화면 실행기로 넘긴다. 서버는 이 목록을 모르고, 양쪽 이름이 맞는지 검사하는 곳도 없다.

브라우저에 오는 것은 AG-UI 이벤트다. 서버의 변환 계층이 ADK 이벤트 하나에서 함수 호출을 꺼내 TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END 세 이벤트를 한 묶음으로 만들고, 서버 실행기가 값을 돌려주면 따로 TOOL_CALL_RESULT를 내보낸다. 화면을 여는 신호를 위한 별도의 커스텀 이벤트는 만들지 않았다. 표준 도구 호출 이벤트를 그대로 쓴다.

json
// 개념 예시. 값은 전부 가짜이고, 필드는 AG-UI 표준 필드만 남겼다.
{ "type": "TOOL_CALL_START", "toolCallId": "call-7f3a", "toolCallName": "navigateToScreen" }
{ "type": "TOOL_CALL_ARGS",  "toolCallId": "call-7f3a",
  "delta": "{\"screenId\":\"MNU-3120\",\"screenName\":\"업무 요청 목록\",\"orgScope\":\"ORG-01\"}" }
{ "type": "TOOL_CALL_END",   "toolCallId": "call-7f3a" }
// -- 브라우저는 여기서 이동을 실행한다. 아래는 그 뒤에 도착한다. --
{ "type": "TOOL_CALL_RESULT", "messageId": "msg-50", "toolCallId": "call-7f3a", "role": "tool",
  "content": "{\"received\":true,\"requestKind\":\"SCREEN\",\"screenId\":\"MNU-3120\",\"screenName\":\"업무 요청 목록\",\"orgScope\":\"ORG-01\"}" }
{ "type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-51", "delta": "업무 요청 목록 화면으로 이동했습니다." }

이벤트 페이로드에는 "이건 브라우저가 실행할 도구"라는 표시가 없다. 판별은 도구 이름 하나로 한다. 목록에 없는 이름은 전부 서버 도구의 활동으로 보고 진행 타임라인에 표시만 한다. 목록에 있는 도구는 실행을 맡는 화면에 따라 두 종류로 나뉜다.

실행 담당 역할 실행 시점
대화 화면이 직접 실행하는 도구 인자 해석부터 실행까지 대화 화면이 맡는다. 화면 이동 도구가 여기에 해당한다 TOOL_CALL_END
업무 화면에 위임하는 도구 대화 화면은 이름만 알고, 인자 검증과 반영은 그 업무 화면이 한다 TOOL_CALL_RESULT까지 기다린다

위임 도구는 승인 대기·거부·서버 오류를 결과 이벤트로 확인한 뒤 실행한다. 현재 화면 이동 도구는 승인 대상이 아니고 서버 실행기도 접수 확인만 돌려주므로, 브라우저는 인자 스트림이 끝나는 TOOL_CALL_END에서 바로 실행하도록 되어 있다. 이는 현재의 실행 시점이지 모든 화면 도구가 결과를 기다릴 필요가 없다는 뜻은 아니다.

모델서버브라우저1functionCall navigateToScreen2TOOL_CALL_START3TOOL_CALL_ARGS4TOOL_CALL_END5사용자 화면 트리에서 MNU-3120 검색 → 이동6TOOL_CALL_RESULT7함수 응답 재주입8TEXT_MESSAGE_CONTENT
  1. functionCall navigateToScreen — 모델이 찾기 결과에서 고른 식별자 MNU-3120으로 화면 이동 도구를 부른다.
  2. TOOL_CALL_START — 변환 계층이 호출 식별자 call-7f3a와 도구 이름을 먼저 보낸다. 브라우저는 이름이 목록에 있으니 대기 목록에 올린다.
  3. TOOL_CALL_ARGS — 서버가 보낸 인자 JSON을 브라우저의 인자 버퍼에 누적한다.
  4. TOOL_CALL_END — 인자 전송 끝. 브라우저는 여기서 실행기를 부른다. 서버 응답을 기다리지 않는다.
  5. 사용자 화면 트리에서 MNU-3120 검색 → 이동 — 식별자가 트리에 있으면 앱 내부 이동. 없으면 접근 거부.
  6. TOOL_CALL_RESULT — 서버 실행기가 돌려준 received:true가 도착한다. 브라우저는 END에서 이미 실행을 마쳤으므로 이 결과로 아무 부수효과도 만들지 않는다. 서버 실행기와 브라우저 이동의 선후는 이 순서만으로 판단하지 않는다.
  7. 함수 응답 재주입 — ADK 러너가 접수 확인을 모델에게 넘긴다. 모델이 아는 결과는 이것뿐이다.
  8. TEXT_MESSAGE_CONTENT — 모델이 "이동했습니다" 취지의 문장을 쓴다.
그림 2. 화면 이동 한 번에 브라우저가 받는 이벤트. 이동은 END에서 일어나고 RESULT는 그 뒤에 온다

React는 같은 이벤트를 화면 도구 실행, 말풍선 갱신, 타임라인·승인 카드 갱신에 각각 사용한다. 화면이 열리는 지점은 첫 갈래 하나뿐이라, 이동이 실패해도 대화 표시는 계속된다. 첫 갈래의 핵심 분기만 남기면 이렇다.

ts
// 개념 예시 (실제 코드 아님). 핵심 이벤트 분기만 남겼다.
// 실행 이력 집합과 재접속 재생 차단 등 중복·지연 방어는 생략했다.
const CLIENT_TOOLS = ['navigateToScreen'];   // 브라우저가 직접 실행할 이름 — 서버는 이 목록을 모른다
const pending = useRef(new Map());   // 호출 식별자 -> { name, argsBuffer, done }

function onEvent(event) {
  if (event.type === 'RUN_STARTED' || event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') {
    pending.current.clear();                       // 실행 경계 밖에서는 부수효과를 만들지 않는다
    return;
  }
  if (event.type === 'TOOL_CALL_START' && CLIENT_TOOLS.includes(event.toolCallName)) {
    pending.current.set(event.toolCallId, { name: event.toolCallName, argsBuffer: '', done: false });
    return;
  }
  if (event.type === 'TOOL_CALL_ARGS') {
    const slot = pending.current.get(event.toolCallId);
    if (slot) slot.argsBuffer += event.delta;        // 조각을 이어 붙인다
    return;
  }
  if (event.type !== 'TOOL_CALL_END') return;      // RESULT는 기다리지 않는다

  const slot = pending.current.get(event.toolCallId);
  if (!slot || slot.done) return;                  // 이미 대기 목록에서 지운 호출의 END는 무시한다
  slot.done = true;
  pending.current.delete(event.toolCallId);

  const outcome = runClientTool(slot.name, safeParse(slot.argsBuffer));
  if (!outcome.ok) markActivityFailed(event.toolCallId, outcome.message);
  // 성공이든 실패든 서버로는 아무것도 보내지 않는다.
}

같은 이동을 중복 실행하면 화면이 두 번 전환될 수 있다. 그래서 코드에는 중복 호출과 실행 종료 뒤의 지연 이벤트를 막는 처리가 여러 겹 있다. 호출 식별자당 한 번만 실행하고 실행 즉시 대기 목록에서 지우며, 새 실행이 시작되면 이전 실행의 후보를 전부 버리고, 실행이 끝난 뒤 늦게 도착한 종료 이벤트로는 부수효과를 만들지 않는다. 진행 중인 실행에 다른 창에서 다시 접속해 이벤트를 재생하는 경로에서는 메시지와 활동 표시만 복원하고 화면 이동은 실행하지 않는다. 메시지를 복원하는 것과 이동을 다시 일으키는 것을 나눈 셈이다. 이 방어 코드는 확인했지만, 재접속·지연 상황의 실행 결과까지 확인한 것은 아니다.

열 수 있는지는 브라우저가 다시 확인한다

화면 도구의 프런트 실행기는 하나다. 실행기는 인자를 검증한 뒤 여는 방식에 따라 현재 화면 교체, 새 탭, 화면 이동으로 갈라지는데, 화면 이동 도구는 늘 마지막 갈래다. 사이드 패널은 어느 경우든 그대로 남는다. 화면 이동은 앱의 공통 이동 함수를 부르는데, 이 함수는 넘겨받은 식별자를 로그인 시 받아 둔 사용자 화면 트리에서 찾는다. 찾으면 그 화면의 주소로 이동하면서 상위 화면의 계층 정보와 넘겨받은 상태 값을 함께 전달하고, 못 찾으면 접근 거부로 처리한다. 모델이 routeHint에 무엇을 적었든 실제 이동 경로는 여기서 정해진다.

화면 표시 권한은 두 곳에서 확인한다. 서버의 찾기 도구가 후보를 사용자 권한으로 거르고, 브라우저의 이동 함수가 화면 트리에서 다시 확인한다. 찾기 도구의 필터는 검색 결과를 도구 응답 이벤트로 저장하기 전에 적용되므로 저장된 이력에도 권한 밖 화면이 남지 않는다.

이동이 허용되는 것은 사이드 패널로 봇을 띄운 경우뿐이다. 전체 화면 봇에는 바꿀 본문 영역이 없고, 업무 화면 위에 뜬 대화 모달은 사용자가 보던 화면을 덮고 있어 뒤 화면을 바꾸면 안 된다. 두 경우에는 실행기가 인자 검증보다 먼저 막고 "안내된 경로에서 직접 이동해 달라"는 취지의 문구를 돌려준다. 도구는 모델에게 계속 보인다.

한 가지는 구분해서 봐야 한다. 두 번 확인하는 것은 화면을 표시할 권한이고, 브라우저는 앞에서 본 화면 트리 조회로 이 권한을 확인한다. 실행기가 인자를 검증할 때 함께 보는 것은 요청한 이용 구분이 로그인한 이용 구분과 같은지다. 조직 경계 확인이지 건별 권한 확인이 아니다. MNU-3120에 도착한 뒤 R-1042를 읽을 수 있는지는 봇 경로의 어디에서도 판정하지 않는다. 해당 데이터의 접근 권한은 목적 화면에서 데이터를 조회할 때 확인한다. 화면을 여는 경로가 데이터 접근을 보증하지 않는다는 뜻이고, 이 배치가 처음부터 의도한 것이었는지는 지금 와서 확실하지 않다.

"이동했습니다"의 근거는 접수 확인이다

실행기가 성공이라고 판정하는 범위는 이동 요청을 전달했다까지다. 화면 이동이라면 이동 함수가 반환한 시점이고, 화면이 실제로 바뀌었는지, 데이터가 로딩됐는지는 보지 않는다. 새 탭으로 여는 갈래는 새 창 열기 함수의 반환값을 보지 않으므로, 브라우저가 팝업을 막아도 실행기 쪽에서는 걸리지 않는다.

이동 함수 내부에서 실패를 처리하는 방식도 봐야 한다. 공통 이동 함수는 화면 트리에서 식별자를 못 찾으면 직접 알림을 띄우고 예외를 다시 던지지 않는다. 실행기는 예외를 못 보고 성공을 반환한다. 사용자에게는 접근 권한 알림이 뜨는데, 실행 타임라인에는 이동이 성공한 것으로 남는다.

이 모든 결과는 서버로도 모델로도 돌아가지 않는다. 브라우저가 실행 결과를 보내는 API가 없고, 다음 요청에 도구 결과 메시지를 실어 보내지도 않는다. 서버는 화면 도구를 자기 실행기로 즉시 처리해 접수 확인을 함수 응답으로 만들어 모델에게 넘기고, 모델은 그것을 받아 답변 문장을 이어 쓰고, 실행은 정상 종료된다. 브라우저를 기다리느라 멈추는 구간은 없다. 이 구현이 실행을 중단했다가 재개하는 데 쓰는 장치는 ADK의 승인 게이트뿐인데, 화면 도구는 그 대상이 아니다.

그래서 "업무 요청 목록 화면으로 이동했습니다"라는 문장의 근거는 서버 자신이 만든 received:true다. 모델은 브라우저가 이동했는지, 막혔는지, 팝업이 차단됐는지 모른다. 따라서 모델의 완료 문구만으로는 실제 화면 이동의 성공 여부를 확인할 수 없다.

여기서는 결과를 모델에게 보내지 않는 것과 브라우저가 실패를 성공으로 표시하는 것을 나누어야 한다. 전자를 유지해도 이동 함수가 실패를 호출자에게 전달하면 타임라인은 실패로 표시할 수 있다. 실제 화면을 확인하지 않은 모델의 문구도 "이동을 요청했습니다"로 제한할 수 있다. 이 둘은 결과 반환을 붙이기 전에도 보완할 수 있는 부분이다.

그다음에는 모델이 다음 행동을 정할 때 이동 결과가 필요한지 판단해야 한다. 필요하다면 결과를 돌려받아 실패 시 경로 안내로 바꾸거나 후속 도구 호출을 멈출 수 있다. 이 구현의 요청·스트림 방식에 붙이려면 결과 제출과 실행 재개, 탭 종료·응답 지연 처리가 필요하다. 이미 있는 승인 결정 제출 경로는 참고할 수 있지만, 사람의 승인과 브라우저의 실행 결과는 다르므로 호출 식별자 대조와 중복 제출 방지를 포함해 별도로 설계해야 한다. 무엇을 성공으로 볼지도 요청 전달·화면 전환·데이터 표시 중에서 정해야 한다. 도구와 이력을 서버에서 관리하면서도 다음 판단에 필요한 결과를 돌려받을 수 있다.

참고

항목 이 글의 확인 기준
에이전트 프레임워크 (google-adk) 1.7.1
모델 SDK (google-genai) 1.65.0
프런트 AG-UI SDK (@ag-ui/client·@ag-ui/core) 0.0.52
서버 AG-UI 변환 계층 자체 구현
코드 확인 시점 2026-09-07
화면 기능 도입 시점 2026-05 — 위 버전들의 도입 시점을 뜻하지 않는다