바로가기
실험실

사우나우

후기 없는 동네 목욕탕까지 찾아주는 지도

화면


01
홈
01

주변·테마·종류별 입구를 한 화면에 모았다

지도
02지도

지역별 개수로 묶어 보여주고, 칩으로 종류를 거른다

매장 상세
03매장 상세

영업시간·요금표·후기·메모가 한 화면에 들어온다

읽을거리
04읽을거리

사우나 문화·이용법을 다룬 읽을거리를 모아 둔다

기술 스택


02
Next.jsReactTypeScriptTailwind CSSSupabasePostgreSQLPostGISVercel

데이터와 API


03
목욕장업 인허가 (공공데이터)

전국 목욕탕 목록의 뼈대. 영업 중인 곳만 걸러 담는다

온천표준데이터 (공공데이터)

진짜 온천으로 인증된 곳을 가려서 표시한다

네이버 검색 API

매장별 블로그 후기를 모으고, 인허가에 안 잡히는 세신샵·효소찜질을 찾아낸다

네이버 플레이스

영업시간·요금표와 업체가 올린 사진을 채운다

map.naver.com
네이버 지도

지도 화면의 바탕. 마커·후기·사진은 우리 데이터로 그린다

Google Places API — 중단

업체 사진이 없는 매장의 폴백 이미지. 지금은 새로 받지 않는다

Google Cloud Vision

사람이 올린 사진을 공개 전에 자동으로 거른다

카카오 로컬

좌표가 비어 있는 주소를 지도에 찍을 수 있게 채운다

Claude Code

매장 소개문을 대신 써 준다

구상


04
내 주변 갈 만한 곳을 3탭 안에 찾는다
공공데이터로 전국을 깔아서 검색에 안 걸리던 곳까지 나온다
큐레이션이랑 읽을거리로 안 가본 곳을 둘러본다
매장 상세가 검색에 잡히게 만든다
사우나실·냉탕 온도가 빈칸 없이 채워져 있다
다녀온 걸 3초 만에 남겨서 데이터가 저절로 갱신된다

기획


05

어떤 서비스인가요?

전국 목욕탕·찜질방·온천·세신샵 6천여 곳을 지도와 목록으로 여는 모바일 웹앱이에요. 위치를 켜면 주변이 거리순으로 깔리고, 칩 하나로 "24시 찜질방"이나 "세신샵"만 남길 수 있습니다

한 곳을 열면 사우나실 온도, 영업시간, 요금표, 사진, 블로그 후기, 위치가 한 화면에 들어와요. 원래는 블로그를 열 개쯤 열어 놓고 짜맞춰야 알던 것들입니다

어떤 계기로 만들게 됐나요?

내 주변 목욕탕이랑, 검색으로는 안 나오는 숨은 목욕탕을 후기까지 같이 보고 싶었어요. 그런 게 한국에는 없더라고요

참고한 서비스가 있었나요?

일본 사우나이키타이 — https://sauna-ikitai.com/ . 사우나를 온도·시설 축으로 정리해 두는 방식을 참고했어요

인스타 고독한사우너 — https://www.instagram.com/godoghan_sauner/ . 직접 발로 뛰어 욕탕 사진을 찍고 장단점을 적어 올리는데, 구독자로서 그게 제일 쓸모 있었어요. 그 밀도의 정보가 한곳에 모이면 좋겠다는 게 출발이었습니다

어떤 계기로 만들게 됐나요?

동네 목욕탕은 후기가 아예 없어요. 요즘 리뷰는 영수증이나 예약 내역으로 방문을 증명해야 쓰는데, 현금 받고 열쇠 주는 목욕탕에선 그 증명이 안 나옵니다

어쩌다 있는 후기도 사우나 좋아하는 사람의 관점이 아니에요. 별점 4.3 은 사우나실이 몇 도인지, 냉탕이 붙어 있는지, 세신이 되는지를 말해주지 않아요

네이버 지도에 "목욕탕"이라고 쳐도 안 나오는 곳이 있어요. 호텔·리조트 부속이나 복지시설 안에 있으면 상호에 그 낱말이 없어서 검색에 안 걸립니다

주로 어떤 분이 쓰나요?

사우나를 즐겨 다니는 2030. 주말이나 퇴근길에 "지금 갈 만한 곳"을 3탭 안에 찾고 싶어해요

될 거라고 본 근거가 있었나요?

검색 기반이 아니라 인허가 전수 기반으로 깔면, 검색어에 안 걸리던 숨은 목욕탕이 저절로 딸려 온다

작업하면서 어떤 제약이 있었나요?

온도는 결국 사람이 채우는 값이라 콜드 데이터에는 거의 없어요. 크롤로 채워지는 축을 기본으로 깔고 온도는 제보가 쌓이면 켜지는 자리로 뒀습니다

혼자 만들고 혼자 운영해요. 사람이 붙어야 굴러가는 기능은 처음부터 후보에서 뺐습니다

지금은 안 하기로 한 게 있나요?

로그인. 디바이스 식별로 충분하다고 봤는데, 이건 나중에 뒤집었어요

자유 텍스트 리뷰·사진 업로드. 모더레이션을 감당할 사람이 없어요 (사진만 나중에 열었습니다)

유저 플로우


06
  1. 01
    홈을 연다

    카테고리 카드와 테마 큐레이션이 깔려 있어요. 목적이 뚜렷하면 굳이 안 보고 바로 빠집니다.

    위치로 찾는다지도조건으로 찾는다목록·필터이름을 안다검색
  2. 02
    조건으로 좁힌다

    지도에서는 상단 칩으로 목욕탕·찜질방·온천·세신샵·효소를 켜고, 화면을 옮기면 이 지역 다시 검색이 떠요. 목록에서는 필터 시트로 온도 범위랑 24시 여부까지 들어갑니다.

  3. 03
    매장 상세를 본다

    온도·영업시간·요금표·사진·후기·위치가 한 화면에 있어요. 여기까지 오면 갈지 말지가 정해집니다.

    가볼 만하다정보가 부족하다블로그 후기 원문으로 나감
  4. 04
    다녀와서 남긴다

    찜한 곳은 찜 탭에 모이고, 다녀온 곳은 마이의 기록 탭에 메모로 남아요. 로그인해 두면 기기를 바꿔도 따라옵니다.

구조


07
보내는 것되돌아오는 것

개발 과정


08
  1. 01

    먼저 전국을 깔아야 이야기가 됐어요

    처음엔 좋은 곳 몇 백 곳만 골라 담을 생각이었어요. 그런데 지도 서비스에서 "내 주변"을 눌렀는데 아무것도 안 뜨면 그 순간 앱이 죽거든요. 커버리지가 먼저였습니다.

    검색 결과를 긁는 방식은 처음부터 안 봤어요. 그렇게 하면 "목욕탕"이라고 검색해서 나오는 곳만 모이는데, 제가 찾고 싶었던 건 그 검색에 안 걸리는 쪽이었거든요. 호텔 지하나 리조트 안에 있는 사우나는 상호에 목욕탕이라는 낱말이 없어요. 인허가는 그런 걸 안 가리고 다 갖고 있습니다. 그래서 공공데이터포털 목욕장업 인허가를 통째로 받아 왔어요. 영업 상태로 거르고, 상호랑 업태 문자열에서 찜질방·온천·24시를 추론해서 플래그로 심었습니다. 좌표가 아예 없는 행이 1.3% 있어서 그것만 카카오 로컬로 주소 지오코딩을 태웠고요. 주변 검색은 애플리케이션에서 계산하지 않고 PostGIS RPC 하나로 끝냈어요.

    동기화를 붙이면서 한 번 걸렸습니다. 폐업으로 바뀐 행을 지우면 거기 매달린 찜이랑 기록이 같이 날아가요. 삭제가 아니라 상태만 바꾸는 쪽으로 갔고, 에디터가 손으로 채운 컬럼은 아예 upsert 매핑에서 빼서 동기화가 덮지 못하게 했습니다.

    붙인 것
    공공데이터포털카카오 로컬SupabasePostGISVercel
  2. 02

    후기가 0건인 채로 열 수는 없었어요

    6천 곳을 깔고 나니 더 큰 문제가 보였어요. 후기가 하나도 없었습니다. 리뷰 쓰는 기능을 만들어 봤자 첫날 사용자가 볼 건 빈 화면이고, 목욕탕은 애초에 방문 증명이 안 나와서 사람이 모여도 잘 안 쌓이는 업종이거든요. 우리 리뷰를 기다리는 건 답이 아니었어요.

    그런데 후기가 없는 게 아니라 리뷰 칸에 없는 거였습니다. 사람들은 목욕탕 다녀온 이야기를 블로그에 써요. 온도도 거기 있고요 — "사우나실 90도 넘어요" 같은 문장으로. 그래서 우리 리뷰 대신 남의 후기를 빌려오기로 했습니다. 네이버 검색 Open API 로 매장 상호를 던져 관련 글만 추렸어요. 본문을 복제하면 안 되니까 제목·발췌·원문 링크·작성일만 저장하고 표시는 링크아웃으로 했습니다. 남의 글로 콜드스타트를 넘긴 셈이에요.

    영업시간이랑 요금표는 검색 API 로 안 나와서 네이버 플레이스 SSR 페이지를 파싱했어요. 처음엔 정규식으로 긁었는데 favicon 만 나오더라고요. __APOLLO_STATE__ 를 JSON 으로 파싱하니까 그때부터 제대로 잡혔습니다. 여기서 한참 고쳐 썼는데, 영업시간을 처음엔 잡히는 대로 다 넣었어요. 목욕탕 업종은 네이버도 값이 없는 경우가 흔해서 부분 정보를 넣으면 오히려 틀린 정보가 됩니다. 풀 스케줄이 확인될 때만 채우게 바꿨고, 요금표는 후기 텍스트에서 숫자를 뽑는 파생 스크립트를 따로 뒀어요.

    붙인 것
    네이버 검색 Open API네이버 플레이스
  3. 03

    사진 출처를 두 번 갈아엎었어요

    네이버 업체제공 사진을 받으려다 캡차에 막혀서 수집 불가로 결론 내고, Google Places API 로 방향을 틀었어요. 결제까지 붙였습니다.

    그런데 구글은 그 사진을 누가 올렸는지 구분할 방법이 없어요. authorAttributions 에 올린 계정 표시명만 있고 업주인지 손님인지는 안 나옵니다. 같은 이름의 카페 사진이 목욕탕 상세에 걸리기 시작했어요. 두 달쯤 뒤에 네이버 쪽을 다시 봤더니 캡차가 걸린 건 특정 경로였고, 영업시간 때문에 이미 매번 받아오던 페이지 안에 사진 원본 URL 이 그대로 있었습니다. mediaSource 로 업체제공·방문자·블로그연동이 갈려서 업체제공만 고를 수도 있었고요. 방문자 사진은 저작자가 개인이라 안 건드렸어요.

    출처를 네이버로 뒤집으면서 원칙 하나를 세웠습니다. 어떤 경로로 받았든 이미지는 전부 우리 Storage 에 재호스팅해서 거기서만 서빙해요. remotePatterns 에 제3자 호스트를 아예 등록하지 않아서 외부 핫링크가 구조적으로 불가능합니다. 출처랑 원본 URL 은 서버 전용이라 클라이언트로 안 내려가고요. 결국 구글 경로는 스크립트 진입점에 가드를 박아 아예 못 돌게 막았어요. 이미 적재된 1,700여 장은 네이버 업체사진이 없는 매장의 폴백으로 남겨 두고, 소스 우선순위에서 네이버가 항상 위에 오게 했습니다. 런타임은 구글을 호출하지 않아요.

    붙인 것
    Google Places APISupabase Storage
  4. 04

    안 하기로 한 걸 두 번 뒤집고, 하기로 한 걸 하나 접었어요

    기획할 때 로그인은 비목표였어요. 디바이스 식별로 충분하다고 봤거든요. 찜이랑 메모를 로컬 스토리지에 넣었는데, 써보니까 폰에서 찜해둔 곳이 노트북에서 안 보여요. 기기를 바꾸면 그냥 사라지고요. 목욕탕은 "다음에 가봐야지" 하고 담아두는 서비스라 이게 치명적이었습니다. Supabase Auth 로 계정을 붙이고 찜·메모를 DB 로 옮겼어요. 옮기고 나서도 손볼 게 남았는데, 비로그인 상태에서 하트를 누르면 아무 일도 안 일어나는 게 제일 나빴습니다. 로그인 시트를 띄워 유도하고, 찜 페이지랑 기록 탭도 비로그인 전용 화면을 따로 그렸어요. 낙관적 업데이트를 쓰고 있어서 쓰기가 실패하면 상태를 되돌리는 것도 양쪽에 붙였고요.

    사진 업로드도 마찬가지였어요. 모더레이션 부담 때문에 처음부터 뺐는데, 크롤로 모은 사진이 매장당 몇 장 안 되는 곳이 꽤 많았습니다. 결국 사진만 열기로 했어요. 혼자 운영하는 서비스라 사람이 검수하는 구조는 못 만드니까 파이프라인 전체를 자동으로 짰습니다. 인증 → 킬스위치 → 레이트리밋(1인 1일 10장) → 타입·크기 검증 → sharp 로 EXIF 회전 반영하고 리사이즈해서 WebP 재인코딩 → Vision SafeSearch 모더레이션 → 게시. insert 가 실패하면 이미 올라간 Storage 객체를 롤백해요. 모더레이션은 페일 클로즈드로 했습니다. 키가 없거나 호출이 실패하면 통과가 아니라 거부예요. 아무도 안 보고 있는 서비스라 미검증 이미지가 공개되는 쪽을 막는 게 맞았어요. 그래도 무언가 새면 재배포 없이 사진·후기 노출을 통째로 끌 수 있게 런타임 킬스위치를 뒀고, 관리자용 사진 관리·신고 처리 화면도 붙였습니다.

    반대로 접은 것도 있어요. "다녀옴" 3초 체크인은 원래 핵심이었습니다. 객관식으로 가볍게 남기면 그게 쌓여서 온도나 혼잡도 같은 값이 갱신되는 그림이었거든요. 그런데 로그인을 붙이고 나니 로컬 방문기록이 계정 동기화랑 겹쳤고, 무엇보다 아무도 안 누를 버튼을 상세 화면 제일 좋은 자리에 두고 있었어요. 버튼을 걷어내고 그 자리를 계정 기반 메모로 대체했습니다.

    붙인 것
    Supabase AuthsharpGoogle Cloud Vision
  5. 05

    다 만들고 나니 느렸어요

    기능이 붙을수록 화면 전환이 굼떴어요. 특히 지도에서 "내 주변"을 누르면 위치 받고, 데이터 받고, 지도 SDK 받는 게 순서대로 돌아서 몇 초씩 흰 화면이었습니다.

    홈·지도·읽을거리를 ISR 로 프리렌더하고, 지도는 셸을 먼저 그린 뒤 캐시된 좌표로 즉시 이동시키면서 데이터랑 SDK 를 병렬로 받게 바꿨어요. 목록은 windowing 을 넣었고, 스켈레톤은 실제 레이아웃과 같은 모양으로 다시 그렸습니다. 모양이 다르면 로딩이 끝나는 순간 화면이 튀어서 오히려 더 느리게 느껴지거든요.

    체감을 손보는 작업이 생각보다 길게 이어졌어요. 바텀시트 그랩 히트 영역이 좁아서 잘 안 잡히던 것, 매장을 고르면 좌측 패널에 가려진 위치로 센터링되던 것, 스플래시가 끝나기도 전에 위치 권한 팝업이 뜨던 것까지. 하나씩은 사소한데 모이면 완성도가 갈립니다.

  6. 06

    아이콘을 직접 그리기 시작했어요

    범용 라인 아이콘을 쓰다 보니 목욕탕이랑 찜질방이 구분이 안 됐어요. 둘 다 그냥 물방울이나 건물 모양이 되더라고요. 이 서비스는 용어 자체가 콘텐츠라서 그게 안 읽히면 곤란했습니다.

    그래서 반복되는 용어마다 전용 플랫 SVG 를 그려서 한 폴더에 모았어요. 대형 씬이랑 16~20px 칩을 짝으로 만들어서, 같은 용어가 홈에서든 지도에서든 같은 그림으로 읽히게 했습니다. 팔레트는 CSS 토큰 한 곳에서만 고치고요. 칩은 다색이라 currentColor 틴트가 안 먹혀서, 활성·비활성 어느 쪽에서도 또렷하게 보이도록 둥근 토큰 배경 위에 얹는 규칙을 따로 뒀어요.

    로고까지 갔습니다. 좌상단만 각진 판 모양 마크를 만들어서 앱 아이콘·파비콘·OG·헤더를 전부 교체했어요. OG 이미지는 satori 가 SVG 그라디언트를 못 받아서 div 두 장을 쌓아 같은 그림을 다시 그렸습니다. 마크 색을 바꾸면 두 곳을 같이 고쳐야 하는 부채가 남아 있어요.

  7. 07

    화면보다 데이터를 고치는 시간이 길어졌어요

    어느 시점부터는 새 기능보다 "이 값이 맞나"를 확인하는 쪽이 대부분이었어요. 헬스장·복지관 부속 목욕시설 189곳이 일반 목욕탕처럼 노출되던 것, 전남·광주 행정통합으로 같은 매장이 두 번 들어온 것, 네이버 매칭이 같은 건물 다른 업소를 잡던 것.

    헬스장 건은 기획이랑 정면으로 부딪혔습니다. 숨은 목욕탕까지 다 보여주자고 인허가 전수를 깔았는데, 그 전수 안에는 헬스장 샤워실이랑 복지관 부속 목욕시설도 들어 있거든요. 목욕하러 갈 곳을 찾는 사람한테 "◯◯휘트니스사우나"가 섞여 나오면 그 순간 목록 전체가 못 믿을 것이 됩니다. 지우지는 않고 venue_type='community' 로 분류해서 기본 발견 대상에서만 뺐어요. 숨은 곳을 판다는 원칙은 호텔·리조트 부속에서 지키고, 목욕 목적이 아닌 시설은 접었습니다. 반대로 호텔 부속은 "호텔·리조트·콘도" 같은 일반어로만 잡았더니 상호에 그 낱말을 안 쓰는 곳이 전부 일반 목욕탕으로 샜어요. 도미인 서울 강남, 노보텔앰배서더강남, 소노캄 각 지점 같은 것들요. 브랜드명을 규칙에 넣되 일반 목욕탕 상호와 겹치는 낱말은 뺐습니다 — "파라다이스목욕탕"이나 "리베라목욕탕"이 호텔로 분류되면 안 되니까요. "하얏트"는 단독으로 안 쓰고 그랜드·파크·리젠시 접두형만, "라한"은 도로명 "청라한내로"에 걸려서 제외했어요.

    축 자체가 좁았던 것도 이때 드러났습니다. 상단 칩이 목욕탕·찜질방·24시·숙박이었는데, 사람들이 실제로 나누는 기준이랑 안 맞았어요. "세신만 하는 1인샵"이랑 "효소찜질"은 완전히 다른 경험인데 둘 다 목욕탕에 섞여 있었거든요. 문제는 그게 목욕장업 인허가에 거의 없다는 거였어요. 세신샵은 업종이 다르고 효소찜질은 데이터에 아예 안 잡힙니다. 그래서 네이버 검색으로 따로 긁는 경로를 팠고, 세신샵은 별도 카테고리로 만들어 일반 목욕탕 필터에서 빠지게 했어요. 새 카테고리마다 전용 칩 일러스트도 그렸습니다 — 세신샵은 하늘색 세신 침대에 초록 때수건, 효소는 모래언덕에 파묻힌 사람.

    검증 스크립트도 이때 키웠어요. Storage 와 DB 정합성을 맞춰보는 감사, 고아 객체 정리, 중복 매장 병합, 디렉터리 사이트 후기 제외 같은 것들이요. 판단이 애매한 행은 지우지 않고 needs_review 로 적재만 하고 노출에서 뺐습니다. 매장 소개문은 Claude Code CLI 를 헤드리스로 돌려서 생성하는데, API 키가 아니라 로그인된 플랜으로 돌아가고 결과에 코드펜스가 섞여 나와서 JSON 추출을 방어적으로 해뒀어요. 아직 전 매장에 적용하지는 못했습니다.

    붙인 것
    Claude Code CLI

시행착오


09
증상Google Places 를 1,424회 돌렸는데 약 2만원이 청구됐다. Text Search 무료 한도가 월 5,000회라 한참 남았다고 알고 있었다.
시도호출 수를 잘못 셌다고 보고 실행 로그를 다시 훑었다. 횟수는 맞았다. SKU 별 사용량을 열어 보니 요청이 Text Search Pro 가 아니라 Enterprise 등급으로 집계돼 있었고, Enterprise 무료분은 1,000회였다.
결론필드마스크에 포함된 최고 등급 필드가 요청 전체의 요금 등급을 정한다. 매칭 근거로 쓰려고 넣어 둔 `places.websiteUri` 하나가 Enterprise 필드였고, 그것 때문에 1,424회 중 424회가 유료 구간으로 넘어갔다. 마스크에서 빼서 Pro 로 되돌리고, 배치 상한을 `--maxsearch` 와 `--maxphoto` 로 둘 다 걸게 했다. 사진만 막고 검색을 열어두면 검색에서 터진다. 이후 구글 사진 경로 자체를 중단하면서 스크립트 진입점에 가드를 박아 실수로도 못 돌게 했다.
Supabase Storage 가 1,197MB 로 무료 1GB 를 넘겼다. 사진을 너무 많이 긁었다고 판단했다.
블로그 후기 중 218건에 썸네일이 없었다. og:image 를 다시 긁어오면 될 일로 봤다.
강남 목욕장 "나인"의 요금표에 아메리카노가 들어 있었다. 같은 이름의 카페 정보가 통째로 들어와 있었다.
네이버 업체제공 사진을 받으려는데 캡차가 떴다. 수집 불가로 결론 내고 구글 Places 사진을 썼다.
매칭 실패분 재시도 배치가 1회차엔 200곳 중 20곳을 회수했는데 2회차엔 2곳으로 떨어졌다. 회수할 게 남지 않았다고 읽었다.
위치 권한을 주지 않고 지도를 열면 마커 하나뿐인 시골 지도가 떴다. 좌표가 없으면 목록 첫 매장에 줌 13 으로 붙는 코드였다.
온도 표시가 어느 매장에서도 뜨지 않았다. 표시 임계치는 제보 2건인데 그 문턱을 넘는 매장이 없었다.

남은 것


10
  • 온도 제보가 0건이에요.제보 동선을 다시 설계하지 않으면 표시 임계치를 영영 못 넘습니다.
  • 매장 소개문 자동 생성이 일부 매장에만 적용돼 있어요.
  • 보강 크롤이 전부 수동 실행이에요.자동화하려면 차단 내성을 지금보다 더 확보해야 합니다.
  • 요금표 정확도가 출처에 따라 갈려요.후기 텍스트에서 뽑은 값은 오래된 경우가 있습니다.
  • 분류 규칙이 코드와 마이그레이션 양쪽에 있어요.한쪽만 고치면 다음 동기화에서 되돌아갑니다.
  • 사진 모더레이션이 업로드 시점 1회예요.사후 신고 처리는 관리자가 손으로 합니다.
  • 로고 마크 색을 바꾸면 SVG 와 OG 렌더 두 곳을 같이 고쳐야 해요.