까보KKABO.DEV
KOEN

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

Gemini function calling, Java로 직접 돌리면 어디까지 해야 하나

Gemini는 함수와 인자를 고를 뿐 실행은 애플리케이션 몫이다. Java 수동 루프, 계층 간 연결이 어긋나도 드러나지 않는 실패, 그리고 Google ADK Java와 AG-UI를 고른 이유를 정리했다.

시점 2025-04 ~ 2026-04 · 기록 2026-08읽는데 16분Read in English#google-genai#google-adk#function-calling#java#spring#llm

TL;DR

  • 모델은 "이 도구를 이런 인자로 부르겠다"까지만 한다. 실행, FunctionResponse 만들기, role="user" 턴으로 재요청은 전부 애플리케이션 몫이다. 이 작업을 할 당시 공식 함수 호출 문서의 예제 탭에 Java는 없어서 Java 패턴을 직접 만들었다. 원고 작성 뒤 기본 문서에 새 Interactions API 기반 Java 예제가 추가됐지만, 이 글에서 다루는 구형 Chat 수동 루프 예제는 아니다.
  • 수동 루프에서 지켜야 하는 규칙은 넷이다. 호출은 모아서 한 번에, 모델이 준 호출 식별자는 그대로 전달하되 없으면 만들지 않기, 도구 실패는 예외가 아니라 결과로, 응답은 하나의 user 턴에.
  • 루프를 브라우저 경유로 돌리면 화면 전용 도구(라우팅·클립보드)를 쓸 수 있다. 이 구조에는 세 가지 부담이 있다. 도구 라운드가 N번이면 스트림은 N+1번 열리고, 루프 변수가 서버에 없고, 실행 스코프 필드가 실제 실행 주체를 표현하지 못한다.
  • 선언(JSON)·구현(Java)·노출 결정(DB 행)을 나누면 도구 하나가 열한 곳에 걸린다. 어느 하나를 빠뜨려도 빌드는 통과한다. 특히 찾기 어려운 것은 아무 신호도 남기지 않는 누락이다.
  • 직접 구현할 것과 프레임워크에 맡길 것을 나눈 기준은 도구가 하는 일이 아니라 도구를 돌리는 주변 코드 — 루프, 등록, 컨텍스트 전파. 결론은 Google ADK Java에 AG-UI.

이 글이 다루는 것은 에이전트 프레임워크 없이 모델 SDK 하나로 도구 호출을 돌리던 구조다. 스택은 Spring Boot 3.3.8 · Java 17이고 모델 SDK는 google-genai 1.44.0, 에이전트 프레임워크 의존성은 이 시점에 존재하지 않는다. 1화에서 만든 프롬프트 관리 계층에 챗봇을 추가했고, 도구 왕복은 애플리케이션이 직접 돌렸다.

모델이 하는 일과 하지 않는 일 — function calling의 실제 동작

함수 호출에서 모델이 하는 일은 하나다. 요청에 실려 온 함수 선언 목록을 보고 무엇을 부를지 고르고 인자를 채워, 응답 파트 하나에 functionCall을 실어 보낸다. 거기까지다. 모델은 함수를 실행하지 않고, 결과를 스스로 가져오지도 않는다. 나머지 넷은 전부 애플리케이션 몫이다.

  1. 스트림으로 흘러오는 파트 중에서 functionCall이 든 것을 골라낸다.
  2. 함수 이름으로 구현을 찾아 실행한다.
  3. 결과를 FunctionResponse로 감싸고, 모델 응답에 호출 식별자가 있으면 같은 값을 붙인다.
  4. 그것들을 role="user"Content로 묶어 같은 대화에 다시 보낸다.

모델이 그 결과를 보고 또 도구를 부르면 1~4를 통째로 한 번 더 돈다. 즉 함수 호출은 API 기능이라기보다 애플리케이션이 짜야 하는 반복문에 가깝고, 그 반복문의 종료 조건도 애플리케이션이 정한다. 이 작업을 할 당시에는 베껴 올 Java 예제가 없었다. 2026-08 확인 시점의 공식 함수 호출 문서에는 Python·JavaScript·REST 예제만 있었고, 아래 패턴은 com.google.genai 1.44.0의 공개 API를 확인해 작성했다. 2026-09 현재 기본 문서에는 새 Interactions API 기반 Java 예제가 생겼지만, 구형 Chat 수동 루프는 여전히 직접 이어야 한다.

수동 루프의 표준 패턴

java
// 개념 예시 (실제 코드 아님). 클래스·메서드 이름은 com.google.genai의 공개 API 표면이다.
public String converse(String question, String promptId) {
    Tool tools = Tool.builder().functionDeclarations(registry.visibleTo(promptId)).build();
    Chat chat = client.chats.create(model,
            GenerateContentConfig.builder().tools(List.of(tools)).build());
    Content next = Content.builder().role("user")
            .parts(List.of(Part.builder().text(question).build())).build();
    StringBuilder answer = new StringBuilder();

    while (true) {
        List<FunctionCall> pending = new ArrayList<>();
        try (ResponseStream<GenerateContentResponse> stream = chat.sendMessageStream(next)) {
            for (GenerateContentResponse chunk : stream) {
                for (Part part : partsOf(chunk)) {
                    if (part.text().isEmpty() && part.functionCall().isPresent()) {
                        pending.add(part.functionCall().get());  // ① 즉시 실행하지 않고 턴이 끝날 때까지 모은다
                    } else {
                        part.text().ifPresent(answer::append);
                    }
                }
            }
        }
        if (pending.isEmpty()) return answer.toString();  // 도구 요청이 없으면 이번 텍스트가 최종 답변이다

        List<Part> responses = new ArrayList<>();
        Set<String> seen = new HashSet<>();
        for (FunctionCall fc : pending) {
            String callId = fc.id().orElse(null);
            if (callId != null && !seen.add(callId)) continue;  // ② 모델이 준 ID가 반복될 때만 중복
            var response = FunctionResponse.builder()
                    .name(fc.name().orElse("")).response(runAsResult(fc));  // ③ 실패도 결과로
            fc.id().ifPresent(response::id);              // 모델이 준 ID만 전달하고 새로 만들지 않는다
            responses.add(Part.builder().functionResponse(response.build()).build());
        }
        next = Content.builder().role("user").parts(responses).build();  // ④ 응답 전부를 한 턴에 담는다
    }
}

/** ③ 실패도 예외가 아니라 모델이 읽을 결과로 바꾼다. */
private Map<String, Object> runAsResult(FunctionCall fc) {
    try {
        return Map.of("status", "success",
                "data", registry.require(fc.name().orElse("")).call(fc.args().orElse(Map.of())));
    } catch (Exception e) {
        return Map.of("status", "error",
                "message", "이 도구는 지금 사용할 수 없다. 같은 도구를 다시 호출하지 말 것.");
    }
}

① 수집. functionCall을 발견할 때마다 실행하면 안 된다. 모델은 한 턴에 여러 호출을 청크로 나눠 내보내므로, 파트 순회가 끝난 뒤 모인 호출을 실행하고, 결과를 하나의 응답 턴으로 돌려준다. 한 라운드 안의 도구들은 병렬로 돌려도 되고 직렬이 강제되는 것은 라운드 사이뿐이다.

② 호출 식별자. 모델은 같은 함수를 인자만 바꿔 여러 번 부른다. 집계 도구를 기준 축만 바꿔 반복 호출하는 식이다. 중복 방어를 함수 이름으로 하면 정상적인 병렬 호출을 중복으로 오인해 결과를 버리게 되고, 응답 개수가 호출 개수와 어긋나 API가 그 요청을 400으로 거절한다. 모델이 호출 식별자를 줬다면 그 값만 중복 판정과 응답 연결에 쓰고, FunctionResponse에도 같은 값을 전달한다. id()가 비어 있으면 임의 UUID를 만들지 않는다. 짝지음은 모델이 준 id로만 성립하므로, 각 호출을 그대로 실행하고 응답 ID를 생략한다. 참고로 구형 generateContent 문서(Legacy 섹션, 2026-09 확인)는 Gemini 3부터 모든 functionCallid가 항상 온다고 적고 있다. 빈 id()는 이 회차 시점의 모델·SDK 조합에서 생기는 일이다.

③ 실패의 표현. 도구 실행 실패를 예외로 던지면 대화가 끊긴다. 실패도 결과다 — {"status":"error", "message":"이 도구는 지금 사용할 수 없다. 같은 도구를 다시 호출하지 말 것."} 처럼 모델이 읽을 값으로 되돌린다. 마지막 문장이 중요하다. 실패 사실만 알려 주면 모델이 같은 도구를 다시 부르는 루프가 생긴다.

④ 한 턴에 담기. 함수 호출 턴 다음에는 함수 응답 턴이 와야 하고, 여러 개를 호출했으면 응답도 하나의 턴으로 와야 한다. 모델이 준 호출 식별자가 있으면 같은 값을 응답에 전달한다. 하나씩 나눠 보내면 대화 히스토리 구조가 깨져 거절당한다. 도구가 하나뿐인 라운드도 같은 모양으로 보내면 단건·배치 분기 자체가 없어진다.

실전 배치 — 화면·서버·모델 3자 왕복

위 루프는 서버의 한 메서드 안에서 실행된다. 실제로 운영한 구조는 그렇지 않았다. 호출을 모으는 곳과 실행하는 곳 사이, 그리고 응답을 다시 보내는 곳과 다음 왕복 사이가 HTTP 경계로 끊겨 있었다.

화면서버모델1질문 (SSE ① 개시)2스트리밍 호출3본문 청크·도구 호출4호출을 모은다5배치 도구 요청, emitter.complete()6서버/화면 도구로 가름7도구 일괄 실행8결과 배열 (JSON)9대화 재개 (새 SSE ②)10결과를 한 턴으로 재주입11최종 답변 또는 다음 도구 호출12본문 청크·완료
  1. 질문 (SSE ① 개시) — 화면이 질문을 보내며 SSE ①이 열린다.
  2. 스트리밍 호출 — 서버가 모델에 스트리밍 호출을 시작한다.
  3. 본문 청크·도구 호출 — 모델이 본문 청크와 functionCall들을 흘려보낸다.
  4. 호출을 모은다 — 서버는 발견 즉시 실행하지 않고 턴이 끝날 때까지 모은다 — '모아서 한 번에'다.
  5. 배치 도구 요청, emitter.complete() — 모인 호출을 이벤트 하나로 내보내고 스트림 ①을 닫는다. 루프 제어권이 브라우저로 넘어간다.
  6. 서버/화면 도구로 가름 — 서버가 알려 준 실행 스코프로 서버 도구와 화면 전용 도구(라우팅·클립보드)를 가른다.
  7. 도구 일괄 실행 — 서버 도구들을 일괄 실행시킨다. 한 라운드 안에서는 병렬이어도 된다.
  8. 결과 배열 (JSON) — 결과가 JSON 배열로 돌아온다. 모델이 준 호출 식별자가 있으면 호출과 응답의 짝을 맞춘다.
  9. 대화 재개 (새 SSE ②) — 대화 재개 요청으로 SSE ②가 열린다. 도구 1라운드를 처리하는 동안 스트림은 두 번 열린다.
  10. 결과를 한 턴으로 재주입 — 결과 전부를 role="user" 한 턴에 담아 다시 보낸다 — '하나의 user 턴' 규칙이다.
  11. 최종 답변 또는 다음 도구 호출 — 도구를 또 부르면 이 그림 전체가 한 번 더 돈다.
  12. 본문 청크·완료 — 최종 본문이 화면으로 흐른다.
그림 1. 도구 한 라운드가 도는 길. 도구 N라운드면 스트림은 N+1회 열린다

여기서 볼 부분은 도구 요청을 보낸 직후다. 서버가 스트림을 닫는다.

java
// 개념 예시 (실제 코드 아님) — 서버의 첫 스트림 끝.
// 위 루프의 '모으기'까지 그대로 수행한 뒤, 텍스트 청크는 흘려보내고 모인 호출은 이벤트 하나로 내보낸다.
if (!pending.isEmpty()) {
    emitter.send(batchToolRequest(pending));   // 함수명·인자·호출 식별자·실행 스코프
    emitter.complete();                        // ← 루프 제어권이 여기서 브라우저로 넘어간다
    return;                                    // 몇 바퀴째인지 세는 변수가 서버에 없다
}

재개 요청이 오면 서버는 세션에서 Chat 인스턴스를 꺼내, 전달받은 도구 결과를 하나의 응답 턴으로 묶어 모델에 다시 보낸다. 즉 저 조각이 반복문의 "한 번의 반복"이고, 반복문 자체는 브라우저에 있다.

왜 브라우저를 경유하나. 도구 중에는 서버가 실행할 수 없는 것이 섞여 있다. "그 화면으로 이동해 줘"는 라우팅이고, 라우팅은 브라우저에만 있다. 클립보드도 마찬가지다. 그래서 도구 정의에 실행 스코프라는 필드를 두고, 서버가 도구 요청을 만들 때 각 항목에 그 값을 실어 보냈다. 화면은 레지스트리를 먼저 뒤지지 않고 서버가 알려 준 스코프로 먼저 가른다. 화면에서만 할 수 있는 일이 도구 목록에 섞여 있으니 루프의 어느 지점은 브라우저를 거쳐야 한다고 봤고, 이 구조에는 다음 세 가지 부담이 있었다.

라운드마다 스트림을 다시 연다. 한 라운드가 끝날 때마다 연결이 닫히므로 도구 라운드가 N번이면 스트림은 N+1번 열리고 닫힌다. Spring MVC의 SseEmitter는 비동기 처리로 서블릿 요청 스레드 자체를 반환하지만, 라운드마다 HTTP 요청이 새로 생기고 개별 응답 쓰기는 여전히 블로킹이다. 인증·서명 헤더도 매번 새로 실린다.

루프 변수가 서버에 없다. 몇 바퀴째인지를 아는 쪽이 화면이다. 서버는 새 도구 호출이 오면 다시 내보내고 스트림을 닫을 뿐, 현재 몇 번째 도구 라운드인지 세지 않는다. 참고로 같은 코드베이스의 단발 호출 경로 — 채팅이 아니라 프롬프트를 한 번 실행하는 경로 — 에는 서버 내부 반복문으로 도는 루프가 붙어 있다. 채팅 경로에서는 화면이 루프를 제어했다.

스코프 필드가 실행 주체를 표현하지 못한다. 화면에서 무언가를 일으키는 경로는 실제로 셋이었다.

경로 서버가 보는 스코프 실제로 누가 하나
정식 화면 도구 화면 레지스트리에서 함수를 찾아 브라우저가 실행
서버 경유 브리지 서버 서버는 인자를 접수 표시와 함께 그대로 되돌려주고, 동작은 화면이 한다
답변 본문 링크 해당 없음 모델이 쓴 특수 스킴 링크를 화면 렌더러가 가로채 팝업을 연다

두 번째 줄은 스코프에 서버라고 적혀 있는데 동작은 화면이 한다. 이 어긋남은 아무 신호도 남기지 않고, 클래스 본문을 읽어야만 드러난다. 세 번째 줄은 도구 메커니즘 자체를 우회해서 실행 상태 표시에도 이력에도 남지 않는다. "이 도구는 어디서 도는가"에 답하려면 세 곳을 다 봐야 한다는 뜻이다.

지금이라면 이렇게 짠다

기록을 다시 보며 든 생각이다. 이 구조에서 정말 필요했던 스트림은 사용자 쪽으로 열어 둔 SSE 하나뿐이었다. 첫 요청이 오면 서버가 emitter를 여는 것까지는 같다. 다시 보니 모델 호출까지 매번 스트리밍으로 받을 필요는 없었다.

  • 서버가 genai를 단건 호출로 부른다. 도구가 필요한 턴의 응답은 어차피 본문이 아니라 functionCall 목록이라, 토큰 단위로 받아 볼 것이 없다.
  • functionCall이 오면 서버가 그 자리에서 실행하고, 같은 루프를 서버 안에서 계속 돈다. "생각 중", "어떤 도구를 호출했다" 같은 진행상황만 열려 있는 emitter로 흘린다.
  • 최종 답변 턴에 도달하면 그때 스트리밍으로 내보내거나, 그냥 한 번에 내보낸다.

genai SDK가 이 조합을 막지 않는다. 같은 Chat에 단건 sendMessage(...)와 스트리밍 sendMessageStream(...)이 함께 있고 둘 다 대화 히스토리를 이어 가며 — 단, 스트리밍 쪽은 스트림을 끝까지 소비한 뒤에야 히스토리에 붙는다 — 단건 응답에서는 functionCalls()로 호출 목록을 바로 꺼낼 수 있다(2026-08 javadoc 확인 — 파트가 없으면 Optional이 아니라 null을 돌려준다). 이렇게 짰다면 스트림은 라운드 수와 무관하게 하나였고, 루프 변수는 서버에 있었고, 화면 전용 도구(라우팅·클립보드)만 예외 경로로 남았을 것이다. 당시에는 "모델 응답은 스트리밍으로 받는 것"을 전제로 삼아 이 방식을 고려하지 못했다.

도구 선언·구현·노출이 어긋나도 빌드는 통과한다

도구 하나는 여러 곳에 나뉘어 있다. 선언은 JSON, 구현은 Java 클래스, 이 프롬프트에서 모델에게 보여줄지는 데이터베이스의 한 행, 화면 표시와 문구는 프런트엔드·리소스의 일곱 곳. 역할이 다르니 파일이 나뉘는 건 정상이다. 문제는 이들 사이의 연결이다. 나뉜 조각을 잇는 것이 문자열로 된 함수 이름과 데이터베이스의 한 행뿐이고, 그 연결이 어긋나도 알려 주는 곳이 없다. 도구 하나를 늘릴 때 확인할 곳을 다 세면 이렇다.

# 수정 지점 어디 무엇을 정하는가
1 함수 선언 JSON 백엔드 리소스 모델에게 보일 이름·설명·파라미터 스키마
2 도구 조회 SQL 백엔드 SQL 매핑 데이터를 읽는 도구의 실제 쿼리
3 도구 구현체 백엔드 Java 실행 로직
4 프롬프트–함수 매핑 행 데이터베이스 이 프롬프트에서 그 도구를 모델에게 보여줄지
5~11 화면 표시·문구 일곱 곳 화면·리소스 표시명, 진행 문구, 인자·완료 요약, 출처 패널 연동, 다국어 값

화면 표시와 관련된 일곱 곳은 한 행으로 묶었다. 여기서는 각 항목이 어디에 있는지를 봐야 한다. 선언(1)은 JSON 리소스, 노출 결정(4)은 데이터베이스 행이라, 코드만 뒤져서는 어긋남이 걸리지 않는다.

점검 기준은 심각도가 아니라 신호다

열한 곳 중 어느 하나를 빠뜨려도 빌드가 통과한다. 빌드가 다 통과한다면 이 지점들은 빌드 결과만으로 점검 우선순위를 정할 수 없다. 심각도는 사후에 매기는 값이고, 실무에서 먼저 부딪히는 것은 "빠뜨린 걸 언제 아느냐"다. 그래서 빠뜨렸을 때 남는 신호를 기준으로 구분했다.

1호출 시점에 예외로 멈춘다조회 SQL — 스택 트레이스가 남는다
2기동 로그에 경고 한 줄선언·구현체 — 경고만 남기고 정상 기동한다
7에러도 경고도 없이 보기만 나빠진다화면 표시·문구 일곱 곳
1아무 신호도 남기지 않는다매핑 행 — 모델만 그 도구를 못 본다
그림 2. 빠뜨렸을 때 남는 신호 네 칸 — 신호가 약할수록 늦게 발견된다

같은 구조를 쓰고 있다면 이 네 칸이 그대로 점검 순서가 된다. 예외가 나면 스택 트레이스로 원인을 좁힐 수 있고, 화면 표시가 잘못되면 사용자가 알려 줄 수 있다. 찾기 어려운 것은 아무 신호도 없는 누락이다. 노출 결정 행이 없는 상태는 이렇게 보인다 — 코드는 빌드되고, 기동 로그에 경고 한 줄 없고, 모델만 그 도구를 못 본다. 소스 어디를 뒤져도 원인이 안 보이니 "모델이 도구를 안 부른다"가 프롬프트 문제로 읽힌다.

짚어 둘 것이 있다. 매핑을 데이터 행으로 관리하는 방식 자체는 문제가 아니다 — 2세대(ADK)로 옮긴 지금도 에이전트별 도구 매핑은 데이터베이스로 관리한다. 키가 프롬프트에서 에이전트로 바뀌었을 뿐, 계속 쓰는 설계다. 문제는 행의 존재가 아니라 부재가 아무 신호도 남기지 않았다는 것이다. 선언·구현 어긋남에는 기동 시 대조를 붙였으면서, 매핑 행에는 그런 대조가 없었다. 1화에서 예고한, 도구를 추가할 때 어려웠던 점 중 하나가 이 무신호다. 뒤에서 다룰 더 큰 한계는 그 모델에 위임을 표현할 구조가 없었다는 점이다.

컴파일러가 못 잡는 세 층, 그리고 기동 시 대조

  1. 화면 쪽 도구 이름의 타입이 리터럴 유니온이 아니라 그냥 string이고, 표시명 맵도 Record<string, string>이다. 새 이름을 어디에 등록하든 말든 타입 검사기가 관여할 여지가 없다. TypeScript strict 모드가 켜져 있어도 이 구조에서는 무력하다.
  2. 백엔드 쪽 선언은 JSON 리소스이고 구현은 Java 클래스다. 둘을 잇는 것은 문자열로 된 함수 이름뿐이라 컴파일 단위가 아예 다르다.
  3. 모델에 실제로 노출될지를 최종 결정하는 것은 코드가 아니라 데이터베이스의 한 행이다.

두 번째 층은 실제로 오진을 만들었다. 모델은 선언만 보고 도구를 부르는데 서버는 이름에 해당하는 구현을 못 찾아 알 수 없는 함수라며 실패하고, 화면에는 도구가 실패했다는 표시만 뜬다. 이 패턴은 기동 시점에 대조해 먼저 발견할 수 있다. 스프링 컨텍스트에서 구현체를 모두 찾아 이름을 키로 맵에 넣고, 선언 목록을 따로 읽어 양쪽을 맞춰 본다. 구현 없는 선언은 모델 노출 목록에서 아예 빼고 경고를 남기고, 선언 없는 구현은 경고만 남긴다. 두 경고 문구를 고정해 두면 기동 로그에서 바로 잡힌다. 디버깅 지침에도 함수 호출 이력에는 있는데 등록 목록에 없으면 모델의 환각보다 구현체 등록 문제를 먼저 의심하라는 항목을 넣었다.

다만 이건 예방이 아니라 감지다. 선언과 구현이 여전히 별개 파일이므로 어긋남 자체는 계속 생기고, 실행 중에야 드러나던 문제가 기동 경고로 먼저 드러날 뿐이다. 범위도 좁다. 대조는 모델 노출 목록만 정리하고, 이름으로 도구를 찾아 실행하는 내부 맵은 건드리지 않는다. 그래서 선언 없는 구현체도 다른 경로로는 여전히 호출된다. 실행을 막은 것이 아니라 모델에게 보여 주지 않을 뿐이다.

무엇을 프레임워크에 넘길지 정하는 기준

처음 붙인 것들은 단건 호출이었다. 요청 내용을 영문으로 옮기고 긴 글을 줄이는 정도라 한 번 보내고 한 번 받으면 끝났고, 1화에서 만든 계층도 단건 호출을 전제로 만들었다. functionCall이 그 전제를 바꿨다. 모델이 답하는 재료가 프롬프트에 미리 넣어 둔 문장이 아니라 그 순간의 업무 데이터가 되기 때문이다. 목표는 업무 시스템의 지식을 자유자재로 조회하고 사용하는 만능 봇 쪽으로 옮겨 갔고, 프롬프트·파라미터·도구 목록을 이미 데이터로 관리하던 1화의 계층에 챗봇을 추가했다.

채팅 에이전트를 만들려면 다음 기능도 필요했다. 왕복 자동화, 대화가 길어질 때의 압축, 역할을 나눠 맡기는 sub agent, 바깥 도구를 잇는 MCP 연동. 이 중 sub agent는 구현 이전의 문제였다. 프롬프트 하나에 도구 목록을 연결한 데이터 모델에는 "다른 에이전트에게 맡긴다"는 관계를 표현할 수 없다 — 1화가 한계로 남긴 지점이다. 그래서 질문을, 이것들을 어떻게 만들 것인가에서 무엇을 직접 구현하고 무엇을 프레임워크에 맡길 것인가로 바꿨다. 나누는 기준은 하나였다. 도구가 하는 일인가, 도구를 돌리는 주변 코드인가. 도구가 하는 일은 그때나 지금이나 데이터를 읽어 구조화된 결과를 돌려주는 것이고, 프레임워크가 대신해 줄 일도 대신해 줄 필요도 없다. 비용이 나온 곳은 전부 도구 바깥이었다.

  • 루프를 누가 돌릴 것인가. 화면이 돌리면 루프 변수도 화면에 있다.
  • 선언을 어디에 둘 것인가. 소스 트리의 JSON 파일이면 배포 없이 바꿀 수 없고, 구현과 컴파일 단위가 갈린다.
  • 등록 실패를 어떻게 다룰 것인가. 경고로 흘리면 기동은 성공하고 도구만 조용히 빠진다. 예외로 터뜨리면 기동이 멈춘다.
  • 세션 맥락을 어떻게 전파할 것인가. 도구 하나는 스스로 검색하지 않고 다른 프롬프트를 모델로 한 번 더 불렀다. 그때 세션 값이 자동으로 따라가지 않아서, 맵 세 곳에 복제해 넣고 받는 쪽이 그 셋을 순서대로 뒤지는 3단 폴백으로 찾았다. 전파 규칙을 통일하지 않아 키 이름도 일치하지 않았다. 세 맵 중 하나만 키 이름이 다르고, 받는 쪽 폴백이 그 차이를 알고 있어야만 동작한다.

넷 다 도구 로직이 아니라 실행 환경이 정할 일이다. 앞의 열한 곳도 결국 같은 이야기였다 — 왕복을 직접 구현하려면 이 연결들도 매번 확인해야 했고, 아무 신호도 남지 않는 누락은 원인을 찾기가 특히 어려웠다. 그래서 넘길 쪽에 요구한 것은 기능 목록이 아니라 약속이었다. 등록이 어긋났을 때 경고가 아니라 실패로 끝날 것, 세션 값이 맵 세 곳이 아니라 실행 상태 한 곳에 실릴 것.

스택은 Spring이고 언어는 Java다. 그 조건에서 쓸 수 있는 것들을 조사했고 LangChain 등도 확인했다. 최종 결론은 Google ADK Java와 AG-UI를 함께 쓰는 방향. 표준을 최대한 지키면서 필요한 곳만 직접 수정하거나 확장하는 쪽을 골랐다.

결정타는 왕복이었다. 1세대에서는 최초 요청을 보낸 뒤 모델이 함수 호출을 돌려주면 그 응답을 파싱해 함수를 직접 실행하고, 함수 응답을 만들어 두 번째 요청을 다시 보내야 했다. 도구가 한 번 더 필요하면 그 순서를 한 번 더 짠다. 이 루프를 프레임워크에 맡기면 최초 요청 한 번으로 끝난다. 최종 응답이 나올 때까지 필요한 만큼 도구를 프레임워크가 직접 호출한다. 중간에 무언가를 해야 하면 — 이벤트 단위로 기록을 남긴다든지 — 해당 확장 지점을 상속해 처리하면 된다. 이 왕복을 프레임워크가 맡게 된다. 2026년 4월, 1세대 위에 얹는 대신 처음부터 다시 만들기로 했다. 구축 이야기는 이어지는 장들에서 다룬다. 한 번에 전부 전환한 것은 아니다. 2세대가 시작된 뒤에도 1세대 도구는 계속 늘었고, 1세대 SDK는 제거되지 않았다.

남기는 질문

넘기기로 한 루프가 서버로 옮겨가도, 화면을 여는 동작은 브라우저에 남는다. 그러면 모델이 고른 화면을 실제로 여는 것은 누구인가. 모델의 도구 호출을 실제 화면 이동으로 연결하는 일은 애플리케이션이 맡아야 한다. 다음 화는 "업무 요청 목록 화면 열어줘"라는 요청이 그 경계를 지나는 과정을 다룬다.

참고

항목 이 회차 시점
모델 SDK (google-genai) 1.44.0
에이전트 프레임워크 (google-adk) 없음
Spring Boot 3.3.8
Java 컴파일 타깃 17
다루는 시점 1세대 도구 체계가 서던 때부터, 2세대 착수 직전까지