관광 앱 검색·필터·거리순 정렬 설계 (로컬 DB vs 서버 API 분리하기)
제주에서 이런저런 관광 관련 앱이나 웹 서비스를 만들다 보면 가장 손이 많이 가고 복잡한 게 바로 '검색'과 '필터' 기능입니다.
사용자들은 단순히 "제주시 카페"만 찾는 게 아니라, "아이와 갈 만한 곳", "지금 내 위치에서 가까운 곳", "오늘 문 연 곳" 처럼 온갖 조건을 조합해서 장소를 찾기 때문입니다.
이걸 매번 화면 상태값으로만 대충 비벼서 처리하거나, 모든 필터를 서버 API 하나에 몰아넣으면 나중에 운영할 때 무조건 한계가 옵니다. 반대로 로컬 DB에 다 밀어 넣자니 실시간 영업 상태나 행사 정보 같은 데이터 동기화가 발목을 잡죠.
저도 매번 구현할 때마다 헷갈리는 부분이 있어서, 공공데이터 기반 관광 앱을 개발할 때 검색·필터·정렬의 역할을 어떻게 나누고 구조를 잡아야 하는지 실무 기준으로 정리해 둡니다.
이래저래 운영하다 보면 생기는 문제들
처음에 설계를 대충 하면 꼭 아래와 같은 버그나 운영상 난관에 부딪힙니다.
화면마다 지역, 카테고리 필터 처리하는 로직이 제각각이다.
앱 로컬에 저장된 정적 데이터랑 서버 API가 뱉는 필터 결과가 안 맞아서 목록이 튄다.
사용자가 위치 권한을 거부했는데, 앱이 거리순 정렬을 기본값으로 잡고 있어서 먹통이 된다.
필터 조건이 너무 많아져서 사용자가 지금 자기가 뭘 선택했는지도 모른다.
공공데이터 포털에서 준 카테고리 코드가 앱 UI 디자인이랑 1:1로 매핑이 안 된다.
디버깅 한답시고 실제 API URL이나 사용자 위치 좌표(위경도)를 로그나 공개 글에 그대로 남기는 보안 실수를 한다.
결국 검색 필터는 단순한 UI 옵션이 아니라 데이터 모델, API 계약, 로컬 캐시, 위치 권한이 다 얽혀 있는 기능으로 보고 접근해야 합니다.
1. 필터 상태는 전용 데이터 모델(Model)로 묶기
필터 조건을 뷰(View)나 화면 상태 변수로만 들고 있으면 화면이 회전되거나 프로세스가 죽었다 살아날 때 다 날아갑니다. Repository나 API 요청 보낼 때도 기준이 중구난방이 되기 쉽고요.
저의 경우 아래처럼 아예 필터 상태만 담당하는 데이터 클래스를 따로 만들어서 관리합니다.
data class TourSearchFilter(
val keyword: String = "",
val regionCode: String? = null,
val categoryCode: String? = null,
val sort: TourSort = TourSort.RECOMMENDED,
val userLatitude: Double? = null,
val userLongitude: Double? = null,
val onlyOpenNow: Boolean = false,
)
enum class TourSort {
RECOMMENDED, // 추천순
DISTANCE, // 거리순
NAME, // 이름순
UPDATED, // 최신수정순
}
이렇게 모델을 하나로 묶어두면 검색 기록을 로컬 DB에 저장하거나, 딥링크로 특정 필터 결과 화면을 공유할 때도 구조가 깔끔해집니다.
2. 검색어 들어올 때 바로 쿼리 날리지 말기 (검색어 검증)
사용자가 입력창에 치는 값은 그대로 서버나 로컬 DB에 밀어 넣으면 안 됩니다. 앞뒤 공백, 연속된 공백, 너무 긴 텍스트 등은 미리 가공해야 합니다.
관광 앱 특성상 "성산"처럼 두 글자 이상은 돼야 유의미한 검색이 되지만, 경우에 따라 "값" 같은 한 글자 행정구역이나 브랜드명이 있을 수 있으니 내 서비스 데이터 특성에 맞춰 가공 클래스를 만듭니다.
class SearchKeywordNormalizer {
fun normalize(input: String): String {
return input
.trim()
.replace(Regex("\\s+"), " ") // 연속된 공백은 하나로
.take(40) // 최대 40자 제한
}
fun isSearchable(keyword: String): Boolean {
// 공백이 아니면서 최소 2글자 이상일 때만 검색 실행 (기획에 따라 조정)
return keyword.isNotBlank() && keyword.trim().length >= 2
}
}
주의할 점: 사용자가 입력한 검색어나 현재 위치 좌표는 절대 원본 그대로 서버 로그(
Log.d등)에 남기지 마세요. 개인정보나 사용자 동선 힌트가 노출될 수 있습니다.
3. 로컬 DB 검색 vs 서버 API 검색 역할 나누기
모든 조건을 하나의 API 쿼리 스트링으로 몰아넣기 전에, 각 조건별로 '누가 책임질지' 영역을 확실히 나눠야 앱이 가벼워집니다.
| 방식 | 추천 상황 및 데이터 성격 | 장단점 및 실무 팁 |
| 로컬 DB 검색 | 고정된 관광지 마스터 정보, 행정구역 주소, 고정 좌표 등 | 장점: 네트워크 안 터져도 빠름, 비용 제로 단점: 실시간 업데이트 반영이 느림 |
| 서버 API 검색 | 실시간 축제 일정, 현재 영업 여부, 실시간 추천 랭킹 등 | 장점: 최신 데이터 보장, 복잡한 정렬 가능 단점: 트래픽 비용 발생, 오프라인 작동 불가 |
| 혼합(하이브리드) 방식 | 기본 장소 정보는 로컬 DB에서 긁고, 실시간 정보만 서버에서 받아와 보강 | 대부분의 중소규모 관광 앱 실무에 가장 적합한 구조 |
공공데이터 카테고리 매핑 팁
공공데이터(예: 한국관광공사 국문 관광정보 서비스 등)에서 주는 대/중/소분류 코드를 앱 UI 카테고리에 그대로 쓰면 사용자가 이해하기 너무 어렵습니다. (예: 코드명이 'A02020100' 구조로 되어 있음)
따라서 원본 코드를 그대로 노출하지 말고, 내 앱 전용 내부 코드를 만들어서 매핑 테이블로 관리해야 유지보수할 때 피를 안 봅니다.
원본 코드:
A02010100(자연관광지 - 산)앱 UI 카테고리:
오름/산(사용자 친화적 이름으로 그룹핑)
4. 거리순 정렬과 위치 권한 예외 처리 (Fallback)
거리순 정렬을 구현할 때 초보 개발자가 가장 많이 하는 실수가 "사용자가 위치 권한을 무조건 허용했을 것"이라고 가정하는 것입니다. 권한이 없거나 거부당했을 때의 예외 처리가 필수입니다.
위치 권한 허용 시: 당연히 현재 GPS 좌표 기준으로 거리 계산해서 정렬 정정 출력.
위치 권한 거부 시: '거리순' 정렬 버튼을 비활성화하거나, 선택 시 "위치 권한이 필요합니다" 안내 후 [추천순]이나 [제주시 전체] 같은 기본값(Fallback) 목록으로 전환되게 처리해야 합니다. 권한이 없다고 화면에 빈 목록만 덜렁 나오면 안 됩니다.
5. 실제 구현했던 Repository 구조 예시
아래는 필터 조건(실시간 데이터 필요 여부)을 보고 로컬 검색을 할지, 서버 API를 호출할지 분기하는 Repository 구현 예시입니다.
class TourSearchRepository(
private val localDataSource: TourLocalDataSource,
private val remoteDataSource: TourRemoteDataSource,
) {
suspend fun search(filter: TourSearchFilter): List<TourPlaceUiModel> {
// 1. 검색어 정제
val normalizedFilter = filter.copy(
keyword = SearchKeywordNormalizer().normalize(filter.keyword),
)
// 2. 조건에 따라 로컬을 볼지 서버를 볼지 판단
val basePlaces = if (requiresRemoteSearch(normalizedFilter)) {
remoteDataSource.searchPlaces(normalizedFilter) // 서버 API 호출
} else {
localDataSource.searchPlaces(normalizedFilter) // 로컬 Room DB 조회
}
// 3. 공통 유효성 검증 및 정렬 처리
return basePlaces
.filter { it.hasValidDisplayData() }
.sortedWith(sortComparator(normalizedFilter))
}
// 실시간 운영 상태나 최신 수정순 정렬이 필요하면 서버 조회가 필수임
private fun requiresRemoteSearch(filter: TourSearchFilter): Boolean {
return filter.onlyOpenNow || filter.sort == TourSort.UPDATED
}
private fun sortComparator(filter: TourSearchFilter): Comparator<TourPlaceUiModel> {
return when (filter.sort) {
TourSort.NAME -> compareBy { it.name }
TourSort.DISTANCE -> compareBy { it.distanceMeters ?: Int.MAX_VALUE }
TourSort.UPDATED -> compareByDescending { it.updatedAt }
TourSort.RECOMMENDED -> compareByDescending { it.recommendScore }
}
}
}
실무 필수 체크리스트
앱 배포하기 전에 아래 항목들은 꼭 적용되었는지 확인하시는 게 좋습니다.
[ ] 검색어 입력값 받아올 때 앞뒤 공백 제거(
trim) 및 길이 제한을 걸었는가?[ ] 사용자의 실제 GPS 위경도 좌표나 날것의 검색어를 로그에 그대로 남기지 않는가?
[ ] 위치 권한을 거부해도 앱이 튕기거나 빈 화면이 나오지 않고 대체 정렬(추천순 등)이 작동하는가?
[ ] 필터가 여러 개 걸려있을 때, 사용자가 쉽게 필터를 끌 수 있는 UI(X 버튼이나 Chip 형태)를 제공하는가?
[ ] 검색 결과가 없을 때 그냥 빈 화면만 주지 않고, "필터를 해제해보세요" 같은 조건 완화 안내 멘트와 [초기화] 버튼을 띄웠는가?
[ ] 서버 검색 API를 설계할 때 백엔드 단에서 허용된
sort,filter값만 받도록 밸리데이션 처리를 했는가?
요약
장소 데이터가 몇십 개 수준으로 엄청 적은 미니 앱이라면 그냥 로컬에 다 박아두고 돌려도 됩니다. 하지만 공공데이터를 엮어서 실시간 축제나 오늘 문 연 음식점 같은 기능을 넣는 순간부터는 로컬과 서버의 역할을 명확히 쪼개야 합니다.
처음부터 TourSearchFilter 같은 상태 모델을 단단하게 정의해 두고 시작하면, 나중에 기획이 바뀌어 필터 조건이 추가되더라도 대공사를 피할 수 있습니다. 자꾸 까먹어서 기록용으로 남겨둡니다.
작업하시다가 막히는 부분 있으시면 언제든 편하게 공유해 주세요.
댓글
댓글 쓰기