React에서 검토 가능한 Gemini 양식 어시스턴트 구축하기
요약
본 글은 React와 TypeScript를 사용하여 Gemini 기반의 검토 가능한 양식 어시스턴트를 구축하는 방법을 설명합니다. 사용자의 간략한 요청을 구조화된 필드로 변환하고, 변경 사항을 '제안(proposal)' 형태로 분리하여 사용자에게 명확히 제시하는 흐름에 초점을 맞춥니다. 특히 제안 생성과 실제 데이터 저장을 별도의 API 호출(`POST` vs `PATCH`)로 분리함으로써, 사용자가 변경 내용을 검토한 후 수락할 때만 원본 데이터를 업데이트하도록 설계했습니다.
핵심 포인트
- Gemini를 활용하여 자연어 요청을 구조화된 양식 필드로 변환합니다.
- 제안(Proposal) 기능을 통해 변경 사항을 임시로 제시하고 사용자 검토 단계를 거칩니다.
- 데이터 생성과 실제 저장/적용 로직을 분리하여 안정성을 높입니다 (POST /api/trips/:id/proposal).
- 부분 패치(Partial patches)를 지원하여 관련 없는 필드는 보존하며 변경 사항만 제안할 수 있습니다.
여행 요청을 작성하는 사람은 이미 메시지에 필요한 정보를 가지고 있는 경우가 많습니다:
SFO에서 JFK로 가는 왕복 여정을 2026년 11월 12일 출발, 2026년 11월 15일 복귀 일정으로, 성인 1명, 이코노미 클래스 기준입니다. 목적: 고객 워크숍.
AI 어시스턴트는 이러한 간략한 내용을 양식 필드로 변환할 수 있습니다. 더 흥미로운 문제는 그 응답이 도착했을 때 발생합니다. 사용자가 날짜를 변경했거나, 목적을 수정했거나, 다른 요청을 열었을 수도 있기 때문입니다.
저는 이러한 상호작용을 탐색하기 위해 합성 여행 데이터가 포함된 React 및 TypeScript 데모인 Trip Workbench를 구축하고 유지 관리하고 있습니다. 이 어시스턴트 플로우는 사용자가 제안 요청, 제안된 필드와 명확화 질문 검사, 그리고 변경 사항을 명시적으로 적용할 수 있도록 합니다.
유용한 작업 단위는 **특정 초안 상태에 연결된 제안(proposal)**입니다. 이는 애플리케이션이 유효성 검사를 수행하고, 표시하며, 오래되었을 때 거부하고, 기존 API를 통해 저장할 수 있는 무언가를 제공합니다.
생성과 저장을 분리하기
이 기능은 두 가지 애플리케이션 작업을 사용합니다:
| 작업 | 책임 |
|---|---|
POST /api/trips/:id/proposal | 권한이 부여된 초안을 읽고, Gemini에게 제안된 필드를 요청하며, 응답을 검증합니다. |
PATCH /api/trips/:id | 인증, 개정판, 도메인 규칙을 확인한 후 사용자가 수락한 패치를 적용합니다. |
제안을 생성하는 것은 저장된 여행 정보를 변경하지 않습니다. 제안된 변경 사항 적용을 클릭하면 별도의 뮤테이션(mutation)이 전송됩니다. 이 데모에서는 적용(applying)이 초안을 저장하고, 검토 및 요청 제출은 그 후에 이루어집니다.
이러한 구분은 인터페이스에서 눈에 띄어야 합니다. 일반적인
Gemini 호출은 @google/genai를 사용하여 Node 백엔드에서 실행됩니다. React 클라이언트는 작성 요약(brief)과 초안 컨텍스트를 애플리케이션 API로 전송합니다. Google의 API 키 가이드는 프로덕션 키가 브라우저 코드에 포함되는 것을 피할 것을 권장합니다.
이 문서는 해당 임베디드 어시스턴트 흐름을 따릅니다. Trip Workbench의 별도 WebMCP 도구들은 외부 에이전트를 위한 또 다른 진입점을 제공하며, 이는 '제안(Suggest)' 버튼과는 관련이 없습니다.
제안서가 포함할 수 있는 내용 정의하기
제안서는 패치(patch)와 명확화 질문(clarification questions) 두 가지를 포함합니다.
개요 작성 요약(opening brief)의 경우, 이는 예시 응답입니다:
{
"patch": {
"origin": "SFO",
...
패치에는 수정 가능한 여행 필드가 포함됩니다. 여기에는 소유자 ID, 테넌트 ID, 승인 결정 또는 제출 상태를 위한 공간은 없습니다.
부분 패치(Partial patches)는 유용합니다. 만약 요약에 “도착지를 JFK로 변경하라”고 되어 있다면, 어시스턴트는 관련 없는 필드를 보존해야 합니다. 도착지가 모호한 경우, 빈 패치를 반환하고 질문을 할 수 있습니다.
예를 들어, “다음 주 금요일 스프링필드(Springfield)”는 더 많은 컨텍스트가 필요합니다. 이 데모는 소규모 공항 목록을 지원합니다. 어시스턴트는 그 제한 사항을 설명하고, 임의로 값을 대체하는 대신 지원되는 도착지를 요청해야 합니다.
어댑터를 확장할 때 사용할 보다 엄격한 제안 스키마가 있습니다. 이는 스키마 경계에서 공항을 제한하며, 전체적으로 알 수 없는 키를 거부합니다:
import { z } from "zod";
import { AIRPORTS } from "../../domain/types.js";
...
날짜 문자열 역시 달력 유효성 검사가 필요합니다. 문자열 형태의 값만으로는 2월 30일이 존재하는지 여부를 확립할 수 없습니다.
패치 의미론(patch semantics)도 명시적으로 정의해야 합니다. 필드를 생략하는 것은 해당 값을 보존한다는 의미이며, null은 선택 가능한 필드를 지운다는 의미입니다. 모델은 불확실한 정보는 생략하고 질문을 해야 합니다. 요청된 변경 사항이 그럴 경우에만 필드를 지워야 합니다.
Gemini가 요약을 해석하는 데 필요한 컨텍스트 제공하기
서버는 접근 권한을 확인한 후 현재 저장된 초안을 로드합니다. 또한 참조 날짜, 애플리케이션 시간대 및 허용되는 값도 제공합니다.
이러한 세부 정보들은 해석에 영향을 미칩니다. “다음 주 금요일”은 참조 날짜에 따라 달라지며 여전히 명확화가 필요할 수 있습니다. 일반 언어에서 언급된 공항이 애플리케이션의 지원 범위 밖에 있을 수도 있습니다.
프롬프트는 이러한 제한 사항을 설명하고 요청된 변경 사항만을 요청해야 합니다. 간략한 내용을 입력 데이터로 취급하고, 그 안에 포함된 지침은 추가적인 애플리케이션 기능을 부여하지 않습니다.
commit 4b3f370에서 저장소는 @google/genai 1.30.0을 고정합니다. 이 어댑터는 responseMimeType을 사용하여 JSON을 요청한 다음, 결과를 로컬에서 구문 분석하고 검증합니다. 아직 Gemini에 출력 스키마를 전달하지는 않습니다.
집중적인 개선 사항은 생성 시에도 스키마를 제공하는 것입니다:
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: config.geminiApiKey! });
...
이 발췌문은 기존 어댑터 내부, API 키 확인 이후에 위치해야 합니다. 이 경로는 구문 분석 및 검증 실패를 애플리케이션 오류로 변환해야 합니다.
SDK 레퍼런스는 이 API의 responseJsonSchema에 대해 설명합니다. 이는 JSON Schema의 일부 하위 집합을 지원하므로, 스키마가 모델에 제공되더라도 문자열 길이 제한이나 달력 규칙과 같은 런타임 검사를 유지해야 합니다. 현재 Google 예제에서는 더 새로운 responseFormat 형태도 보여주므로, 실제로 배포하는 버전을 확인하지 않고 SDK 세대 간 필드를 복사하지 마십시오.
이 예제들은 리포지토리의 models.generateContent 경로를 따릅니다. Google은 이제 해당 경로를 Generate Content API Legacy에서 문서화하고 있으며, 더 새로운 Interactions API는 다른 요청 형태를 사용합니다. 코드를 조정할 때 SDK 버전과 API 표면(API surface)을 일관되게 유지하세요.
다음 검사는 애플리케이션별입니다. 제안된 패치를 저장된 필드와 병합하고 기존 도메인 유효성 검사기(domain validator)를 실행합니다. 복귀 날짜는 실제 날짜일지라도 출발 날짜보다 앞설 수 있습니다. 유효한 객실도 여전히 정책 검토가 필요할 수 있습니다.
Google의 structured-output 문서 역시 애플리케이션 유효성 검사를 요구합니다. 응답 형태를 제한하는 것이 그 값이 사람의 의도와 일치함을 확립하지는 못하기 때문입니다.
제안을 생성한 상태에 바인딩하기
다음 시퀀스를 고려해 보세요:
- 제안 요청은 목적이 “고객 워크숍”으로 시작합니다.
- 사람이 목적을 “리더십 회의”로 변경합니다.
- 원래의 제안이 “고객 워크숍”과 함께 도착합니다.
서버 리비전만 확인하는 것은 문제를 놓칩니다. 사람의 더 새로운 텍스트는 여전히 저장되지 않았을 수 있습니다.
Trip Workbench는 이 이유로 세 가지 버전을 추적합니다:
| 버전 | 추적 내용 |
|---|---|
| Saved revision | 백엔드에 의해 영구 저장된 변경 사항 |
| ... | |
| 서버는 이러한 바인딩을 모델이 생성한 제안 외부에서 반향(echo)시킵니다. 이것들은 애플리케이션 메타데이터이며, Gemini가 선택하는 것이 아닙니다. |
코디네이터의 신선도 검사에는 다음이 포함됩니다:
return (
bound.boundRevision !== state.savedDraft.revision ||
bound.boundClientEditVersion !== state.editVersion ||
...
주변 구현은 또한 초안의 ID, 세션 및 편집기 생성을 확인합니다. 이는 한 편집기 컨텍스트에 대한 응답이 다른 편집기의 제안으로 처리되는 것을 방지합니다.
응답이 도착했을 때와 사용자가 '적용'을 클릭했을 때 다시 신선도(freshness)를 확인해야 합니다. 두 번째 확인은 제안서가 읽히는 동안 구식이 될 수 있기 때문에 중요합니다.
가장 간단한 정책은 제안서를 오래된 것으로 표시하고 새로운 제안을 요청하는 것입니다. 여덟 개의 필드라면, 이전 제안과 새로운 의도를 자동으로 병합하는 것보다 설명하기가 더 쉽습니다. 규모가 큰 에디터의 경우 더 세분화된 충돌 처리(conflict handling)를 정당화할 수 있습니다.
또 다른 실질적인 세부 사항이 있습니다. 이 엔드포인트는 모델 컨텍스트로 **저장된 필드(saved fields)**를 사용합니다. 저장되지 않은 양식 편집 내용은 Gemini로 전송되지 않습니다. 코디네이터는 로컬 편집 내용이 저장되지 않은 상태일 때 에이전트 스타일 패치(agent-style patch)를 차단합니다. 사용자 인터페이스(UI)가 더 명확하다면, 제안을 요청하기 전에 해당 편집 내용을 저장하거나 폐기하도록 사용자에게 요청해야 합니다.
버전 확인은 이 특정 핸드오프(handoff)를 보호합니다. 그 자체만으로는 공유 에디터의 모든 새로고침 또는 복구 경로가 로컬 변경 사항을 보존하는지 증명하지 못합니다.
검토를 유용하게 만들고 기존 API를 통해 적용하기
현재 React 패널은 제안된 값과 명확화 질문 목록을 보여줍니다. 제안이 오래되었거나(stale) 패치가 비어 있으면 '적용' 버튼을 비활성화합니다.
기존에 값이 채워진 양식의 경우, 현재 값을 표시하는 열을 추가할 것입니다:
| 필드 | 현재 값 (Current) | 제안 값 (Proposed) |
|---|---|---|
| 목적지 (Destination) | BOS | JFK |
| ... | ||
| 그 표는 현재 데모의 스크린샷이 아니라, 개선된 검토를 위한 제안이며, 적용했을 때 어떤 결과가 나올지 더 쉽게 확인할 수 있게 합니다. |
명확화 질문은 필드 옆에 계속 보이게 유지해야 합니다. 부분적인 제안이라도 제출할 준비가 되어 있지 않더라도 유용할 수 있습니다. 현재 디자인에서는 사용자가 간략한 내용을 다듬고 다른 제안을 요청할 수 있습니다.
'적용'(Apply) 핸들러는 신선도를 확인한 다음, 기존 코디네이터에게 위임합니다:
if (coordinator.isProposalStale(proposal)) {
// 양식을 변경하지 않고 다른 제안을 요청하도록 합니다.
return;
...
백엔드는 사용자의 권한을 확인하고, 패치(patch)를 현재 초안과 검증하며, 예상되는 개정 사항을 강제합니다. 만약 다른 작가(writer)가 초안을 변경했다면, 다른 변경 사항을 적용하기 전에 새로운 검토를 요구해야 합니다.
이 두 번째 검증은 제안이 이미 확인되었음에도 불구하고 필요합니다. 초안은 생성과 적용 사이에 변경될 수 있기 때문입니다. 클라이언트 측 검토 역시 권한 경계(authorization boundary)가 아닙니다.
비즈니스 케빈 요청(business-cabin request)을 예로 들면, 패치는 성공적으로 저장될 수 있지만 나중에 수행되는 검증 단계에서는 검토가 필요하다는 응답이 돌아올 수 있습니다. 초안을 저장하는 것이 정책 예외를 승인하거나 티켓을 구매하는 것을 의미하지 않습니다.
실패 상황도 이해하기 쉽게 유지하기
보조 기능(assistance)에 실패하더라도 수동 양식은 사용 가능해야 합니다.
거부된 모델 응답은 필드를 변경하지 않고 어떠한 제안도 준비할 수 없음을 설명해야 합니다. 오래된 제안(stale proposal)의 경우, 간략한 내용(brief)이나 초안이 변경되었음을 설명해야 합니다. 개정 충돌(revision conflict)이 발생하면 다시 시도하기 전에 현재 데이터를 검토하도록 유도해야 합니다.
프로덕션 기능의 경우, finally 블록에서 “제안 중(Suggesting)” 상태를 또한 출시하고, 제공업체 실패와 유효하지 않은 출력을 구별해야 합니다. 현재 어댑터는 그렇지 않은 분류가 된 제공업체 예외를 AI_TIMEOUT으로 매핑하는데, 이 레이블만으로는 타임아웃이 발생했는지 여부를 확립할 수 없습니다.
생성 실패와 저장 실패는 다른 처리가 필요합니다. 손실된 제안 응답은 여행을 저장하지 않은 것입니다. 손실된 PATCH 응답은 성공적인 저장을 따랐을 수 있습니다. 후자는 또 다른 변경(mutation)을 생성하기 전에 원래 명령의 결과를 조정하는 것이 필요합니다.
제안과 핸드오프를 분리하여 평가하기
Trip Workbench에는 비활성화 모드, 테스트 환경(fixture), 라이브 모드가 있습니다. 테스트 환경 모드는 인터페이스가 반복적으로 사용될 수 있도록 미리 준비된 예시(canned examples)를 반환합니다. 이의 SFO–JFK 예시는 고정된 날짜를 사용하므로 Gemini의 추출 능력을 측정하지 않습니다.
애플리케이션 동작에 대해 결정론적 검사(deterministic checks)를 사용하세요:
| 체크 | 예상 동작 |
|---|---|
| 알 수 없는 패치 키 또는 유효하지 않은 캘린더 날짜 | 저장된 필드를 변경하지 않고 제안을 거부한다 |
| ... | |
| 그다음, 완전한 브리프(brief), 모호한 브리프, 그리고 기존 초안에 대한 부분 업데이트를 가진 라이브 Gemini로 평가합니다. 각각 여러 번 실행하고 모델, 프롬프트, 제안된 필드, 명확화 질문, 사용자 수정 사항, 최종 저장 상태를 기록합니다. |
부분 업데이트의 경우, 무엇이 변경되지 않았는지 명시적으로 확인해야 합니다. 사용자가 변경하라고 요청하지 않은 목적지까지 덮어쓰는 경우, 올바른 도착지라는 것만으로는 충분하지 않습니다.
리포지토리의 결과 문서에는 테스트 케이스 증거가 기록되어 있으며, 라이브 Gemini는 검증되지 않은 상태로 남겨둡니다. 이러한 연습들은 다음 평가를 정의하는 것이며, 보고된 성공률이 아닙니다.
마지막으로, 전체 작업을 수동 입력과 비교합니다. 브리프 작성 시간, 대기 시간, 검토 및 수정 시간을 포함해야 합니다. 짧은 양식의 경우, 수동 입력이 더 빠를 수 있습니다. 사용자가 이미 필요한 정보를 담고 있는 긴 브리프를 가지고 있을 때 지원 기능이 더욱 흥미로워집니다.
제가 피드백을 받고 싶은 질문은 구체적입니다: 사람이 직접 입력하는 것보다 적은 노력으로 제안된 변경 사항을 이해하고 수정할 수 있는가?
이것이 이 디자인이 평가하게 하는 결과입니다. 모델이 후보 변경 사항을 제공하고, 인터페이스는 이를 검토 가능하게 만들며, 애플리케이션은 저장될 수 있는지 여부를 결정합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기