📌 앱 소개: 네이버 지역 검색 API와 현재 위치 기반 장소 검색 기능을 제공하는 앱
🕒 기간: 2025.04.20 ~ 2025.04.22 (3일)
📱 플랫폼: Flutter 크로스 플랫폼 앱 (iOS, Android)
👥 개발 인원: 1명 (개인 프로젝트)
💼 역할: 앱 전체 개발 및 UI/UX 설계, API 연동
🛠️ 주요 사용 기술: Flutter
Dart
Naver Local API
VWorld API
Riverpod
Geolocator
InAppWebView
URL Launcher
Dio
🔗 GitHub: daehan-lim/flutter-place-finder
어디든GO는 네이버 지역 검색 API와 VWorld API를 활용하여 사용자가 장소명이나 주소로 검색할 수 있는 위치 기반 검색 애플리케이션입니다. GPS를 통한 현재 위치 기반 검색과 다양한 지도 앱 연동을 지원하며, 사용자가 직관적으로 주변 장소를 탐색하고 상세 정보에 접근할 수 있는 모바일 환경을 제공합니다.
├── app/ # 애플리케이션 설정 및 구성 관련 파일
│ ├── constants/ # 앱 전체에서 사용되는 상수 정의
│ │ ├── app_colors.dart # 앱의 색상 테마 및 색상 상수
│ │ ├── app_constants.dart # 앱에서 사용되는 일반 상수값 (문자열, 숫자 등)
│ │ └── app_styles.dart # 앱의 텍스트 스타일, 여백 등 스타일 상수
│ ├── app_providers.dart # Riverpod 프로바이더 설정 및 전역 상태 정의
│ └── theme.dart # 앱의 MaterialApp 테마 설정
│
├── core/ # 핵심 기능 및 공통 유틸리티 클래스
│ ├── exceptions/ # 앱 전체에서 사용되는 예외 클래스
│ │ └── data_exceptions.dart # API 및 데이터 관련 예외 정의
│ ├── services/ # 비즈니스 로직 및 외부 서비스 연동
│ │ └── map_launcher_service.dart
│ └── utils/ # 헬퍼 함수 및 유틸리티 클래스
│ ├── geolocator_util.dart
│ ├── snackbar_util.dart
│ └── string_format_utils.dart
│
├── data/ # 데이터 관련 클래스 및 데이터 액세스 계층
│ ├── dto/ # 데이터 전송 객체 (API 응답 직접 매핑용)
│ │ ├── naver_place_dto.dart
│ │ └── vworld_district_dto.dart
│ ├── model/ # 앱 내에서 사용되는 데이터 모델
│ │ └── place.dart # 장소 정보를 나타내는 모델 클래스
│ ├── network/ # 네트워크 통신 관련 클래스
│ │ └── dio_clients.dart
│ └── repository/ # 데이터 접근 및 비즈니스 로직 구현
│ └── location_repository.dart
│
├── ui/ # 사용자 인터페이스 관련 코드
│ ├── pages/ # 앱의 주요 화면들
│ │ ├── home/ # 홈 화면 관련 파일
│ │ │ ├── home_page.dart
│ │ │ ├── home_view_model.dart
│ │ │ └── widgets/ # 홈 화면 전용 위젯
│ │ │ └── home_list_item.dart
│ │ └── web/ # 웹뷰 화면 관련 파일
│ │ ├── place_web_page.dart
│ │ └── place_web_page_view_model.dart
│ └── widgets/ # 앱 전체에서 재사용 가능한 공통 위젯
│ └── error_layout.dart
│
└── main.dart # 앱의 진입점
Geolocator
를 활용한 GPS 좌표 획득 및 VWorld API
연동으로 사용자 현재 위치의 행정구역 자동 인식Bearer Token
기반 인증과 Dio
HTTP 클라이언트를 통한 안정적인 API 통신 구현VWorld API
의 좌표 기반 행정구역 정보 조회로 정확한 위치 기반 검색 지원BaseOptions
를 통한 일관된 HTTP 설정 관리Dio
클라이언트 10초 연결/수신 타임아웃 설정으로 응답성 보장LogInterceptor
활용한 개발 효율성 향상Geo URI
를 통해 사용자가 기기에 설치된 지도 앱(구글 맵, 네이버 지도, 카카오맵 등) 중 선택하여 장소를 열 수 있는 시스템 구현Apple Maps
대체 실행URL Launcher
를 활용한 외부 앱 연동으로 끊김 없는 사용자 워크플로우 제공Custom User Agent
설정으로 모바일 최적화된 웹 페이지 로딩 구현InAppBrowserView
모드 활용으로 앱 내 통합 경험 제공ApiException
, NetworkException
, EnvFileException
등 상황별 맞춤형 예외 클래스 구현Tooltip
제공으로 가독성 향상InkWell
효과와 그림자를 활용한 카드 형태 리스트 아이템 구현GestureDetector
를 통한 화면 터치 시 키보드 자동 숨김 기능으로 사용자 편의성 향상Provider
패턴과 의존성 주입을 통한 테스트 가능한 아키텍처 구현AsyncValue
를 활용한 로딩, 에러, 데이터 상태의 일관된 관리DTO
와 Model
분리를 통한 외부 API 의존성 최소화 및 데이터 무결성 보장flutter_dotenv
를 활용한 API 키 보안 처리 및 환경별 설정 분리.env.example
파일 제공으로 개발 환경 설정 가이드 제공HomeListItem
, MessageLayout
등 독립적인 위젯 컴포넌트 구현StringFormatUtils
, SnackbarUtil
등 공통 유틸리티 클래스로 코드 중복 제거iOS 지도 앱 연동 Silent Failure 문제
문제 상황
iOS에서 네이버 지도가 설치되지 않은 상태에서 launchUrl()
을 통해 커스텀 스킴(nmap://
)을 실행하면 아무런 반응 없이 조용히 실패. try/catch
로 설정한 Apple Maps
fallback도 실행되지 않아 사용자가 아무 피드백이나 대안도 받지 못하는 상황
canLaunchUrl()
의 신뢰성 문제를 경험함. 실행 가능한 geo:
URI에 대해서도 false
를 반환하는 현상 발생:if (await canLaunchUrl(Uri.parse('geo:0,0?q=$encoded'))) {
// 실행 가능한 URI임에도 불구하고 false를 반환
}
canLaunchUrl()
이 신뢰하기 어려울 것이라 판단하여 양쪽 플랫폼 모두 try/catch
방식으로 실패 처리 선택
static Future<void> openInMap(String queryAddress) async {
if (Platform.isIOS) {
try {
await launchUrl(naverUri, mode: LaunchMode.externalApplication);
} catch (e) {
// iOS에서는 이 catch 블록이 실행되지 않음
final appleUri = Uri.parse('http://maps.apple.com/?q=$encoded');
await launchUrl(appleUri, mode: LaunchMode.externalApplication);
}
}
}
launchUrl()
은 Naver Map이 설치되어 있지 않더라도 예외를 던지지 않음catch
블록이 실행되지 않음Apple Maps
실행도 무시됨
canLaunchUrl()
로 사전 검증 필요geo:
와 같은 표준 스킴에 대해 예외 기반 처리가 안정적으로 작동최종 해결 방법
각 OS의 특성에 맞는 플랫폼별 처리 구현:
static Future<void> openInMap(String queryAddress) async {
if (Platform.isIOS) {
final naverUri = Uri.parse('nmap://search?query=$encoded&appname=$appName');
if (await canLaunchUrl(naverUri)) {
await launchUrl(naverUri, mode: LaunchMode.externalApplication);
} else {
// 네이버 지도 사용 불가, Apple Maps 사용
final appleUri = Uri.parse('http://maps.apple.com/?q=$encoded');
if (await canLaunchUrl(appleUri)) {
await launchUrl(appleUri, mode: LaunchMode.externalApplication);
}
}
} else {
// Android: geo URI에 대한 try/catch 방식 유지
try {
await launchUrl(geoUri, mode: LaunchMode.externalApplication);
} catch (e) {
log('Could not launch map: $e');
}
}
}