← 목차 · 케이스 2

POI 지도 검색

2025.04 ~ 2026.05 · Next.js · TypeScript · Google Maps · LangChain · FastAPI · Elasticsearch
팀 구성 — 개발 2 · PO 1 · 서버 3 · 디자인 1 · 7인 TF

"에펠탑 근처 호텔"이 검색되지 않던 상태에서, 그 말을 그대로 받는 검색을 화면과 API 모두 새로 내어 PC·모바일 프로덕션에 올렸습니다.
기존 검색은 끄지 않았습니다. 현재 월 21.9만 회 호출 · 순 방문자 7,051명이 씁니다.

이 문서는 배경 → 문제정의 → 해결방안 → 결과 → 정직하게 순서입니다. 이력서의 POI 절과 같은 틀이고, 이력서의 각 줄은 아래 ①②③④ 블록의 첫 문장입니다.

배경 — 에펠탑을 검색하면 익산의 모텔이 나왔습니다

"에펠탑"을 검색하면 1위로 익산의 모텔이 나왔습니다. (2026-08 라이브 재현) 파리 호텔을 찾으려던 사람이 전혀 다른 지역의 숙소를 받는 겁니다.

원인은 검색이 지역 코드와 시설 코드로만 돌아가기 때문입니다. 도시를 고르면 그 도시 ID에 매달린 호텔이 나오는 구조인데, 랜드마크에는 대응하는 코드가 없습니다. 그래서 질의로 표현할 방법 자체가 없고, 걸리는 것은 호텔 이름 텍스트 매칭뿐입니다.

  • 검색 경로 7개 지점을 전수 확인한 결과 좌표를 쓰는 곳이 0개였습니다. 도시 ID 매핑이 틀리면 구제할 경로가 없어 운영자가 손으로 고치는 것 외에 방법이 없습니다.
  • 랜드마크 기능은 과거에 있었으나 세 겹으로 끊겨 있었습니다 — 정책적으로 제거됐고, 자동완성 진입로가 잘렸고, 백엔드 경로는 살아 있으나 도달할 수 없었습니다.

여기까지는 "좌표를 쓰면 되겠다"로 끝날 문제입니다. 진짜 제약은 그다음이었습니다.

정산·정책·SEO가 전부 지역 코드에 묶여 있습니다. 어느 도시에서 팔렸는지로 정산이 갈리고, 노출 정책이 도시 단위로 걸리고, 도시별 랜딩 페이지가 검색엔진에 색인돼 있습니다. 검색의 축을 좌표로 갈아끼우면 이 셋이 한꺼번에 흔들립니다. 바꿀 수 없는 것이 검색 바깥에 있었습니다.

문제정의 — 왜 어려운가

검색창의 문장 한 줄이 지도 위의 호텔과 가격이 되기까지, 풀어야 할 문제가 넷이었습니다. 각각 다른 종류의 문제입니다.

① 자연어에서 의도를 어떻게 분류할까 — 호텔 이름인가, 장소(지역·랜드마크)인가

  • "에펠탑 근처 호텔"은 지역 코드도 시설 코드도 아닙니다. 호텔 이름을 찾는 건지, 장소를 찾는 건지, 장소라면 지역인지 랜드마크인지부터 갈라야 합니다.
  • 랜드마크는 좌표부터 알아내야 하는데, "에펠탑"이 지구 어디인지는 우리가 갖고 있지 않습니다.
  • 좌표를 얻어도 끝이 아닙니다. 랜드마크는 "그 지점에서 몇 미터"가 자연스럽지만, 도시에 반경을 쓰면 경계 밖 호텔이 통째로 빠집니다.

② 최대 1,000건까지 오는 결과를 지도에 어떻게 표시할까

  • 검색 서비스는 반경·폴리곤 안의 호텔을 최대 1,000건까지 한 번에 돌려줍니다. 페이징이 없습니다.
  • 1,000개 마커를 그대로 찍으면 지도는 읽히지 않고, 정렬·필터를 바꿀 때마다 서버를 다시 부르면 느립니다.
  • 목록과 지도가 같은 것을 보여줘야 합니다 — 목록에서 고른 호텔이 지도에서도 골라지고, 정렬을 바꾸면 지도도 따라와야 합니다.

③ 그 많은 호텔의 가격을 어떻게 가져올까

  • 호텔 목록과 좌표는 검색 서비스가 한 번에 주지만, 요금은 별도 API이고 호텔 수만큼 조회량이 늘어납니다.
  • 요금을 검색 응답에 묶으면 제일 느린 조회가 첫 화면 전체를 잡습니다. 사용자가 먼저 보는 것은 "어디에 무엇이 있나"인데, 그것까지 요금을 기다리게 됩니다.

④ 클라이언트에서 이 상태를 어떻게 관리할까

  • 이 화면은 검색 조건·결과·지도·요금·최근 검색이 한꺼번에 움직입니다. 상태를 어디에 두느냐가 화면 구조를 결정합니다.
  • 모듈 전역 스토어가 코드는 가장 짧습니다. 그런데 서버 렌더링에서는 한 서버 프로세스가 여러 사용자의 요청을 처리해서, 전역 인스턴스를 두면 A가 넣은 검색 조건이 B의 첫 화면에 남습니다. 눈에 안 보이는 종류의 버그입니다.

해결방안 — 무엇을 했나

먼저 틀을 정했습니다. 정산·정책·SEO 때문에 축을 갈아끼울 수 없으니, 기존 지역 코드 검색은 그대로 두고, 문장으로 들어온 검색어를 처리하는 화면과 API를 따로 냈습니다. 새로 낸 쪽이 잘못돼도 기존 검색은 영향을 받지 않습니다. 아래 ①②③④는 전부 그 새 경로 안의 이야기입니다.

전체 그림부터 봅니다. 아래는 지금 돌고 있는 모습입니다. 어떻게 이 모양이 됐는지는 ①②③④에서 다루고, 여기서는 조각의 위치만 봅니다. 화면 하나로 보이지만 뒤에 사내 API 셋이 따로 있고, 그 사이를 Next 서버가 중계합니다.

pinch to zoom · drag to pan

검색어는 텍스트 한 줄로 나갑니다. 좌표는 브라우저가 만들지 않습니다 — POI 검색 서비스가 구글 지도 API로 해석해 호텔 목록과 함께 위경도를 돌려주고, 브라우저가 좌표를 얻는 경로는 그 응답뿐입니다. 그래서 지도 마커는 검색 결과의 부산물입니다.

구글 지도와 검색엔진은 하는 일이 다릅니다. 구글은 장소명을 좌표로 바꿉니다 — "에펠탑"이 지구 어디인지는 우리가 갖고 있지 않아서 외부에 묻습니다. 검색엔진은 좌표에서 호텔로 갑니다 — 호텔 문서에 위경도가 함께 색인돼 있어, 그 점에서 반경 몇 미터 안에 있는 호텔을 찾습니다. 랜드마크 사전을 우리가 만들지 않았다는 뜻이기도 합니다. 지역의 경계도 밖에서 옵니다 — 구글이 돌려준 좌표를 OSM(OpenStreetMap) Nominatim에 넣으면 그 점이 속한 행정구역의 폴리곤을 돌려주고, 지역명으로 캐시해 다시 묻지 않습니다.

아래 ①②③④는 이 그림의 네 지점을 확대한 것입니다 — ① POI 검색 서비스 안, ② 브라우저의 지도·목록, ③ Next 서버와 요금 API 사이, ④ 브라우저의 상태.

① 질의 분류기를 따로 두지 않고, 모델이 3개의 검색 도구 중 하나를 고르게 해 분류와 실행이 한 번에 끝나게 했습니다

사용자는 검색창에 문장 하나를 넣습니다. 그게 지역인지 랜드마크인지 호텔 이름인지 먼저 갈라야 합니다. 라벨을 내놓는 분류기를 따로 두지 않고, 3개의 검색 도구를 모델에게 쥐여주고 어느 것을 부를지 고르게 했습니다.

pinch to zoom · drag to pan
  • 분류와 실행이 한 번에 끝납니다. 라벨을 먼저 받고 그 라벨로 다시 분기하면 둘이 어긋날 수 있는데, 도구 호출은 고른 것이 곧 실행입니다.
  • 좌표와 장소명은 구글 지도 API로, 지역의 경계는 OSM Nominatim으로 해석합니다. 요청 본문은 텍스트 하나이고, 좌표는 서비스 안에서 만들어집니다.
  • 좌표를 쓰되, 지역까지 반경으로 바꾸지는 않았습니다. 랜드마크는 "그 지점에서 몇 미터"가 자연스럽지만 도시는 그렇지 않아, 지역은 폴리곤 교차, 랜드마크만 반경으로 나눴습니다. 좌표가 정답인 대상은 도시가 아니라 랜드마크라고 봤기 때문입니다.
  • 같은 질의는 캐시해 모델을 다시 부르지 않습니다. 검색어는 반복되기 때문에 이게 비용과 지연을 함께 줄입니다.

대신 포기한 것 — 일관성입니다. 지역은 폴리곤, 랜드마크는 반경으로 두 방식이 공존합니다. 한 방식으로 통일하는 편이 코드는 깔끔하지만 결과가 틀립니다.

② 결과 1,000건은 클라이언트가 들고 정렬·필터하되, 지도에는 한 페이지 분량만 그리고 겹치는 마커는 묶었습니다

서버를 다시 부르지 않습니다. 검색 1회로 받은 결과 집합(최대 1,000건)을 브라우저가 들고, 요금 병합·필터·정렬 5종(추천·가격↑↓·후기·평점) 을 전부 클라이언트에서 계산합니다. 지도를 움직이거나 줌을 바꿔도 재조회("이 지역 재검색")를 하지 않고, 지도는 결과 전체가 들어오도록 뷰포트만 맞춥니다.

출시 때(2025-06)는 목록이 30건씩 더 불러오는 무한스크롤이었고 지도는 불러온 만큼을 그렸습니다. 1,000건을 다 보여주려면 호텔 목록이 전부 로딩될 때까지 기다려야 했습니다. 그래서 2026-01 개편에서 30건씩 숫자 페이지네이션으로 바꾸고, 지도도 현재 페이지 30건에 맞췄습니다. 클러스터링은 처음부터 있었습니다.

pinch to zoom · drag to pan
  • 목록과 지도가 같은 배열을 받습니다. 정렬·필터를 바꾸면 지도가 따로 갱신 로직 없이 따라오고, 목록에서 고른 호텔은 지도에서도 골라집니다(마커 강조 · 이동, 반대 방향도 같음).
  • 지도 마커는 현재 페이지 30건만 그립니다. 그래도 밀집 지역에서는 겹치므로 클러스터로 묶고, 선택한 마커만 클러스터에서 빼서 따로 띄웁니다.
  • 뷰포트는 결과 전체 좌표로 맞추고, 요금이 도착해 항목이 바뀌어도 재중심화하지 않습니다 — 지도가 사용자 손 밑에서 움직이지 않게.
  • 반경 원과 중심 핀은 랜드마크 결과일 때만 그립니다. 도시 검색과 랜드마크 검색을 화면에서 구분하는 지점입니다.

대신 포기한 것 — 한 화면의 전체 결과입니다. 1,000건이 한 번에 지도에 뜨지 않고 페이지 단위로 보입니다.

③ 지도와 목록을 먼저 띄우고, 요금은 묶음으로 나눠 병렬로 받아 도착하는 대로 채웠습니다

요금을 좌표에서 떼어내 300건씩 나눠 병렬 호출하고, 한 묶음이 도착할 때마다 즉시 화면에 반영합니다. 전부 기다리지 않습니다.

로딩 상태도 화면 단위가 아니라 호텔 한 건마다 들고 있습니다. 그래서 요금이 온 호텔부터 마커 라벨이 채워지고, 아직 안 온 호텔은 그 자리만 비어 있습니다. 리스트와 마커는 이미 떠 있습니다.

pinch to zoom · drag to pan

대신 포기한 것 — 응답의 완결성입니다. 첫 화면의 요금 칸이 잠깐 비어 있습니다. 사용자가 먼저 보는 것은 "어디에 무엇이 있나"이지 금액이 아닙니다.

④ 스토어를 요청마다 새로 만들어 Provider에 쥐여, 서버 렌더링에서 사용자 간 상태가 섞이지 않게 했습니다

모듈 전역 스토어를 쓰지 않았습니다. 스토어를 팩토리로 만들고 Provider가 자기 인스턴스를 쥐게 했습니다.

pinch to zoom · drag to pan

구독은 useXStore(selector)로 필요한 조각만 걸고, Provider 밖에서 쓰면 즉시 throw합니다. 잘못 쓴 자리가 조용히 넘어가지 않고 그 자리에서 드러납니다.

스토어는 관심사별로 넷으로 갈랐습니다. 가르고 나니 저장 정책도 스토어 단위로 정할 수 있었습니다.

스토어담는 것브라우저에 남기나
검색 조건키워드 · 날짜 · 객실 · 공휴일아니오
검색 결과호텔 목록 · 중심좌표 · 반경 · 요금 · 로딩 상태아니오
최근 검색최근 검색어예
대체 결과결과가 없을 때 보여줄 목록아니오

대신 포기한 것 — 간결함입니다. 스토어 하나로 끝낼 것을 넷으로 나누고, 전역 대신 Provider를 거칩니다. 전역 스토어는 코드가 짧지만 서버 렌더링에서 조용히 새어 나갑니다.

무엇을 버리고 무엇을 샀나

어디버린 것산 것왜
틀단일 경로 — 검색 경로가 둘이 되어 유지 비용이 늘고, 언제 하나를 걷을지 숙제가 남습니다되돌릴 수 있는 형태 — 새 경로에 문제가 생겨도 기존 검색은 그대로 돕니다정산·정책·SEO가 지역 코드에 걸려 있어, 전면 교체는 실패했을 때 되돌릴 방법이 없습니다
①일관성 — 지역은 폴리곤, 랜드마크는 반경으로 두 방식이 공존합니다정확도 — 도시에 반경을 쓰면 경계 밖 호텔이 통째로 빠집니다대상의 성질이 다릅니다. 한 방식으로 통일하는 편이 코드는 깔끔하지만 결과가 틀립니다
②한 화면의 전체 결과 — 1,000건이 페이지 단위로만 보입니다기다리지 않는 첫 화면 — 30건이면 바로 뜨고, 정렬·필터는 서버 재조회 없이 클라이언트에서 끝납니다1,000건을 다 보여주려면 목록이 전부 로딩될 때까지 기다려야 했습니다
③응답의 완결성 — 첫 화면의 요금 칸이 잠깐 비어 있습니다체감 속도 — 지도와 리스트가 먼저 뜹니다사용자가 먼저 보는 것은 "어디에 무엇이 있나"이지 금액이 아닙니다
④간결함 — 스토어 하나로 끝낼 것을 넷으로 나누고, 전역 대신 Provider를 거칩니다격리 — 서버에서 요청 간 상태가 섞이지 않고, 저장 정책을 스토어별로 정합니다전역 스토어는 코드가 짧지만 서버 렌더링에서 조용히 새어 나갑니다

결과 — 측정과 지금 상태

2026-08 기준 30일 동안 7,051명이 썼고, 기존 검색은 한 번도 끊기지 않았습니다.

운영 규모 (2026-08 기준 30일, 서버 액세스 로그)

30일 호출순 방문자회원
POI 지도 검색219,4117,0515,240

서비스 전체는 일 262만 요청 규모이고, 그중 이 기능이 일 7,314건입니다.

검색창에 장소를 치는 사람은 100명 중 4명입니다. 출시 뒤인 2026-07에 기존 검색창의 자동완성에서 잰 값(Datadog RUM, 14일)으로, 행정단위가 아닌 장소(서브지역·거리·역·명소)를 입력한 세션이 4.11%(1,218/29,671)였습니다. 개발의 근거가 아니라 지금 수요의 크기입니다. 표면과 기간을 뺀 "랜드마크 검색량 4%"로 줄여 말하지 않습니다.

설계 판단의 사후 확인. 1년 뒤(2026-07) 검색 품질을 별도로 진단하면서 ①의 판단을 수치로 다시 봤습니다. 반경 5km는 서울 호텔의 52%를 누락했고, 기존 행정계층은 폴리곤과 99.1% 일치했습니다. "좌표가 정답인 대상은 도시가 아니라 랜드마크"라는 당시 판단이 수치로 확인된 셈입니다.

⚠️ 이 측정은 개발보다 13개월 뒤입니다. 설계를 만든 근거가 아니라 나중에 맞았는지 확인한 것입니다. 순서를 바꿔 말하면 사실이 아닙니다.

지금 상태

  • PC·모바일 프로덕션. 기존 검색은 한 번도 끊기지 않았습니다.
  • 검색 API는 설계와 최초 구현 뒤 동료가 구조 정리를 이어받았고, 저는 사내 모노레포로 옮겨 인프라·운영을 맡았습니다 — 검색엔진 버전 업그레이드, 인증 키·시크릿 구성.
  • 진행 중: 분류와 검색을 그래프로 다시 짜는 작업(supervisor 재설계). 아직 머지 전입니다.

정직하게 — 한계와 다음 문제

  • 프론트 최초 커밋은 제가 아닙니다. 이 경로의 최다 기여자이고 상태 구조·지도·마커·클러스터·반경원·요금 병렬을 맡았지만, "처음부터 제가 만들었다"는 사실이 아닙니다.
  • 프런트는 동료 한 명과 둘이 만들었습니다. 상태 계층 8개 파일은 제가 단독으로 썼고, GA 계측·필터 UI·뱃지 조건·QA는 동료가 맡았습니다. 다만 카드·마커·가격 표시 같은 컴포넌트는 둘이 같은 파일을 고쳤습니다. 영역이 깔끔하게 갈린 것은 상태 계층뿐입니다.
  • LLM 분류는 설계와 최초 구현이 제 것이고, 이후 패키지 구조 정리와 프롬프트 튜닝은 동료가 이어받았습니다. 지금 코드의 상당 부분은 그 리팩터링 결과입니다.
  • 아직 프로덕션이 아닌 것 — supervisor 재설계는 미머지, 검색어 판정 API(rule → cache → LLM 3계층)는 로컬 PoC입니다. 그 API의 정확도 100%는 자체 평가셋 133건 기준이라 일반화할 수 없습니다. 자동완성의 랜드마크 복원과 추천 키워드 칩도 프로덕션에 닿지 않았습니다.
  • 이 병행 결정이 조직 합의를 거쳤는지는 전달로만 압니다. 정산·정책·SEO가 지역 코드에 묶여 있다는 제약은 제가 코드와 데이터에서 확인한 것이고, "기존 경로는 끄지 않는다"가 주간 회의에서 정리됐다는 것은 전해 들은 것입니다. 그 자리에 있지 않았고 합의 문서도 갖고 있지 않습니다. 제가 확인한 범위와 전달받은 범위를 갈라 둡니다.
  • 전환율은 못 쟀습니다. 1차 릴리즈에 그 로그를 심지 않았습니다. 지금 댈 수 있는 것은 호출량과 방문자 수뿐이고, 검색에서 상세로 얼마나 넘어갔는지는 말할 수 없습니다. 2차 이후 경로에는 계측이 들어가 있어 앞으로는 볼 수 있습니다.
  • 랜드마크 해석과 지역 경계를 외부 API에 의존합니다. 자체 사전이 없으니 구글이 모르는 표기는 우리도 못 찾고, 호출 비용과 정책 변경이 그대로 리스크입니다 — 실제로 Nominatim은 요청 헤더가 사용 정책에 맞지 않아 오류가 나 2026-02에 고쳤습니다. 나중에 자체 사전을 시험 삼아 만들어 보니 별칭 데이터가 거의 없어(28만 건 중 3건) 표기 변형을 덮기 어렵다는 것까지 확인했습니다. 다만 이 확인은 만들고 한참 뒤의 일이라, 당시 선택의 근거는 아니었습니다.
  • 진단에서 나온 위험군 건수는 상한선입니다. 라이브 샘플 3건 중 1건만 재현돼, 확정된 피해 규모가 아닙니다.
  • 페이지 30건과 요금 묶음 300건은 실측으로 정한 값이 아닙니다. "이 정도면 되겠다"로 잡은 정성적 값이고, 다른 값과 비교한 기록이 없습니다. 지금까지 문제가 없었다는 것이 그 값이 최적이라는 뜻은 아닙니다.
© 2026 이남기 · portfolio.namgee.dev