🤔 고민해본 사항
원래는 Fragment를 호출할때마다 API를 호출하도록 로직을 작성했었다.
근데 이렇게하면 내가 발급받은 API의 일일호출량은 최대 1000개로 제한되어있어서, Fragment를 몇번만 접속해도 API호출량을 초과하는 문제가 발생할 수 있었다. 심지어 배포예정 앱이였기 때문에 여러명의 사용자들이 동시에 앱을 접속할 경우, 일일호출량을 쉽게 넘어버려 호출에러가 발생할 수 있었다... 실제 배포앱에서는 굉장히 치명적인 오류라고 할수있다.
따라서 앱을 처음 다운로드하거나, 위치가 크게 변경했을경우에만 API를 호출하고
그렇지 않은 경우에는 로컬 데이터베이스 캐싱을 적용하는 방식으로 결정했다.
또한 로컬데이터베이스에 저장하면 오프라인상태에서도 이용가능하기 때문에, 재난관리앱에서는 특히 유용하다고 생각했다.
애초에 로컬데이터에 전부 저장해서 불러쓰는방법도 있지만, 내 앱특성상 최신데이터를 받아오는것도 중요해서 일정기간이 지나면 API를 받아와 최신데이터도 반영해주기로 했다.
💻 구현하기
1. 전체적인 앱 동작흐름
- 최초 앱 실행 시
- API를 호출하여 데이터를 가져오고, 가져온 데이터를 RoomDB에 저장한다.
- 다음 호출 시
- 위치가 크게 바뀌지 않은 경우 or 갱신 주기가 지나지 않았고 데이터가 충분한 경우 => RoomDB에 저장된 데이터를 반환한다.
- 다음 조건 중 하나라도 충족되면 API를 호출하여 데이터를 갱신한다
- 갱신 주기가 경과했을 경우
- RoomDB에 저장된 데이터가 충분하지 않을 경우
- 위치가 크게 변경된 경우 (지정된 거리이상 이동 or 일정시간 이상이동)
3. API 호출 후 처리
- 가져온 데이터를 RoomDB에 저장하고, 로컬 데이터로 활용한다.
2. 코드구현
@Dao
interface ShelterDao {
...
// shelterType(대피소유형-"임시주거시설" 또는 "야외대피장소")의 대피소수를 계산
// 특정유형의 대피소가 얼마나 저장되었는지 확인할때 사용
@Query("SELECT COUNT(*) FROM ShelterEntity WHERE shelterType = :shelterType")
suspend fun getShelterCountByType(shelterType: String): Int
// shelterType(대피소유형-"임시주거시설" 또는 "야외대피장소")의 특정 유형의 대피소만 표시할 때 사용
@Query("SELECT * FROM ShelterEntity WHERE shelterType = :shelterType")
fun getSheltersByType(shelterType: String): Flow<List<ShelterEntity>>
// shelterType(대피소유형-"임시주거시설" 또는 "야외대피장소")의 특정 유형의 대피소를 삭제하는데 사용
@Query("DELETE FROM ShelterEntity WHERE shelterType = :shelterType")
suspend fun deleteSheltersByType(shelterType: String)
}
ShelterDao.kt
먼저 @Query 어노테이션을 사용해서 필요한 Dao를 위와 같이 구현해준다.
@Singleton
class OutdoorEvacuationRepositoryImpl @Inject constructor(
@Named("OutdoorEvacuationService") private val outdoorEvacuationService: OutdoorEvacuationService,
private val shelterDao: ShelterDao,
@ApplicationContext private val context: Context
) : OutdoorEvacuationRepository {
private val sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
override suspend fun requestOutdoorEvacuation(): EarthquakeOutdoorsShelterResponse {
return outdoorEvacuationService.getOutdoorEvacuation(pageNo = "1")
}
// 특정 페이지 요청 구현
override suspend fun requestOutdoorEvacuationByPage(pageNo: Int): EarthquakeOutdoorsShelterResponse {
return outdoorEvacuationService.getOutdoorEvacuation(pageNo = pageNo.toString())
}
// 모든 데이터 요청 구현
override suspend fun requestAllOutdoorEvacuation(): List<EarthquakeOutdoorsShelterResponse.Shelter> {
try {
// 데이터를 다시 로드할 필요가 있는지 판단
val lastUpdate = sharedPreferences.getLong(KEY_LAST_OUTDOOR_UPDATE, 0) //마지막 업데이트 시간
val totalCachedCount = sharedPreferences.getInt(KEY_TOTAL_OUTDOOR_COUNT, 0) //전체 대피소의 개수
val dbCount = shelterDao.getShelterCountByType("야외대피장소") //"야외대피장소"의 대피소수
val currentTime = System.currentTimeMillis()
// 마지막 업데이트 이후 90일이 지났는지 or DB에 저장된 데이터수가 API에서 가져온 전체데이터의 90% 이상인지
val needsUpdate = currentTime - lastUpdate > UPDATE_INTERVAL || dbCount < totalCachedCount * 0.9
// 만족하지않으면(갱신필요하지 않으면)
if (!needsUpdate && dbCount > 0) {
Log.d("OutdoorEvacuationRepo", "야외 대피소 정보를 캐시에서 로드합니다. DB 항목 수: $dbCount")
// RoomDB에서 데이터를 반환
return convertDbToApiFormat(shelterDao.getSheltersByType("야외대피장소").first())
}
// API에서 첫 페이지 요청하여 전체데이터 개수 확인
Log.d("OutdoorEvacuationRepo", "야외 대피소 API 호출을 시작합니다")
val firstPageResponse = requestOutdoorEvacuationByPage(1)
val totalCount = firstPageResponse.totalCount
Log.d("OutdoorEvacuationRepo", "총 대피소 개수: $totalCount")
val itemsPerPage = 100
// 총 페이지 수 계산
val totalPages = if (totalCount % itemsPerPage == 0) {
totalCount / itemsPerPage
} else {
(totalCount / itemsPerPage) + 1
}
// 첫 페이지 데이터 추가
val allShelters = firstPageResponse.body.toMutableList()
// 나머지 페이지 데이터 요청
for (page in 2..totalPages) {
try {
val response = requestOutdoorEvacuationByPage(page)
allShelters.addAll(response.body)
} catch (e: Exception) {
Log.e("OutdoorEvacuationRepo", "페이지 $page 데이터 요청 실패: ${e.message}")
continue
}
}
// 데이터를 RoomDB에 저장
saveOutdoorSheltersToDB(allShelters)
// 마지막 업데이트시간과 총항목수 sharedPreferences로 저장
sharedPreferences.edit()
.putLong(KEY_LAST_OUTDOOR_UPDATE, System.currentTimeMillis())
.putInt(KEY_TOTAL_OUTDOOR_COUNT, totalCount)
.apply()
Log.d("OutdoorEvacuationRepo", "총 ${allShelters.size}개의 야외 대피소 데이터를 저장했습니다")
return allShelters
} catch (e: Exception) {
Log.e("OutdoorEvacuationRepo", "전체 데이터 요청 실패: ${e.message}", e)
// API 호출 실패 시 캐시된 데이터 반환
val cachedData = shelterDao.getSheltersByType("야외대피장소").first()
Log.d("OutdoorEvacuationRepo", "캐시된 데이터 사용: ${cachedData.size}개 항목")
return if (cachedData.isNotEmpty()) {
convertDbToApiFormat(cachedData)
} else {
emptyList()
}
}
}
// DB의 엔티티를 API 응답 형식으로 변환
private fun convertDbToApiFormat(entities: List<ShelterEntity>): List<EarthquakeOutdoorsShelterResponse.Shelter> {
return entities.map { entity ->
EarthquakeOutdoorsShelterResponse.Shelter(
vtAcmdfcltyNm = entity.vtAcmdfcltyNm,
la = entity.latitude.toString(),
lo = entity.longitude.toString(),
eqkAcmdfcltyAdres = entity.address,
dtlAdres = entity.detailAddress,
vtAcmdPsblNmpr = entity.vtAcmdPsblNmpr,
rnDtlAdres = entity.detailAddress,
useSeCd = "",
acmdBuldMngNo = "",
bdongCd = "",
arcd = "",
hdongCd = "",
acmdfcltySn = 0,
)
}
}
// 야외 대피소 데이터 RoomDB에 저장 (기존 데이터 삭제 후 새로 저장)
private suspend fun saveOutdoorSheltersToDB(shelters: List<EarthquakeOutdoorsShelterResponse.Shelter>) {
withContext(Dispatchers.IO) {
// 기존 야외대피소 데이터 삭제
shelterDao.deleteSheltersByType("야외대피장소")
// 새 데이터를 ShelterEntity형식으로 변환하여 저장
val entities = shelters.mapNotNull { shelter ->
val latitude = shelter.la?.toDoubleOrNull() ?: return@mapNotNull null
val longitude = shelter.lo?.toDoubleOrNull() ?: return@mapNotNull null
ShelterEntity(
vtAcmdfcltyNm = shelter.vtAcmdfcltyNm ?: "이름 없음",
address = shelter.eqkAcmdfcltyAdres ?: "주소 없음",
detailAddress = shelter.dtlAdres ?: "",
latitude = latitude,
longitude = longitude,
shelterType = "야외대피장소",
vtAcmdPsblNmpr = shelter.vtAcmdPsblNmpr ?: "알수없음",
lastUpdated = System.currentTimeMillis(),
acmdfcltyDtlCn = "",
mngpsTelno = "",
)
}
if (entities.isNotEmpty()) {
shelterDao.insertShelters(entities)
}
}
}
}
OutdoorEvacuationRepositoryImpl.kt
해당 OutdoorEvacuationRepositoryImpl코드에 구체적인 구현체를 작성해주었다.
마지막 업데이트 이후 90일이 지났는지 or DB에 저장된 데이터수가 API에서 가져온 전체데이터수의 90%이상인지를 검사해서,
만족하지 않으면(갱신필요X) RoomDB에 저장된 데이터를 반환하고, 만족하면(갱신필요O) 필요한 페이지수를 계산하여 API를 호출하는 방식으로 구현했다.
saveOutdoorSheltersToDB()함수에서 기존 데이터를 삭제하지 않고 새데이터를 추가하면 중복된 항목이 저장될수있기 때문에,
기존 데이터를 삭제한 뒤 새 데이터를 ShelterEntity 형식으로 변환하여 저장하는 방식으로 구현했다.
그리고 갱신필요여부를 판단하는 마지막 업데이트 시간, 전체 대피소의 개수같은 경우는 간단한 값이기때문에 sharedPreferences로 저장을 했다. 반면 대피소데이터들은 대량의 데이터기 때문에 RoomDB에 저장해주도록 했다.
@HiltViewModel
class OutdoorEvacuationViewModel @Inject constructor(
private val outdoorEvacuationRepository: OutdoorEvacuationRepository,
) : ViewModel() {
private val _shelters = MutableStateFlow<List<EarthquakeOutdoorsShelterResponse.Shelter>>(emptyList())
val shelters: StateFlow<List<EarthquakeOutdoorsShelterResponse.Shelter>> = _shelters
// 위치 데이터
private val _currentLocation = MutableStateFlow<LatLng?>(null)
val currentLocation: StateFlow<LatLng?> = _currentLocation
// 로딩 상태
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
// 에러 상태
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage
// API로 currentAddress 데이터를 가져오고 업데이트
fun fetchOutdoorShelters(currentAddress: String) {
// 이미 로딩 중이면 중복 호출 방지
if (_isLoading.value) {
Log.d("OutdoorEvacuationViewModel", "이미 로딩 중입니다")
return
}
viewModelScope.launch {
try {
_isLoading.value = true
// 모든 페이지의 데이터 요청
val allShelters = outdoorEvacuationRepository.requestAllOutdoorEvacuation()
Log.d("OutdoorEvacuationViewModel", "가져온 대피소 수: ${allShelters.size}")
// 현재위치를 사용할 수있는 경우 거리별 필터링로직 작성
....
} catch (e: Exception) {
Log.e("OutdoorEvacuationViewModel", "API 요청 실패: ${e.message}", e)
_errorMessage.value = "outdoor대피소 정보를 불러오는데 실패했습니다: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
OutdoorEvacuationViewModel.kt
이제 ViewModel에서 아까 구현한 outdoorEvacuationRepository.requestAllOutdoorEvacuation()를 불러와줬다.
@UiThread
override fun onMapReady(naverMap: NaverMap) {
this.naverMap = naverMap
naverMap.locationSource = locationSource
naverMap.uiSettings.isLocationButtonEnabled = true // 현위치 버튼 컨트롤을 활성화
naverMap.locationTrackingMode = LocationTrackingMode.Follow //위치이동에따라 카메라도 이동(위치추적기능)
// 마커 갱신을 호출하여 지도에 기존 마커를 다시 그림
observeViewModels()
// 위치가 변경될때마다 데이터 요청
naverMap.addOnLocationChangeListener { location ->
// 현재 위치
val currentLocation = Location("current").apply {
latitude = location.latitude
longitude = location.longitude
}
val currentTime = System.currentTimeMillis()
// 이전 위치가 있고, 최소 거리 이동 조건과 최소 시간 경과 조건을 검사
// ex) MIN_DISTANCE_FOR_UPDATE = 50m(50m 이상 이동해야 함) / MIN_TIME_BETWEEN_UPDATES = 5 * 60 * 1000ms(5분 이상 경과해야 함)
val shouldUpdate = lastProcessedLocation == null ||
(currentLocation.distanceTo(lastProcessedLocation!!) >= MIN_DISTANCE_FOR_UPDATE &&
currentTime - lastApiCallTime >= MIN_TIME_BETWEEN_UPDATES)
// 즉시 위치 업데이트
outdoorViewModel.updateCurrentLocation(location.latitude, location.longitude)
indoorViewModel.updateCurrentLocation(location.latitude, location.longitude)
if (shouldUpdate) {
Log.d("위치업데이트", "유의미한 위치 변경: ${location.latitude}, ${location.longitude}")
// 위치 정보와 API 호출 시간 업데이트
lastProcessedLocation = currentLocation
lastApiCallTime = currentTime
// Geocoder를 비동기적으로 실행
lifecycleScope.launch {
val currentAddress = getCurrentAddress(location.latitude, location.longitude)
// currentAddress가 유효한 경우에만 API 요청
if (!currentAddress.isNullOrEmpty()) {
Log.d("위치변경_API_요청", "현재위치: ${location.latitude}, ${location.longitude}, 주소: $currentAddress")
outdoorViewModel.fetchOutdoorShelters(currentAddress)
indoorViewModel.fetchIndoorShelters(currentAddress)
} else {
// 주소 변환 실패시에도 위치 기반으로만 데이터 요청
outdoorViewModel.fetchOutdoorShelters("")
indoorViewModel.fetchIndoorShelters("")
}
}
}
}
companion object {
// 위치 업데이트 관련 상수
private const val MIN_DISTANCE_FOR_UPDATE = 4000 // 4000m(4km) 이상 이동 시 업데이트
private const val MIN_TIME_BETWEEN_UPDATES = 3600000L // 1시간이상 경과해야함
}
}
EvacuateFragment.kt
마지막으로 Fragment에서 Map이 호출될때, 위치가 일정위치 이상 움직이면 API를 재요청하여 fetchOutdoorShelters()를 다시 호출하도록 구현했다.
나는 API호출을 너무 빈번하게 하지 않기위해, 4km이상 이동하거나 1시간이상 경과해야 데이터가 업데이트되도록 설정해줬다
3. 결과
데이터를 캐싱하지 않고 API를 매번 호출할 경우, API만료 가능성과 메모리누수문제가 발생할 가능성이 높았었다.
그치만 로컬디비에 데이터를 캐싱함으로써, 매번 호출할 필요가 없어서 불필요한 호출을 줄일수있었고, 특히 API만료 가능성이 확 줄어들어서 사용성 측면이 매우 향샹되었다고 생각한다.
이렇게 구현하면
장점은 뭘까?
- 속도 향상 및 사용자경험 개선
- 네트워크 비용절감
- 서버 부하감소 + 안정성 증가
- 로컬 디비에 있는 데이터는 오프라인 상태에서도 확인가능
💡마치며..
기존에는 API호출만 구현하기 바빴는데, 배포할 앱을 스스로 만들어보는 과정에서 API호출을 최적화 할 수 있는 방안에 대해 고민해보는 계기가 되었다.
데이터의 중요성을 더 실감하게됐고, 사용자가 많은 앱을 직접 구현하며 문제를 해결해보고싶다는 생각을 했다.
또 무조건 이방법이 좋은 방법이 아니라, 앱에 따라서 구현방법이 달라질수있음을 느꼈다.