이 글은 Claude Code(Anthropic CLI)의 도움을 받아 작성되었습니다.
(현실에서 일어날 수 있는 가상 케이스)
카페를 운영하는 지인이 있다. 단골 거래처에서 단톡방으로 주문이 온다.
내일 오후 2시 아아 3잔 카라멜마끼아또 1잔 역삼동 사무실로 배달 부탁
이걸 사장님이 POS에 하나하나 손으로 입력한다. 메뉴명, 수량, 사이즈, 배달 주소, 시간. 하루에 수십 건이 오는데, 메시지 형식이 제각각이라 복사-붙여넣기도 안 된다.
"아아"가 아이스 아메리카노인 건 사람만 안다. "카라멜마끼"가 카라멜 마키아토인 것도. "내일"이 며칠인지도 매번 달력을 봐야 한다.
이걸 AI한테 시키면 안 되나?
Google AI Studio는 구글이 만든 AI 개발 도구다. 코드를 한 줄도 안 쓰고 커스텀 AI를 만들 수 있다.
핵심 기능은 Playground다. Playground는 Gemini 모델을 직접 테스트하고 커스터마이징하는 공간이다. System Instructions에 역할과 규칙을 써넣고, Run Settings를 조정하고, 바로 테스트한 뒤 "Get code" 버튼으로 API 호출 코드를 뽑을 수 있다.
무료로 시작할 수 있고, Playground에서 바로 테스트하고, 버튼 하나로 REST API 코드를 뽑을 수 있다.
Google AI Studio에 접속하면 좌측에 "Playground"가 보인다. 클릭하면 바로 테스트 화면이 나온다.
우측 패널의 Run settings에서 설정할 게 크게 세 가지다.
하나씩 보자.
System Instructions는 AI에게 "너는 누구이고, 어떤 규칙으로 동작해라"를 알려주는 곳이다. 여기가 가장 중요하다. 잘 쓰면 95% 정확도, 못 쓰면 쓸모없는 응답이 나온다.
내가 작성한 구조는 이렇다.
## 역할
당신은 카페 주문 파싱 전문가입니다.
카카오톡 단체방 메시지를 분석하여 주문에 필요한 구조화된 데이터를 추출합니다.
## 처리 규칙
1. 입력으로 카카오톡 메시지 원문이 주어집니다.
2. 메시지에서 주문에 필요한 필드를 추출합니다.
3. 추출 결과를 아래 JSON 스키마에 맞춰 반환합니다.
4. 추출할 수 없는 필드는 null로 표기합니다.
5. 반드시 JSON만 출력하고, JSON 외의 텍스트는 포함하지 마세요.
핵심은 "JSON만 출력하라"는 규칙이다. 이걸 안 넣으면 "네, 분석 결과를 알려드리겠습니다~"같은 잡담이 섞인다.
AI가 어떤 형식으로 답해야 하는지 명확하게 알려줘야 한다.
{
"menu_category": "string (필수. 커피|스무디|주스|티|디저트|베이커리 중 하나)",
"menu_name": "string (정식 메뉴명. 예: '아이스 아메리카노')",
"menu_size": "string (톨|그란데|벤티. 기본값 톨)",
"quantity": "number (수량. 기본값 1)",
"options": "string (옵션. 예: '샷추가, 시럽빼기')",
"order_date": "string (YYYY-MM-DD 형식)",
"order_time": "string (HH:MM 형식, 24시간제)",
"delivery_address": "string (배달 주소)",
"contact_name": "string (주문자 이름)",
"contact_number": "string (연락처)",
"payment_method": "string (카드|현금|후불. 기본값 후불)",
"memo": "string (전달사항)",
"is_urgent": "number (긴급 여부. 1=긴급, 0=일반)",
"confidence": "number (파싱 신뢰도. 0.0~1.0)",
"missing_fields": ["string (추출 못 한 필드 목록)"],
"warnings": ["string (모호한 부분 설명)"],
"original_text": "string (입력된 원본 메시지)"
}
confidence와 missing_fields가 포인트다. AI가 "이건 확실하지 않다"고 스스로 말하게 해두면, 나중에 서버에서 신뢰도 낮은 결과를 사람에게 확인시킬 수 있다.
실제 사용자는 정식 메뉴명을 안 쓴다. 은어와 줄임말을 매핑해줘야 한다.
## 메뉴 동의어 매핑
| 정식명 | 동의어/줄임말 |
|--------|-------------|
| 아이스 아메리카노 | 아아, 아메, 아이스아메, 아샷추 |
| 카페라떼 | 라떼, 까페라떼, 카라 |
| 카라멜 마키아토 | 카라멜마끼, 카마, 카라멜마끼아또 |
| 바닐라 라떼 | 바라, 바닐라라떼 |
| 에스프레소 | 에쏘, 에스프레쏘 |
| 녹차 라떼 | 녹라, 녹차라떼, 녹차라때 |
| 딸기 스무디 | 딸스, 딸기스무디 |
주의: "아샷추"는 "아이스 아메리카노 + 샷 추가"다. 메뉴명 + 옵션이 합쳐진 은어. 이런 복합 은어를 AI가 풀어내려면 시스템 프롬프트에 명시해줘야 한다.
## 날짜/시간 해석 규칙
| 표현 | 해석 |
|------|------|
| 내일 | 오늘 + 1일 |
| 모레, 모래 | 오늘 + 2일 |
| 글피 | 오늘 + 3일 |
| 오전 | 09:00 (시간 미지정시) |
| 오후 | 13:00 (시간 미지정시) |
| 점심 | 12:00 |
| 저녁 | 18:00 |
이 규칙이 있어야 "내일 점심"이 2026-04-03 12:00으로 변환된다. 없으면 Gemini가 "내일"을 해석 못 한다. (이건 뒤에 삽질 파트에서 자세히 다룬다.)
System Instructions의 마지막에 실제 입출력 예시를 넣는다. 이게 few-shot prompting이다. 규칙을 백 줄 쓰는 것보다 예시 3개가 더 효과적이다.
## 파싱 예시
### 예시 1: 기본 주문
입력: "내일 오후 2시 아아 3잔 역삼동 사무실로 배달"
출력:
{
"menu_category": "커피",
"menu_name": "아이스 아메리카노",
"menu_size": "톨",
"quantity": 3,
"options": "",
"order_date": "2026-04-03",
"order_time": "14:00",
"delivery_address": "역삼동 사무실",
"contact_name": null,
"contact_number": null,
"payment_method": "후불",
"memo": null,
"is_urgent": 0,
"confidence": 0.95,
"missing_fields": [],
"warnings": [],
"original_text": "내일 오후 2시 아아 3잔 역삼동 사무실로 배달"
}
## 예시 2: 은어 + 옵션 복합
입력: "아샷추 2잔이랑 바라 1잔 얼음 많이 지금 바로 보내주세요"
출력:
{
"menu_category": "커피",
"menu_name": "아이스 아메리카노",
"menu_size": "톨",
"quantity": 2,
"options": "샷추가",
"order_date": "2026-04-02",
"order_time": "14:00",
"delivery_address": null,
"contact_name": null,
"contact_number": null,
"payment_method": "후불",
"memo": "얼음 많이",
"is_urgent": 1,
"confidence": 0.80,
"missing_fields": ["delivery_address"],
"warnings": ["'지금 바로' → 긴급 판정", "바닐라 라떼 1잔은 별도 항목으로 분리 필요"],
"original_text": "아샷추 2잔이랑 바라 1잔 얼음 많이 지금 바로 보내주세요"
}
예시를 넣을 때 팁:
나는 총 12개를 넣었다. 많을수록 정확도가 올라간다. 다만 토큰을 먹으니 비용과의 균형을 잡아야 한다.
System Instructions에 모든 걸 넣으면 너무 길어진다. 자주 변하는 데이터나 상세 참조 자료는 Knowledge 파일로 분리하는 게 좋다.
나는 두 개 파일을 업로드했다.
전체 메뉴 목록, 카테고리별 분류, 사이즈 옵션, 가격대 정보를 정리한 파일이다. 메뉴가 추가되면 이 파일만 업데이트하면 된다. System Instructions를 안 건드려도 된다.
# 카페 메뉴 사전
## 커피
| 메뉴명 | 사이즈 | 기본가격 |
|--------|--------|---------|
| 아이스 아메리카노 | 톨/그란데/벤티 | 4,500원 |
| 카페라떼 | 톨/그란데/벤티 | 5,000원 |
| 카라멜 마키아토 | 톨/그란데/벤티 | 5,500원 |
...
Gemini가 추출한 JSON이 최종적으로 서버 API에 전달된다. 그 API가 어떤 필드를 받는지 명세해둔 파일이다. Gemini가 이 스펙을 보고 호환되는 형식으로 응답한다.
Knowledge 파일은 AI Studio 화면에서 "Upload files" 버튼으로 올린다. 마크다운(.md), 텍스트(.txt), PDF, 이미지 등을 지원한다.
여기가 가장 삽질을 많이 한 부분이다. 기본값으로 돌리면 결과가 들쭉날쭉하고 느리고 비싸다. 각 설정의 의미를 알아야 제대로 튜닝할 수 있다.
뭐하는 값인가: AI의 "창의성" 조절. 높을수록 다양한 표현, 낮을수록 일관된 표현.
왜 낮췄나: 우리는 창의적인 답변이 필요 없다. 같은 입력에 항상 같은 JSON이 나와야 한다. Temperature가 1이면 같은 메시지를 넣어도 필드값이 미묘하게 달라진다. "order_time": "14:00"이 어떤 때는 "order_time": "오후 2시"로 나온다.
0이 아닌 이유: 0으로 하면 너무 경직돼서 살짝 다른 표현을 못 알아먹는 경우가 있었다. 0.2가 "일관되면서도 유연한" 스위트 스팟이었다.
Temperature: 0.2
뭐하는 값인가: Gemini의 내부 추론(Chain-of-Thought) 깊이. High면 깊이 생각하고, Low면 빠르게 답한다.
왜 낮췄나: SMS 파싱은 복잡한 추론이 필요 없다. "아아" → 아이스 아메리카노, "내일" → +1일. 이건 패턴 매칭이지 논리적 사고가 아니다. Thinking을 High로 두면 2~3초 더 걸리는데, 결과 품질은 거의 차이가 없었다.
그리고 이것 때문에 삽질했다. 뒤에서 다시 다룬다.
Thinking Level: LOW
뭐하는 값인가: AI가 지정된 JSON 스키마에 맞춰서만 응답하도록 강제하는 옵션.
왜 켰나: 이걸 안 켜면 "아, 이 메시지는 주문이 아닌 것 같습니다"처럼 자연어로 답하는 경우가 간간이 있다. ON으로 켜고 JSON 스키마를 등록해두면, 100% JSON 형식으로만 답한다.
AI Studio 화면에서 "Structured output" 토글을 켜면 스키마 입력창이 나온다. 거기에 위에서 정의한 JSON 스키마를 넣는다.
{
"type": "object",
"properties": {
"menu_category": {
"type": "string",
"enum": ["커피", "스무디", "주스", "티", "디저트", "베이커리"]
},
"menu_name": { "type": "string" },
"quantity": { "type": "number" },
"confidence": { "type": "number" },
"original_text": { "type": "string" }
},
"required": ["confidence", "original_text"]
}
위는 핵심 필드만 뽑은 간소화 버전이다. 실제로는 모든 필드를 등록했다.
뭐하는 값인가: 최대 출력 토큰 수. AI가 한 번에 생성할 수 있는 텍스트의 최대 길이.
왜 줄였나: 주문 파싱 결과 JSON은 아무리 길어도 500토큰이면 충분하다. 65536은 소설을 쓸 때나 필요한 값이다. 쓸데없이 크면 비용만 올라간다.
Max Output Tokens: 4096
뭐하는 값인가: 다음 단어를 고를 때 상위 몇 퍼센트의 후보만 고려할지. Temperature랑 비슷한 역할인데, 좀 더 미세한 조정이다.
왜 줄였나: 0.95면 상위 95%의 단어 후보를 고려한다. 0.9으로 줄이면 상위 90%만 고려해서 좀 더 예측 가능한 출력이 나온다. Temperature를 0.2로 낮춘 상태에서 Top P까지 줄이면 "거의 결정론적"인 출력을 얻을 수 있다.
Top P: 0.9
| 설정 | 기본값 | 내 값 | 이유 |
|---|---|---|---|
| Temperature | 1 | 0.2 | JSON 일관성 |
| Thinking Level | High | Low | 단순 파싱, 속도 우선 |
| Structured Outputs | OFF | ON | JSON 스키마 강제 |
| Output Length | 65536 | 4096 | 비용 절약 |
| Top P | 0.95 | 0.9 | 출력 안정성 |
설정이 끝나면 Playground에서 바로 테스트할 수 있다. 입력란에 카톡 메시지를 넣고 보내면 된다.
입력: "내일 점심 아아 5잔이랑 카라멜마끼 2잔 선릉역 3번 출구 앞 빌딩 카드결제"
{
"menu_category": "커피",
"menu_name": "아이스 아메리카노",
"quantity": 5,
"order_date": "2026-04-03",
"order_time": "12:00",
"delivery_address": "선릉역 3번 출구 앞 빌딩",
"payment_method": "카드",
"confidence": 0.90,
"missing_fields": [],
"warnings": ["카라멜 마키아토 2잔은 별도 항목으로 분리 필요"],
"original_text": "내일 점심 아아 5잔이랑 카라멜마끼 2잔 선릉역 3번 출구 앞 빌딩 카드결제"
}
잘 나온다. 여기서 만족하면 안 된다. 엣지 케이스를 테스트해야 한다.
문제없으면 다음 단계로.
Playground에서 테스트가 끝났으면, 우측 상단의 "Get code" 버튼을 클릭한다. 그러면 현재 설정 그대로를 REST API로 호출하는 코드가 생성된다.
지원하는 언어:
나는 PHP 서버에서 쓸 거라 cURL 코드를 뽑았다. Get code로 나온 cURL 구조를 PHP로 옮기면 된다.
Get code로 뽑은 구조를 PHP에 옮긴 코드다.
class CafeOrderParser
{
private static $API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent';
public static function parse(string $messageText): array
{
$apiKey = env('GEMINI_API_KEY');
if (!$apiKey) {
return ['error' => 'API 키가 설정되지 않았습니다.'];
}
// 핵심: 오늘 날짜를 동적으로 주입
$today = date('Y-m-d');
$dayOfWeek = ['일','월','화','수','목','금','토'][date('w')];
$userMessage = "[오늘: {$today} ({$dayOfWeek})]\n{$messageText}";
// System Instructions (미리 작성해둔 파일에서 로드)
$systemPrompt = file_get_contents('/path/to/system-instruction.md');
// 요청 본문
$requestBody = [
'contents' => [
[
'role' => 'user',
'parts' => [['text' => $userMessage]]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 4096,
'topP' => 0.9,
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'menu_category' => ['type' => 'STRING', 'enum' => ['커피','스무디','주스','티','디저트','베이커리']],
'menu_name' => ['type' => 'STRING'],
'quantity' => ['type' => 'NUMBER'],
'order_date' => ['type' => 'STRING', 'nullable' => true],
'order_time' => ['type' => 'STRING', 'nullable' => true],
'delivery_address' => ['type' => 'STRING', 'nullable' => true],
'confidence' => ['type' => 'NUMBER'],
'original_text' => ['type' => 'STRING'],
],
'required' => ['confidence', 'original_text'],
],
'thinkingConfig' => ['thinkingLevel' => 'LOW'],
],
'systemInstruction' => [
'parts' => [['text' => $systemPrompt]]
]
];
// cURL 호출
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::$API_URL . '?key=' . $apiKey);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json; charset=utf-8'
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode != 200) {
return ['error' => "API 오류 (HTTP {$statusCode})"];
}
// 응답에서 JSON 추출
$data = json_decode($response, true);
$resultText = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$resultText) {
return ['error' => '응답을 파싱할 수 없습니다.'];
}
return json_decode($resultText, true) ?: ['error' => 'JSON 파싱 실패'];
}
}
핵심 포인트:
responseMimeType + responseSchema — 이 두 가지가 세트다. responseMimeType만 넣으면 "JSON 형태로 답해라"일 뿐이고, responseSchema까지 넣어야 "이 스키마에 맞는 JSON으로 답해라"가 된다. 스키마 없이 MIME 타입만 설정하면 AI가 임의의 키를 만들어낼 수 있다.thinkingConfig — Gemini 3 계열 모델 전용 설정. gemini-3-flash-preview 모델을 쓸 때만 유효하다. 2.0 모델에는 넣지 않는다.$userMessage에 오늘 날짜를 붙여서 보낸다. 이유는 바로 아래 삽질 파트에서.System Instructions에 이렇게 써놨다.
| 내일 | 오늘 + 1일 |
| 모레 | 오늘 + 2일 |
그리고 Playground에서 이렇게 입력했다.
내일 오후 2시 아아 3잔 배달
결과:
{
"order_date": null,
"warnings": ["'내일'의 기준 날짜를 알 수 없습니다"]
}
"내일"을 해석 못 한다. 당연하다. Gemini는 오늘이 며칠인지 모른다. 우리가 ChatGPT에 "오늘 며칠이야?"라고 물어보면 답하는 것과는 다르다. API로 호출할 때는 현재 날짜 정보가 주어지지 않는다.
해결은 간단했다. 매 요청마다 오늘 날짜를 메시지 앞에 붙인다.
$today = date('Y-m-d');
$dayOfWeek = ['일','월','화','수','목','금','토'][date('w')];
$userMessage = "[오늘: {$today} ({$dayOfWeek})]\n{$messageText}";
이렇게 하면 실제로 Gemini가 받는 메시지는 이렇게 된다.
[오늘: 2026-04-02 (목)]
내일 오후 2시 아아 3잔 배달
이제 "내일"을 2026-04-03으로, "다음주 월"을 2026-04-06으로 정확히 변환한다.
요일도 같이 넣는 이유는 "다음주 월요일"을 계산하려면 오늘이 무슨 요일인지 알아야 하기 때문이다.
이건 좀 황당한 버그였다.
Playground에서는 잘 나오는데, API로 호출하면 JSON이 이상하게 나왔다. 이런 식이다.
{
"menu_category": "커피",
"menu_name": "아이스 아메리카노",
"thinking": "사용자가 '아아'라고 했는데 이건 아이스 아메리카노의 줄임말이다. 날짜는...",
"quantity": 3
}
thinking이라는 필드가 갑자기 끼어든다. 이건 내가 정의한 스키마에 없는 필드다.
처음에는 thinkingConfig 설정을 안 넣어서 그런 줄 알았다. 하지만 진짜 원인은 다른 곳에 있었다.
responseSchema를 안 넣은 게 원인이었다.
responseMimeType: 'application/json'만 설정하면 AI는 "JSON 형태로 답해야겠다"고만 인식할 뿐, 어떤 키(key)가 허용되는지는 모른다. 그래서 AI가 자기 추론 과정을 출력하고 싶은데 JSON으로 답해야 하니까, "thinking"이라는 임의의 키를 만들어서 억지로 끼워 넣은 것이다. 일종의 환각(Hallucination)이다.
responseSchema를 명시적으로 넣어서 허용되는 키를 강제하니까 이 현상이 사라졌다.
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [ // ← 이게 핵심
'type' => 'OBJECT',
'properties' => [
'menu_category' => ['type' => 'STRING'],
'menu_name' => ['type' => 'STRING'],
// ... 허용할 필드만 정의
],
'required' => ['menu_category', 'original_text']
],
'thinkingConfig' => ['thinkingLevel' => 'LOW'], // Gemini 3 모델 전용
]
정리하면: responseMimeType은 "JSON으로 답해라", responseSchema는 "이 구조로만 답해라". 둘 다 넣어야 완전한 Structured Outputs다.
테스트를 열심히 하다 보니 갑자기 이런 에러가 나왔다.
HTTP 429 Too Many Requests
{
"error": {
"code": 429,
"message": "Resource has been exhausted (e.g. check quota)."
}
}
무료 tier의 한도에 걸린 거다.
Google AI Studio는 무료로 시작할 수 있지만, 월 지출 한도가 기본 $5(약 6,500원)로 설정되어 있다. Gemini Flash 모델 기준으로 꽤 많은 요청을 보낼 수 있긴 한데, few-shot 예시 12개 + Knowledge 파일 2개를 매 요청에 보내다 보니 토큰 소비가 컸다.
해결:
흔한 오해: "예산 및 알림(Budgets & Alerts)에서 지출 한도를 올리면 된다"고 생각하기 쉬운데, 이건 알림만 보내주는 기능이지 API 호출 제한(Quota)을 제어하지 않는다. 예산과 할당량은 다른 개념이다.
그리고 서버 코드에서 429 에러를 잡아서 사용자에게 안내하는 로직은 반드시 넣어야 한다.
if ($statusCode == 429) {
return ['error' => '일시적으로 요청이 많습니다. 잠시 후 다시 시도해주세요.'];
}
규칙을 50줄 쓰는 것보다 정확한 입출력 예시 5개가 더 효과적이다. AI는 규칙을 "해석"하지만 예시는 "패턴"으로 학습한다. 특히 은어 매핑은 규칙으로 설명하기 어렵기 때문에 예시가 필수다.
이 조합이면 같은 입력에 같은 출력이 거의 100% 보장된다. 가끔 warning 메시지의 문구가 살짝 달라지는 정도. 핵심 필드값은 흔들리지 않는다.
메뉴가 추가되면 Knowledge 파일만 교체하면 된다. System Instructions를 수정하면 전체 동작에 영향을 줄 수 있지만, Knowledge 파일은 참조 데이터일 뿐이라 안전하다.
"아아 2잔이랑 바라 1잔 그리고 딸스도 하나 보내주세요 선릉역 앞" 같은 메시지에서 "선릉역 앞"이 배달 주소라는 걸 알아서 파악한다. "보내주세요"라는 문맥에서 배달 주문이라는 것도 추론한다. 이건 규칙 기반 파서로는 절대 못 하는 부분이다.
Gemini Flash 모델 기준으로, 요청 1건당 약 0.5~2원 정도다. (입력 토큰 수에 따라 다름.) 하루 100건 처리해도 월 6,000원 수준. 사람이 하나하나 입력하는 인건비에 비하면 거의 공짜다.
responseMimeType: 'application/json' 설정responseSchema 에 JSON 스키마 명시 (이게 없으면 환각 필드 생김)thinkingConfig: { thinkingLevel: 'LOW' } — Gemini 3 모델 전용, 2.0 모델에는 넣지 않기[오늘: YYYY-MM-DD (요일)] 동적 주입confidence 값 체크 (0.7 미만이면 사람이 확인)Written with Claude Code (Anthropic CLI) — 2026.04.02