Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/App Development/EN

Travel App That Turns Links into Map Pins

모아둔 링크를 지도 위 핀으로 바꾸는 여행 앱

단톡방에 쌓인 링크를 지도 위 핀으로 바꾸는 앱

쌓이는 링크와 비어 있는 지도

여행 갈 곳을 정할 때 실제로 하는 일은 검색이 아니라 링크 모으기입니다. 블로그 글, 인스타 게시물, 유튜브 브이로그, 단톡방에 올라온 네이버 카페 후기가 브라우저 탭과 대화방에 쌓이죠. 그런데 정작 현지에 도착하면 그 링크들은 지도 위에 아무것도 남기지 않습니다. 하나씩 열어 가게 이름을 읽고, 지도 앱에 옮겨 적고, 저장 목록에 넣는 손작업이 남을 뿐이에요.

프로젝트의 요구는 한 문장으로 정리됩니다. 유저가 링크 하나를 붙여 넣으면 그 글에 등장한 장소들이 추출되어 내 지도에 핀으로 꽂히고, 그 지도를 통째로 남에게 공유해 구독시킬 수 있어야 한다는 것입니다. 제약은 두 가지였습니다. 하나는 수집 대상에 네이버 블로그와 네이버 카페가 포함되는데, 카페 글 중에는 로그인해야 읽히는 비공개 글이 있다는 점. 다른 하나는 구글 지도에 이미 쌓아 둔 저장 목록을 그대로 가져올 수 있어야 한다는 점입니다.

이 글에서는 로그인 담장 너머의 콘텐츠 수집과 장소 추출 파이프라인, 이 두 축만 다룹니다. 큐 기반 후처리와 Expo dev client 및 OTA 배포 전략, 구글 지도 마이그레이션과 검색 화면은 각각 별도의 글로 다룹니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

람다 하나와 큐 세 개, 심사 없는 배포

tRPC 라우터 하나를 단일 AWS Lambda 에 올리고 CloudFront Router(sst.aws.Router)를 앞에 세웠습니다

앱, 어드민, 웹 세 클라이언트가 같은 타입을 그대로 공유해야 했습니다. 스키마를 따로 관리하지 않고 AppRouter 타입만 import 하면 되니 계약이 어긋날 자리가 없어요. 링크 처리 트래픽이 스파이크성이라 상시 인스턴스보다 함수 단위 과금이 맞기도 했습니다 Lambda 타임아웃이 60초라 링크 하나를 그 안에 끝내야 했습니다. memory: 2048 MB, architecture: arm64 로 올리고, production 에서만 VPC 프라이빗 서브넷에 넣어 DB 에 붙였습니다

Postgres 를 Supabase 에 두되 REST 는 쓰지 않고 PostGIS 와 Drizzle 로만 접근했습니다

지도 화면이 요구하는 질의는 결국 "지금 보이는 뷰포트 안에 있는, 내가 켜 둔 지도들의 장소"입니다. place.location 을 geometry(point, 4326) 로 두고 GIST 인덱스를 걸면 ST_DWithin 한 줄로 끝나요. Supabase 는 Storage 와 Auth 만 취했습니다 RLS 를 쓰지 않기로 했기 때문에 권한 검사가 전부 tRPC procedure 안으로 들어옵니다. 공개 여부, 소유자 여부, 구독 여부를 매 procedure 가 직접 확인해야 했습니다

장소 추출에 gpt-4o-mini 를 쓰되, 모델에게는 좌표를 묻지 않았습니다

모델은 "어떤 장소 이름이 이 글의 주제인가"만 판단하고, 실제 좌표와 place id 는 Google Places 와 네이버 지도 검색이 확정합니다. 자세한 이야기는 아래 하이라이트에 적었습니다 structured output(zodResponseFormat)으로 스키마를 강제해야 후처리가 단순해집니다

배포와 인프라 구성
배포와 인프라 구성

서버가 추출하고 기기가 로그인을 통과

데이터 모델의 중심은 place 와 map_to_items 입니다. place 는 전역 공용이고 google_place_id 와 naver_place_id 가 유니크 키예요. 누가 담든 같은 장소는 같은 행을 가리킵니다. 유저마다 다른 것은 map_to_items(어느 지도에 어느 링크로 담겼는가)와 user_map_enable(지금 이 지도를 켜 뒀는가) 쪽에 모여 있습니다. 지도 화면이 그리는 마커는 결국 "켜져 있는 지도들의 map_to_items 를 place 로 조인한 뒤 반경으로 자른 결과"입니다.

핵심 데이터 모델
핵심 데이터 모델

로그인 담장 너머의 글은 서버가 아니라 사용자 기기가 읽는다

비공개 네이버 카페 글을 읽어야 하니, 서버에 서비스용 네이버 계정을 하나 만들어 쿠키를 저장해 두고 그걸로 긁는 방법을 먼저 떠올리게 됩니다. 조금 더 나아가면 Lambda 에 헤드리스 브라우저를 얹어 로그인 세션을 유지하려 하고요. 그런데 이건 계정 하나가 모든 유저의 요청을 대신 수행하는 구조라, 그 계정이 가입하지 않은 카페의 글은 여전히 못 읽고, 차단되면 전 유저가 동시에 막힙니다. 무엇보다 남의 카페 회원 자격을 서버가 대신 흉내 내는 셈이라 깨끗하지 않아요.

서버는 익명으로 한 번 시도해 보고, 로그인 페이지로 튕기거나 카페 API 가 401 을 주면 거기서 멈춥니다. 그리고 naver-cafe-on-device-crawl-needed 라는 전용 결과를 앱에 돌려줘요. 앱은 그 신호를 받으면 화면 밖에 숨겨 둔 WebView 를 띄웁니다. sharedCookiesEnabled 와 useSharedProcessPool 을 켜 두었기 때문에 이 WebView 는 사용자의 네이버 세션을 그대로 씁니다. 세션이 없으면 그때만 네이버 로그인 폼을 모달로 노출하는데, 헤더와 푸터, 앱 로그인 버튼 같은 요소를 주입한 스크립트로 걷어 내고 본인 확인 영역만 남깁니다. 로그인이 끝나면 원래 글로 이동해 post_title 이나 ArticleTitle 이 나타날 때까지 폴링하다가 outerHTML 을 통째로 앱에 넘기고, 앱은 그것을 html 파라미터에 실어 createLink 를 다시 호출합니다. 서버 쪽 추출기는 preCrawledContent 가 있으면 네트워크를 타지 않고 그 HTML 에서 바로 div.se-main-container 를 파싱하도록 갈라져 있어, 두 경로가 같은 파이프라인으로 합류합니다.

자격 증명이 서버로 넘어오지 않습니다. 유저의 쿠키는 유저 기기의 WebView 안에만 있고, 서버가 받는 것은 이미 렌더링된 HTML 한 덩어리예요. 유저가 가입한 카페면 읽히고 아니면 안 읽히니 권한 경계도 원래 서비스의 것을 그대로 따릅니다. Lambda 에 헤드리스 브라우저를 넣지 않아도 되니 배포 크기와 콜드스타트도 지킬 수 있었고요. 무엇보다 실패가 조용히 나지 않습니다. 서버가 던지는 신호가 에러 문자열 하나로 명시돼 있어서, 앱이 "이건 로그인이 필요한 글"과 "이건 그냥 실패한 글"을 구분해 다른 화면을 보여 줄 수 있어요.

LLM 에게 좌표를 묻지 않고 검색어만 물었다

이름과 주소, 위경도까지 한 번에 JSON 으로 달라고 요청하는 방식입니다. 모델은 모르는 좌표도 좌표처럼 생긴 숫자로 지어냅니다.

모델에게는 { name, region, isKorea, isChina } 만 받고, 좌표와 주소, place id 는 한국이면 네이버 지도 검색이, 아니면 Google Places 가 확정합니다. 본문에 박힌 maps.app.goo.gl 단축 URL 은 LLM 을 거치지 않고 정규식으로 펼쳐 후보에 합칩니다. 저장 단계에서는 place id 를 유니크 키로 두고 onConflictDoNothing 으로 넣습니다.

좌표를 지어낼 수 있는 주체가 파이프라인에서 사라지고, 검색 결과가 0건인 후보는 그대로 탈락합니다. 중복 제거를 나중에 하는 게 아니라 스키마가 애초에 중복을 못 만들게 막습니다. 이 추출 파이프라인의 프롬프트와 후처리 상세는 따로 다룹니다.

유저가 할 수 있는 일

처음 앱을 열고 소셜 계정으로 시작한다
처음 앱을 열고 소셜 계정으로 시작한다

링크를 붙여 넣어 스팟을 지도에 담는다
링크를 붙여 넣어 스팟을 지도에 담는다

로그인해야 보이는 카페 글도 스팟으로 가져온다
로그인해야 보이는 카페 글도 스팟으로 가져온다

AI 가 놓친 장소를 직접 검색해서 넣는다
AI 가 놓친 장소를 직접 검색해서 넣는다

핀을 눌러 그 장소의 링크와 방문 기록을 본다
핀을 눌러 그 장소의 링크와 방문 기록을 본다

카테고리로 거르고 이름이나 메모로 스팟을 찾는다
카테고리로 거르고 이름이나 메모로 스팟을 찾는다

지도 목록에서 지도를 켜고 끄고 새로 만든다
지도 목록에서 지도를 켜고 끄고 새로 만든다

지도 이름과 주소를 바꾸고 공개 여부를 정한다
지도 이름과 주소를 바꾸고 공개 여부를 정한다

내 지도를 공유하고, 받은 사람이 구독한다
내 지도를 공유하고, 받은 사람이 구독한다

구독한 지도를 들여다보고 구독을 끊는다
구독한 지도를 들여다보고 구독을 끊는다

구글 지도에 쌓아 둔 저장 목록을 통째로 옮긴다
구글 지도에 쌓아 둔 저장 목록을 통째로 옮긴다

약관을 읽고, 로그아웃하거나 탈퇴한다
약관을 읽고, 로그아웃하거나 탈퇴한다

가이드북을 펼쳐 목차와 지도를 넘겨 본다
가이드북을 펼쳐 목차와 지도를 넘겨 본다

1 / 1

핀 50개에서 멈춘 지도

모든 것을 깔끔하게 마무리하지는 못했습니다. 가장 눈에 밟히는 건 마커입니다. 지금 지도는 같은 좌표를 uniqBy 로 접은 뒤 앞에서 50개만 잘라 그립니다. 코드에도 "추후 마커 합치기로 변경"이라는 TODO 가 그대로 남아 있어요. 스팟을 몇백 개 담은 유저에게는 지도가 사실과 다르게 보인다는 뜻입니다.

제일 위험한 의존은 네이버 지도 검색입니다. 공개 API 가 아니라 m.map.naver.com 응답 HTML 안의 window.__RQ_STREAMING_STATE__ 를 정규식으로 긁어 JSON.parse 하고 있어요. 마크업이 한 번 바뀌면 국내 장소 추출이 통째로 멈춥니다. 그럴 걸 알면서도 정확도를 포기할 수 없어서 골랐지만, 다시 한다면 이 지점에 합성 모니터를 하나 붙여 두었을 겁니다. 응답 구조가 바뀐 날 유저보다 먼저 알아야 하니까요.

테스트는 사실상 없습니다. jest-expo 설정과 컴포넌트 테스트 하나가 전부고, 링크 처리 파이프라인처럼 분기가 많고 외부 의존이 많은 곳에도 회귀 테스트가 없어요.

Read next

원문·번역 동시 표시 영어 강의 자막 플레이어

DualLangSub — 2025