[3/6] 같은 질문을 두 번 묻지 않기 - 조회 상태를 대화 밖으로 - AI는 대답하지 말고 화면을 내라
"이번엔 B플랜트로 바꾸고, 구매처는 이쪽, 기준일은 저쪽으로." 한 문장에 조건이 다섯 개면 모델은 넷을 맞히고 하나를 틀립니다. 그리고 어느 하나가 틀렸는지 표만 봐서는 모릅니다. 조회 상태를 대화 이력에 두지 말고 화면에 두는 방법, 재조회를 LLM 없이 보내는 방법, 그 조건을 저장하고 파일로 들고 나가는 방법을 다룹니다.
1. 조건 다섯 개짜리 문장
1편의 구매 담당자가 첫 결과를 받았습니다. 미입고 품목 275건. 이제 진짜 일이 시작됩니다.
이번엔 SM10 플랜트로 바꾸고, 구매처는 한빛스틸만, 구매그룹은 301, 기준일은 지난달 말로 해서, 납기 지난 것만 보여줘.
한 문장에 조건이 다섯 개입니다. 실제로 이 문장을 보내 봤습니다. 모델은 구매처 이름을 코드로 바꾸려고 검색 도구를 한 번 더 부른 뒤 조회 도구를 불렀고, 플랜트·구매처·구매그룹·납기 경과 조건은 정확히 넣었습니다. 기준일이 문제였어요. “지난달 말”을 2026-02-28로 넣었습니다. 9월에 물었는데 2월 말이 들어간 겁니다.

구현 예 - 조건 다섯 개 중 넷은 맞았고, 기준일은 틀렸습니다. 이 사실은 도구 카드를 펼쳐 입력 JSON을 읽어야 보입니다. 표만 봐서는 모릅니다.
담당자는 33건짜리 결과 표를 받습니다. 지연일수가 이상하게 크다는 걸 눈치챌 수도 있고 못 챌 수도 있어요. 눈치챘다면 다시 말합니다. “기준일은 8월 31일로.” 세 번째 턴에서야 원하던 표가 나옵니다. 그사이 도구 호출 셋, 모델 호출 셋, 대기 시간은 합쳐 30초쯤.
여기서 모델을 탓하기 쉬운데, 사실 구조 문제입니다.
2. 진단 - 조회 상태가 대화 이력 속에 있다
이 챗봇에서 “지금 어떤 조건으로 조회했는가”는 어디에 있을까요. 대화 이력 속 도구 호출 기록에 있습니다. 사용자도 모델도 그걸 직접 보지 않아요. 사용자는 표만 보고, 모델은 다음 턴에 이력을 다시 읽어 조건을 재구성합니다.
그래서 매 턴마다 세 가지가 반복됩니다.
- 모델이 이전 조건을 이력에서 복원한다. 길어진 대화에서는 놓친다.
- 사용자의 새 문장에서 바뀐 조건만 골라 덮어쓴다. 여기서 빠뜨린다.
- 결과를 받아 사용자에게 설명한다. 조건이 틀렸어도 표는 그럴듯하다.
비용과 지연은 눈에 보이는 문제이고, 더 나쁜 건 비결정성입니다. 같은 문장을 두 번 말해도 같은 인자가 나온다는 보장이 없어요. 업무 조회에서 이건 치명적입니다. 어제 본 숫자와 오늘 본 숫자가 다른데 조건이 달랐는지 데이터가 달라졌는지 알 수 없으니까요.
해법은 상태를 옮기는 겁니다. 조회 조건은 대화가 아니라 화면에 둔다. 그러면 사용자는 지금 조건을 눈으로 보고, 바꿀 건 직접 바꾸고, 바꾸지 않은 건 그대로 남습니다. 모델은 처음 한 번만 조건을 채우면 돼요.
3. 조건 칸은 서버가 낸다
2편에서 앱이 호스트를 거쳐 도구를 부를 수 있다고 했습니다. 그 통로로 조회 화면은 두 도구를 씁니다. describe_query와 run_query입니다.
앱이 뜨면 먼저 describe_query(tool)을 부릅니다. 서버가 그 도구의 조건 명세를 돌려줘요. 어떤 조건이 있고, 각각 타입이 뭐고, 기본으로 보일 것은 무엇이고, 코드 필드면 어디서 검색하는지.
{
"tool": "mm_po_open_items",
"title": "미입고 구매오더 품목",
"filters": [
{ "name": "werks", "label": "플랜트", "type": "string", "searchHelp": { "source": "value_help", "view": "I_Plant" } },
{ "name": "lifnr", "label": "구매처", "type": "string", "searchHelp": { "source": "tool", "tool": "md_supplier_lookup" } },
{ "name": "ekgrp", "label": "구매그룹", "type": "string" },
{ "name": "keydate", "label": "기준일", "type": "date" },
{ "name": "late_only", "label": "납기 경과만", "type": "boolean" }
],
"primaryFilters": ["werks", "lifnr", "keydate"],
"columns": ["ebeln", "ebelp", "lifnr_name", "matnr", "open_qty", "open_amount", "eindt", "delay_days"],
"top": 200
}
중요한 건 이 명세를 앱이 아니라 서버가 만든다는 점입니다. 도구의 종류마다 조건이 나오는 자리가 다르거든요. OData 목록 도구면 EntityType의 필터 가능한 속성, 테이블 도구면 where에 쓸 수 있는 컬럼, SQL이나 코드블럭이면 파라미터, 분석 쿼리면 차원과 변수. 앱은 이 차이를 모릅니다. 명세대로 칸을 그릴 뿐이에요. 덕분에 조회 화면 HTML 한 장이 도구 수백 개를 다 받습니다.
첫 조건 값은 모델이 도구를 부를 때 넣은 인자를 서버가 역으로 풀어서 채웁니다. 그래서 앱이 뜨는 순간 사용자는 “모델이 어떤 조건으로 조회했는지”를 칸에서 봅니다. 1절의 기준일이 2026-02-28로 들어간 것도 칸에서 바로 보여요. 도구 카드를 펼쳐 JSON을 읽을 필요가 없습니다.

구현 예 - 모델이 넣은 조건이 칸에 그대로 보입니다. 빈 칸은 모델이 넣지 않은 조건이고, 틀린 값은 여기서 고칩니다.
4. 재조회는 모델을 거치지 않는다
사용자가 기준일을 8월 31일로 고치고 조회를 누르면 앱은 run_query(tool, filters, top)을 부릅니다. 이 호출은 채팅 세션의 규칙을 그대로 타지만 모델은 끼지 않습니다. 서버가 조건 값을 도구 인자로 바꾸고(이것도 도구 종류마다 다른데 역시 서버 몫입니다), 도구를 실행하고, 결과를 앱에 돌려줍니다.
// 조회 화면 앱 안
runButton.onclick = async () => {
const filters = readConditions(); // 화면의 조건 값
const result = await app.callServerTool({ name: 'run_query', arguments: { tool: spec.tool, filters, top: topInput.value } });
view.setResult(result); // 표를 다시 그린다
};
문자열 조건에 *를 넣으면 부분 일치가 되고, 여러 값을 넣으면 OData는 or, 테이블은 IN, 분석 쿼리는 멤버 목록으로 바뀝니다. 날짜는 앱이 YYYY-MM-DD로 맞추고 서버가 종류에 맞는 형식으로 바꿉니다. 사용자는 이 변환을 몰라도 되고, 모델은 아예 관여하지 않습니다.
이 두 도구는 일반 MCP 도구로 등록되어 있습니다. 그래서 저희 채팅만이 아니라 MCP Apps를 지원하는 다른 호스트에서도 같은 앱이 같은 방식으로 재조회합니다. 앱을 못 그리는 호스트에서는 모델이 run_query를 직접 부를 수도 있고요. 코드 필드의 돋보기 검색도 같은 길입니다. 이름을 치면 앱이 검색 도구를 부르고, 고르면 코드가 칸에 들어갑니다.
1절의 장면을 다시 보면, 두 번째 턴부터는 모델이 필요 없습니다. 조건 다섯 개를 문장으로 설명하는 대신 칸 다섯 개를 고치고 조회를 누릅니다. 기준일 칸에 8월 31일을 넣고 조회. 도구 호출 하나, 모델 호출 없음, 대기 시간은 SAP 응답 시간뿐입니다.
5. 조건이 화면에 있으니 저장할 수 있다
조건이 눈에 보이는 상태로 있으면 자연스럽게 다음 질문이 나옵니다. 이걸 내일 또 보려면?
앱 머리글의 저장 버튼은 2편에서 말한 호스트 전용 도구를 부릅니다. 저장되는 건 조건, 최대 행 수, 그리고 그 대화가 쓰던 프로파일과 대상 시스템입니다. 결과 데이터는 저장하지 않아요. 다시 열면 같은 조건으로 최신 데이터를 조회합니다. 스냅샷이 아니라 조회 정의를 저장하는 겁니다.
날짜 조건에는 상대 토큰을 쓸 수 있습니다. 기준일에 today-30이라고 저장해 두면 열 때마다 “30일 전”으로 풀립니다. “지난달 말”을 매번 계산하던 일이 사라져요. 열어서 조건을 고치고 다시 저장하면 새로 만들지 않고 덮어씁니다.

구현 예 - 앱 머리글의 저장 버튼이 여는 대화상자. 이름만 정하면 조건·프로파일·시스템이 함께 저장됩니다.

구현 예 - 저장되는 건 결과가 아니라 조건입니다. 카드에 werks=SP10, max=300이 그대로 적혀 있고, 누르면 그 조건으로 다시 조회합니다.
이 지점에서 채팅은 이미 채팅이 아닙니다. 조건을 말로 설명해서 얻은 화면을 이름 붙여 두고, 다음부터는 대화 없이 엽니다. 대화는 “이런 조회가 있다는 걸 찾는 데” 쓰였고, 반복은 화면이 맡습니다.
6. 파일로 들고 나가도 살아 있다
마지막 한 조각은 내보내기입니다. 앱의 내보내기 버튼은 지금 화면을 .html 파일 하나로 내려받습니다. 조건 칸은 읽기 전용 스냅샷으로 굳히지만, 결과 표는 살아 있습니다. 파일 안에 결과 데이터와 화면 명세, 그리고 표를 그리는 렌더러 스크립트가 함께 들어가기 때문에 서버 없이 브라우저에서 열어도 정렬·필터·CSV 복사가 됩니다.
만드는 방법은 단순합니다. 현재 문서를 복제하고, 스크립트를 지우되 렌더러를 정의한 인라인 스크립트는 남기고, 지금 그려진 결과와 명세를 JSON으로 박아 넣고, 열릴 때 그 JSON으로 다시 그리는 부트스트랩 한 줄을 붙입니다.
const copy = document.documentElement.cloneNode(true);
copy.querySelectorAll('script').forEach((el) => { if (!isRenderer(el)) el.remove(); });
const state = WeaverView.exportState(); // [{ id, view, result, columns }]
copy.querySelector('body').append(
el('script', { id: 'weaver-export', type: 'application/json' }, JSON.stringify(state)),
el('script', {}, `for (const e of JSON.parse(document.getElementById('weaver-export').textContent))
WeaverView.create(document.querySelector('[data-vw="' + e.id + '"]')).restore(e);`),
);
downloadFile('po-open-items.html', '<!doctype html>' + copy.outerHTML);
보고서에 첨부해 보내면 받는 사람이 정렬해 가며 봅니다. 물론 데이터가 파일에 들어 있으니 전달할 때는 그 점을 알고 보내야 합니다.

서버 없이 연 내보내기 파일. 지연일수 열 머리글을 눌러 정렬한 상태입니다. 결과 내 필터와 CSV 복사도 그대로 동작합니다.
7. 여기까지의 한계, 그리고 다음 글
읽기 도구는 이제 채팅에서 시작해 화면으로 굳어지고, 저장되고, 파일로 나갑니다. 그런데 4절에서 슬쩍 지나간 문장이 하나 있어요. “앱이 호스트를 거쳐 도구를 부를 수 있다.” 읽기 도구를 부를 수 있으면 쓰기 도구도 부를 수 있다는 뜻입니다. 판매오더를 만들고, 수정하고, 지우는 도구요.
그걸 모델이 직접 부르게 둘 것인가. 저희 답은 “아니오”였고, 그 이유와 구조가 4편입니다.
이 글의 구현은 Intellidesk Weaver에 들어 있습니다. 다음 글: [4/6] LLM에게 저장 버튼을 누르게 하지 마라 - 쓰기 도구와 입력 폼.
Intellidesk Weaver
SAP 자산을 AI가 쓰는 도구로, 답은 화면으로.
OData·CDS·테이블·ABAP 자산을 하나의 카탈로그로 묶어 MCP 도구로 내보내고, 결과는 채팅 안의 조회 화면·입력 폼·업무 앱으로 돌려줍니다.
Weaver 살펴보기