점심 메뉴 고르는 사이트 만들기 (카카오 로컬 API + Cloud Storage)
Published:
Google Cloud
- Google Cloud Compute Engine에 Docker로 Nginx 띄우기
- Cloud Run에 Docker 이미지 배포하기
- Google Cloud Storage로 정적 웹사이트 호스팅하기 (+ 로또 예상번호 사이트)
- Terraform으로 GCP에 VPC + VM 만들고 지워보기
- 점심 메뉴 고르는 사이트 만들기 (카카오 로컬 API + Cloud Storage)
- Cloud Monitoring으로 내 사이트 감시하기 (가동시간 체크 + 이메일 알림)
- 월 5천원으로 GCP에 커뮤니티 사이트 운영하기 (Cloud SQL·로드밸런서 없이)
- 클라우드 공부하려고 학습 사이트 26개를 만든 이야기 (IT 기초 · 쿠버네티스 · 구글 클라우드)
점심시간마다 “뭐 먹지?”로 5분씩 쓰는 게 아까워서 사이트를 하나 만들었다. 회사 반경 500m 안의 식당만 모아서, 여럿이 하나씩 고른 뒤 그중에서 뽑거나 룰렛이나 월드컵으로 정한다.
구성은 Cloud Storage 정적 호스팅 글에서 만든 패턴을 그대로 가져왔다. 버킷 하나, 키 파일 없는 GitHub Actions 배포. 이번 글은 그 위에서 새로 부딪힌 것들, 그러니까 식당 데이터를 어디서 가져오느냐와 GCP 결제 한도, 그리고 화면 버그 이야기다.

오른쪽 사진 벽은 근처 식당 후기 글에서 가져온 대표 사진이다. 열마다 반대 방향으로 천천히 흐른다.
최종 구성은 이렇다.
GitHub Actions (매월 1일)
└─ 카카오 로컬 검색 ─ 반경 500m 음식점 ─┐
└─ 카카오 블로그 검색 ─ 대표 사진 ──────┴→ restaurants.json → 되커밋
│
(Workload Identity Federation, 키 파일 없음)
↓
Cloud Storage 버킷 → 브라우저
브라우저 ─ Leaflet + OpenStreetMap 타일 (지도, API 키 없음)
1. 식당 데이터는 어디서 가져오나 — 세 번 바꿨다
처음 계획은 네이버였다. 막상 붙이려고 보니 두 가지가 걸렸다.
- 지도를 띄우려면 NAVER Cloud Platform에서 Maps 앱을 따로 만들어야 하고, 식당 검색은 NAVER Developers에서 또 앱을 만들어야 한다. 키가 두 종류다.
- 지역 검색 API는 쿼리당 최대 5건이고 반경 파라미터가 없다. “지식정보타운역 김치찌개”, “과천 갈현동 김치찌개”처럼 지역 키워드를 바꿔 여러 번 검색한 뒤, 좌표로 거리를 계산해서 500m 밖을 버리는 식으로 우회해야 했다.
그래서 키가 필요 없는 OpenStreetMap부터 확인했다. Overpass API로 반경 500m 안 음식점을 조회해봤다.
$ curl -s --data-urlencode 'data=[out:json];nwr(around:500,<위도>,<경도>)[amenity~"^(restaurant|fast_food)$"];out center tags;' \
https://overpass-api.de/api/interpreter | jq '.elements | length'
3
3곳. 신도시 상가라 지도 데이터가 아직 안 채워진 동네였다. 데이터 소스로는 못 쓴다.
다음은 Google Places. 이미 쓰는 GCP 프로젝트에서 gcloud services api-keys create 로 키를 바로 만들 수 있어서 가입 절차가 없다는 게 장점이었다. 반경 검색 한 번에 20곳이 나왔다. 다만 국내 식당 정보가 카카오·네이버보다 적고, 나중에 평점·리뷰까지 붙이려고 보니 구글 데이터를 구글 지도가 아닌 지도와 함께 쓰면 약관에 걸린다.
최종 선택은 카카오 로컬 API였다.
| 후보 | 결과 |
|---|---|
| 네이버 지역 검색 | 쿼리당 5건, 반경 파라미터 없음, 키 2종 |
| OpenStreetMap | 키 없음, 하지만 반경 안 식당 3곳 |
| Google Places | 한 번에 20곳, 국내 데이터 부족, 지도 약관 |
| 카카오 로컬 | radius + 음식점 카테고리 필터, 쿼리당 45건, 분류가 자세함 |
카카오는 키워드 검색에 좌표와 반경, 카테고리 그룹 코드(FD6 = 음식점)를 같이 넘길 수 있다. 메뉴 이름으로 검색하면 그 메뉴를 파는 반경 안 식당이 바로 나온다. 거리도 응답에 들어 있다.
body = get(LOCAL, {
"query": menu, "category_group_code": "FD6",
"x": center["lng"], "y": center["lat"], "radius": 500,
"sort": "distance", "page": page, "size": 15,
}, key)
분류도 음식점 > 한식 > 찌개,전골 처럼 자세하게 온다. 메뉴 92개로 돌렸더니 식당 79곳이 모였다. 같은 반경의 카카오 음식점 전체가 89곳이니, 메뉴 이름 검색만으로 90% 가까이 잡힌 셈이다.
지도는 Leaflet + OpenStreetMap 타일로 그렸다. 식당 데이터로 쓰기엔 비어 있던 OSM이지만, 길과 건물을 그리는 배경 지도로는 충분하다. 덕분에 지도용 키가 하나도 필요 없어졌다.
2. 엉뚱한 식당 사진 — 이미지 검색을 버렸다
룰렛을 원판 대신 식당 사진으로 돌리고 싶었다. 카카오 로컬 응답에는 사진이 없어서 같은 키로 쓸 수 있는 이미지 검색을 붙였다. “식당 이름 + 과천시”로 검색해서 첫 결과의 썸네일을 쓰는 방식이다.
첫 번째 제약은 크기였다. 썸네일 주소의 크기 부분만 바꾸면 더 큰 사진을 받을 수 있을까 싶었는데, 130px 말고는 전부 404였다.
$ curl -s -o /dev/null -w "%{http_code}\n" "https://search4.kakaocdn.net/argon/130x130_85_c/<id>"
200
$ curl -s -o /dev/null -w "%{http_code}\n" "https://search4.kakaocdn.net/argon/480x360_85_c/<id>"
404
그래서 큰 사진 한 장 대신 작은 사진 여러 장이 흘러가는 릴로 디자인을 바꿨다.
두 번째가 진짜 문제였다. 배포하고 나서 “사진이 이상하다”는 말을 들었다. 중식당 칸에 키보드 사진이 떠 있었다. 이미지 검색 결과에는 제목도 본문도 없어서, 그 사진이 이 식당 사진인지 확인할 방법이 애초에 없었다.
해결은 블로그 검색이었다. 블로그 검색은 글 제목과 본문 일부를 같이 준다. 거기에 식당 이름과 지역 단어가 둘 다 들어 있는 글의 대표 썸네일만 쓰기로 했다.
def pick_photo(docs, name, region_words, exclude=()):
want = squash(base_name(name)) # '서울현방 과천점' → '서울현방'
for d in docs:
text = squash(d["title"] + d["contents"]) # 태그·엔티티·공백 제거
if (d["thumbnail"] and d["url"] not in exclude
and want in text and any(squash(w) in text for w in region_words)):
return d["thumbnail"], d["url"]
return None, None # 맞는 글이 없으면 사진 없음이 낫다
지점명(과천점)을 떼는 이유는 후기 글마다 표기가 제각각이라서다. 비교 전에 <b> 태그, < 같은 엔티티, 띄어쓰기를 전부 걷어낸다. “이화 향”, “<이화향>“도 같은 이름으로 잡힌다.
결과는 사진이 있는 식당이 71곳에서 77곳으로 오히려 늘었다. 79곳을 한 화면에 깔아서 눈으로 확인해보니 음식, 가게 외관, 메뉴판 사진이 나왔다.
하나 더 걸린 게 있었다. 한 식당은 후기 글 대표 사진에 블로그 작성자의 아이 얼굴이 나왔다. 사진 내용까지 코드로 판별할 수는 없어서, menus.json 에 제외할 글 주소 목록(photoExclude)을 두고 거기 넣은 글은 건너뛰게 했다.
3. GCP에서 걸린 곳 — 프로젝트 ID와 결제 한도
프로젝트 이름은 lunch-box 로 하고 싶었는데 이미 누가 쓰고 있었다. 프로젝트 ID는 전 세계에서 고유해야 한다.
$ gcloud projects create lunch-box --organization=<ORG_ID>
ERROR: (gcloud.projects.create) Project creation failed.
The project ID you specified is already in use by another project.
표시 이름은 lunch-box 로 두고 ID만 다르게 만들었다.
진짜 막힌 건 결제 연결이었다.
$ gcloud billing projects link my-project --billing-account=<BILLING_ID>
- description: 'Cloud billing quota exceeded: https://support.google.com/code/contact/billing_quota_increase'
결제 계정 하나에 연결할 수 있는 프로젝트 수에 한도가 있다. 새로 만든 결제 계정이라 한도가 5개였고, 실습하느라 만든 프로젝트들로 이미 꽉 차 있었다. 이상하게도 버킷 생성은 결제 없이도 통과했는데, 파일을 올리는 순간 거부됐다.
ERROR: (gcloud.storage.cp) HTTPError 403: The billing account for the owning project is disabled in state absent.
선택지는 두 개였다. 한도 증설을 요청하거나, 안 쓰는 프로젝트의 결제를 떼는 것. VM도 버킷도 없는 Terraform 실습 프로젝트가 있어서 그쪽 결제를 해제하고 새 프로젝트에 붙였다.
$ gcloud billing projects unlink <안-쓰는-프로젝트>
$ gcloud billing projects link my-project --billing-account=<BILLING_ID>
그리고 결제 연결 직후에 돌린 첫 rsync는 일부 파일만 올라갔다. 반영에 몇 초가 걸리는 것 같다. 한 번 더 돌리니 전부 올라갔다. 연결 직후라면 업로드 결과를 꼭 확인하는 게 좋다.
WIF(Workload Identity Federation) 구성과 legacyBucketReader 권한은 지난 글에서 겪은 대로 처음부터 넣었더니 한 번에 통과했다.
4. 기능: 결정은 먼저, 연출은 나중에

PC 화면. 왼쪽에서 4명이 식당을 고르고 뽑으면 오른쪽에 당첨 식당과 지도가 뜬다.
탭은 세 개다.
- 함께: 인원(2~10명)을 정하고 사람마다
식당 이름 (메뉴)목록에서 고르거나 주사위로 채운다. 다 고르면 그중 하나를 뽑는다. - 룰렛: 종목(한식, 중식 등)의 근처 식당 전체에서 하나를 뽑는다.
- 월드컵: 메뉴로 8강·16강·32강 토너먼트를 한다.
처음 “함께” 탭은 메뉴를 고르게 만들었다. 써보니 “김치찌개”보다 “정가네 (김치찌개, 된장찌개)”를 보고 고르는 게 훨씬 빨리 결정된다는 피드백이 나와서 식당 단위로 바꿨다.
뽑기 확률은 한 사람이 한 표다. 4명 중 2명이 같은 식당을 고르면 그 식당은 표를 두 장 가진다. 따로 가중치 로직을 짤 필요 없이, 고른 목록에서 그냥 무작위로 하나를 집으면 그렇게 된다.

왼쪽은 모바일 메뉴 월드컵, 오른쪽은 다크 모드에서 사진 릴이 멈춘 모습.
사진 릴에서 신경 쓴 건 당첨을 애니메이션이 정하지 않게 하는 것이었다. 흔한 구현은 릴을 무작위 거리만큼 돌리고 멈춘 칸을 당첨으로 읽는 건데, 그러면 칸 너비나 반올림에 따라 확률이 미묘하게 흔들린다.
그래서 순서를 뒤집었다. 당첨을 먼저 뽑고, 릴에는 그 식당을 특정 칸에 박아둔 뒤 거기까지 미끄러지는 연출만 한다.
// 릴에 깔 칸 목록. 당첨은 target 칸에 두고 나머지는 흘러가는 연출용
export function reelStrip(pool, winner, { length = 60, tail = 6, rand = Math.random } = {}) {
const target = length - tail - 1;
return { target, items: Array.from({ length }, (_, i) =>
i === target ? winner : pool[Math.floor(rand() * pool.length)]) };
}
화면 쪽은 그 칸의 가운데가 표시선에 오도록 translateX 값을 계산해서 CSS transition으로 5.5초 동안 감속시킨다. 이 함수는 DOM을 몰라서 node 테스트에서 “당첨이 늘 target 칸에 있는지”를 그대로 검증한다.
5. 화면에서 걸린 곳
사진 띠가 화면을 수천 px로 늘렸다. 모바일에서 갑자기 모든 글자가 사라지고 가로 스크롤이 생겼다. 원인은 릴의 사진 띠였다. 60칸짜리 띠에 width: max-content 를 줬는데, 이걸 담은 1열 그리드의 칸 너비가 기본값 auto 라서 자식의 최대 콘텐츠 너비만큼 늘어났다.
/* 전: grid-template-columns 미지정 → auto → 7000px */
.layout { display: grid; grid-template-columns: minmax(0, 1fr); }
minmax(0, 1fr) 로 최솟값을 0으로 못 박으니 칸이 화면 너비에 고정되고 띠는 안에서 잘렸다.
hidden 속성이 안 먹었다. 결과가 나왔는데 “여기에 식당이 나와요” 안내가 그대로 남았다. 안내 요소에 display: grid 를 줬는데, 이게 브라우저 기본 스타일의 [hidden] { display: none } 보다 우선이었다. 한 줄로 해결했다.
[hidden] { display: none !important; }
같은 클래스 이름이 두 군데서 쓰였다. “함께” 탭에서 아직 안 고른 줄에 .empty 클래스를 붙였더니 그 줄만 높이 220px로 부풀었다. 결과 영역의 빈 상태 안내가 이미 .empty 였다. 이름을 .unset 으로 바꿨다.
한글이 단어 중간에서 끊겼다. “과천지식정보타 / 운점”. word-break: keep-all 을 body에 줬다.
6. 비용
| 항목 | 규모 | 월 비용 |
|---|---|---|
| Cloud Storage 저장 | 약 150KB | 사실상 0 |
| 네트워크 이그레스 | 방문자 수에 비례. 사진은 카카오 CDN에서 받는다 | 1GB당 약 $0.12 |
| 카카오 API | 월 1회 수백 호출 | 무료 한도 안 |
사진과 지도 타일은 전부 외부 CDN에서 받아서 버킷이 내보내는 건 HTML·JS·JSON뿐이다.
정리
- 식당 데이터는 카카오 로컬이 제일 편했다. 반경과 음식점 카테고리를 한 번에 걸 수 있다. 네이버는 쿼리당 5건, OSM은 동네에 따라 텅 비어 있다.
- 이미지 검색 첫 결과를 믿지 말자. 제목·본문이 있는 블로그 검색으로 이름과 지역을 대조해야 엉뚱한 사진이 안 섞인다.
- 결제 계정에는 연결 가능한 프로젝트 수 한도가 있다. 실습 프로젝트를 여러 개 만들었다면 먼저 확인하자. 버킷 생성은 통과하고 업로드에서 403이 난다.
- 무작위 뽑기에 애니메이션을 붙일 땐 당첨을 먼저 정하고 연출은 거기에 맞춘다.
- CSS 그리드 안에 긴 요소를 넣을 땐
minmax(0, 1fr).auto칸은 자식 따라 늘어난다.
다음엔 이 인프라를 Terraform으로 옮겨볼 생각이다. 버킷, 서비스 계정, WIF까지 gcloud 명령이 꽤 길어졌다.

Comments