전화로만 되는 일본 식당을 대신 예약하는 서비스
전화로만 예약되는 일본 식당을 대신 잡아주는 서비스
예약 창구가 전화뿐인 나라
일본에는 온라인 예약 창구가 없는 가게가 많고, 전화 예약은 일본어로만 가능합니다. 이 서비스는 대신 가게에 전화를 걸어 예약을 잡아 주는 방식으로 그 문제를 풉니다.
제가 합류했을 때 서비스는 이미 v1이 돌고 있었습니다. NestJS + GraphQL + Prisma 서버 한 벌과 Expo 앱, Mantine 어드민, 어드민 웹이 각각 다른 레포에 흩어져 있었고(mimitable-server, mimitable-mobile-app, mimitable-admin, mimitable-admin-web), 서버는 apps/app, apps/admin, apps/caller 세 개의 GraphQL 엔드포인트를 따로 띄우고 있었습니다. 스키마 한 줄을 바꾸면 codegen을 돌리고 레포 세 개에 PR을 올려야 했습니다.
요구사항은 두 가지였습니다. 앱을 다시 만들되 예약 요청부터 통화, 결과 통보, 환불까지가 하나의 상태 기계 위에서 돌게 할 것. 그리고 콜러가 슬랙에서 처리한 기록이 전부 DB에 남게 할 것.

레포 다섯 개 합치고 서버 삭제
레포 다섯 개를 Turborepo + yarn v4 워크스페이스 하나로 합쳤습니다
앱(Expo)·어드민(Vite)·SEO 웹(Remix)·서버·크론·웹훅이 전부 같은 DB 스키마와 같은 enum을 봅니다. packages/db의 Drizzle 스키마와 packages/enum의 BookingStatus를 워크스페이스로 공유하니 스키마를 고치면 여섯 개 앱에서 동시에 타입 에러가 납니다. codegen을 돌릴 필요도, 레포 세 개에 PR을 올릴 필요도 없어졌어요. v1이 이미 운영 중이었기 때문에 한 번에 갈아엎을 수 없었고, apps/_admin-old를 남겨 둔 채 새 어드민을 옆에 띄우며 옮겼습니다.
GraphQL을 버리고 tRPC + Drizzle로 갔습니다
클라이언트가 셋인데 전부 TypeScript입니다. 스키마 언어를 따로 두고 코드젠 파이프라인을 유지할 이유가 없었어요. AppRouter 타입 하나를 import 하면 RN에서도 Remix 로더에서도 자동완성이 됩니다. Drizzle을 고른 건 예약 가능 시간 계산처럼 CASE WHEN과 생성 컬럼이 필요한 쿼리를 ORM 밖으로 빠져나가지 않고 쓰기 위해서였습니다. Lambda 한 개에 전체 라우터를 얹는 구조라 콜드 스타트가 아쉬웠습니다. arm64 + 60초 타임아웃으로 타협했어요.
상시 서버 대신 SST v3(Pulumi)로 Lambda와 CloudFront Router를 찍어냈습니다
트래픽이 하루 종일 낮다가 저녁 예약 시간대에만 몰립니다. tRPC, 이미지 변환, blurhash, 웹훅, 크론이 전부 독립 Lambda이고 앞단에 sst.aws.Router가 붙습니다. 스테이지 이름이 서브도메인에 그대로 들어가서(trpc.{stage}.…) PR마다 완전한 환경을 하나 띄울 수 있어요. 인프라를 전담하는 인력이 따로 없는 조건이었습니다. IaC를 TypeScript로 쓸 수 있다는 점이 결정적이었습니다.

요청은 큐로, 기록은 슬랙으로
이 글에서는 예약 가능한 시간을 계산하는 문제와 예약 요청이 콜러에게 닿는 경로, 두 가지만 다룹니다. 결제와 환불, 이미지 파이프라인, 공개 웹 페이지는 각각 따로 다룰 가치가 있는 이야기라 여기서는 접습니다.
1분마다 도는 booking-assignment 크론이 confirmed와 cancel_requested_by_user 상태의 예약을 긁어 booking_assignment를 한 건 만들고, 같은 트랜잭션 안에서 예약 상태를 assigned로 올립니다. 그리고 웹훅 Lambda를 때려 슬랙 예약 채널에 카드를 하나 던집니다. 이때 슬랙이 돌려준 메시지 ts를 booking_assignment.slack_thread_id에 저장하는데, 이 한 컬럼이 운영 도구 전체의 축입니다.

예약 가능한 시간은 한 곳에서 나오지 않습니다
가게마다 영업시간 테이블을 하나 두고, 그걸 30분 단위로 잘라 슬롯을 만들면 될 것 같습니다. 실제로 v1이 그랬고 저도 처음엔 그렇게 시작했어요. 그런데 이 방식은 세 지점에서 무너집니다. 첫째, 타베로그 웹 예약이 열려 있는 가게는 계산된 시간이 아니라 타베로그가 아는 공석이 진실입니다. 둘째, 일본 가게의 영업시간은 17:00 - 다음날 02:00처럼 요일을 넘깁니다. open_day === close_day를 가정한 순간 새벽 시간대가 통째로 사라집니다. 셋째, "이번 주 수요일만 쉼", "매주 월요일 쉼", "오봉 연휴 쉼"은 서로 다른 종류의 예외인데, 영업시간 테이블에 밀어 넣으면 표현이 안 됩니다.
진실의 출처를 하나로 정하지 않고 우선순위를 매겼습니다. restaurant.tabelog.availableDates와 availableTimes는 (1) 타베로그 공석 API, (2) 손으로 등록한 restaurant_booking_availability, (3) restaurant_open_time 순서로 후보를 만듭니다. 앞 단계가 비면 다음 단계로 떨어지고, 타베로그 호출이 실패해도 예외를 삼킨 뒤 Sentry에 브레드크럼만 남기고 폴백합니다.
요일 넘김은 normalizeOpenTime에서 정규화합니다. (closeDay - openDay + 7) % 7로 며칠에 걸치는지 구하고, 첫날은 여는 시각부터 23:59까지, 중간 날은 종일, 마지막 날은 00:00부터 닫는 시각까지로 쪼갭니다. 그렇게 만든 조각을 요일별로 묶어 분 단위로 환산해 겹치는 구간을 병합하고, 길이가 0인 구간은 버립니다.
차단은 항상 마지막에 뺍니다. restaurant_block_time을 specific, weekdays, holidays 세 타입으로 나누고, specific은 normalizeBlockedTimes로 날짜별 조각으로 펼친 뒤 종일 차단만 날짜 필터에 쓰고 시간 차단은 시간 필터에 씁니다. holidays는 public_holiday 테이블의 기간과 대조합니다.
소스마다 신뢰도가 다르다는 사실을 코드 구조로 인정한 것이 핵심입니다. 타베로그가 열려 있으면 자체 계산은 틀릴 수밖에 없고, 타베로그가 죽으면 자체 계산이라도 있어야 화면이 비지 않습니다. 폴백을 예외 처리가 아니라 정상 경로로 두었기 때문에 외부 의존이 흔들려도 달력에는 항상 뭔가가 칠해집니다.
그리고 차단을 후보 생성과 분리한 덕에, "이 가게 다음 주 통째로 막아 주세요"라는 요청이 들어오면 소스가 타베로그든 영업시간이든 상관없이 같은 방식으로 빠집니다. 세 종류의 차단을 한 테이블에 담되 type 컬럼으로 갈라 놓은 것도, 나중에 "연말연시 전체 가게 차단" 같은 요구가 왔을 때 holidays 하나만 늘리면 되게 하려는 의도였어요.
화자 분리를 모델이 아니라 회선에서 해결했습니다
믹스다운된 한 트랙에서 "이건 우리 콜러, 이건 가게 직원"을 가르는 건 Whisper가 하는 일이 아닙니다.
Twilio 녹음을 듀얼 채널로 받아 두었습니다. 콜러의 목소리와 가게의 목소리가 애초에 좌우 채널로 분리되어 저장돼요.
화자 분리를 AI가 아니라 인프라 레벨에서 끝냈기 때문에 정확도가 음질이나 겹쳐 말하기에 흔들리지 않습니다.
유저가 할 수 있는 일
조용히 틀리는 실패의 위험
모든 것을 아름답게 정리해 두지는 못했습니다. 가장 아픈 곳부터 적겠습니다.
타베로그 스크래핑이 하드코딩된 쿠키와 User-Agent에 매달려 있습니다. 마크업이 바뀌면 cheerio 셀렉터가 빈 문자열을 뱉고, 그게 그대로 LLM에 들어가 그럴듯한 값이 채워집니다. 조용히 틀리는 게 제일 나쁘죠. 최소한 필수 필드가 비었을 때 예외를 던지는 방어가 필요했습니다.














