문제
쏠마린캠핑카는 충북 옥천의 중고 캠핑카 정식 판매점입니다. 매입하고, 팔고, 위탁을 받고, 정비합니다. 오랫동안 잘해 온 일이지만, 웹에 자체 매장 페이지가 없었습니다.
이미 매장을 아는 사람에게는 통하지만, 1억원을 쓸지 고민하는 처음 보는 손님에게는 통하지 않습니다. "중고 캠핑카"를 검색한 사람은 이곳에 닿지 않았습니다. 검색 엔진이 찾아갈 것이 아무것도 없었기 때문입니다. 중고 모터홈에서 무엇을 확인해야 하는지 몇 년에 걸쳐 써 둔 사장님의 가장 좋은 글들도 마찬가지로 닿을 수 없었습니다.
그리고 질문이 있었습니다. 직원 셋인 매장에는 매일 같은 질문이 들어옵니다. 주행거리가 얼마인지, 가격 조정이 되는지, 성능점검은 받았는지, 토요일에 보러 가도 되는지. 이 질문은 밤 아홉 시에도 오고, 점점 매장에서 아무도 읽지 못하는 언어로도 옵니다.
가장 어려웠던 것은 재고 기록이었습니다. 가격이 자유 입력으로 관리되어 적힌 대로 담겨 있었습니다. 두 대의 가격이 10,500원과 17,000원으로 나왔습니다. 실제 가격은 1억 500만원과 1억 7,000만원이었습니다. 그 숫자를 그대로 공개하는 매장은 없느니만 못합니다.
접근 방식
서로 어긋나면 안 되는 두 개의 반쪽을 가진 하나의 제품으로 보고 시작했습니다.
- 검색 엔진이 읽을 수 있는 매장. 아무 데도 가입하지 않고 손님이 도착할 수 있어야 합니다
- 출처를 따라갈 수 있는 가격. 공개된 페이지의 틀린 가격은 오타가 아니라 책임입니다
- 쏠마린이 직접 쓴 글에 기반한 챗봇. 언어 모델이 캠핑카에 대해 알고 있다고 믿는 내용이 아니라
- 일찍 넘기는 인계. 이 일의 성사는 전화 한 통이나 방문 예약이고, 그것까지 하려는 챗봇은 오히려 놓칩니다
시작할 때 적어 둔 성공 조건은 체류 시간도 폼 제출도 아니었습니다. 전화 한 통, 또는 잡힌 방문 약속이었습니다.
만든 것
근거를 댈 수 있는 가격
모든 가격을 사장님이 직접 쓴 글과 대조해 확인했습니다. 38대 중 34대가 그렇게 확인되었고, 매물마다 무엇으로 확인했는지 기록해 두었습니다. 확인되지 않은 가격은 추측하지 않고 "가격 문의"로 표시합니다. 틀린 숫자가 나와도 출처를 따라갈 수 있습니다.
9개 언어, 그리고 루트에 놓인 한국어
한국어, 영어, 일본어, 중국어, 베트남어, 러시아어, 스페인어, 네팔어, 태국어로 공개합니다. 한국어는 접두어 없이 루트에 있습니다. 네이버가 색인하는 주소가 그것이기 때문입니다.
정적 사이트에서 이 조합은 생각보다 까다롭습니다. 경로를 다시 쓰는 미들웨어가 없으므로, 한국어 페이지는 링크가 가리키는 주소에 실제 파일로 있어야 합니다. 언젠가 누군가 지워 버릴 재작성 규칙이 아니라요. 브라우저 언어 감지는 빌드가 끝난 결과물에 끼워 넣는 작은 스크립트인데, 일부러 소심하게 만들었습니다. 한국어 루트에서만, 손님이 아직 아무것도 고르지 않았을 때만, 언어가 정확히 일치할 때만 이동합니다. 브라질 포르투갈어를 요청한 브라우저는 한국어를 봅니다. 엉뚱한 언어가 기본 언어보다 나쁘기 때문입니다.
검색에 잡히는 일, 이 프로젝트의 출발점
다른 모든 결정 밑에 깔린 과제는 검색이었습니다. 검색 엔진이 닿을 것이 하나도 없었으니, 정작 이 매장이 파는 것을 이미 찾고 있던 사람에게 매장이 보이지 않았습니다.
4,803개 페이지는 전부 디스크에 놓인 정적 HTML입니다. 크롤러가 자바스크립트를 실행하지 않고도 매물 내용을 그대로 읽습니다. 제대로 색인되는 것과 빈 껍데기로 색인되는 것의 차이입니다.
한국어는 접두어 없이 루트에 있습니다. 네이버가 색인하는 형태가 그것이고, 한국에서 중고차를 찾는 사람에게 중요한 검색 엔진이 네이버이기 때문입니다. 그리고 모든 주소가 9개 언어의 hreflang과 x-default를 함께 선언합니다. 일본어로 검색한 일본인 손님이 한국어 홈이 아니라 그 차량의 일본어 페이지로 바로 도착합니다.
제목과 설명에는 별도의 빌드 검사를 두었습니다. 번역되지 않은 키가 페이지 제목으로 한 번 실제 서비스에 나간 적이 있습니다. "meta.vehicles.title | 쏠마린캠핑카"로 보였고, 이런 것은 검색 결과에 떠 있기 전까지 아무도 알아채지 못합니다. 지금은 번역 키가 화면에 글자로 나오면 빌드가 실패합니다.
나머지는 속도입니다. 느린 페이지는 순위에서 밀리고, 그전에 손님이 떠납니다. 이미지는 미리 만들어 두고, 폰트는 언어별로 나눠 불러오며(스페인어 독자에게 수 메가바이트의 한중일 글자를 보낼 이유가 없습니다), 크롤러와 파일 사이에 실행되는 것이 없습니다.
24,549장의 이미지, 그리고 이미지 서버 없음
모든 사진을 AVIF와 WebP로, 세 가지 너비로 미리 만들어 둡니다. 2.9MB짜리 원본이 133KB AVIF가 됩니다. 원본 4,091장에서 만들어진 24,549개의 파일이고, 덕분에 이 시스템에는 실행 중에 이미지를 처리하는 서버가 아예 없습니다. 사이트 전체가 정적으로 남을 수 있는 이유입니다.
완성된 결과물은 53,385개 파일입니다. Cloudflare Pages가 받아 주는 수를 넘습니다. 문서가 아니라 배포하다가 알게 되는 종류의 한계여서, 사이트는 R2 버킷에 두고 Worker로 서빙합니다. 이미지는 별도 버킷에서 별도 주소로 나갑니다.
사장님이 쓴 글에서 답을 찾는 챗봇
챗봇은 모든 페이지에 있습니다. 그 뒤에는 매장이 직접 쌓아 온 자료에 대한 검색이 있습니다. 정리해 둔 기본 정보, 사업자 정보, 공지, 38대의 매물 전체, 공개한 35편의 글, 그리고 이미 받아 본 질문에 사장님이 직접 써 둔 답변입니다. 매장이 답한 적 없는 것에서는 배울 것이 없으므로 넣지 않았습니다.
여기서 짚어 둘 만한 결정이 셋 있습니다.
임베딩은 컨테이너 이미지에 넣어 둔 다국어 모델로 서버에서 직접 계산합니다. 질문은 9개 언어로 들어오고 자료는 전부 한국어라 언어를 넘나드는 모델이어야 했고, 직접 돌리면 질문마다 드는 비용이 없으며, 남의 네트워크가 살아 있어야 시작되는 일도 없습니다.
벡터 데이터베이스는 쓰지 않았습니다. 색인은 1,168개 조각, 벡터로 1.7MB입니다. 검색은 파일 하나와의 행렬 곱 한 번입니다. 여기에 데이터베이스를 두는 것은 이유 없는 인프라입니다.
비슷하기만 해서는 부족하므로, 자료마다 신뢰도에 따라 가중치를 둡니다. 정리해 둔 기본 정보가 매물 정보보다, 매물 정보가 3년 전에 적어 둔 메모보다 앞섭니다. 오래된 공지는 지난 영업시간을 현재형으로 적어 두고, 언어 모델은 그것이 이미 바뀌었다는 것을 알 방법이 없습니다.
검색으로 풀지 않은 부분
"제일 비싼 카라반이 뭔가요"는 찾아낼 문장이 아닙니다. 재고 전체를 놓고 하는 비교입니다.
그래서 매물은 임베딩하지 않았습니다. 38대를 한 장의 표로 만들어 모든 질문에 함께 넣고, 최고가와 최저가, 예산 조건은 파이썬에서 계산해 확정된 사실로 건네줍니다. 가정해서 만든 규칙이 아닙니다. 표를 읽고 알아서 판단하라고 두었을 때, 모델은 1,600만원짜리를 가장 싸다고 말하면서 같은 문장에 1,200만원짜리를 함께 적었고, 예산을 넘는 다섯 대를 "이하" 항목에 넣었고, 세 대를 두 대로 셌습니다.
가격을 한글과 숫자로 함께 적는 것도 같은 이유입니다. 일본어 답변이 1억 7,000만원을 1억 7,000만엔으로 바꿔 버린 적이 있습니다.
멈출 때를 아는 일
여기가 가장 중요한 부분이고, 정직하게 말하면 이 챗봇은 비켜서도록 만들어졌습니다.
계약, 할부 승인, 특정 차량의 최종 가격 협의, 방문 예약, 그리고 찾은 자료로 답할 수 없는 것은 사람에게 넘깁니다. 확신도 점수 같은 것은 쓰지 않습니다. 이름이 붙은 항목들입니다. 점수는 끝없이 조정해야 하는 숫자이고, 규칙은 사장님이 읽고 동의할 수 있는 문장이기 때문입니다.
그 아래에 두 가지 장치가 더 있고, 둘 다 모델을 믿지 않습니다. 답변에 적힌 금액이 재고 목록에 그대로 있지 않으면 그 답변은 버리고 사람에게 넘깁니다. 챗봇이 지어낸 가격은 결국 사장님이 지켜야 하는 가격입니다. 모델이 응답하지 않거나 형식이 깨져도 마찬가지로 넘깁니다. 이 챗봇은 구조적으로 대화를 막다른 곳에 둘 수 없습니다.
넘길 때 "자료에 없습니다"라고 말하지 않습니다. 손님에게는 그런 사실이 없다는 말로 들리기 때문입니다. 지금 확인이 어려워 담당자에게 넘기겠다고 말합니다. 동시에 전화번호를 보여 줍니다. 채팅창에서 기다리는 것은 갇히는 일이 아니라 손님이 고르는 일이어야 하기 때문입니다. 직원은 대시보드에서 대화를 이어받고, 사람이 한 번 답한 뒤로 챗봇은 그 대화에서 완전히 빠집니다.
하나의 대화, 두 개의 언어
답변 언어는 지금 보고 있는 페이지가 아니라 손님이 쓴 글자로 정합니다. 가장 최근 메시지가 기준이라, 대화 중간에 언어를 바꿔도 그대로 됩니다.
그리고 대화 기록이 양쪽 모두에게 자기 언어로 보입니다. 모든 메시지는 원래 언어 그대로 저장되고, 읽는 사람에게 맞춰 그때그때 번역됩니다. 한국인 직원은 베트남어 질문을 한국어로 읽고, 손님은 한국어 답변을 베트남어로 읽습니다. 양쪽 다 눌러서 원문을 볼 수 있습니다. 가격이나 날짜의 번역은 실제로 무엇이라고 쓰였는지 확인해 볼 가치가 있기 때문입니다.
전화번호까지 언어에 따라 다릅니다. 한국어 답변에는 유선번호가, 나머지 언어에는 국제 형식의 휴대폰 번호가 나갑니다. 유선번호는 앞자리 0을 빼고 +82를 붙여야 한다는 것을 알아야 해외에서 걸 수 있고, 태국어로 묻는 손님은 정확히 그것을 모르는 사람이기 때문입니다.
결과
- 9개 언어로 4,803개 페이지가 공개되었고, 전부 hreflang을 갖춘 정적 HTML이라 검색에 잡히며, 가입 없이 볼 수 있습니다
- 38대의 매물에 출처를 확인한 가격이 붙었고, 확인되지 않으면 추측 대신 문의 상태로 둡니다
- 973장의 사진을 이미지 서버 없이 24,549개의 미리 만든 AVIF와 WebP로 서빙합니다
- 매장이 직접 쌓아 온 1,168개 조각의 자료에서 9개 언어로 답하며, 벡터 데이터베이스도 질문마다 드는 임베딩 비용도 없습니다
- 사장님이 읽고 동의할 수 있는 규칙으로 사람에게 넘기고, 재고에 없는 금액은 걸러 냅니다
- 대화 기록은 180일간 보관하며, 페이지에 적어 둔 약속이 아니라 배포할 때마다 코드가 실제로 지웁니다
숫자 너머
이 프로젝트에서 어려웠던 것은 한 번도 모델이 아니었습니다. 챗봇이 무엇을 말하면 안 되는지 정하는 일이었습니다.
판매점의 신뢰는 같은 가격을 두 번 똑같이 말하는 데서 쌓입니다. 여기 들어간 대부분의 작업은 챗봇이 그것을 무너뜨릴 수 없게 만드는 일이었습니다. 가격을 지어낼 수 없고, 손님의 개인적인 글을 다른 사람에게 옮길 수 없고, 닿지 못하는 사실을 없는 사실인 것처럼 말할 수 없고, 사람이 들어온 뒤에는 계속 말할 수 없습니다.
매장이 얻은 것은 판매를 마무리하는 도우미가 아닙니다. 같은 마흔 개의 질문에 9개 언어로 답하면서, 그중 어느 것이 사람의 몫인지 정확히 아는 야간 근무입니다.
그리고 이 사이트는 계속 저희가 돌봅니다. 저장소를 넘기고 떠나는 방식이 아닙니다. 사이트와 사진과 챗봇의 자료를 이어서 관리하기 때문에, 새로 들어온 차량이 9개 언어와 챗봇의 자료에 반영되는 동안 매장에서는 배포 절차를 배울 일이 없습니다. 판매점은 캠핑카를 팔아야지, 빌드를 돌리고 있을 이유가 없습니다.