← Blog

맛도리 개발기 — 동네 사람이 뽑은 맛집이라는 문제 정의

JunGyu Kim · · 12분 읽기Next.jsExpo모노레포사이드프로젝트
목차

리뷰를 읽을수록 모르겠는 맛집

낯선 동네에 갈 일이 생기면 누구나 하는 일이 있습니다. 지도 앱을 열고, 맛집을 검색하고, 별점 순으로 정렬한 다음 리뷰를 읽는 것입니다. 저도 오랫동안 그렇게 해왔습니다. 그런데 언젠가부터 이 과정이 이상하게 피곤해졌습니다. 리뷰를 읽는 시간의 절반이 "이 리뷰를 믿어도 되나"를 판별하는 데 쓰이고 있었기 때문입니다.

"소중한 초대를 받아 다녀왔습니다"로 시작하는 협찬 후기, 사진 구도가 지나치게 전문적인 체험단 리뷰, 개업 첫 주에 몰려 있는 5점 세례. 지도 앱의 리뷰는 이미 광고와 협찬으로 오염된 지 오래고, 우리는 그 사실을 알기 때문에 리뷰를 읽으면서도 끊임없이 의심합니다. 정보가 없어서 못 고르는 게 아니라, 신뢰가 없어서 못 고르는 상황입니다.

별점 평균이라는 지표 자체의 한계도 있습니다. 별점 4.3이라는 숫자는 "지금 내 상황"과 아무 관련이 없습니다. 제가 알고 싶은 건 "혼밥하기 좋은 곳", "회식 2차로 조용히 이야기할 수 있는 술집", "웨이팅 없이 들어갈 수 있는 점심집" 같은 것인데, 불특정 다수의 만족도를 평균 낸 숫자는 이 질문에 답해주지 못합니다. 게다가 그 평균에는 관광객의 별점과 현지인의 별점이 구분 없이 섞여 있습니다.

그래서 사람들이 실제로 하는 행동을 관찰해보면 재미있습니다. 지도 앱의 별점을 참고는 하되 결정은 다른 곳에서 합니다. 그 동네에 사는 친구에게 카톡을 보내고, 회사 근처 맛집은 동료에게 묻고, 커뮤니티에 "이번 주말에 성수 가는데 어디 가야 하나요"라고 글을 올립니다. 즉, 신뢰가 무너진 자리에서 사람들은 이미 "리뷰를 읽는 것"에서 "아는 사람에게 묻는 것"으로 옮겨가 있었습니다. 다만 그걸 도와주는 제품이 없었을 뿐입니다.

흥미로운 건, 제가 원하는 정보가 세상에 없는 게 아니라는 점이었습니다. "그 동네 사람이 실제로 가는 집"이라는 정보는 분명히 존재합니다. 당근 동네생활에, 지역 커뮤니티에, 회사 동료의 머릿속에 있습니다. 문제는 이 정보가 흩어져 있고, 검색이 안 되고, 축적되지 않는다는 것입니다. 커뮤니티에 올라온 "을지로 점심 어디 가세요?" 글은 좋은 답변이 달려도 일주일이면 밀려 내려가서 다시는 찾을 수 없게 됩니다. 같은 질문이 매주 반복되지만 답은 매번 휘발됩니다.

리뷰를 읽는 대신, 그 동네 사람에게 직접 물어보면 되지 않을까?

이 한 문장이 맛도리의 시작이었습니다. 놀러갈 동네에 맛집 질문(설문)을 올리면, GPS로 동네가 인증된 현지인이 실제 가게를 붙여 추천하고, 설문이 마감되면 그 답변들이 동네별 맛집 랭킹으로 영구히 축적되는 위치기반 앱. 이번 글에서는 이 문제 정의에서 출발해 iOS와 Android에 동시 출시하고 v1.3.0까지 운영하기까지, 혼자 개발하며 내린 설계 결정들을 정리해보겠습니다.


2. '동네 인증'이라는 해법, 그리고 트레이드오프

문제의 본질이 "누가 쓴 리뷰인지 모른다"는 것이라면, 해법은 답하는 사람의 자격을 보증하는 것이어야 했습니다. 맛도리의 핵심 신뢰 장치는 GPS 동네 인증입니다. 답변자는 자신이 추천하려는 동네에 실제로 있는 상태에서 GPS로 현장 인증을 해야 하고, 그 동네와의 관계를 주민·직장인·학생 중에서 선택합니다. 거주자만 현지인으로 인정하면 오피스 상권이 비어버리기 때문입니다. 을지로의 진짜 맛집을 아는 사람은 을지로 주민이 아니라 을지로로 매일 출근하는 사람입니다.

기술적으로는 단순한 흐름입니다. 앱이 현재 좌표를 받아 서버로 보내면, 서버는 카카오의 coord2regioncode API로 좌표를 행정동 코드로 변환하고, 행정동 코드만 저장한 뒤 원본 좌표는 즉시 폐기합니다. 정확한 좌표는 데이터베이스 어디에도 남지 않습니다. 처음부터 프라이버시를 고려한 결정이었지만, 결과적으로 스토어 심사에서도 유리했습니다. "정확한 위치는 일시적 처리 후 미저장, 대략적 위치(행정동)만 저장"이라고 데이터 안전성 양식에 그대로 적을 수 있었고, 개인정보처리방침과 실제 구현이 완전히 일치하니 설명할 게 없었습니다. 위치기반 앱을 만들면서 배운 것 중 하나는, 수집하지 않은 데이터는 지킬 필요도 없다는 단순한 사실입니다.

인증의 단위도 고민이 필요했습니다. 동 단위로 자격을 제한하면 너무 좁아서 답변자가 거의 없고, 시 단위로 열면 "그 동네 사람"이라는 약속이 무의미해집니다. 그래서 권한 단위는 구, 표시 단위는 동으로 정했습니다. 강남구에서 인증한 사람은 강남구 전체 설문에 답할 수 있지만, 프로필에는 인증한 동이 표시되는 방식입니다.

물론 이 설계에는 명확한 트레이드오프가 있습니다. 답변 자격을 제한한다는 건 공급자 풀을 스스로 좁힌다는 뜻입니다. 아무나 답할 수 있으면 콘텐츠는 빨리 쌓이지만 기존 리뷰 플랫폼과 다를 게 없어지고, 현지인만 답할 수 있으면 신뢰는 생기지만 초기에는 답변이 잘 달리지 않습니다. 콜드 스타트가 구조적으로 더 어려워지는 선택입니다. 그럼에도 이 제약을 유지한 이유는, 이 제약이 곧 제품의 정체성이기 때문입니다. 제약을 풀어서 얻는 성장은 맛도리를 "리뷰 앱 하나 더"로 만들 뿐입니다.

대신 답변 하나하나의 밀도를 높였습니다. 답변은 자유 텍스트가 아니라 카카오 장소검색으로 실제 가게를 첨부하고, 음식 사진 한 장과 한 줄 이유를 붙이는 구조입니다. 같은 설문에서 여러 사람이 같은 가게를 추천하면 추천 수가 +1로 합산됩니다. 그리고 설문이 마감되면 답변은 사라지지 않고 동네별 맛집 랭킹으로 축적됩니다. 이 랭킹은 "동네 인증을 통과한 사람들의 추천 수"로만 만들어지기 때문에, 광고비로 순위를 살 방법이 없습니다. 조작 불가능한 로컬 랭킹 — 이것이 맛도리가 기존 지도 앱과 갈라지는 지점입니다.


3. 모노레포: 웹이 백엔드이자 SEO 표면이 되는 설계

솔로 개발에 무자본 운영이라는 조건에서 아키텍처의 제1원칙은 "굴리는 서버 수를 최소화한다"였습니다. 구조는 pnpm 워크스페이스 모노레포입니다. apps/mobile(Expo SDK 57, prebuild + dev build), apps/web(Next.js 16 App Router), 로컬 전용 운영 콘솔인 apps/admin, 그리고 점수·등급·설문 상태 규칙 같은 도메인 로직과 zod 스키마를 담은 packages/core, Drizzle 스키마와 공용 쿼리를 담은 packages/db까지 다섯 개의 워크스페이스로 나눴습니다. 공유 원칙은 하나입니다 — 로직만 공유하고 UI는 공유하지 않는다. 웹과 앱의 화면을 억지로 통일하려다 둘 다 어중간해지는 것보다, 순수 함수와 스키마만 공유하는 편이 유지보수가 훨씬 단순했습니다.

이 구조에서 가장 잘한 결정이라고 생각하는 건, 별도의 백엔드 서버를 두지 않은 것입니다. Next.js 웹 프로젝트 하나가 두 가지 역할을 겸합니다. 하나는 SEO 표면입니다. 마감된 설문 결과와 동네 랭킹 페이지를 SSG와 ISR로 공개해서, "성수 혼밥 맛집" 같은 검색으로 유입된 사람이 앱 설치로 이어지는 깔때기를 만듭니다. 답변 데이터가 랭킹이 되고, 랭킹이 검색되는 콘텐츠가 되고, 콘텐츠가 새 질문자를 데려오는 순환 구조입니다. 다른 하나는 모바일 앱의 백엔드입니다. 같은 프로젝트의 Route Handlers(/api/v1/*)가 앱 전용 API를 제공하는데, 운영 버전 기준으로 이 엔드포인트가 90여 개까지 늘었습니다. 인증, 설문, 답변, 랭킹, 알림, 이미지 업로드, 신고와 차단까지 전부 이 안에 있습니다. NestJS 같은 별도 서버를 세웠다면 배포 파이프라인과 모니터링 대상이 두 배가 됐을 텐데, Vercel 배포면 하나로 웹과 API가 함께 나가니 혼자서도 감당이 됐습니다.

데이터 계층은 Neon(서버리스 Postgres)과 Drizzle ORM 조합입니다. scale-to-zero 덕분에 트래픽이 없는 시간에는 비용이 발생하지 않고, Vercel 프리뷰 배포와 Neon 브랜치가 연동되어 마이그레이션을 실데이터 사본 위에서 검증할 수 있었습니다. 이미지는 Cloudflare R2에 presigned URL로 직접 업로드하는데, R2를 고른 결정적 이유는 egress 비용이 0원이라는 점입니다. 이미지가 많은 서비스에서 전송량 과금은 무자본 운영의 최대 리스크이기 때문입니다. 업로드된 사진은 서버에서 썸네일 변형을 만들며 EXIF를 제거합니다. 사진 원본의 GPS 메타데이터가 남으면 좌표를 저장하지 않는 설계가 무의미해지니까요. 로그인은 카카오·구글·애플 세 가지를 자체 JWT(액세스 1시간 + 리프레시 로테이션) 위에 얹었고, 푸시는 지역 토픽 구독이 필요해서 Expo Push Service 대신 FCM을 직접 붙였습니다.

웹과 앱을 잇는 마지막 조각은 딥링크입니다. matdori.apporbit.xyz는 유니버설 링크로 앱과 연결되어 있어서, 공유된 설문 링크를 탭하면 앱 설치자는 바로 해당 화면으로 이동합니다. 문제는 미설치 유저입니다. 스토어에 다녀오는 동안 링크의 목적지가 증발하기 때문에, deferred deep-link claims라는 장치를 만들었습니다.

공유 링크 클릭 (앱 미설치)
  → 웹 랜딩: 목적지(target)와 지문(IP 해시 + 플랫폼)을 클레임으로 기록
  → 스토어 이동, 설치
  → 앱 첫 실행: 같은 지문의 클레임을 조회 → 원래 목적지 화면으로 복원

클레임에는 상태를 바꾸는 힘이 전혀 없다는 게 설계의 핵심입니다. 지문이 위조되거나 엉뚱하게 매칭되어도 최악의 결과는 "첫 화면이 다른 화면으로 열리는 것"뿐이라, 보안 검토를 복잡하게 만들지 않고도 설치 전 공유 경험을 이어붙일 수 있었습니다.


4. 설문 마감은 누가 시켜주나 — 서버리스 운영 자동화

맛도리의 설문에는 3일 또는 7일의 마감 기한이 있습니다. 마감 시각이 되면 설문 상태를 전환하고, 답변을 집계해 랭킹에 반영하고, 질문자에게 푸시를 보내야 합니다. 상시 가동 서버가 있다면 cron 한 줄이면 끝나는 일이지만, 서버리스 스택에서는 "미래의 특정 시점에 코드를 실행한다"가 의외로 까다로운 문제였습니다.

후보를 하나씩 검토했습니다. Neon의 pg_cron은 컴퓨트가 깨어 있을 때만 동작하는데, scale-to-zero로 운영하는 이상 마감 시각에 데이터베이스가 잠들어 있을 수 있어 탈락했습니다. Vercel의 무료 cron은 하루 한 번만 실행할 수 있어서, 자정에 올린 설문이 3일 뒤 자정에 마감되어야 하는 요구를 맞출 수 없었습니다. 그래서 최종적으로는 어느 하나에 전부를 걸지 않는 3중 구조를 만들었습니다.

[주력] 설문 생성 시 QStash 지연 콜백 예약
  publishJSON({ url: "/api/v1/jobs/close-survey",
                body: { surveyId },
                notBefore: 마감시각 })   // 마감 시각에 API를 다시 두드린다
  → 예약이 실패해도 설문 생성은 그대로 성공 처리
 
[안전망] lazy 상태 계산
  조회 시점에 deadline < now 면 저장된 상태와 무관하게 '마감'으로 취급
 
[청소부] Vercel 일일 cron
  콜백이 누락된 설문을 일괄 마감하고 잔여물을 정리

주력은 Upstash QStash의 지연 콜백입니다. 설문을 생성할 때 마감 시각을 notBefore로 지정해 메시지를 예약해두면, QStash가 그 시각에 마감 처리 API를 호출해줍니다. 설문마다 마감 시각이 제각각이어도 분 단위 정확도로 처리됩니다. 여기서 신경 쓴 부분은 실패를 대하는 태도입니다. QStash 예약이 실패하더라도 설문 생성 자체는 막지 않습니다. 예약은 어디까지나 최적화이고, 정합성은 lazy 상태 계산이 보장하기 때문입니다. 마감 시각이 지난 설문은 누가 조회하든 그 시점에 마감으로 취급되므로, 콜백이 유실되어도 유저가 잘못된 상태를 보는 일은 없습니다. 마지막으로 하루 한 번 도는 cron이 콜백을 놓친 설문을 실제로 마감 처리하며 데이터를 정리합니다.

세 경로가 겹칠 수 있다는 점도 처음부터 전제했습니다. 같은 설문에 QStash 콜백과 일일 cron이 모두 도착하는 상황은 언제든 생길 수 있으므로, 마감 처리 잡은 이미 마감된 설문을 받으면 조용히 성공으로 끝나도록 멱등하게 만들었습니다. 덕분에 "혹시 두 번 실행되면 어떡하지"를 고민할 필요 없이 안전망을 겹겹이 쌓을 수 있었습니다. 마감 콜백 API는 QStash의 서명을 검증해서 외부에서 임의로 마감을 트리거할 수 없게 막았고, 환경 변수가 없는 로컬 개발 환경에서는 예약 없이 통과하고 안전망만으로 굴러가게 해서 개발 편의도 챙겼습니다. 상시 서버가 있었다면 cron 한 줄로 끝났을 일에 구조를 세 겹이나 쌓은 셈이지만, 그 대가로 서버 관리 비용이 0이 된다면 남는 장사라고 판단했습니다.

이 인프라는 한 번 만들어두니 재사용처가 계속 생겼습니다. v1.3.0에서 추가한 모임 투표의 마감도 같은 QStash 예약 위에서 돌아가고, 사진 업로드 후처리(변형 생성과 EXIF 제거)도 같은 잡 콜백 패턴으로 처리합니다. 서버리스에서 스케줄링 문제를 한 번 제대로 풀어두면, 이후의 시간 기반 기능은 전부 공짜에 가까워진다는 걸 체감했습니다.


출시하고 배운 것

iOS와 Android에 동시 출시하면서 예상하지 못한 벽은 기술이 아니라 심사였습니다. 맛도리의 핵심인 GPS 동네 인증은 한국 안에서만 통과할 수 있는데, 스토어 심사자는 대부분 한국 밖에 있습니다. 설명 없이 제출하면 심사자에게는 "눌러도 동작하지 않는 기능"으로 보일 뿐입니다. 그래서 심사 메모에 "추천 등록은 한국 내 GPS 인증이 필요하지만, 인증 없이도 지도·랭킹·질문과 답변 열람은 전부 가능하다"를 버전마다 반복해서 적었습니다. 지역성이 곧 기능인 앱이라면, 심사자가 그 지역 밖에 있다는 전제로 제출 자료를 준비해야 합니다. 구글 로그인을 추가한 것도 같은 맥락이었습니다. 카카오 테스트 계정을 심사자에게 넘기는 건 계정 공유 금지 약관에 걸리는 회색지대인 데다 해외 IP 로그인은 이상 접속으로 차단될 수 있어서, 심사자가 자기 계정으로 들어올 수 있는 구글 로그인이 가장 깔끔한 답이었습니다.

운영에서 마주한 가장 큰 현실은 예상대로 콜드 스타트였습니다. 질문과 답변의 순환은 공급자가 어느 정도 모여야 돌기 시작하는데, 그 전까지 지도가 비어 보이면 첫인상에서 실패합니다. 그래서 유저 콘텐츠가 쌓이기 전의 발견 축으로 큐레이션 기능을 병행했습니다. 지하철역을 중심으로 동네를 탐색하는 역세권 허브, 방송과 유튜브에 나온 가게를 모아 보여주는 셀럽픽 같은 것들입니다. 이후 버전에서는 다녀온 맛집을 나만의 지도로 채워 공유하는 마이맵, 모임 장소를 함께 정하는 모임 투표까지 붙이며 "설문에 답할 이유"보다 "앱을 열 이유"를 먼저 만드는 방향으로 다듬어왔습니다.

돌아보면 이 프로젝트에서 가장 값졌던 건 기술적 성취가 아니라 문제 정의의 힘을 체감한 것입니다. "동네 사람이 뽑은 맛집"이라는 한 문장이 서 있으니, 기능을 넣고 뺄 때마다 판단 기준이 명확했습니다. 이 기능이 '그 동네 사람의 답'이라는 약속을 강화하는가? 좌표를 저장하지 않는 결정도, 답변 자격을 제한하는 결정도, 랭킹을 영구 축적하는 결정도 전부 이 질문 하나에서 나왔습니다. 사이드 프로젝트가 방향을 잃는 순간은 대부분 기술이 부족할 때가 아니라 이 한 문장이 없을 때라고, 이제는 말할 수 있습니다.

맛도리는 App Store와 Google Play에서 받아보실 수 있습니다. 놀러갈 동네가 생겼다면, 리뷰를 읽는 대신 그 동네 사람에게 한번 물어보세요.

App Store에서 받기 | Google Play에서 받기 | 웹에서 둘러보기

광고

관련 글