chyam

[Android/Kotlin] - 사용자 참여형 장소 제보 및 승인 시스템(Firebase) 본문

Android

[Android/Kotlin] - 사용자 참여형 장소 제보 및 승인 시스템(Firebase)

chyam_eun 2026. 6. 1. 17:10

기획 배경

공공데이터로 CCTV와 보안등 API를 사용하였는데, 학교와 같은 시설들은 누락된 경우가 많아서, 앱에서 사용자가 직접 제보를하면 검토 후 승인하여 모든 사용자의 지도에 즉각 반영되는 시스템을 개발하고자하였습니다.

 

시스템 흐름

1. 위치 선택 : 사용자가 앱에서 해당 위치를 길게 터치합니다.

2. 정보 입력 및 사진 촬영 : 시트에 CCTV인지 보안등인지 선택한 뒤, 카메라를 켜서 현장사진을 필수로 촬영하도록 합니다. (장난으로 하는 것 방지)
3. 업로드 : 사진은 Firebase Storage에, 좌표 및 상태값은 Firestore에 업로드 됩니다.

4. 관리자 승인 : 관리자가 Firebase console에서 사진을 확인한 후 상태값을 "APPROVED"로 변경합니다.

5. 지도 반영 : 앱에서 "APPROVED" 상태인 데이터만 실시간으로 불러와 지도에 새로운 마커로 렌더링합니다.

 

Firebase 세팅

기본적으로 Firebase는 만든상태라고 가정하겠습니다! 

 

먼저 firestore을 들어가줍니다.  데이터베이스 만들기를 누른 뒤 Standard 버전으로 선택합니다.

위치를 Seoul로 해준뒤, 

테스트모드에서 시작으로 합니다.

만들기를 한뒤, "규칙"에 들어가서 아래처럼 입력한 뒤 게시해줍니다.

파이어베이스 프로젝트를 처음 생성하면 외부의 접근이 완전히 차단되어 있어 앱에서 업로드 시 Permission Denied 에러가 발생합니다. 초기 개발 및 테스트를 위해 Firestore와 Storage의 보안 규칙을 임시로 개방해 주어야 합니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true; // 테스트용으로 일단 모두 열기
    }
  }
}

 

Storage도 동일하게 진행하는데, 이건 버전을 업그레이드해야해서 업그레이드를해줍니다. 사용량에 따라 돈이 부과된다고합니다. 규칙에 아래처럼 추가해줍니다.

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if true; // 테스트용으로 일단 모두 열기
    }
  }
}

 

이벤트 및 카메라 연동

앱에서 해당 위치를 길게 누르면 임시 마커를 찍고, 제보용 바텀 시트를 띄웁니다. 

// 지도 길게 누르기 감지
naverMap.setOnMapLongClickListener { _, latLng ->
    tempReportMarker?.map = null
    tempReportMarker = Marker().apply {
        position = latLng; map = naverMap
        iconTintColor = Color.parseColor("#FF6600")
        captionText = "제보 위치"
    }
    showReportBottomSheet(latLng) // 바텀 시트 오픈
}

// 카메라 호출 및 임시 파일(Uri) 생성
private fun dispatchTakePictureIntent() {
    val file = createImageFile()
    photoUri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
    val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
        putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
    }
    takePhotoLauncher.launch(intent)
}

 

촬영한 뒤 제보하기를 누르면됩니다.

 

Firebase 데이터 업로드

촬영된 사진을 Storage에 올리고, 다운로드 URL을 받아 Firestore에 위치 정보를 함께 저장합니다. 

private fun uploadReport(latLng: LatLng, type: String) {
    val storageRef = FirebaseStorage.getInstance().reference
    val firestore = FirebaseFirestore.getInstance()
    val imageRef = storageRef.child("reports/${System.currentTimeMillis()}.jpg")

    // 1. Storage에 이미지 업로드
    imageRef.putFile(photoUri!!).addOnSuccessListener {
        imageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
            
            // 2. Firestore에 저장할 데이터 구성
            val reportData = hashMapOf(
                "latitude" to latLng.latitude,
                "longitude" to latLng.longitude,
                "type" to type,
                "imageUrl" to downloadUrl.toString(),
                "status" to "PENDING", //  최초 업로드 시 상태는 '대기 중'
                "timestamp" to com.google.firebase.Timestamp.now()
            )

            // 3. Firestore에 문서 생성
            firestore.collection("reports").add(reportData)
                .addOnSuccessListener {
                    Toast.makeText(this, "제보 완료! 관리자 검토 후 반영됩니다.", Toast.LENGTH_SHORT).show()
                }
        }
    }
}

 

승인된(APPROVED) 제보 데이터를 지도에 표시하기

관리자가 제보 사진을 확인하고 상태를 "APPROVED"로 변경하면 앱에서 이를 감지하고 지도에 마커로 그려줍니다. 이때는 아래에 '사용자 제보'라고 표시됩니다.

private fun updateMarkers(type: String) {
    val bounds = naverMap.contentBounds
    
    // 로컬 DB(공공데이터) 마커 그리기 로직 (생략) ...
    
    // Firebase에서 승인된 사용자 제보 데이터만 불러오기!!
    FirebaseFirestore.getInstance().collection("reports")
        .whereEqualTo("status", "APPROVED") // 상태가 승인된 것만 필터링!
        .whereEqualTo("type", type)
        .get()
        .addOnSuccessListener { documents ->
            for (document in documents) {
                val lat = document.getDouble("latitude") ?: continue
                val lng = document.getDouble("longitude") ?: continue

                // 현재 사용자가 보고 있는 지도 화면(bounds) 안에 있는 마커만 생성
                if (lat in bounds.southWest.latitude..bounds.northEast.latitude &&
                    lng in bounds.southWest.longitude..bounds.northEast.longitude) {
                    
                    val marker = Marker().apply {
                        position = LatLng(lat, lng)
                        map = naverMap
                        icon = OverlayImage.fromResource(R.drawable.custom_marker_icon)
                        
                        // 사용자 제보 데이터임을 명시
                        captionText = "사용자 제보"
                        captionColor = Color.parseColor("#3D6BF5")
                        captionMinZoom = 15.0
                    }
                    activeMarkers.add(marker)
                }
            }
        }
}