한 줄 요약 — 사용자가 나 하나뿐인 앱은 "다들 이렇게 하니까"를 버리고, 내 손에 맞는 결정을 하나씩 쌓아가는 일이었다.
파구스(Pagus)는 파주 인근 로컬 맛집 지도입니다. 라틴어로 '마을'이라는 뜻인데, 동네에서 직접 먹어 보고 괜찮았던 집을 지도에 하나씩 꽂아 두려고 만들었습니다. 지금은 48곳이 공개돼 있고, 소스는 GitHub에 공개했습니다.
이 글은 기능 소개보다 결정의 기록입니다. 혼자 쓰는 앱이라서 내릴 수 있었던 결정, 계획대로 안 돼서 바꾼 것, 그리고 쓰다 보니 생긴 작은 개선들(태그 검색, 로고, 지역 확대)을 이슈와 커밋 순서대로 따라갑니다.
1. 출발점: 이슈 #30 한 장
시작은 이슈 하나였습니다. 제목은 "파구스 개발 개요", 첫 줄은 이렇게 적었습니다.
개인 블로그처럼 나 혼자 올린다. (로그인 후 운영자 메뉴 필요) 회원가입은 없다.
이 두 문장이 이후 거의 모든 결정의 기준이 됐습니다. 나머지는 화면과 기능을 나열한 정도였습니다.
- 첫 화면: 네비바 + 지도(맛집 표시), 좌측에 카테고리(한식·중식·양식 등)와 검색
- 입력: 지도에서 직접 클릭하거나 주소 검색으로 위치를 잡고 정보 입력
- 맛집 정보: 카테고리, 상호, 연락처, 홈페이지, 사진, 설명, 메뉴, 태그
- 운영자 메뉴: 카테고리 관리, 맛집 관리(등록·수정·숨기기·검색)
- 참고 데이터: 등록할 때 식신 같은 사이트에서 상호명으로 검색해 가져오기
개발 순서도 하위 이슈로 쪼개 두었습니다. 운영자 인증과 스키마(#37) → 카테고리·맛집 CRUD → 지도 위치 선택·주소 검색(#35) → 공개 지도·검색 → 사진 업로드(#33) → 문의하기 → 외부 참고 데이터(#34) 순입니다. 지도는 먼저 만든 사진 동선 서비스 Iter의 Leaflet + OpenStreetMap 구조를 그대로 가져다 쓰기로 했습니다.
구현은 대부분 클로드코드가 했고, 저는 이슈를 쓰고 결과를 확인하는 쪽이었습니다. 이슈마다 클로드코드가 남긴 작업 내역에는 composer check(CS Fixer), composer analyse(PHPStan), composer test(PHPUnit) 통과 여부가 매번 붙어 있습니다.
2. 혼자 쓰는 앱이라서 내린 결정들
사용자가 나 하나라는 전제를 받아들이면, 보통 앱에서 "당연히 있어야 하는" 것들이 하나씩 빠집니다.
회원가입 없음, 운영자 계정 하나
#37의 범위는 "운영자 계정 1개, 로그인·로그아웃·세션 인증, 서버 권한 검사"가 전부입니다. 비밀번호 찾기도, 권한 등급도, 프로필도 없습니다. 방문자는 로그인 없이 지도와 목록을 보고, 운영자만 세션의 is_admin 값으로 관리 화면에 들어갑니다.
로그인 버튼은 푸터로
처음 개요에는 네비바에 "홈, 문의하기, 로그인"을 나란히 두었습니다. 그런데 만들어 놓고 보니 이상했습니다. 로그인할 사람은 나 하나인데, 방문자 모두에게 가장 눈에 띄는 자리에 로그인 버튼을 보여 주고 있었던 겁니다.
그래서 8월 28일 커밋 하나로 옮겼습니다. 변경은 한 줄 빼고 한 줄 넣는 것뿐입니다.
// home.php — 네비바에서 제거
<a href="<?= site_url('login') ?>">로그인</a>
// 푸터에 추가
<p><a href="<?= site_url('login') ?>">로그인</a></p>
블로그 관리자 링크가 보통 푸터 구석에 있는 것과 같은 이치입니다. 방문자에게는 "홈"과 "문의하기"만 보이고, 저는 어차피 그 자리를 압니다.
로그인했으면 "운영자 메뉴"로
푸터로 옮긴 뒤 한 가지가 거슬렸습니다. 이미 로그인한 상태에서 공개 화면에 오면 푸터에 여전히 "로그인"이 떠 있어서, 다시 로그인해야 하는 것처럼 보였습니다. 9월 13일에 관리자 필터(AdminFilter)와 같은 조건으로 분기해, 로그인 상태면 같은 자리에 "운영자 메뉴" 링크를 띄우도록 바꿨습니다.
<p><a href="<?= session()->get('is_admin') === true ? site_url('admin') : site_url('login') ?>">
<?= session()->get('is_admin') === true ? '운영자 메뉴' : '로그인' ?>
</a></p>
푸터의 그 한 줄이 나에게는 관리 화면으로 가는 문이 됐습니다. 별도 관리자 도메인이나 숨은 URL을 외울 필요가 없습니다.
혼자 쓰지만 공개는 한다
혼자 올리는 앱이어도 방문자와 닿는 창구는 몇 개 남겼습니다. 문의하기는 DB에 저장해 운영자 화면에서만 봅니다. 장소 공유는 공유 URL에 식당 ID만 쓰도록 했습니다. 방문 후기도 붙였는데, 혼자 관리하는 만큼 맛집별 일일 작성 제한, 본인 후기 신고 차단, 신고 알림 같은 방어 장치를 함께 넣었습니다. 푸터에는 오픈소스 링크도 달아 두었습니다.
3. 계획대로 안 된 것들
개요에 적은 것 중 두 가지는 실제로 만들면서 방향을 틀었습니다.
식신 대신 카카오 로컬 API
맛집을 등록할 때 상호명으로 참고 데이터를 끌어오고 싶었습니다. 처음 떠올린 건 식신이었는데, 확인해 보니 공식 개발자 API가 없었습니다. 그래서 공식 문서와 API 키 발급이 있는 카카오 로컬(장소 검색) API로 바꿨습니다.
#34에서 정한 원칙은 세 가지였습니다.
- 외부 조회가 실패하거나 키가 없어도 등록은 막히지 않는다. 서비스는
null을 돌려주고 운영자가 직접 입력합니다. - 검색 결과를 고르면 상호·주소·연락처·좌표만 채운다. 공개 여부는 외부 데이터가 아니라 운영자가 정한다.
- 테스트는 외부 API를 mock해서 재현 가능하게 만든다.
나중에 AI 카테고리 추천(Groq)도 붙였는데, 같은 원칙으로 추천 버튼만 제공하고 고르는 건 제가 합니다.
Leaflet/OSM에서 카카오맵으로
지도는 Iter에서 쓰던 Leaflet + OpenStreetMap을 재사용하려 했습니다. 그런데 실제로 써 보니 주소가 이렇게 나왔습니다.
문향로, 문산읍, 파주시, 경기도, 10815, 대한민국
내국인이 보는 서비스인데, 우리가 읽는 순서는 "경기도 파주시 문산읍 문향로 57"입니다. 메인 수정사항 이슈(#49)에 이걸 적었고, 지도 자체를 카카오맵으로 교체했습니다. 같은 이슈에서 맛집 제목을 누르면 새 창으로 가던 것도 지도 마커 옆 레이어로 바꿨습니다. 목록에서 이름을 누르면 해당 마커와 레이어가 같이 열립니다.
재사용은 출발을 빠르게 해 줬지만, 결국 "누가 쓰는가"가 라이브러리 선택을 뒤집었습니다. 혼자 쓰는 앱이라도 화면에 찍히는 주소는 내 눈에 익숙해야 했습니다.
4. 태그를 누르면 검색으로
태그는 처음엔 상세 화면에 쉼표로 이어진 글자일 뿐이었습니다. "막국수, 주차가능, 광탄"처럼요. 그런데 맛집이 쌓이니 태그를 보고 "이 태그 붙은 다른 집은 어디였지?" 하는 순간이 생겼습니다.
8월 27일, 태그를 쉼표 기준으로 나눠 하나하나 누를 수 있는 칩으로 바꿨습니다. 누르면 메인 화면의 검색어(q)로 넘어가 그 태그로 검색한 결과를 바로 보여 줍니다.
<?php
$tagList = array_values(array_filter(
array_map('trim', explode(',', (string) ($restaurant['tags'] ?? ''))),
static fn (string $tag): bool => $tag !== ''
));
?>
<?php foreach ($tagList as $tag): ?>
<a class="tag-chip" href="<?= esc(site_url('/') . '?' . http_build_query(['q' => $tag]), 'attr') ?>">#<?= esc($tag) ?></a>
<?php endforeach; ?>
새 검색 기능을 만든 게 아니라, 이미 있던 메인 검색에 링크만 이은 겁니다. 태그 전용 필터나 태그 테이블을 따로 두지 않았습니다. 데이터는 그대로 쉼표 문자열이고, 화면에서만 나눠 보여 줍니다. 혼자 쓰는 규모에서는 이 정도면 충분했습니다.
태그 입력 쪽에서는 자잘한 버그를 두 번 잡았습니다. 쉼표를 칠 때 같은 태그가 두 번 생기는 문제, 그리고 한글 입력(IME) 조합 중에 마지막 글자가 중복 태그로 들어가는 문제입니다. 한글 서비스를 만들면 한 번씩 꼭 만나는 그 버그입니다.
5. 로고와 파비콘
혼자 쓰는 앱에 로고가 필요할까 싶었지만, 브라우저 탭에 기본 아이콘이 떠 있는 게 계속 눈에 걸렸습니다. 홈 화면에 추가했을 때 아이콘이 없는 것도 그랬고요.
로고는 이름에서 바로 나왔습니다. 파구스는 '마을'이니, 지도 핀과 집을 합친 마크로 정했습니다. 색과 모양은 먼저 정리해 둔 디자인 시스템 문서(DESIGN.md)의 토큰을 그대로 썼습니다. 그래서 따로 색을 고르지 않아도 화면과 로고의 톤이 맞았습니다.
8월 27일 커밋 하나로 들어간 자산은 이렇습니다.
|
파일 |
용도 |
|---|---|
|
|
헤더 마크, 공개·운영자 화면 공통 |
|
|
가로형 로고 |
|
|
구형 브라우저 포함 탭 아이콘 |
|
|
최신 브라우저용 벡터 파비콘 |
|
|
모바일 홈 화면 아이콘 |
공개 화면, 로그인 화면, 운영자 화면의 <head> 전체에 파비콘 링크를 넣고, 헤더에는 로고 마크를 붙였습니다. 운영자 헤더도 "파구스 운영" 글자 앞에 같은 마크가 붙습니다. 내가 보는 관리 화면까지 같은 얼굴을 하고 있으니, 비로소 하나의 서비스처럼 느껴졌습니다.
6. 파주에서 고양·연천으로
처음 이름은 "파주 로컬 맛집 지도"였고, 코드도 정말 파주만 받았습니다. 카카오 로컬 검색에는 파주 범위의 사각형 좌표를 넘기고, 결과 주소에 "파주시"가 없으면 버렸습니다. 8월 29일에는 외부 참고 검색을 아예 파주로 제한하는 수정도 했습니다.
그런데 동네 맛집이 행정구역을 지키지는 않습니다. 일산에도 자주 가는 집이 있고, 연천 전곡읍 쪽에도 있습니다. 10월 19일, 등록 지역을 넓혔습니다.
// 파주·고양·연천(전곡읍 포함)을 아우르는 검색 영역
private const SEARCH_RECT = '126.65,37.55,127.35,38.30'; // 이전: '126.65,37.64,126.98,37.98'
/** 등록 허용 지역: 주소에 포함돼야 하는 시·군 명칭 */
private const ALLOWED_REGIONS = ['파주시', '고양시', '연천군'];
주소에 "파주시"가 있는지 보던 조건은 isAllowedRegion()으로 바꿔 세 지역 중 하나면 통과시켰습니다. 테스트도 같이 고쳤습니다. 파주·고양·전곡·서울 식당 4건을 mock으로 넣고, 서울만 빠진 3건이 남는지 확인합니다.
여기서 하나 정한 게 있습니다. 서비스 이름을 "파주·고양·연천 맛집"으로 늘리지 않고, **"파주 인근 로컬 맛집"**으로 바꿨습니다. 화면 문구와 문서도 같은 날 함께 갱신했습니다. 지역이 또 늘어도 이름은 그대로 둘 수 있고, 기준은 여전히 "내가 사는 파주"라는 게 드러납니다. 지금 공개된 48곳에도 고양 일산동구와 연천 신서면 주소가 섞여 있습니다.
7. 돌아보며
파구스의 커밋 기록은 8월 27~29일에 몰려 있고, 그 뒤로는 9월 13일과 10월 19일에 몇 개씩 띄엄띄엄 있습니다. 며칠 몰아서 만들고, 쓰다가 거슬리는 게 생기면 그때 고치는 리듬이었습니다. 개인용 앱은 원래 그렇게 자라는 것 같습니다.
- "나 혼자"는 기능을 빼는 근거가 된다. 회원가입, 권한 등급, 눈에 띄는 로그인 버튼. 남들 앱에 다 있다는 이유로 넣었다면 쓸데없는 코드와 화면만 늘었을 겁니다.
- 혼자 써도 화면은 남이 본다. 로그인 버튼을 푸터로 보낸 건 나를 위해서가 아니라 방문자 화면을 깨끗하게 하려는 결정이었습니다. 반대로 "운영자 메뉴" 전환은 순전히 나를 위한 것이었고요.
- 재사용은 출발점이지 결론이 아니다. Iter의 Leaflet 구조 덕분에 빨리 시작했지만, 주소 표기 하나 때문에 지도를 바꿨습니다.
- 작은 연결이 체감은 크다. 태그 검색은 기존 검색에 링크만 이은 17줄짜리 변경이었습니다.
- 이름은 범위보다 기준을 담는다. 지역을 넓히면서 이름을 "파주 인근"으로 바꾼 게, 코드를 고친 것보다 오래 남을 결정입니다.
개인용 앱을 만든다면, 이 순서로
- 첫 이슈에 "누가 쓰는가"를 한 줄로 적는다. 이후 결정은 전부 이 줄에 비춰 본다.
- 회원·권한 체계는 계정 하나와 세션 검사로 시작한다.
- 관리 진입점은 푸터처럼 방문자 눈에 덜 띄는 곳에 두고, 로그인 상태면 관리 링크로 바꾼다.
- 외부 API는 실패해도 등록이 막히지 않게 만들고, 공개 여부 같은 판단은 사람이 한다.
- 새 기능보다 이미 있는 기능끼리 잇는 것부터 찾는다(태그 → 검색).
- 로고·파비콘은 디자인 토큰이 정리된 뒤에 만든다.
- 범위를 넓힐 때는 이름이 그 범위를 계속 감당할 수 있는지 먼저 본다.
댓글 0개