Files
lunchpick/lib/core/services/geocoding_service.dart
JiWoong Sul c607a52962 fix(geocoding): 주소에서 층수/상호명 제거 로직 추가
- _cleanAddress() 메서드 추가
- 건물번호 뒤 층수 정보 제거 (예: "6-4 1층" → "6-4")
- 건물번호 뒤 상호명 제거 (예: "6-4 이자카야 혼네" → "6-4")
- geocode() 호출 전 주소 정리 적용
2026-01-28 18:54:37 +09:00

163 lines
5.8 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:lunchpick/core/constants/api_keys.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
/// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스
class GeocodingService {
static const _endpoint = 'https://nominatim.openstreetmap.org/search';
static const _fallbackLatitude = 37.5665; // 서울시청 위도
static const _fallbackLongitude = 126.9780; // 서울시청 경도
/// 도로명/지번 주소를 기반으로 위경도를 조회한다.
///
/// 무료(Nominatim) 엔드포인트를 사용하며 별도 API 키가 필요 없다.
/// 실패 시 null을 반환하고, 호출 측에서 기본 좌표를 사용할 수 있게 둔다.
Future<({double latitude, double longitude})?> geocode(String address) async {
if (address.trim().isEmpty) return null;
// 주소 전처리: 상세 주소(층수, 상호명 등) 제거
final cleanedAddress = _cleanAddress(address);
// 1차: VWorld 지오코딩 시도 (키가 존재할 때만)
final vworldResult = await _geocodeWithVworld(cleanedAddress);
if (vworldResult != null) {
return vworldResult;
}
// 2차: Nominatim (fallback)
try {
final uri = Uri.parse(
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(cleanedAddress)}',
);
// Nominatim은 User-Agent 헤더를 요구한다.
final response = await http.get(
uri,
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
);
if (response.statusCode != 200) {
AppLogger.debug('[GeocodingService] 실패 status: ${response.statusCode}');
return null;
}
final List<dynamic> results = jsonDecode(response.body) as List<dynamic>;
if (results.isEmpty) return null;
final first = results.first as Map<String, dynamic>;
final lat = double.tryParse(first['lat']?.toString() ?? '');
final lon = double.tryParse(first['lon']?.toString() ?? '');
if (lat == null || lon == null) {
AppLogger.debug('[GeocodingService] 응답 파싱 실패: ${first.toString()}');
return null;
}
return (latitude: lat, longitude: lon);
} catch (e) {
AppLogger.debug('[GeocodingService] 예외 발생: $e');
return null;
}
}
/// 기본 좌표(서울시청)를 반환한다.
({double latitude, double longitude}) defaultCoordinates() {
return (latitude: _fallbackLatitude, longitude: _fallbackLongitude);
}
Future<({double latitude, double longitude})?> _geocodeWithVworld(
String address,
) async {
final apiKey = ApiKeys.vworldApiKey;
if (apiKey.isEmpty) {
return null;
}
try {
final uri = Uri.https('api.vworld.kr', '/req/address', {
'service': 'address',
'request': 'getcoord',
'format': 'json',
'type': 'road', // 도로명 주소 기준
'key': apiKey,
'address': address,
});
final response = await http.get(
uri,
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
);
if (response.statusCode != 200) {
AppLogger.debug(
'[GeocodingService] VWorld 실패 status: ${response.statusCode}',
);
return null;
}
final Map<String, dynamic> json = jsonDecode(response.body);
final responseNode = json['response'] as Map<String, dynamic>?;
if (responseNode == null || responseNode['status'] != 'OK') {
AppLogger.debug('[GeocodingService] VWorld 응답 오류: ${response.body}');
return null;
}
// VWorld 포인트는 WGS84 lon/lat 순서(x=lon, y=lat)
final result = responseNode['result'] as Map<String, dynamic>?;
final point = result?['point'] as Map<String, dynamic>?;
final x = point?['x']?.toString();
final y = point?['y']?.toString();
final lon = x != null ? double.tryParse(x) : null;
final lat = y != null ? double.tryParse(y) : null;
if (lat == null || lon == null) {
AppLogger.debug(
'[GeocodingService] VWorld 좌표 파싱 실패: ${point.toString()}',
);
return null;
}
return (latitude: lat, longitude: lon);
} catch (e) {
AppLogger.debug('[GeocodingService] VWorld 예외: $e');
return null;
}
}
/// 주소에서 상세 주소(층수, 상호명 등)를 제거하여 순수 도로명 주소만 추출한다.
///
/// 예시:
/// - "서울 관악구 관악로14길 6-4 1층 이자카야 혼네" → "서울 관악구 관악로14길 6-4"
/// - "서울특별시 강남구 테헤란로 123 B1 스타벅스" → "서울특별시 강남구 테헤란로 123"
String _cleanAddress(String address) {
final trimmed = address.trim();
// 패턴 1: 건물번호 뒤에 층수 정보가 있는 경우 (1층, B1, 지하1층 등)
// 도로명 주소의 건물번호는 숫자 또는 숫자-숫자 형태
final floorPattern = RegExp(
r'(\d+(?:-\d+)?)\s+(?:\d+층|[Bb]\d+|지하\d*층?).*$',
);
final floorMatch = floorPattern.firstMatch(trimmed);
if (floorMatch != null) {
final buildingNumber = floorMatch.group(1);
final beforeMatch = trimmed.substring(0, floorMatch.start);
return '$beforeMatch$buildingNumber'.trim();
}
// 패턴 2: 건물번호 뒤에 상호명이 바로 오는 경우 (공백 + 한글/영문)
// 단, 구/동/로/길 같은 주소 구성요소는 제외
final namePattern = RegExp(
r'(\d+(?:-\d+)?)\s+(?![가-힣]+[구동로길읍면리]\s)([가-힣a-zA-Z&]+.*)$',
);
final nameMatch = namePattern.firstMatch(trimmed);
if (nameMatch != null) {
final buildingNumber = nameMatch.group(1);
final beforeMatch = trimmed.substring(0, nameMatch.start);
return '$beforeMatch$buildingNumber'.trim();
}
return trimmed;
}
}