fix(privacy): 개인정보 처리방침 및 지오코딩 동작 정리

- 스토어 설명에 네이버 지도앱 공유 링크를 수정하지 않고 그대로 붙여넣어야 한다는 안내를 추가하고, 실제 동작과 맞는 URL 사용 조건을 명시했습니다.
- doc/store_desc/privacy_policy.md에 현재 구현 기준(키 기반 네이버 로컬 검색 미사용, 네이버 지도 웹/GraphQL 파싱, VWorld+Nominatim 지오코딩, 기상청 Open API, Google AdMob)을 반영한 개인정보 처리방침을 추가/정리했습니다.
- lib/data/api/naver_api_client.dart에서 searchLocal 구현을 변경하여 네이버 로컬 검색 Open API를 더 이상 호출하지 않고, 항상 빈 결과를 반환하면서 디버그 로그만 남기도록 비활성화했습니다.
- 네이버 URL/검색으로 가져온 식당 정보를 편집하는 뷰에서 위도/경도 필드를 선택 입력으로 완화하여, 지오코딩 실패 시에도 폼 검증만으로 저장이 막히지 않도록 조정했습니다.
- AddRestaurantViewModel._resolveCoordinates에 allowFallbackWhenGeocodingFails 플래그를 추가하고, 네이버 URL 기반 추가 시에는 지오코딩 실패를 현재 위치/기본 좌표로 자동 대체하지 않고 명시적인 오류로 처리하여, 잘못된 주소로 저장되지 않도록 했습니다.
This commit is contained in:
JiWoong Sul
2025-12-05 19:26:11 +09:00
parent 0c6b10d4f6
commit cf7e187985
5 changed files with 225 additions and 26 deletions

View File

@@ -0,0 +1,191 @@
# 오늘 뭐 먹Z? 개인정보 처리방침
오늘 뭐 먹Z? (이하 “앱”)는 사용자의 개인정보 보호를 최우선 가치로 삼습니다.
앱은 점심 메뉴 추천 및 맛집 관리 기능 제공을 위해 최소한의 정보만을 사용하며, **개발자가 직접 운영하는 별도 서버를 두지 않습니다.**
다만, 위치 기반 추천, 날씨 정보 제공, 네이버 지도 연동, 광고 노출 및 좌표 확인을 위해 제3자 서비스와 통신할 수 있습니다.
본 개인정보 처리방침은 앱이 어떤 정보를 어떤 목적과 방식으로 처리하는지 설명합니다.
---
## 1. 수집하는 개인정보
### 1-1. 직접 식별 가능한 정보
앱은 다음과 같은 의미에서 사용자를 직접 식별할 수 있는 개인정보(이름, 이메일, 전화번호 등)를 **직접 수집하지 않습니다.**
- 회원가입, 로그인 기능이 없습니다.
- 주민등록번호, 이름, 이메일, 전화번호, 주소 등 사용자를 직접 식별할 수 있는 정보를 요구하지 않습니다.
- 개발자가 운영하는 자체 서버로 사용자의 개인정보를 수집·저장하지 않습니다.
### 1-2. 서비스 제공을 위해 처리되는 정보
앱 기능 제공을 위해 다음과 같은 정보가 기기 내에서 처리되거나 제3자 서비스로 전송될 수 있습니다.
1) **위치 정보**
- 내용: 현재 위치 좌표(위도, 경도)
- 사용 목적
- 주변 맛집 추천 및 거리 계산
- 날씨(기상청 Open API) 조회
- 현재 위치 반경 내 추천 후보를 제한하는 데 활용
- 처리 방식
- 위치 정보는 주로 **실시간 계산 및 API 호출**에 사용되며, 사용자의 “위치 이력”을 장기적으로 별도 저장하지 않습니다.
- 앱에서 저장하는 위치 정보는 주로 “식당 좌표(맛집 위치)”이며, 이는 사용자의 거주지나 신원과 직접 연결되지 않습니다.
- 위치 정보는 기상청 Open API 등 날씨 서비스에 한해, 격자 좌표(nx, ny) 형태로 전송될 수 있습니다(자세한 내용은 아래 3절 참조).
2) **맛집 및 방문 기록 정보**
- 내용
- 사용자가 직접 입력하거나 네이버 지도에서 가져온 식당 정보
- 식당 이름, 카테고리, 설명, 전화번호, 도로명/지번 주소
- 위도·경도, 주소, 영업시간 등
- 방문 기록 및 통계 정보
- 방문 일자, 방문 여부, 방문 횟수 등
- 사용 목적
- 점심 메뉴 추천, 중복 방문 방지, 방문 기록 조회, 통계 제공 등
- 처리 방식
- 위 정보는 모두 **사용자의 기기 내 로컬 데이터베이스**에만 저장됩니다.
- 개발자는 이 데이터를 서버를 통해 열람하거나 수집하지 않습니다.
3) **앱 사용 설정 정보**
- 내용: 알림 시간, 추천 거리/날씨 관련 설정, 다크모드 여부 등 앱 내 환경 설정
- 사용 목적: 사용자 맞춤 추천 및 알림 제공
- 처리 방식: 기기 내 로컬 저장소에만 저장되며, 서버로 전송되지 않습니다.
---
## 2. 데이터 저장 및 처리 방식
1) **로컬 저장소(기기 내 저장소)**
- 맛집 정보, 방문 기록, 앱 설정, 일부 캐시 데이터(날씨 등)는
**기기 내 로컬 데이터베이스에만 저장**됩니다.
- 앱을 삭제하면 일반적으로 해당 앱과 연계된 로컬 데이터도 함께 삭제됩니다.
다만, 운영체제나 백업 설정에 따라 일부 데이터가 OS 또는 클라우드 백업에 남을 수 있으며, 이는 각 플랫폼(예: Apple, Google)의 정책을 따릅니다.
2) **네트워크 통신 및 제3자 전송**
앱은 자체 서버를 운영하지 않지만, 다음과 같은 제3자 서비스와 통신합니다.
- **지도·식당 정보 제공을 위한 네이버 지도 웹 서비스**
- 전송되는 정보(예시): 사용자가 앱에 붙여넣은 네이버 지도 URL, 해당 URL에 포함된 식당 ID 등
- 사용 목적: 네이버 지도 페이지 및 관련 API(예: GraphQL)를 통해 식당 이름·주소·좌표·전화번호 등을 조회하고, 앱 내 식당 정보로 변환
- 현재 버전에서는 네이버의 “로컬 검색 Open API(키 기반 검색)”를 사용하지 않으며, 사용자가 제공한 지도 링크를 기반으로 한 페이지 파싱 방식만 사용합니다.
- **지오코딩(Geocoding) 서비스: OpenStreetMap Nominatim**
- 전송되는 정보(예시): 사용자가 입력한 식당 주소(도로명·지번 등)
- 사용 목적: 주소를 위도·경도 좌표로 변환하여 지도/거리 계산 및 추천 알고리즘에 활용
- **기상청 Open API(공공데이터포털)**
- 전송되는 정보(예시): 위치를 기반으로 변환된 격자 좌표(nx, ny)
- 사용 목적: 현재 및 단기(1시간 후) 날씨 정보 조회
- **광고 네트워크(Google AdMob 등)**
- 자세한 내용은 아래 3절 “광고 및 제3자 서비스” 참조
앱 개발자는 이들 제3자 서비스의 서버에 저장되는 데이터에 직접 접근하지 않으며,
제3자 서비스에서 수집·처리하는 정보는 각 서비스의 개인정보 처리방침을 따릅니다.
---
## 3. 광고 및 제3자 서비스
앱은 무료 제공을 위해 광고를 노출할 수 있으며, 이 과정에서 **Google AdMob** 등 제3자 광고 네트워크가 참여합니다.
### 3-1. 광고 네트워크가 수집할 수 있는 정보
앱은 직접 사용자의 개인정보를 수집하지 않지만, 광고 네트워크는 다음과 같은 정보를 수집·처리할 수 있습니다(예시).
- 광고 식별자 (예: Android 광고 ID, IDFA 등)
- 기기 정보 (단말기 모델명, OS 버전, 언어/국가 설정 등)
- 대략적인 위치 정보 (국가/지역 수준, IP 기반 위치 등)
- 앱 사용 정보 (광고 조회/클릭 여부, 광고 노출 횟수 등)
이러한 정보는 **개발자가 아닌 광고 네트워크 사업자**가 수집·처리하며,
수집 범위와 이용 목적은 해당 사업자의 개인정보 처리방침을 따릅니다.
보다 자세한 내용은 각 서비스의 정책을 참고하세요.
- Google AdMob / Google Mobile Ads: https://policies.google.com/privacy
- 네이버(Naver): https://policy.naver.com/
- 공공데이터포털·기상청 Open API: 각 제공 기관의 개인정보 처리방침
### 3-2. 제3자 서비스 관련 안내
- 앱은 제3자 서비스에 사용자의 이름, 이메일, 전화번호 등 **직접 식별 정보**를 의도적으로 전송하지 않습니다.
- 위치 정보, 검색어, 기기 정보 등은 제3자 서비스의 기술적 처리 과정에서 사용될 수 있습니다.
- 제3자 서비스에서 제공하는 맞춤형 광고 또는 추천 기능 등은 해당 서비스의 정책에 따라 동작합니다.
---
## 4. 권한 사용
앱은 기능 제공을 위해 다음과 같은 권한을 사용할 수 있습니다.
각 권한은 **명시된 목적 외의 용도로 사용되지 않습니다.**
1) **위치 권한**
- 사용 목적
- 현재 위치를 기준으로 주변 맛집을 추천하기 위해 사용
- 현재/1시간 후 날씨 정보를 조회하기 위해 사용
- 추천 대상이 되는 식당 범위를 “현재 위치 반경”으로 제한하기 위해 사용
- 특징
- 위치 권한을 거부할 경우, 앱은 서울 시청(기본 좌표)을 기준으로 동작합니다.
- 사용자의 위치 이력을 장기적으로 추적하거나, 별도 계정과 연결하여 프로파일링하지 않습니다.
2) **알림 권한**
- 사용 목적
- 점심 식사 후 방문 기록을 남기도록 안내하는 **방문 확인 알림** 발송
- 알림 진동·소리, 정확한 시각의 알림 예약, 기기 재부팅 후 예약 알림 복원
- 특징
- 알림 내용에는 주로 추천된 식당 이름, 방문 여부 확인 요청 등의 간단한 메시지가 포함됩니다.
- 알림 권한을 거부해도 앱의 기본 사용은 가능하나, 방문 리마인더 기능은 제한될 수 있습니다.
3) **블루투스 권한**
- 사용 목적
- 주변 기기와 **맛집 리스트를 공유**하기 위해 사용
- 팀/동료와 함께 맛집 목록을 교환하는 기능에 활용
- 특징
- 공유 대상 데이터는 식당 이름, 주소, 카테고리 등으로, 사용자의 이름·이메일 등 직접 식별 정보는 포함하지 않습니다.
- 공유는 기기간 통신을 전제로 하며, 개발자가 운영하는 중앙 서버를 경유하지 않습니다(향후 실제 Bluetooth 스택 도입 시에도 동일 원칙을 적용합니다).
4) **네트워크 권한**
- 사용 목적
- 네이버 지도 페이지 및 관련 API 호출(붙여넣은 지도 링크 기반 식당 정보 조회)
- 지오코딩 서비스(OpenStreetMap Nominatim) 및 기상청 Open API 호출(좌표·날씨 정보)
- 광고(Google AdMob) 로딩 및 통계 전송
- 특징
- 앱은 별도의 자체 백엔드 서버를 운영하지 않으며, 네트워크 요청은 위와 같은 제3자 API·광고 서비스에 한정됩니다.
---
## 5. 아동의 개인정보
- 앱은 성인(직장인 등 일반 사용자)을 주요 대상으로 설계되었습니다.
- 만 14세 미만(또는 각 국가에서 정한 연령 기준 미만)의 아동을 대상으로 개인정보를 수집하려는 의도가 없습니다.
- 만약 아동의 개인정보가 부주의로 수집된 사실을 인지하게 될 경우, 가능한 한 신속히 해당 정보를 삭제하기 위한 조치를 취하겠습니다.
---
## 6. 개인정보 처리방침의 변경
- 본 개인정보 처리방침은 서비스 개선, 관련 법령 및 가이드라인 개정, 기능 추가/변경 등에 따라 수시로 수정될 수 있습니다.
- 중요한 내용(수집 항목, 이용 목적, 제3자 제공 등)이 변경되는 경우, 앱 내 공지 또는 스토어 설명 등을 통해 변경 내용을 안내하겠습니다.
- 변경된 개인정보 처리방침은 명시된 시행일로부터 효력이 발생합니다.
**시행일자: 2025.12.05**
---
## 7. 문의처
앱의 개인정보 처리방침과 관련하여 문의, 의견 제출, 권리 행사(열람, 정정, 삭제 요청 등)가 필요하신 경우 아래 연락처로 문의해 주세요.
- 담당자: 네이처브릿지AI 앱개발팀
- 이메일: naturebridgeai@gmail.com

View File

@@ -21,7 +21,7 @@
- 한식·중식·일식·카페 등 카테고리를 선택해, 그날 기분에 맞는 맛집만 골라서 추천 - 한식·중식·일식·카페 등 카테고리를 선택해, 그날 기분에 맞는 맛집만 골라서 추천
2. 네이버 지도 연동 맛집 수집 2. 네이버 지도 연동 맛집 수집
- 네이버 지도앱에서 공유한 링크(naver.me 등)를 그대로 붙여넣으면, 가게 이름·주소·카테고리·좌표를 자동으로 불러와 등록 - 네이버 지도앱의 ‘공유’ 기능으로 복사한 링크(naver.me 등)를 **수정하지 말고 그대로** 붙여넣으면, 가게 이름·주소·카테고리·좌표를 자동으로 불러와 등록
- 회사 구내식당이나 단골 분식집은 직접 입력으로 손쉽게 등록 - 회사 구내식당이나 단골 분식집은 직접 입력으로 손쉽게 등록
- 메모, 전화번호까지 함께 저장해 두고, 동료에게 설명할 때도 한 번에 보여줄 수 있습니다. - 메모, 전화번호까지 함께 저장해 두고, 동료에게 설명할 때도 한 번에 보여줄 수 있습니다.
@@ -49,4 +49,4 @@
- 캘린더와 통계 화면 덕분에, 점심 시간이 단순 소비가 아니라 나만의 작은 라이프로그가 됩니다. - 캘린더와 통계 화면 덕분에, 점심 시간이 단순 소비가 아니라 나만의 작은 라이프로그가 됩니다.
- 팀 점심·회식까지 한 앱으로 해결해, 메뉴 선택 스트레스를 팀 전체에서 줄일 수 있습니다. - 팀 점심·회식까지 한 앱으로 해결해, 메뉴 선택 스트레스를 팀 전체에서 줄일 수 있습니다.
- 지금 ‘오늘 뭐 먹Z?’를 설치하고, 한국 직장인의 가장 큰 고민 중 하나인 점심 메뉴 선택을 1분 안에 끝내 보세요. - 지금 ‘오늘 뭐 먹Z?’를 설치하고, 한국 직장인의 가장 큰 고민 중 하나인 점심 메뉴 선택을 1분 안에 끝내 보세요.
※ 본 서비스는 현재 한국(대한민국) 지역에서만 사용 가능합니다. ※ 본 서비스는 현재 한국(대한민국) 지역에서만 사용 가능합니다.

View File

@@ -34,9 +34,12 @@ class NaverApiClient {
_proxyClient = NaverProxyClient(networkClient: _networkClient); _proxyClient = NaverProxyClient(networkClient: _networkClient);
} }
/// 네이버 로컬 검색 API 호출 /// 네이버 로컬 검색 API 호출 (현재 비활성화됨)
/// ///
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다. /// 개인정보 처리방침 및 운영 정책에 따라
/// 네이버 로컬 검색 Open API(키 기반 검색)는 사용하지 않는다.
/// 이 메서드는 네트워크 요청을 보내지 않고 항상 빈 리스트를 반환한다.
/// (향후 정책 변경 시, 기존 구현을 복원하여 사용할 수 있다.)
Future<List<NaverLocalSearchResult>> searchLocal({ Future<List<NaverLocalSearchResult>> searchLocal({
required String query, required String query,
double? latitude, double? latitude,
@@ -45,14 +48,10 @@ class NaverApiClient {
int start = 1, int start = 1,
String sort = 'random', String sort = 'random',
}) async { }) async {
return _localSearchApi.searchLocal( AppLogger.debug(
query: query, '[NaverApiClient] searchLocal 호출됨 - 로컬 검색 Open API는 현재 비활성화 상태입니다.',
latitude: latitude,
longitude: longitude,
display: display,
start: start,
sort: sort,
); );
return <NaverLocalSearchResult>[];
} }
/// 단축 URL을 실제 URL로 변환 /// 단축 URL을 실제 URL로 변환

View File

@@ -183,12 +183,11 @@ class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
), ),
onChanged: widget.onFieldChanged, onChanged: widget.onFieldChanged,
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value != null && value.isNotEmpty) {
return '위도를 입력해주세요'; final latitude = double.tryParse(value);
} if (latitude == null || latitude < -90 || latitude > 90) {
final latitude = double.tryParse(value); return '올바른 위도값을 입력해주세요';
if (latitude == null || latitude < -90 || latitude > 90) { }
return '올바른 위도값을 입력해주세요';
} }
return null; return null;
}, },
@@ -208,14 +207,13 @@ class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
), ),
onChanged: widget.onFieldChanged, onChanged: widget.onFieldChanged,
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value != null && value.isNotEmpty) {
return '경도를 입력해주세요'; final longitude = double.tryParse(value);
} if (longitude == null ||
final longitude = double.tryParse(value); longitude < -180 ||
if (longitude == null || longitude > 180) {
longitude < -180 || return '올바른 경도값을 입력해주세요';
longitude > 180) { }
return '올바른 경도값을 입력해주세요';
} }
return null; return null;
}, },

View File

@@ -304,6 +304,7 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
jibunAddress: state.formData.jibunAddress, jibunAddress: state.formData.jibunAddress,
fallbackLatitude: fetchedData.latitude, fallbackLatitude: fetchedData.latitude,
fallbackLongitude: fetchedData.longitude, fallbackLongitude: fetchedData.longitude,
allowFallbackWhenGeocodingFails: false,
); );
restaurantToSave = fetchedData.copyWith( restaurantToSave = fetchedData.copyWith(
@@ -436,6 +437,7 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
required String jibunAddress, required String jibunAddress,
double? fallbackLatitude, double? fallbackLatitude,
double? fallbackLongitude, double? fallbackLongitude,
bool allowFallbackWhenGeocodingFails = true,
}) async { }) async {
final parsedLat = double.tryParse(latitudeText); final parsedLat = double.tryParse(latitudeText);
final parsedLon = double.tryParse(longitudeText); final parsedLon = double.tryParse(longitudeText);
@@ -468,8 +470,17 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
); );
} else { } else {
state = state.copyWith( state = state.copyWith(
geocodingStatus: '지오코딩 실패: $address, 현재 위치/기본 좌표로 대체', geocodingStatus: '지오코딩 실패: $address, 주소를 다시 확인해 주세요.',
); );
if (!allowFallbackWhenGeocodingFails) {
state = state.copyWith(
errorMessage:
'주소가 지도에서 인식되지 않습니다. '
'도로명 주소 전체를 정확히 입력했는지 확인해 주세요.',
);
throw Exception('지오코딩 실패: $address');
}
} }
} }