차량 대여를 위한 대화형 예약 에이전트 구축: MCP 엔드포인트, Dialog Passports, 그리고 대안 검색
요약
차량 대여 비즈니스를 위한 대화형 예약 에이전트의 아키텍처와 구현 방식을 다룹니다. MCP 프로토콜의 의미론을 따르는 REST API 레이어와 Redis 기반의 Dialog Passports를 활용하여 대화 맥락 유지 및 자동 예약 프로세스를 구축하는 기술적 방법을 설명합니다.
핵심 포인트
- AI 에이전트 레이어와 비즈니스 로직 레이어(Django)를 완전히 분리하여 컨테이너 기반으로 설계
- MCP 프로토콜의 도구 호출 의미론을 활용한 RESTful API 엔드포인트 구축
- Redis 기반의 Dialog Passports를 통해 대화 중 맥락 상실 문제 해결
- X-Bot-Token을 통한 인증 및 데이터 범위 제한(scoping) 구현
- WhatsApp 및 Telegram 등 메시징 채널과 연동된 완전 자동화 예약 시스템
차량 대여를 위한 대화형 예약 에이전트 구축: MCP 엔드포인트, Dialog Passports, 그리고 대안 검색
우리가 차량 대여 비즈니스를 위한 CRM인 WorCo를 구축했을 때, AI 예약 에이전트는 부가적인 기능일 뿐이었습니다. 하지만 그것은 제품의 핵심이 되었습니다. 다음은 우리 에이전트가 실제로 어떻게 작동하는지에 대한 기술적 분석입니다. 즉, 어떻게 MCP 호환 API 레이어를 설계했는지, Redis 기반의 Dialog Passports를 사용하여 "AI 에이전트가 대화 도중 내용을 잊어버리는" 문제를 어떻게 해결했는지, 그리고 LLM이 실제 예약 엔드포인트를 호출하도록 허용하면서 무엇을 배웠는지에 대한 내용입니다.
메시징 우선 대여 비즈니스의 문제점
동남아시아의 소규모 대여 사업자들 — 푸켓의 오토바이, 발리의 스쿠터, 코 사무이의 보트 — 은 전체 판매 퍼널을 WhatsApp과 Telegram을 통해 운영합니다. 고객이 메시지를 보내 가용성을 묻고, 날짜와 가격을 협상하며, 예약을 하거나 혹은 연락을 끊어버립니다(ghosts). 운영자에게 모든 대화는 수동 작업입니다. 고객에게는 느린 경험입니다.
우리의 시스템은 이러한 메시징 채널에 직접 연결됩니다. AI 에이전트는 들어오는 메시지를 읽고, 차량 보유 현황을 검색하며, 시즌별 가격을 계산하고, 사람의 개입 없이(without a human in the loop) 실제 예약 기록을 생성합니다. 시스템이 잘 작동할 때, 고객이 "다음 주에 오토바이를 빌리고 싶어요"라고 타이핑한 지 몇 초 이내에 예약 건이 운영자의 칸반(Kanban) 보드에 나타납니다.
아키텍처: 세 가지 별도의 레이어
Telegram / WhatsApp (수신 메시지)
│
▼ OpenAI Agents SDK (bot_image/openai_agents/)
├── @function_tool로 장식된 도구(tools)를 가진 에이전트
├── CRMClient — 동기식 HTTP 래퍼(wrapper)
└── Redis 기반 Dialog Passport
│
(X-Bot-Token 인증 HTTP)
▼ Django REST API (/api/mcp/ 엔드포인트)
├── 차량 가용성 + 충돌 감지
├── 시즌별 가격 계산
├── 멱등성(idempotency)을 갖춘 예약 생성
└── 클라이언트 CRM (자동 생성 + 병합)
핵심 아키텍처 결정 사항: AI 레이어(OpenAI Agents SDK)와 비즈니스 로직 레이어(Django)는 완전히 분리된 컨테이너입니다.
에이전트는 다른 외부 시스템과 동일한 방식으로 HTTP 엔드포인트 (HTTP endpoints)를 호출합니다. 공유된 프로세스나 에이전트 측에서의 직접적인 ORM 접근은 존재하지 않습니다. MCP API 레이어: 우리는 정식 MCP 프로토콜 서버를 실행하지 않기로 결정했습니다. 대신, MCP의 도구 호출 (tool-call) 의미론을 따르는 REST 엔드포인트를 구축했습니다. 각 엔드포인트는 단일하고 명확한 목적을 가지며, 구조화된 입력 (structured input)을 받고 구조화된 출력 (structured output)을 반환합니다. HTTP 호출을 할 수 있는 에이전트라면 무엇이든 이를 사용할 수 있습니다. 모든 엔드포인트는 /api/mcp/ 하위에 위치하며 X-Bot-Token 헤더를 요구합니다. 이 토큰은 봇과 그와 연결된 회사를 식별하며, 모든 쿼리는 해당 회사의 데이터 범위로 자동 제한 (scoped)됩니다.
# modules/communications/views/mcp.py
def authenticate_bot ( request ) -> tuple [ bool , Bot , str ]:
bot_token = request . headers . get ( ' X-Bot-Token ' )
if not bot_token :
return False , None , " Missing X-Bot-Token header "
try :
bot = Bot . objects . get ( token = bot_token , is_active = True )
return True , bot , ""
except Bot . DoesNotExist :
return False , None , " Invalid or inactive bot token "
모든 뷰 (view)는 authenticate_bot()으로 시작합니다. 회사 컨텍스트 (company context)는 봇 레코드로부터 자동으로 흐르며, 에이전트는 회사 ID (company ID)를 명시적으로 전달하지 않습니다. 우리가 노출하는 도구 (tools)들은 다음과 같습니다:
GET /api/mcp/vehicles/search_available/ # 필터를 사용한 차량 검색 (fleet search)
GET /api/mcp/vehicles/availability/ # 특정 차량의 가용성 확인
GET /api/mcp/vehicles/pricing/ # 차량별 가격 등급 (pricing tiers)
GET /api/mcp/vehicles/{uuid}/bookings/ # 기존 예약 내역
POST /api/mcp/vehicles/rentals/ # 예약 생성
GET /api/mcp/company_info/ # 회사 상세 정보, 통화 (currency)
GET /api/mcp/offices/ # 사무실 위치
GET /api/mcp/client/info/ # Telegram ID를 통한 고객 조회
POST /api/mcp/client/update/ # 고객 프로필 업데이트
GET /api/mcp/health/ # 생존 확인 (liveness check)
토큰화된 매칭을 사용한 차량 검색
검색 엔드포인트 (/api/mcp/vehicles/search_available/)는 일반적인 예약 대화에서 가장 많이 호출되는 도구입니다.
우리는 사용자의 입력에 관대한(forgiving) 시스템을 만드는 데 상당한 노력을 기울였습니다. 고객들은 "Honda PCX 150"이라고 정확히 말하지 않고, "작은 스쿠터" 또는 "혼다 뭐 자동 모델"과 같이 말하기 때문입니다. 브랜드/모델 필터는 '토큰 기반 AND(AND-over-tokens)' 로직을 사용합니다. 즉, 입력을 비알파뉴메릭(non-alphanumeric) 문자를 기준으로 토큰으로 분리한 다음, 각 토큰이 여러 필드에 걸쳐 일치하도록 요구합니다.
# modules/communications/views/mcp.py
import re as _re
tokens: list[str] = []
for part in [brand or "", model or ""]:
if part:
for t in _re.split(r"[^\w]+", part, flags=_re.IGNORECASE):
t_norm = (t or "").strip().lower()
if len(t_norm) >= 2:
tokens.append(t_norm)
if tokens:
for t in tokens:
tok_filter = (
Q(brand__id__icontains=t) |
Q(brand__translations__name__icontains=t) |
Q(model__slug__icontains=t) |
Q(model__translations__name__icontains=t) |
Q(custom_model_details__icontains=t) |
Q(license_plate__icontains=t)
)
vehicles_qs = vehicles_qs.filter(tok_filter).distinct()
"BMW GS 1200"은 토큰 ["bmw", "gs", "1200"]이 됩니다. 각 토큰은 AND 조건으로 결합되어 차량이 모든 토큰과 일치해야 하지만, 각 토큰은 브랜드 슬러그(brand slug), 번역된 브랜드 이름, 모델 슬러그, 번역된 모델 이름, 커스텀 상세 정보(custom details), 그리고 번호판(license plate) 전체를 검색합니다.
가용성 필터링(Availability filtering)은 엄격한 구간 중첩(strict interval overlap) 방식을 사용합니다. 만약 rental.start_date < requested_end 이고 rental.end_date > requested_start 인 상태가 ['pending', 'confirmed', 'active'] 중 하나인 대여 건이 존재한다면 해당 차량은 제외됩니다.
conflicting = VehicleRental.objects.filter(
vehicle__in=vehicles_qs,
start_date__lt=end_dt,
end_date__gt=start_dt,
status__in=['pending', 'confirmed', 'active']
).values_list('vehicle__uuid', flat=True)
available = vehicles_qs.exclude(uuid__in=list(conflicting))
대안 기간 검색 (Alternative period search)
우리가 예상하지 못했던 문제가 하나 있었습니다. 단순히 "죄송합니다, 예약이 불가능합니다"라고만 말하는 AI 에이전트는 쓸모가 없다는 점입니다. 그래서 우리는 요청된 기간이 차 있을 경우 에이전트가 자동으로 호출할 수 있는 세 가지 전략의 대안 검색(alternative search) 기능을 구축했습니다.
bot_image/openai_agents/tools.py
def _find_alternative_periods (
vehicle_uuid : str ,
vehicle_info : Dict [ str , Any ],
start_date : date ,
end_date : date ,
booked_ranges : List [ Tuple [ date , date ]],
max_results : int = 3
) -> List [ Dict [ str , Any ]]:
"""
전략 1: 첫 번째 충돌 이전의 기간 (Period BEFORE first conflict) - 동일한 시작일(start_date)을 유지하되, 충돌이 시작되기 전으로 종료일을 조정
전략 2: 마지막 충돌 이후의 기간 (Period AFTER last conflict) - 충돌이 끝나는 시점부터 시작하여 동일한 기간(duration)을 유지
전략 3: 여러 예약 사이의 간격 (Gaps BETWEEN multiple bookings) - 기존 예약들 사이의 빈 시간대(free windows)를 탐색
"""
에이전트는 /api/mcp/vehicles/{uuid}/bookings/를 통해 기존 예약 정보를 가져오고, 로컬에서 충돌 탐지(conflict detection)를 실행한 다음, 미리 계산된 가격과 함께 대안을 제시합니다. 고객은 다음과 같은 메시지를 받게 됩니다:
"6월 10일17일은 예약이 되어 있지만, 동일한 요금으로 6월 3일10일 또는 6월 20일~27일을 제안해 드릴 수 있습니다."
우리는 두 경계(boundaries)를 동시에 이동시키지 않습니다. 대안은 요청된 시작일(start date) 또는 충돌 이후의 날짜(post-conflict date) 중 하나에 고정되어야 하며, 자유롭게 떠다니는 형태가 되어서는 안 됩니다.
Dialog Passport 문제
다회차(Multi-turn) 예약 대화에는 근본적인 상태(state) 문제가 존재합니다. AI 에이전트는 설계상 무상태(stateless)입니다. 즉, 각 메시지는 독립적으로 처리됩니다. 하지만 예약 대화는 여러 턴(turn)에 걸쳐 진행됩니다:
- 턴 1: "6월 10일~17일에 오토바이를 빌리고 싶어요" → 에이전트가 검색을 수행하여 Honda CB500F를 찾고 가격을 제시함
- 턴 2: "네, 그 Honda 모델로 할게요. 제 이름은 Alex입니다" → 에이전트는 어떤 차량인지, 어떤 날짜인지 기억해야 함
- 턴 3: "+66 812 345 678" → 에이전트는 예약을 생성하기 위해 차량, 날짜, 고객 이름이 필요함
지속적인 상태(persistent state)가 없다면, 에이전트는 매 턴마다 검색 엔드포인트를 다시 호출하거나, 대화 기록에서 문맥(context)을 추출하기 위해 LLM에 의존해야 합니다. 이는 신뢰할 수 없으며 비용이 많이 드는 방식입니다.
우리의 해결책은 chat_id를 키(key)로 하여 Redis에 저장되는 Dialog Passport입니다.
bot_image/openai_agents/redis_store.py
def _passport_key(chat_id: str) -> str:
return f"passport: {chat_id}"
def set_passport(chat_id: str, passport: Dict[str, Any], ttl_seconds: Optional[int] = None) -> bool:
r = get_redis()
key = _passport_key(chat_id)
payload = json.dumps(passport, ensure_ascii=False)
if ttl_seconds and ttl_seconds > 0:
r.setex(key, ttl_seconds, payload)
else:
r.set(key, payload)
return True
def get_passport(chat_id: str) -> Optional[Dict[str, Any]]:
r = get_redis()
data = r.get(_passport_key(chat_id))
return json.loads(data) if data else None
The passport has two sections:
{
"client": {
"first_name": "Alex",
"contacts": [
{"type": "phone", "value": "+66812345678"},
{"type": "telegram_id", "value": "123456789"}
]
},
"rental_context": {
"vehicle_uuid": "abc-123-...",
"start_date": "2026-06-10",
"end_date": "2026-06-17",
"total_price": 280.0
}
}
The agent writes to the passport as it collects information.
The create_vehicle_rental tool reads it when building the booking payload:
bot_image/openai_agents/tools.py
def _collect_booking_context(conv_id, explicit_data):
ctx_data = {**explicit_data}
if conv_id:
chat_id = _conv_id_to_chat_id(conv_id)
passport = rs.get_passport(chat_id) or {}
ctx_client = passport.get('client') or {}
ctx_rental = passport.get('rental_context') or {}
ctx_data['client'] = ctx_client
ctx_data['rental'] = dict(ctx_rental)
return ctx_data
The passport has a configurable TTL — typically a few hours. If the conversation goes cold, the next message from that customer starts fresh.
또한 동일한 chat_id 접두사 아래에 Redis에 보조 데이터를 저장합니다:
search:{chat_id}— 마지막 검색 결과 (에이전트가 재쿼리 없이 "두 번째 옵션"을 참조할 수 있도록 함)last_uuid:{chat_id}— 현재 논의 중인 차량의 UUIDstatus:{chat_id}— 대화 단계 (searching / quoting / confirming / booked)history:{chat_id}— 다듬어진 메시지 기록 (최대 50개 항목, FIFO)
멱등성 (Idempotency)을 적용한 예약 생성
POST /api/mcp/vehicles/rentals/ 엔드포인트는 예약을 생성합니다. 여기서 중요한 문제는 LLM이 동일한 예약에 대해 이 엔드포인트를 두 번 호출할 수 있다는 점입니다. 한 번은 재시도(retry)에 의해, 다른 한 번은 예약이 성공했는지에 대한 혼란스러운 추론(reasoning)에 의해 발생할 수 있습니다. 우리는 명시적인 idempotency_key를 사용하여 이를 처리합니다:
# modules/communications/views/mcp.py
if idempotency_key:
existing = VehicleRental.objects.filter(
company=company,
vehicle=vehicle,
client__first_name=client_first_name,
start_date=start_date,
end_date=end_date,
extra_data__idempotency_key=idempotency_key
).first()
if existing:
return JsonResponse({
'rental_uuid': str(existing.uuid),
'order_number': existing.order_number,
'status': existing.status,
'idempotent': True # 이것이 중복 호출임을 알리는 신호
})
idempotency_key는 extra_data (rental 모델의 JSONField)에 저장됩니다. 에이전트는 대화 문맥(conversation context)으로부터 키를 생성하며, 일반적으로 f"{chat_id}:{vehicle_uuid}:{start_date}:{end_date}" 형식을 사용합니다. 만약 해당 키로 이미 예약이 존재한다면, 우리는 idempotent: True와 함께 기존 레코드를 반환하여 에이전트가 이것이 반복된 호출임을 알 수 있게 합니다.
충돌 감지(Conflict detection)는 생성 전에 실행됩니다:
conflicting_rentals = VehicleRental.objects.filter(
vehicle=vehicle,
start_date__lt=end_dt,
end_date__gt=start_dt,
status__in=['pending', 'confirmed', 'active']
)
if conflicting_rentals.exists():
return JsonResponse({
'error': 'Vehicle unavailable for requested dates',
'conflicting_rentals': [
{
'start_date': r.start_date.isoformat(),
'end_date': r.
end_date.isoformat(), 'status': r.status} for r in conflicting_rentals ] }, status=400) 이 엔드포인트는 충돌하는 대여(rentals) 정보를 반환하여, 에이전트가 추가적인 왕복 통신(round-trip) 없이 대안을 제안할 수 있도록 합니다.
클라이언트 자동 생성 및 고객 병합 (Client auto-create and merge Customers)
Telegram을 통한 메시징은 아직 CRM 레코드가 없는 경우가 많습니다. 예약(booking) 엔드포인트는 클라이언트를 자동으로 생성하며, 플레이스홀더(placeholder) 클라이언트가 나중에 실제 연락처 정보를 갖게 될 때 발생하는 병합(merge) 문제를 처리합니다.
흐름은 다음과 같습니다:
- Telegram 사용자 123456789로부터 첫 메시지가 도착합니다. — 해당 사용자의 Telegram ID를 유일한 연락처로 하는 플레이스홀더 클라이언트가 생성됩니다.
- 고객이 전화번호를 제공합니다. — 패스포트(passport)가 업데이트됩니다.
- 예약 생성 시, 전화번호로 기존 클라이언트를 먼저 조회한 다음, Telegram ID로 조회합니다.
- 플레이스홀더(자동 생성되었으며 이름이 없는 상태)를 찾으면, 이를 유입된 데이터와 병합합니다.
- 실제 클라이언트를 찾으면, 예약을 해당 클라이언트와 연결하고 누락된 연락처 정보를 업데이트합니다.
이는 3개월 전에 메시지를 보내고 전화번호를 남겼던 고객이 다시 메시지를 보낼 때 자동으로 인식됨을 의미합니다.
OpenAI Agents SDK 측면 (The OpenAI Agents SDK side)
에이전트 도구(tools)는 OpenAI Agents SDK의 @function_tool을 사용하여 정의됩니다. 각 도구는 MCP 엔드포인트와 일대일로 매핑됩니다:
bot_image/openai_agents/tools.py
from agents import
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기