까보KKABO.DEV
KOEN

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

번역 요청 하나가 사내 LLM 플랫폼이 되기까지

구두로 먼저 들어온 번역 버튼 요청 하나. genai 호출 한 번이면 하루로 끝날 일이었지만, 번역 기능 대신 프롬프트·지침·도구를 화면에서 관리하는 계층을 먼저 만들었다.

시점 2025-03 · 기록 2026-08읽는데 9분Read in English#google-adk#java#spring#llm#enterprise

TL;DR

  • 2025년 초, "요청 내용을 영문으로 번역하는 버튼을 달아 달라"는 요건이 구두로 먼저 들어왔다. genai API 호출 한 번이면 하루로 끝날 일이었다.
  • 하지만 이 방식이 선례가 되면 기능이 늘어나는 만큼 지침을 관리할 곳도 늘어나고, 자연어 지침은 코드보다 자주 바뀐다 — 문구 한 줄을 고칠 때도 배포해야 한다.
  • 그래서 번역 기능 대신 프롬프트·지침·도구를 화면에서 관리하는 계층을 먼저 만들었다. 스택이 Spring + React였기 때문에 이 계층도 처음부터 Java로 만들었다 — Python API 서버를 따로 세우는 안은 접었다.
  • 2025년 4월, 정식 요건이 문서로 도착했을 때 새로 쓴 번역 로직은 0줄이었다. 등록해 둔 번역 프롬프트를 버튼 하나로 호출하게 연결하는 것으로 끝났다.
  • 이 계층에 챗봇을 추가하면서 '프롬프트에 도구를 매핑한다'는 모델의 한계가 드러났다. 다음 화에서는 어떤 기능을 표현할 수 없었고, 도구를 추가할 때 무엇이 어려웠는지 다룬다.

상황

대기업 SaaS 시스템의 업무 개발자로 일하던 2025년 초, 요건 하나가 문서보다 먼저 말로 들어왔다. 버튼 하나짜리 요청이었고, 업무 시스템에서 이런 요청이 하나로 끝나는 법이 없다는 것도 알고 있었다.

요청 화면에 영문 번역 버튼을 하나 만들어 주세요. 누르면 요청 내용이 번역돼서 영문 칸에 채워지게요. Google GenAI를 써 주세요.

아직 정식 요건 문서가 등록되기 전이었다. 기술적으로는 어려울 게 없다. GenAI SDK를 의존성에 추가하고, 시스템 지침 문자열을 만들고, 사용자 입력을 붙여 호출한 뒤 결과를 응답에 담으면 된다.

java
// 개념 예시 (실제 코드 아님)
String instruction = "다음 텍스트를 영문으로 번역하라. 원문의 서식을 유지하라.";
var response = genaiClient.models.generateContent(model, instruction + userText, config);
return response.text();

바로 구현하지는 않았다.

문제 — 두 번째 기능이 올 때

이 구현이 다음 AI 기능을 만드는 기준이 될 수 있다는 점이 걸렸다.

AI 기능은 하나로 끝나지 않는다. 번역 다음엔 요약이 오고, 그다음엔 분류가 오고, 검색 질의 확장이 온다. 각각을 이 방식으로 만들면 이렇게 된다.

  • 시스템 지침이 여러 서비스 클래스에 문자열 상수로 흩어진다.
  • 지침 한 줄을 고치려면 코드 수정 → 빌드 → 배포를 거쳐야 한다.
  • 어떤 기능이 어떤 모델을 어떤 설정으로 부르는지 한눈에 볼 방법이 없다.
  • 프롬프트를 바꿨을 때 이전 버전이 무엇이었는지 아무도 모른다.
  • 호출이 실패하거나 품질이 나쁠 때 무엇을 보냈는지 재구성할 수 없다.

업무 시스템에서 이건 익숙한 문제다. 하드코딩된 비즈니스 규칙이 코드 곳곳에 흩어지는 것과 정확히 같다. 다만 대상이 SQL이나 조건문이 아니라 자연어 지침일 뿐이다.

그리고 자연어 지침은 코드보다 더 자주 바뀐다. 모델을 바꿔도 바뀌고, 출력이 마음에 안 들어도 바뀌고, 업무 담당자가 표현 하나를 고쳐달라고 해도 바뀐다. 그때마다 배포를 할 수는 없다.

시도 — 기능 대신 기반부터

번역 기능을 만들기 전에 여러 AI 기능에서 공통으로 쓸 관리 기능을 먼저 만들기로 했다. 정식 요건이 도착하기 전인 2025년 3월에 착수했다.

두 구조를 그림으로 비교하면 이렇다. 첫 그림은 기능마다 지침·모델 설정을 코드에 넣는 방식이다. 두 번째는 화면에서 등록한 지침·설정을 공용 호출 코드가 읽는 방식이다.

번역 서비스요약 서비스추출 서비스genai
  • 1행: 번역 서비스, 요약 서비스, 추출 서비스
  • 2행: genai
  • 번역 서비스 → genai
  • 요약 서비스 → genai
  • 추출 서비스 → genai
그림 1. 흔한 방식 — 지침·모델 상수가 기능마다 코드에 복제된다
관리 화면프롬프트·지침·도구업무 화면들공용 엔드포인트genai호출 이력등록·버전·확정프롬프트 ID
  • 1행: 관리 화면, 프롬프트·지침·도구
  • 2행: 업무 화면들, 공용 엔드포인트
  • 3행: genai, 호출 이력
  • 관리 화면 → 프롬프트·지침·도구: 등록·버전·확정
  • 업무 화면들 → 공용 엔드포인트: 프롬프트 ID
  • 프롬프트·지침·도구 → 공용 엔드포인트
  • 공용 엔드포인트 → genai
  • 공용 엔드포인트 → 호출 이력
그림 2. 만든 구조 — 공용 호출 코드가 화면에서 등록한 지침·설정을 읽는다

만든 것은 프롬프트 관리 화면이다. CMS가 글을 코드 밖으로 빼내 화면에서 다루게 하듯, 지침을 코드 밖으로 빼내 화면에서 다루게 했다. 첫 커밋 이후 확장하면서 아래 기능들을 추가했다.

기능 왜 필요했나
프롬프트 등록·수정·버전 관리 확정 시 이전 본을 이력으로 남긴다. 품질이 나빠졌을 때 되돌릴 수 있어야 한다
파라미터 5종 (텍스트/HTML/공통코드/시맨틱/코드셋) 업무 시스템의 값을 프롬프트에 주입하는 통로
예약 변수 자동 치환 (현재시각·오늘·연도) "오늘 기준"을 요구하는 지침이 많다
프롬프트–함수 매핑 어떤 프롬프트가 어떤 도구를 쓸 수 있는지를 데이터로 관리
파일검색 스토어 매핑 + 게시판 동기화 모델 내장 RAG에 사내 문서를 연결
채팅 (SSE 스트리밍, 세션·메시지 DB 저장) 대화형 사용처
호출 이력·트레이스 이력 (단계별 소요·토큰) 무엇을 보냈고 얼마나 썼는지 사후에 볼 수 있어야 한다
호출 테스트 팝업 + 만족도 평가 배포 없이 프롬프트를 시험

핵심은 마지막 두 개다. 관측 가능성이 없으면 LLM 기능은 운영할 수 없다. 실패했을 때 "무엇을 보냈는지"를 재구성하지 못하면 고칠 수도 없기 때문이다.

이 기능을 구현한 테이블 구조는 이렇다(공통 구분·감사 컬럼은 생략했다). 별도 이력 테이블 없이 버전이 새 행으로 쌓이고, 파라미터·도구 매핑이 전부 버전 키를 참조하므로 프롬프트를 고쳐도 과거 호출이 참조하던 구성이 그대로 보존된다. 이력 테이블을 따로 두는 흔한 방식과 갈리는 지점이 여기다 — 현재 행과 이력 행이 다른 테이블에 있으면 매핑마다 어느 쪽을 가리킬지 정해야 하지만, 버전을 기본키에 넣으면 과거와 현재가 같은 모양이라 매핑이 가리킬 곳은 하나뿐이다. 호출 이력에는 호출 시점의 버전이 기록된다.

프롬프트파라미터 정의파라미터 매핑도구 매핑호출 이력트레이스 헤더트레이스 단계버전별 파라미터정의 재사용버전별 도구 노출호출 시 버전 기록추적 연결단계 전개
  • 1행: 프롬프트, 파라미터 정의
  • 2행: 파라미터 매핑, 도구 매핑, 호출 이력
  • 3행: 트레이스 헤더
  • 4행: 트레이스 단계
  • 프롬프트 → 파라미터 매핑: 버전별 파라미터
  • 파라미터 정의 → 파라미터 매핑: 정의 재사용
  • 프롬프트 → 도구 매핑: 버전별 도구 노출
  • 프롬프트 → 호출 이력: 호출 시 버전 기록
  • 호출 이력 → 트레이스 헤더: 추적 연결
  • 트레이스 헤더 → 트레이스 단계: 단계 전개
그림 3. 테이블 사이의 관계. 파라미터·도구 매핑과 호출 이력이 프롬프트의 버전을 참조한다

컬럼은 표로 둔다. 굵게 표시한 것이 기본키다.

테이블 기본키 나머지 주요 컬럼
프롬프트 프롬프트식별자 + 버전번호 프롬프트유형, 시스템지침, 상태코드, 모델코드
파라미터 정의 파라미터식별자 데이터유형
파라미터 매핑 프롬프트식별자 + 버전번호 + 파라미터식별자
도구 매핑 프롬프트식별자 + 버전번호 + 함수명
호출 이력 호출순번 프롬프트식별자, 버전번호, 파라미터JSON, 토큰수, 추적식별자
트레이스 헤더 추적식별자 결과상태
트레이스 단계 추적식별자 + 단계식별자 단계유형, 소요시간ms

매핑 두 테이블의 기본키를 보면 이 설계의 요지가 드러난다. 프롬프트를 가리킬 때 식별자만으로는 부족하고 버전번호까지 함께 있어야 한 행이 정해진다. 그래서 프롬프트를 고쳐 새 버전이 쌓여도 과거 호출이 참조하던 파라미터·도구 구성이 그대로 남는다.

공통 유틸에서 관리 화면까지

이걸 만들려면 테이블이 필요했다. 그런데 나는 업무 개발자다.

대기업 개발 조직은 분업이 되어 있다. DB 테이블 설계는 설계자의 역할이고, 개발자가 임의로 테이블을 만드는 것은 원칙적으로 하면 안 되는 일이다. 나도 그 원칙에 동의한다. 스키마가 통제 없이 늘어나면 나중에 아무도 감당하지 못한다.

당시 조직의 주문은 따로 있었다. genai 호출은 우리 시스템에서 내가 처음이었고, 관리자는 처음 만드는 것이니 다른 사람들이 나중에 호출할 때 편하게 잘 만들어 달라고 했다. 백엔드 코드 관점의 공통 유틸을 만들어 달라는 뜻이다.

공통 유틸은 다음 개발자가 호출 코드를 쉽게 작성하도록 돕는다. 내가 보고 있던 문제는 업무 담당자가 문구 한 줄을 고치려 할 때였고, 이건 유틸을 아무리 잘 만들어도 DB 없이는 그대로 남는다 — 지침 한 줄 고치는 데 빌드와 배포가 필요한 구조는 유틸 함수로 해결되지 않는다. 차이는 누구의 작업을 편하게 만들려는지에 있었다. 처음부터 계획한 순서는 이랬다. 테이블을 먼저 만들어 관리 기능까지 진행하고, 앞으로 이런 문제 없이 AI 서비스를 쉽게 만들 수 있는 구조라는 것을 실물로 보여주는 것.

LLM 관련 테이블을 직접 설계하고 구현해서, 돌아가는 화면을 만들었다. 그리고 보여줬다.

결과적으로 아키텍처 조직에서 이 구조에 반응했다. 설명하는 자리도 만들어졌다.

이때는 동작하는 화면을 보여주는 것이 제안한 구조를 설명하는 데 도움이 됐다.

다만 이 방법을 일반화하고 싶지는 않다. 이건 되돌릴 수 있는 범위에서만 쓸 수 있는 방법이다. 테이블 몇 개는 지우면 그만이지만, 운영 데이터가 쌓인 뒤에는 같은 방식을 쓸 수 없다.

정식 요건이 도착한 날

2025년 4월, 구두로 들었던 그 요청이 정식 요건으로 문서에 등록됐다.

한 일은 두 가지다. 번역용 프롬프트를 관리 화면에서 등록했고, 요청 화면에 영문 번역 버튼을 달아 그 프롬프트 ID로 공용 엔드포인트를 호출하게 연결했다. 그날 새로 쓴 번역 로직은 0줄이다. 이후 지침 문구를 다듬는 일도 전부 화면에서 이루어졌고, 그때마다 배포는 없었다.

그날 이후 번역 호출은 이렇게 흐른다.

사용자요청 화면공용 엔드포인트프롬프트 DBgenai1번역 버튼 클릭2프롬프트 ID + 내용3확정 버전 지침·설정 로드4파라미터·예약 변수 치환5시스템 지침 + 입력6번역 결과7호출 이력 기록 (버전 기록)8영문 칸에 채움
  1. 번역 버튼 클릭 — 사용자는 버튼 하나를 누를 뿐이다. 이 경로에 번역 전용 로직이 몇 줄 나오는지 세면서 따라가면 된다.
  2. 프롬프트 ID + 내용 — 화면이 보내는 것은 프롬프트 ID와 내용뿐이다. 번역 지침도 모델 설정도 화면 코드에는 없다.
  3. 확정 버전 지침·설정 로드 — 관리 화면에서 등록해 둔 지침·모델 설정을 확정 버전으로 읽는다.
  4. 파라미터·예약 변수 치환 — "오늘" 같은 예약 변수와 업무 파라미터를 지침에 채운다.
  5. 시스템 지침 + 입력 — 변수를 채운 지침과 사용자 입력으로 모델을 호출한다. 어느 프롬프트든 같은 경로다.
  6. 번역 결과 — 모델 응답이 돌아온다. 여기까지도 번역 전용 코드는 등장하지 않았다.
  7. 호출 이력 기록 (버전 기록) — 무엇을 보냈는지, 호출 시점의 프롬프트 버전과 함께 남는다.
  8. 영문 칸에 채움 — 결과가 화면 칸으로 들어간다. 번역 전용 로직은 끝까지 0줄이다 — 그날 새로 쓴 번역 로직이 0줄인 이유다.
그림 4. 영문 번역 버튼을 눌렀을 때의 호출 경로. 번역 전용 로직은 어디에도 없다

미리 만들어 둔 관리 기능 덕분에 정식 요건이 도착했을 때는 프롬프트를 등록하고 버튼을 연결하면 됐다. 이후 지침을 화면에서 수정할 수 있다는 장점도 실제 번역 기능에 적용됐다.

왜 Java였나

언어를 고를 때도 이후의 운영과 유지보수를 고려했다. 기존 시스템에 언어가 하나 늘면 무엇을 더 관리해야 하는지 살폈다.

시스템 스택이 Spring Boot(백엔드) + React(프론트)였다. AI 부분만 Python으로 만들면 다음과 같은 부담이 생긴다.

  • 언어가 하나 늘어난다 → 유지보수 인력의 범위가 갈린다
  • 배포 대상이 하나 늘어난다 → 파이프라인, 모니터링, 인증서, 방화벽 정책이 각각 필요하다
  • 네트워크 홉이 하나 늘어난다 → 장애 지점과 지연이 늘어난다
  • 인증·세션·트랜잭션을 두 번 구현해야 한다

번역 기능 하나를 붙이자고 치르기엔 큰 비용이다. 그래서 처음부터 Java 라이브러리를 썼다.

Google ADK Java로 옮긴 뒤에도 서버는 Spring Boot, 화면은 React라는 스택을 유지했다. 채팅 요청이 서버의 도구 호출을 거쳐 브라우저의 화면 동작으로 이어지는 경로는 3장에서 다룬다.

결과와 남은 한계

이 프롬프트 관리 계층은 1세대가 되어, 이후 들어오는 AI 기능과 도구를 화면에서 등록해 사용할 수 있도록 확장됐고, 2025년 8월에는 챗봇을 추가했다.

운영하는 동안 이 구조의 이득은 분명했다. 지침 수정이 배포에서 분리됐고, 업무 담당자의 문구 요청은 화면에서 끝났다. 모델명을 공통코드 데이터로 관리한 덕분에 모델이 여러 세대 바뀌는 동안에도 호출 코드는 그대로였다.

그리고 챗봇을 만들면서 이 구조의 한계가 드러나기 시작했다. 프롬프트 단위로 도구를 매핑하는 모델에는 에이전트나 위임이라는 개념이 없었고, 도구 호출 루프를 애플리케이션이 직접 관리해야 했다. 도구 하나를 추가하려면 여러 곳을 함께 수정해야 하는 상태가 됐다.

2장에서는 이 문제들과, 2026년 4월에 Google ADK Java로 처음부터 다시 만들기로 한 과정을 다룬다.

참고