본문 바로가기
인생사 필요한 정보를 공유 합니다
IT 관련

Flutter 현장 앱 개발 | 사진에 GPS EXIF + 메모 동시 저장하는 아키텍처 완전 정리

반응형

Flutter 현장 앱 개발 ❘ 사진에 GPS EXIF + 메모 동시 저장하는 아키텍처 완전 정리
Flutter geolocator + native_exif 실전 가이드 ❘ 현장 사진 위치 저장 구현법

📍 Flutter 현장 앱 — 사진에 GPS + 메모를 동시에 저장하는 아키텍처 구현법
현장 조사용 Flutter 앱에서 촬영 사진에 GPS EXIF 좌표와 사용자 메모를 동시에 커스텀 저장하는 전체 아키텍처를 설명합니다. image_picker의 GPS 누락 문제, native_exif 쓰기, 로컬 DB 연동까지 실무 기준으로 정리했습니다.

현장 조사, 시설 점검, 부동산 실사, 환경 모니터링 같은 업무 앱을 Flutter로 개발할 때 빠지지 않는 요구사항이 있습니다. "현장에서 사진 찍을 때 GPS 좌표와 메모를 같이 저장해 주세요."

쉬워 보이지만 실제로 구현하면 여러 장벽을 만납니다. image_picker로 촬영한 사진에는 Android에서 GPS EXIF가 종종 비어 있고, geolocator로 좌표를 따로 얻어와도 사진 파일에 직접 쓰는 방법이 막막합니다. 게다가 현장 메모까지 동시에 연결해 저장하려면 어느 레이어에서 어떻게 조합할지 설계가 먼저 있어야 합니다.

실제 현장 데이터 수집 앱을 만들면서 이 문제를 직접 파고들었습니다. 이 글에서는 GPS 획득 → EXIF 쓰기 → 메모 DB 저장까지 이어지는 전체 아키텍처와 핵심 코드를 정리합니다.

🔹 1. 현장 앱에서 GPS + 메모 동시 저장이 어려운 이유

Flutter에서 이 기능을 구현할 때 맞닥뜨리는 문제는 크게 세 가지입니다.

① image_picker의 Android GPS EXIF 누락 문제
Flutter 공식 image_picker로 카메라를 열어 촬영하면, Android에서는 GPS EXIF 데이터가 비어 있는 경우가 많습니다. 이는 image_picker가 Android의 MediaStore API를 통해 시스템 카메라를 호출하는데, 카메라 앱의 위치 권한과 앱 자체의 위치 권한이 별개로 관리되기 때문입니다. iOS에서는 문제가 없어 개발 중 발견하기 어렵습니다.
② EXIF에 GPS를 직접 쓰는 Flutter 패키지 부재
Flutter의 exif 패키지는 읽기 전용입니다. GPS 좌표를 사진 파일에 직접 기록하려면 native_exif 같은 네이티브 연동 패키지가 필요하며, 접근법도 iOS와 Android가 다릅니다.
③ 메모와 사진의 연결 관리
EXIF에 저장할 수 있는 정보는 제한적입니다. 긴 메모, 카테고리, 사용자 ID 같은 구조화된 데이터는 EXIF가 아닌 별도의 로컬 DB(SQLite)에 파일 경로를 키로 연결해 저장해야 합니다. 이 두 저장소를 트랜잭션 없이 따로 저장하면 사진은 있는데 메모가 없는 데이터 불일치가 발생합니다.

🔹 2. 전체 아키텍처 설계 — 3레이어 구조

이 문제를 해결하는 핵심은 촬영 → GPS 획득 → EXIF 쓰기 → DB 저장을 하나의 단위 작업(atomic operation)처럼 묶는 것입니다.

데이터 흐름 다이어그램
[UI Layer]  카메라 버튼 탭
      ↓
[Service Layer]  FieldPhotoService.capture()
   ├─ ① geolocator → 현재 GPS 좌표 획득
   ├─ ② image_picker → 카메라 촬영 (JPEG 파일 경로)
   ├─ ③ native_exif → 사진 파일에 GPS EXIF 쓰기
   └─ ④ 파일 앱 전용 디렉토리로 복사 (갤러리 외부)
      ↓
[Data Layer]  FieldPhotoRepository.save()
   ├─ SQLite: 파일경로 + GPS + 메모 + 타임스탬프 저장
   └─ 저장 성공 시 UI에 FieldPhotoRecord 반환
  
💡 설계 원칙
EXIF 쓰기 실패 시 DB 저장을 하지 않고, DB 저장 실패 시 파일을 롤백하는 방식으로 데이터 일관성을 보장합니다. 오프라인 현장 환경을 가정하므로 서버 동기화는 별도 큐로 분리합니다.

🔹 3. GPS 좌표 획득 — geolocator 실시간 위치

geolocator 패키지를 사용해 촬영 시점의 위치를 획득합니다. 촬영 직전이 아닌 앱 진입 시부터 위치 스트림을 pre-warm해두는 것이 핵심입니다. 현장에서 버튼 누르는 순간 GPS Fix를 기다리면 수 초 딜레이가 생기기 때문입니다.

pubspec.yaml 의존성
dependencies:
  geolocator: ^13.0.0
  image_picker: ^1.1.2
  native_exif: ^0.1.2
  sqflite: ^2.3.3
  path_provider: ^2.1.3
  permission_handler: ^11.3.1
GPS 위치 획득 코드
import 'package:geolocator/geolocator.dart';

class LocationService {
  Position? _lastPosition;

  // 앱 진입 시 스트림으로 위치 캐싱
  void startWatching() {
    Geolocator.getPositionStream(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 5, // 5m 이상 이동 시 업데이트
      ),
    ).listen((pos) => _lastPosition = pos);
  }

  // 촬영 시 즉시 반환 (pre-warmed)
  Future<Position> getCurrent() async {
    if (_lastPosition != null) return _lastPosition!;
    return Geolocator.getCurrentPosition(
      desiredAccuracy: LocationAccuracy.high,
    );
  }
}

🔹 4. EXIF에 GPS 쓰기 — native_exif 실전 코드

native_exif 패키지는 JPEG 파일에 GPS 위도·경도를 직접 쓰는 기능을 제공합니다. GPSLatitude, GPSLongitude는 String 타입으로 입력하며, 음수값도 지원하지만 반환 시에는 양수로 돌아오므로 GPSLatitudeRef와 GPSLongitudeRef를 함께 설정해야 합니다.

native_exif로 GPS 쓰기
import 'package:native_exif/native_exif.dart';

Future<void> writeGpsExif(
    String filePath, double lat, double lng) async {
  final exif = await Exif.fromPath(filePath);

  await exif.writeAttributes({
    'GPSLatitude': lat.abs().toString(),
    'GPSLatitudeRef': lat >= 0 ? 'N' : 'S',
    'GPSLongitude': lng.abs().toString(),
    'GPSLongitudeRef': lng >= 0 ? 'E' : 'W',
  });

  await exif.close();
}
⚠️ Android ACCESS_MEDIA_LOCATION 권한 필수
Android 10(Q) 이상에서는 갤러리에서 가져온 사진의 GPS EXIF를 읽으려면 ACCESS_MEDIA_LOCATION 권한이 별도로 필요합니다. AndroidManifest.xml에 추가하고 permission_handler로 런타임 요청해야 합니다. 카메라로 직접 촬영해 앱 전용 디렉토리에 저장하는 경우에는 이 권한이 불필요합니다.

🔹 5. 메모 + 메타데이터 로컬 저장 — SQLite 연동

EXIF에는 위도·경도 좌표만 담고, 현장 메모, 점검 카테고리, 작성자 정보 등 구조화된 데이터는 sqflite로 SQLite DB에 저장합니다. 파일 경로를 기본키로 양쪽을 연결합니다.

DB 스키마 및 저장 코드
// 테이블 생성
await db.execute('''
  CREATE TABLE field_photos (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    filepath TEXT    UNIQUE NOT NULL,
    lat      REAL    NOT NULL,
    lng      REAL    NOT NULL,
    memo     TEXT,
    category TEXT,
    taken_at INTEGER NOT NULL
  )
''');

// GPS + 메모 통합 저장 (Service Layer)
Future<void> captureAndSave(String memo, String category) async {
  // 1. GPS pre-warm 위치 획득
  final pos = await locationService.getCurrent();

  // 2. 카메라 촬영
  final photo = await ImagePicker().pickImage(source: ImageSource.camera);
  if (photo == null) return;

  // 3. 앱 전용 경로로 복사
  final dir = await getApplicationDocumentsDirectory();
  final dest = '${dir.path}/field/${DateTime.now().millisecondsSinceEpoch}.jpg';
  await File(photo.path).copy(dest);

  // 4. EXIF 쓰기
  await writeGpsExif(dest, pos.latitude, pos.longitude);

  // 5. DB 저장 (EXIF 성공 후)
  await db.insert('field_photos', {
    'filepath': dest,
    'lat': pos.latitude,
    'lng': pos.longitude,
    'memo': memo,
    'category': category,
    'taken_at': DateTime.now().millisecondsSinceEpoch,
  });
}
💡 갤러리 노출 방지
현장 사진을 getApplicationDocumentsDirectory() 하위에 저장하면 일반 갤러리 앱에 표시되지 않습니다. 업무용 앱에서 개인 갤러리 오염을 막는 데 중요한 포인트입니다.

🔹 6. 실무 함정 — Android GPS 누락 & 권한 처리

현장에서 실제로 부딪히는 문제와 그 해결책입니다.

🟢 함정 1 — image_picker 촬영 시 GPS가 비어있음 (Android)
image_picker는 시스템 카메라를 Intent로 호출합니다. 시스템 카메라의 위치 권한이 꺼져 있으면 EXIF GPS가 비웁니다. 해결책은 image_picker로 촬영 후 앱에서 geolocator로 좌표를 따로 가져와 native_exif로 직접 써주는 것입니다. 시스템 카메라 GPS 설정에 의존하지 않는 구조가 더 안정적입니다.
🟢 함정 2 — GPS Fix 실패 (실내/지하 현장)
지하 현장이나 철골 구조물 안에서는 GPS 정확도가 떨어집니다. geolocatoraccuracyLocationAccuracy.medium으로 낮추거나, 마지막으로 성공한 위치(lastKnownPosition)를 폴백으로 사용하고 DB에 GPS 신뢰도(accuracy 값)를 함께 기록해두면 나중에 의심 데이터를 필터링할 수 있습니다.
🟢 함정 3 — Android 권한 요청 타이밍
Android에서 위치 권한 외에 카메라 권한, (Android 10 이하) WRITE_EXTERNAL_STORAGE 권한이 추가로 필요합니다. 앱 최초 실행 시 permission_handler로 일괄 요청하고, 거부 시 사용자에게 설정 화면으로 이동하는 다이얼로그를 제공해야 현장에서 당황하는 일이 없습니다.

"해당 배너는 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."

❓ 7. 자주 묻는 질문

Q. camera 패키지를 쓰면 image_picker GPS 문제가 없나요?

Flutter 공식 camera 패키지도 마찬가지로 EXIF에 GPS를 자동으로 써주지 않습니다. 결국 geolocator + native_exif 조합으로 직접 써야 합니다. 다만 camera 패키지는 프리뷰와 UI 커스터마이징이 자유롭다는 장점이 있습니다. 더 나아가 camera_with_gps 패키지는 카메라 UI를 열면서 GPS 좌표를 EXIF에 자동 임베드하는 기능을 통합 제공합니다.

Q. 오프라인 현장에서 나중에 서버에 업로드하려면 어떻게 하나요?

SQLite에 sync_status(pending/synced) 컬럼을 추가하고, 네트워크 연결 복구 시 pending 레코드를 서버로 전송하는 백그라운드 큐 방식이 일반적입니다. connectivity_plus 패키지로 연결 상태를 감지하고 자동 업로드를 트리거하면 됩니다.

Q. EXIF에 메모를 넣으면 안 되나요?

EXIF의 ImageDescription이나 UserComment 필드에 짧은 메모를 넣는 것은 가능합니다. 그러나 길이 제한이 있고, 검색·필터링이 어려우며, 메모 수정 시 원본 파일을 재쓰기해야 합니다. 짧은 주석 외의 구조화된 데이터는 SQLite 관리가 훨씬 유연합니다.

📋 핵심 요약 — 현장 사진 저장 아키텍처

  1. geolocator로 앱 시작 시 위치 스트림 pre-warm
  2. image_picker로 촬영 → 앱 전용 디렉토리에 복사
  3. native_exif로 JPEG 파일에 GPS 좌표 직접 쓰기
  4. sqflite에 파일 경로·GPS·메모·카테고리 통합 저장
  5. Android GPS 누락은 시스템 카메라 의존 버리고 앱 레벨에서 직접 처리

현장 데이터 수집 앱의 핵심 가치는 신뢰성입니다. 사진은 찍혔는데 위치가 없거나, 메모는 있는데 사진과 연결이 끊어진 데이터는 현장에서는 발견하기도 어렵습니다. GPS 획득 → EXIF 쓰기 → DB 저장을 하나의 원자적 흐름으로 묶고, 실패 시 롤백하는 구조를 처음부터 갖추는 것이 장기적으로 훨씬 안전한 선택입니다.

728x90
반응형