어플리케이션, 앱 (Application)/안드로이드 (Android)

안드로이드 jetpack compose 공부 정리 11강 (위치 서비스)

sobal 2025. 6. 28. 21:38

1. AndroidManifest에 위치 권한 추가

Android 앱에서 위치 서비스를 사용하기 위해서는 먼저 사용자의 위치 정보에 접근할 수 있는 권한을 선언해야 한다. 이는 사용자의 개인정보를 보호하기 위한 Android의 보안 메커니즘이라고 할 수 있다.

주요 위치 권한의 종류

Android에서는 위치 정보의 정확도에 따라 두 가지 주요 권한을 제공한다:

ACCESS_COARSE_LOCATION (대략적인 위치):

  • 셀 타워, Wi-Fi 네트워크를 통한 대략적인 위치 정보
  • 정확도: 약 수 킬로미터 범위
  • 배터리 소모가 상대적으로 적음

ACCESS_FINE_LOCATION (정확한 위치):

  • GPS, GNSS 등을 통한 정밀한 위치 정보
  • 정확도: 수 미터 범위
  • 배터리 소모가 많지만 높은 정확도 제공

AndroidManifest.xml 설정 방법

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.yourcompany.yourapp">

    <!-- 대략적인 위치 권한 -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    
    <!-- 정확한 위치 권한 -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    
    <!-- Android 10 (API 29) 이상에서 백그라운드 위치 접근 시 필요 -->
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme">
        
        <!-- Activity 선언 등 -->
        
    </application>
</manifest>

중요 참고사항:

  • Android 6.0 (API 23) 이상에서는 위험한 권한에 대해 런타임 권한 요청이 필요하다
  • ACCESS_FINE_LOCATION 권한을 요청하면 ACCESS_COARSE_LOCATION도 자동으로 포함된다
  • 백그라운드에서 위치 접근이 필요한 경우 ACCESS_BACKGROUND_LOCATION 권한도 추가해야 한다.

2. Context의 이해와 위치 서비스에서의 역할

Context는 Android 개발의 핵심 개념 중 하나로, 애플리케이션의 현재 상태와 환경에 대한 정보를 제공하는 인터페이스다.

Context의 본질적 의미

Context를 집 열쇠에 비유할 수 있다. 집 열쇠가 있어야 집 안의 모든 방과 시설에 접근할 수 있듯이, Context가 있어야 Android 시스템의 다양한 서비스와 리소스에 접근할 수 있다.

위치 서비스에서 Context의 역할

시스템 서비스 접근:

// LocationManager 서비스 가져오기
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager

// FusedLocationProviderClient 생성
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)

권한 확인:

// 위치 권한 상태 확인
val fineLocationPermission = ContextCompat.checkSelfPermission(
    context,
    Manifest.permission.ACCESS_FINE_LOCATION
)

val coarseLocationPermission = ContextCompat.checkSelfPermission(
    context,
    Manifest.permission.ACCESS_COARSE_LOCATION
)

Context 획득 방법

Activity에서:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 'this'를 사용하여 Activity Context 참조
        val context = this
        // 또는 applicationContext를 사용하여 Application Context 참조
        val appContext = applicationContext
    }
}

Composable 함수에서:

@Composable
fun LocationScreen() {
    // Compose에서 현재 Context 가져오기
    val context = LocalContext.current
    
    // Context를 사용하여 위치 서비스 초기화
    val fusedLocationClient = remember {
        LocationServices.getFusedLocationProviderClient(context)
    }
}

Fragment에서:

class LocationFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // requireContext()를 사용하여 안전하게 Context 가져오기
        val context = requireContext()
        // 또는 activity?.applicationContext 사용
    }
}

Context 사용 시 주의사항

메모리 누수 방지:

  • 장기간 참조가 필요한 경우 applicationContext 사용
  • Activity나 Fragment의 생명주기보다 오래 지속되는 작업에는 Application Context 권장
// 좋은 예: Application Context 사용
class LocationRepository(private val appContext: Context) {
    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(appContext)
}

// 피해야 할 예: Activity Context를 장기간 보관
// class LocationRepository(private val activityContext: Activity) // 메모리 누수 위험

3. 위치 접근 권한 확인 함수 구현

런타임 권한 시스템에서는 앱 실행 중에 권한 상태를 확인하고 필요에 따라 사용자에게 권한을 요청해야 한다.

권한 확인 함수 구현

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat

/**
 * 위치 권한이 부여되었는지 확인하는 함수
 * @param context 앱의 Context
 * @return 모든 필요한 위치 권한이 부여된 경우 true, 그렇지 않으면 false
 */
fun checkLocationPermissions(context: Context): Boolean {
    // 정확한 위치 권한 확인
    val hasFineLocationPermission = ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.ACCESS_FINE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED

    // 대략적인 위치 권한 확인
    val hasCoarseLocationPermission = ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.ACCESS_COARSE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED

    // Android 10 이상에서 백그라운드 위치 권한 확인 (필요한 경우)
    val hasBackgroundLocationPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
        ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_BACKGROUND_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    } else {
        true // Android 10 미만에서는 백그라운드 권한이 필요 없음
    }

    // 일반적으로 FINE_LOCATION만 있으면 충분 (COARSE_LOCATION 포함)
    return hasFineLocationPermission
}

/**
 * 개별 권한 상태를 확인하는 함수
 */
fun getLocationPermissionStatus(context: Context): LocationPermissionStatus {
    val fineLocation = ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.ACCESS_FINE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED

    val coarseLocation = ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.ACCESS_COARSE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED

    return LocationPermissionStatus(
        hasFineLocation = fineLocation,
        hasCoarseLocation = coarseLocation,
        hasAnyLocationPermission = fineLocation || coarseLocation
    )
}

data class LocationPermissionStatus(
    val hasFineLocation: Boolean,
    val hasCoarseLocation: Boolean,
    val hasAnyLocationPermission: Boolean
)

Composable에서 권한 확인 사용 예제

@Composable
fun LocationPermissionScreen() {
    val context = LocalContext.current
    var permissionStatus by remember { mutableStateOf(false) }

    // 권한 상태를 정기적으로 확인
    LaunchedEffect(Unit) {
        permissionStatus = checkLocationPermissions(context)
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        if (permissionStatus) {
            Text(
                text = "위치 권한이 부여되었습니다.",
                style = MaterialTheme.typography.h6,
                color = Color.Green
            )
            Button(
                onClick = {
                    // 위치 정보 가져오기 시작
                }
            ) {
                Text("위치 정보 가져오기")
            }
        } else {
            Text(
                text = "위치 권한이 필요합니다.",
                style = MaterialTheme.typography.h6,
                color = Color.Red
            )
            Button(
                onClick = {
                    // 권한 요청 로직
                }
            ) {
                Text("권한 요청하기")
            }
        }
    }
}

4. Jetpack Compose에서 권한 요청 처리

Jetpack Compose에서는 rememberLauncherForActivityResult를 사용하여 권한 요청과 결과 처리를 우아하게 관리할 수 있다.

기본 권한 요청 구현

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*

@Composable
fun LocationPermissionHandler(
    onPermissionGranted: () -> Unit,
    onPermissionDenied: () -> Unit
) {
    val context = LocalContext.current
    
    // 권한 요청 결과를 처리하는 launcher 생성
    val locationPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        // 결과 처리
        val fineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
        val coarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false
        
        when {
            fineLocationGranted -> {
                // 정확한 위치 권한이 부여됨 (대략적 위치도 포함)
                onPermissionGranted()
            }
            coarseLocationGranted -> {
                // 대략적인 위치 권한만 부여됨
                onPermissionGranted()
            }
            else -> {
                // 모든 권한이 거부됨
                onPermissionDenied()
            }
        }
    }

    // 권한 요청 함수
    fun requestLocationPermissions() {
        locationPermissionLauncher.launch(
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.ACCESS_COARSE_LOCATION
            )
        )
    }

    // UI에서 권한 요청 버튼
    Button(
        onClick = { requestLocationPermissions() }
    ) {
        Text("위치 권한 요청")
    }
}

고급 권한 관리 시스템

@Composable
fun AdvancedLocationPermissionScreen() {
    val context = LocalContext.current
    var permissionState by remember { mutableStateOf(PermissionState.NotRequested) }
    var showRationale by remember { mutableStateOf(false) }

    val locationPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        val allGranted = permissions.values.all { it }
        val anyGranted = permissions.values.any { it }
        
        permissionState = when {
            allGranted -> PermissionState.Granted
            anyGranted -> PermissionState.PartiallyGranted
            else -> PermissionState.Denied
        }
    }

    // 권한 요청 함수
    fun requestPermissions() {
        val activity = context as? ComponentActivity
        val shouldShowRationale = activity?.let { act ->
            ActivityCompat.shouldShowRequestPermissionRationale(
                act,
                Manifest.permission.ACCESS_FINE_LOCATION
            )
        } ?: false

        if (shouldShowRationale) {
            showRationale = true
        } else {
            locationPermissionLauncher.launch(
                arrayOf(
                    Manifest.permission.ACCESS_FINE_LOCATION,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                )
            )
        }
    }

    // 초기 권한 상태 확인
    LaunchedEffect(Unit) {
        permissionState = if (checkLocationPermissions(context)) {
            PermissionState.Granted
        } else {
            PermissionState.NotRequested
        }
    }

    // UI 렌더링
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        when (permissionState) {
            PermissionState.NotRequested -> {
                Text("위치 서비스를 사용하려면 권한이 필요합니다.")
                Spacer(modifier = Modifier.height(16.dp))
                Button(onClick = { requestPermissions() }) {
                    Text("권한 요청")
                }
            }
            PermissionState.Granted -> {
                Text("위치 권한이 부여되었습니다!")
                LocationContent()
            }
            PermissionState.PartiallyGranted -> {
                Text("일부 위치 권한이 부여되었습니다.")
                Text("더 정확한 위치를 위해 정밀 위치 권한을 허용해주세요.")
                Button(onClick = { requestPermissions() }) {
                    Text("정밀 위치 권한 요청")
                }
            }
            PermissionState.Denied -> {
                Text("위치 권한이 거부되었습니다.")
                Text("앱 설정에서 수동으로 권한을 허용해주세요.")
                Button(
                    onClick = {
                        // 앱 설정으로 이동
                        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                            data = Uri.fromParts("package", context.packageName, null)
                        }
                        context.startActivity(intent)
                    }
                ) {
                    Text("앱 설정으로 이동")
                }
            }
        }
    }

    // 권한 설명 다이얼로그
    if (showRationale) {
        AlertDialog(
            onDismissRequest = { showRationale = false },
            title = { Text("위치 권한이 필요합니다") },
            text = {
                Text(
                    "이 앱은 다음과 같은 기능을 위해 위치 정보가 필요합니다:\n" +
                    "• 현재 위치 표시\n" +
                    "• 주변 정보 제공\n" +
                    "• 위치 기반 서비스"
                )
            },
            confirmButton = {
                TextButton(
                    onClick = {
                        showRationale = false
                        locationPermissionLauncher.launch(
                            arrayOf(
                                Manifest.permission.ACCESS_FINE_LOCATION,
                                Manifest.permission.ACCESS_COARSE_LOCATION
                            )
                        )
                    }
                ) {
                    Text("권한 허용")
                }
            },
            dismissButton = {
                TextButton(onClick = { showRationale = false }) {
                    Text("취소")
                }
            }
        )
    }
}

enum class PermissionState {
    NotRequested,
    Granted,
    PartiallyGranted,
    Denied
}

5. 권한 설명 및 사용자 경험 개선

사용자에게 권한이 필요한 이유를 명확히 설명하는 것은 권한 승인율을 높이고 앱의 신뢰성을 향상시키는 중요한 요소이다.

shouldShowRequestPermissionRationale 활용

import androidx.core.app.ActivityCompat

/**
 * 권한 설명이 필요한지 확인하고 적절한 액션을 수행하는 함수
 */
fun handleLocationPermissionRequest(
    activity: ComponentActivity,
    onShowRationale: () -> Unit,
    onRequestPermission: () -> Unit,
    onPermissionAlreadyGranted: () -> Unit
) {
    when {
        // 이미 권한이 부여된 경우
        checkLocationPermissions(activity) -> {
            onPermissionAlreadyGranted()
        }
        
        // 권한 설명이 필요한 경우
        ActivityCompat.shouldShowRequestPermissionRationale(
            activity,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) -> {
            onShowRationale()
        }
        
        // 바로 권한 요청
        else -> {
            onRequestPermission()
        }
    }
}

사용자 친화적인 권한 요청 UI

@Composable
fun UserFriendlyPermissionRequest() {
    val context = LocalContext.current
    val activity = context as ComponentActivity
    
    var showPermissionDialog by remember { mutableStateOf(false) }
    var permissionDeniedCount by remember { mutableStateOf(0) }
    
    val locationPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
        
        if (!granted) {
            permissionDeniedCount++
            if (permissionDeniedCount >= 2) {
                // 두 번 이상 거부된 경우 설정으로 안내
                showPermissionDialog = true
            }
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            imageVector = Icons.Default.LocationOn,
            contentDescription = "위치 아이콘",
            modifier = Modifier.size(64.dp),
            tint = MaterialTheme.colors.primary
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Text(
            text = "위치 서비스 활성화",
            style = MaterialTheme.typography.h5,
            fontWeight = FontWeight.Bold
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        Text(
            text = "더 나은 서비스 제공을 위해\n위치 정보 접근 권한이 필요합니다",
            style = MaterialTheme.typography.body1,
            textAlign = TextAlign.Center,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
        )
        
        Spacer(modifier = Modifier.height(24.dp))
        
        // 권한의 이점을 설명하는 카드들
        LazyColumn {
            items(locationBenefits) { benefit ->
                BenefitCard(benefit = benefit)
                Spacer(modifier = Modifier.height(8.dp))
            }
        }
        
        Spacer(modifier = Modifier.height(24.dp))
        
        Button(
            onClick = {
                handleLocationPermissionRequest(
                    activity = activity,
                    onShowRationale = {
                        // 설명 다이얼로그 표시
                    },
                    onRequestPermission = {
                        locationPermissionLauncher.launch(
                            arrayOf(
                                Manifest.permission.ACCESS_FINE_LOCATION,
                                Manifest.permission.ACCESS_COARSE_LOCATION
                            )
                        )
                    },
                    onPermissionAlreadyGranted = {
                        // 이미 권한이 있는 경우 처리
                    }
                )
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("위치 권한 허용하기")
        }
    }

    // 설정으로 이동을 안내하는 다이얼로그
    if (showPermissionDialog) {
        AlertDialog(
            onDismissRequest = { showPermissionDialog = false },
            title = { Text("권한 설정 필요") },
            text = {
                Text(
                    "위치 서비스를 사용하려면 앱 설정에서 " +
                    "위치 권한을 수동으로 허용해주세요."
                )
            },
            confirmButton = {
                TextButton(
                    onClick = {
                        showPermissionDialog = false
                        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                            data = Uri.fromParts("package", context.packageName, null)
                        }
                        context.startActivity(intent)
                    }
                ) {
                    Text("설정으로 이동")
                }
            },
            dismissButton = {
                TextButton(onClick = { showPermissionDialog = false }) {
                    Text("나중에")
                }
            }
        )
    }
}

@Composable
fun BenefitCard(benefit: LocationBenefit) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = 2.dp
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                imageVector = benefit.icon,
                contentDescription = null,
                tint = MaterialTheme.colors.primary
            )
            Spacer(modifier = Modifier.width(16.dp))
            Column {
                Text(
                    text = benefit.title,
                    style = MaterialTheme.typography.subtitle1,
                    fontWeight = FontWeight.Medium
                )
                Text(
                    text = benefit.description,
                    style = MaterialTheme.typography.body2,
                    color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
                )
            }
        }
    }
}

data class LocationBenefit(
    val icon: ImageVector,
    val title: String,
    val description: String
)

val locationBenefits = listOf(
    LocationBenefit(
        icon = Icons.Default.Map,
        title = "정확한 위치 표시",
        description = "지도에서 현재 위치를 정확하게 확인할 수 있습니다"
    ),
    LocationBenefit(
        icon = Icons.Default.Search,
        title = "주변 정보 제공",
        description = "주변 상점, 음식점 등의 정보를 제공받을 수 있습니다"
    ),
    LocationBenefit(
        icon = Icons.Default.Navigation,
        title = "길 안내 서비스",
        description = "목적지까지의 최적 경로를 안내받을 수 있습니다"
    )
)

6. @SuppressLint 어노테이션의 올바른 사용

@SuppressLint는 Android Lint 도구의 특정 경고를 무시하도록 지시하는 어노테이션이다. 위치 서비스에서 특히 자주 사용되는 경우를 살펴보자.

MissingPermission 경고 처리

import android.annotation.SuppressLint
import androidx.compose.runtime.*

@Composable
fun LocationManager() {
    val context = LocalContext.current
    val fusedLocationClient = remember {
        LocationServices.getFusedLocationProviderClient(context)
    }
    
    var currentLocation by remember { mutableStateOf<Location?>(null) }
    
    // 권한이 이미 확인된 상황에서 위치를 가져오는 함수
    @SuppressLint("MissingPermission")
    fun getCurrentLocation() {
        // 이 함수는 권한이 확인된 후에만 호출된다
        // 따라서 MissingPermission 경고를 안전하게 무시할 수 있다
        fusedLocationClient.lastLocation
            .addOnSuccessListener { location ->
                currentLocation = location
            }
            .addOnFailureListener { exception ->
                // 위치 가져오기 실패 처리
                Log.e("LocationManager", "위치 가져오기 실패", exception)
            }
    }
    
    // 권한 확인 후 위치 가져오기
    LaunchedEffect(Unit) {
        if (checkLocationPermissions(context)) {
            getCurrentLocation()
        }
    }
}

안전한 @SuppressLint 사용 패턴

class LocationService(private val context: Context) {
    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
    
    /**
     * 권한이 확인된 후 호출되는 안전한 위치 가져오기 함수
     * @param onSuccess 위치를 성공적으로 가져왔을 때 호출되는 콜백
     * @param onFailure 위치 가져오기 실패 시 호출되는 콜백
     */
    @SuppressLint("MissingPermission") // 권한 확인이 상위 레벨에서 이미 수행됨
    fun getLastKnownLocation(
        onSuccess: (Location?) -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        // 이 메서드를 호출하기 전에 반드시 권한 확인이 선행되어야 함
        require(checkLocationPermissions(context)) {
            "위치 권한이 부여되지 않았습니다. 권한 확인 후 이 메서드를 호출하세요."
        }
        
        fusedLocationClient.lastLocation
            .addOnSuccessListener(onSuccess)
            .addOnFailureListener(onFailure)
    }
    
    @SuppressLint("MissingPermission") // 권한 체크가 이미 완료된 상태
    fun requestLocationUpdates(
        locationRequest: LocationRequest,
        locationCallback: LocationCallback,
        looper: Looper?
    ) {
        // 권한 확인이 선행되어야 함을 명시적으로 체크
        if (!checkLocationPermissions(context)) {
            throw SecurityException("위치 권한이 필요합니다")
        }
        
        fusedLocationClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            looper
        )
    }
}

@SuppressLint 사용 시 주의사항

올바른 사용:

// 권한 확인 로직이 명확히 분리된 경우
class SafeLocationManager(private val context: Context) {
    
    fun getLocationIfPermitted(callback: (Location?) -> Unit) {
        if (checkLocationPermissions(context)) {
            getLocationInternal(callback) // @SuppressLint 적용된 내부 메서드 호출
        } else {
            callback(null)
        }
    }
    
    @SuppressLint("MissingPermission")
    private fun getLocationInternal(callback: (Location?) -> Unit) {
        // 이미 권한이 확인된 상태에서만 호출되는 내부 메서드
        val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
        fusedLocationClient.lastLocation.addOnSuccessListener(callback)
    }
}

잘못된 사용:

// 권한 확인 없이 무분별하게 @SuppressLint 사용 (위험)
@SuppressLint("MissingPermission") // 이는 잠재적 보안 위험
fun getBadLocation(): Location? {
    // 권한 확인 없이 바로 위치 접근 - 매우 위험함
    return LocationServices.getFusedLocationProviderClient(context).lastLocation.result
}

7. 위치 추적을 위한 핵심 구성 요소

Android의 위치 서비스는 여러 핵심 구성 요소들이 유기적으로 연동되어 작동한다. 각 구성 요소의 역할과 사용법을 상세히 알아보자.

FusedLocationProviderClient

Google Play Services에서 제공하는 통합 위치 API로, 다양한 위치 소스를 효율적으로 조합하여 최적의 위치 정보를 제공한다.

import com.google.android.gms.location.*
import androidx.compose.runtime.*

@Composable
fun LocationTrackerScreen() {
    val context = LocalContext.current
    
    // FusedLocationProviderClient 초기화
    val fusedLocationClient = remember {
        LocationServices.getFusedLocationProviderClient(context)
    }
    
    var currentLocation by remember { mutableStateOf<Location?>(null) }
    var isTracking by remember { mutableStateOf(false) }
    
    // 위치 업데이트를 받기 위한 콜백 정의
    val locationCallback = remember {
        object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult) {
                locationResult.lastLocation?.let { location ->
                    currentLocation = location
                }
            }
            
            override fun onLocationAvailability(locationAvailability: LocationAvailability) {
                if (!locationAvailability.isLocationAvailable) {
                    // 위치 서비스를 사용할 수 없는 상태
                    Log.w("LocationTracker", "위치 서비스를 사용할 수 없습니다")
                }
            }
        }
    }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(
            text = "현재 위치: ${currentLocation?.let { 
                "위도: ${it.latitude}, 경도: ${it.longitude}" 
            } ?: "위치 정보 없음"}",
            style = MaterialTheme.typography.body1
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(
            onClick = {
                if (checkLocationPermissions(context)) {
                    if (isTracking) {
                        stopLocationUpdates(fusedLocationClient, locationCallback)
                        isTracking = false
                    } else {
                        startLocationUpdates(fusedLocationClient, locationCallback)
                        isTracking = true
                    }
                }
            }
        ) {
            Text(if (isTracking) "위치 추적 중지" else "위치 추적 시작")
        }
    }
}

@SuppressLint("MissingPermission")
private fun startLocationUpdates(
    fusedLocationClient: FusedLocationProviderClient,
    locationCallback: LocationCallback
) {
    val locationRequest = LocationRequest.Builder(
        Priority.PRIORITY_HIGH_ACCURACY, // 높은 정확도
        10000L // 10초마다 업데이트
    ).apply {
        setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
        setWaitForAccurateLocation(false)
    }.build()
    
    fusedLocationClient.requestLocationUpdates(
        locationRequest,
        locationCallback,
        Looper.getMainLooper()
    )
}

private fun stopLocationUpdates(
    fusedLocationClient: FusedLocationProviderClient,
    locationCallback: LocationCallback
) {
    fusedLocationClient.removeLocationUpdates(locationCallback)
}

LocationRequest.Builder 상세 설정

LocationRequest는 위치 업데이트의 품질과 빈도를 제어하는 핵심 구성 요소이다.

/**
 * 다양한 시나리오에 맞는 LocationRequest 설정 예제
 */
object LocationRequestFactory {
    
    /**
     * 고정밀 실시간 추적용 (배터리 소모 많음)
     * 내비게이션, 피트니스 앱 등에 적합
     */
    fun createHighAccuracyRequest(): LocationRequest {
        return LocationRequest.Builder(
            Priority.PRIORITY_HIGH_ACCURACY,
            5000L // 5초마다 업데이트
        ).apply {
            setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
            setWaitForAccurateLocation(true) // 정확한 위치를 기다림
            setMinUpdateDistanceMeters(10f) // 10미터 이동 시에만 업데이트
            setMaxUpdateDelayMillis(10000L) // 최대 10초 지연
        }.build()
    }
    
    /**
     * 균형잡힌 위치 추적 (일반적인 용도)
     * 대부분의 앱에 적합
     */
    fun createBalancedRequest(): LocationRequest {
        return LocationRequest.Builder(
            Priority.PRIORITY_BALANCED_POWER_ACCURACY,
            30000L // 30초마다 업데이트
        ).apply {
            setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
            setWaitForAccurateLocation(false)
            setMinUpdateDistanceMeters(50f) // 50미터 이동 시에만 업데이트
        }.build()
    }
    
    /**
     * 저전력 위치 추적 (배터리 절약)
     * 백그라운드 위치 추적, 날씨 앱 등에 적합
     */
    fun createLowPowerRequest(): LocationRequest {
        return LocationRequest.Builder(
            Priority.PRIORITY_LOW_POWER,
            300000L // 5분마다 업데이트
        ).apply {
            setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
            setWaitForAccurateLocation(false)
            setMinUpdateDistanceMeters(100f) // 100미터 이동 시에만 업데이트
        }.build()
    }
    
    /**
     * 패시브 위치 수신 (다른 앱의 위치 요청 활용)
     * 최소한의 배터리 소모
     */
    fun createPassiveRequest(): LocationRequest {
        return LocationRequest.Builder(
            Priority.PRIORITY_PASSIVE,
            600000L // 10분마다 업데이트
        ).apply {
            setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
            setWaitForAccurateLocation(false)
        }.build()
    }
}

LocationCallback 고급 활용

/**
 * 향상된 LocationCallback 구현
 */
class EnhancedLocationCallback(
    private val onLocationUpdate: (Location) -> Unit,
    private val onLocationUnavailable: () -> Unit,
    private val onAccuracyChanged: (Int) -> Unit
) : LocationCallback() {
    
    override fun onLocationResult(locationResult: LocationResult) {
        locationResult.lastLocation?.let { location ->
            // 위치 정확도 검증
            if (isLocationAccurate(location)) {
                onLocationUpdate(location)
            }
        }
    }
    
    override fun onLocationAvailability(locationAvailability: LocationAvailability) {
        if (!locationAvailability.isLocationAvailable) {
            onLocationUnavailable()
        }
    }
    
    /**
     * 위치 정확도 검증 함수
     */
    private fun isLocationAccurate(location: Location): Boolean {
        // 정확도가 100미터 이하인 경우만 유효한 위치로 판단
        return location.hasAccuracy() && location.accuracy <= 100f
    }
}

@Composable
fun EnhancedLocationTracker() {
    val context = LocalContext.current
    var locationInfo by remember { mutableStateOf<LocationInfo?>(null) }
    var locationStatus by remember { mutableStateOf("대기 중") }
    
    val fusedLocationClient = remember {
        LocationServices.getFusedLocationProviderClient(context)
    }
    
    val locationCallback = remember {
        EnhancedLocationCallback(
            onLocationUpdate = { location ->
                locationInfo = LocationInfo(
                    latitude = location.latitude,
                    longitude = location.longitude,
                    accuracy = location.accuracy,
                    timestamp = System.currentTimeMillis()
                )
                locationStatus = "위치 업데이트됨"
            },
            onLocationUnavailable = {
                locationStatus = "위치 서비스 사용 불가"
            },
            onAccuracyChanged = { accuracy ->
                locationStatus = "정확도 변경: $accuracy"
            }
        )
    }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = 4.dp
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(
                    text = "위치 정보",
                    style = MaterialTheme.typography.h6,
                    fontWeight = FontWeight.Bold
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                locationInfo?.let { info ->
                    Text("위도: ${String.format("%.6f", info.latitude)}")
                    Text("경도: ${String.format("%.6f", info.longitude)}")
                    Text("정확도: ${String.format("%.1f", info.accuracy)}m")
                    Text("업데이트 시간: ${formatTimestamp(info.timestamp)}")
                } ?: Text("위치 정보 없음")
                
                Spacer(modifier = Modifier.height(8.dp))
                
                Text(
                    text = "상태: $locationStatus",
                    style = MaterialTheme.typography.caption,
                    color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
                )
            }
        }
    }
}

data class LocationInfo(
    val latitude: Double,
    val longitude: Double,
    val accuracy: Float,
    val timestamp: Long
)

private fun formatTimestamp(timestamp: Long): String {
    val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
    return sdf.format(Date(timestamp))
}

Looper와 스레드 관리

/**
 * 위치 업데이트를 백그라운드 스레드에서 처리하는 예제
 */
class BackgroundLocationManager(private val context: Context) {
    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
    private val backgroundHandler: Handler
    private val backgroundThread: HandlerThread
    
    init {
        backgroundThread = HandlerThread("LocationThread").apply { start() }
        backgroundHandler = Handler(backgroundThread.looper)
    }
    
    @SuppressLint("MissingPermission")
    fun startBackgroundLocationUpdates(callback: (Location) -> Unit) {
        val locationRequest = LocationRequestFactory.createBalancedRequest()
        
        val locationCallback = object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult) {
                locationResult.lastLocation?.let { location ->
                    // 백그라운드 스레드에서 위치 처리
                    processLocationInBackground(location) { processedLocation ->
                        // 메인 스레드에서 UI 업데이트
                        Handler(Looper.getMainLooper()).post {
                            callback(processedLocation)
                        }
                    }
                }
            }
        }
        
        fusedLocationClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            backgroundThread.looper // 백그라운드 스레드 Looper 사용
        )
    }
    
    private fun processLocationInBackground(
        location: Location,
        onComplete: (Location) -> Unit
    ) {
        // 백그라운드에서 수행할 위치 처리 작업
        // 예: 데이터베이스 저장, 네트워크 전송 등
        backgroundHandler.post {
            try {
                // 무거운 작업 수행
                Thread.sleep(100) // 시뮬레이션
                
                // 처리된 결과를 콜백으로 전달
                onComplete(location)
            } catch (e: Exception) {
                Log.e("BackgroundLocationManager", "위치 처리 중 오류 발생", e)
            }
        }
    }
    
    fun cleanup() {
        backgroundThread.quitSafely()
    }
}

8. Geocoder: 좌표와 주소 간 변환

Geocoder는 지리적 좌표를 사람이 읽을 수 있는 주소로 변환하거나 그 반대의 작업을 수행하는 클래스다.

기본 Geocoder 사용법

import android.location.Geocoder
import android.location.Address
import kotlinx.coroutines.*
import java.io.IOException
import java.util.*

/**
 * 코루틴을 활용한 안전한 Geocoder 구현
 */
class SafeGeocoder(private val context: Context) {
    private val geocoder = Geocoder(context, Locale.getDefault())
    
    /**
     * 좌표를 주소로 변환 (역지오코딩)
     */
    suspend fun getAddressFromLocation(
        latitude: Double,
        longitude: Double
    ): Result<String> = withContext(Dispatchers.IO) {
        try {
            if (!Geocoder.isPresent()) {
                return@withContext Result.failure(
                    Exception("Geocoder 서비스를 사용할 수 없습니다")
                )
            }
            
            val addresses = geocoder.getFromLocation(latitude, longitude, 1)
            
            if (addresses?.isNotEmpty() == true) {
                val address = addresses[0]
                val fullAddress = address.getAddressLine(0) ?: buildAddressString(address)
                Result.success(fullAddress)
            } else {
                Result.failure(Exception("주소를 찾을 수 없습니다"))
            }
        } catch (e: IOException) {
            Result.failure(Exception("네트워크 오류로 주소를 가져올 수 없습니다: ${e.message}"))
        } catch (e: Exception) {
            Result.failure(Exception("주소 변환 중 오류가 발생했습니다: ${e.message}"))
        }
    }
    
    /**
     * 주소를 좌표로 변환 (지오코딩)
     */
    suspend fun getLocationFromAddress(
        addressString: String
    ): Result<Pair<Double, Double>> = withContext(Dispatchers.IO) {
        try {
            if (!Geocoder.isPresent()) {
                return@withContext Result.failure(
                    Exception("Geocoder 서비스를 사용할 수 없습니다")
                )
            }
            
            val addresses = geocoder.getFromLocationName(addressString, 1)
            
            if (addresses?.isNotEmpty() == true) {
                val address = addresses[0]
                Result.success(Pair(address.latitude, address.longitude))
            } else {
                Result.failure(Exception("해당 주소의 좌표를 찾을 수 없습니다"))
            }
        } catch (e: IOException) {
            Result.failure(Exception("네트워크 오류로 좌표를 가져올 수 없습니다: ${e.message}"))
        } catch (e: Exception) {
            Result.failure(Exception("좌표 변환 중 오류가 발생했습니다: ${e.message}"))
        }
    }
    
    /**
     * Address 객체에서 읽기 쉬운 주소 문자열 생성
     */
    private fun buildAddressString(address: Address): String {
        return buildString {
            address.thoroughfare?.let { append("$it ") }          // 도로명
            address.subThoroughfare?.let { append("$it ") }       // 건물번호
            address.locality?.let { append("$it ") }              // 시/군/구
            address.adminArea?.let { append("$it ") }             // 시/도
            address.countryName?.let { append(it) }               // 국가
        }.trim()
    }
    
    /**
     * 상세한 주소 정보 가져오기
     */
    suspend fun getDetailedAddressInfo(
        latitude: Double,
        longitude: Double
    ): Result<DetailedAddress> = withContext(Dispatchers.IO) {
        try {
            if (!Geocoder.isPresent()) {
                return@withContext Result.failure(
                    Exception("Geocoder 서비스를 사용할 수 없습니다")
                )
            }
            
            val addresses = geocoder.getFromLocation(latitude, longitude, 1)
            
            if (addresses?.isNotEmpty() == true) {
                val address = addresses[0]
                val detailedAddress = DetailedAddress(
                    fullAddress = address.getAddressLine(0) ?: "",
                    countryName = address.countryName ?: "",
                    countryCode = address.countryCode ?: "",
                    adminArea = address.adminArea ?: "",      // 시/도
                    locality = address.locality ?: "",        // 시/군/구
                    subLocality = address.subLocality ?: "",  // 동/면/읍
                    thoroughfare = address.thoroughfare ?: "", // 도로명
                    subThoroughfare = address.subThoroughfare ?: "", // 건물번호
                    postalCode = address.postalCode ?: "",
                    latitude = address.latitude,
                    longitude = address.longitude
                )
                Result.success(detailedAddress)
            } else {
                Result.failure(Exception("주소 정보를 찾을 수 없습니다"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

data class DetailedAddress(
    val fullAddress: String,
    val countryName: String,
    val countryCode: String,
    val adminArea: String,
    val locality: String,
    val subLocality: String,
    val thoroughfare: String,
    val subThoroughfare: String,
    val postalCode: String,
    val latitude: Double,
    val longitude: Double
)

Compose에서 Geocoder 활용

@Composable
fun AddressLookupScreen() {
    val context = LocalContext.current
    val safeGeocoder = remember { SafeGeocoder(context) }
    val scope = rememberCoroutineScope()
    
    var currentLocation by remember { mutableStateOf<Location?>(null) }
    var currentAddress by remember { mutableStateOf<String?>(null) }
    var searchQuery by remember { mutableStateOf("") }
    var searchResult by remember { mutableStateOf<String?>(null) }
    var isLoading by remember { mutableStateOf(false) }
    var errorMessage by remember { mutableStateOf<String?>(null) }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // 현재 위치와 주소 표시
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = 4.dp
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(
                    text = "현재 위치",
                    style = MaterialTheme.typography.h6,
                    fontWeight = FontWeight.Bold
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                currentLocation?.let { location ->
                    Text("위도: ${String.format("%.6f", location.latitude)}")
                    Text("경도: ${String.format("%.6f", location.longitude)}")
                    
                    currentAddress?.let { address ->
                        Spacer(modifier = Modifier.height(8.dp))
                        Text(
                            text = "주소: $address",
                            style = MaterialTheme.typography.body2
                        )
                    }
                } ?: Text("위치 정보 없음")
                
                Spacer(modifier = Modifier.height(8.dp))
                
                Button(
                    onClick = {
                        if (checkLocationPermissions(context)) {
                            getCurrentLocationWithAddress(context, safeGeocoder, scope) { location, address ->
                                currentLocation = location
                                currentAddress = address
                            }
                        }
                    },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text("현재 위치 가져오기")
                }
            }
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // 주소 검색
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = 4.dp
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(
                    text = "주소 검색",
                    style = MaterialTheme.typography.h6,
                    fontWeight = FontWeight.Bold
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                OutlinedTextField(
                    value = searchQuery,
                    onValueChange = { searchQuery = it },
                    label = { Text("주소를 입력하세요") },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = true
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                Button(
                    onClick = {
                        if (searchQuery.isNotBlank()) {
                            isLoading = true
                            errorMessage = null
                            
                            scope.launch {
                                safeGeocoder.getLocationFromAddress(searchQuery)
                                    .onSuccess { (lat, lng) ->
                                        searchResult = "위도: ${String.format("%.6f", lat)}, " +
                                                     "경도: ${String.format("%.6f", lng)}"
                                        isLoading = false
                                    }
                                    .onFailure { error ->
                                        errorMessage = error.message
                                        searchResult = null
                                        isLoading = false
                                    }
                            }
                        }
                    },
                    modifier = Modifier.fillMaxWidth(),
                    enabled = !isLoading && searchQuery.isNotBlank()
                ) {
                    if (isLoading) {
                        CircularProgressIndicator(
                            modifier = Modifier.size(16.dp),
                            color = MaterialTheme.colors.onPrimary
                        )
                    } else {
                        Text("좌표 검색")
                    }
                }
                
                searchResult?.let { result ->
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = "검색 결과: $result",
                        style = MaterialTheme.typography.body2,
                        color = Color.Green
                    )
                }
                
                errorMessage?.let { error ->
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = "오류: $error",
                        style = MaterialTheme.typography.body2,
                        color = Color.Red
                    )
                }
            }
        }
    }
}

@SuppressLint("MissingPermission")
private fun getCurrentLocationWithAddress(
    context: Context,
    geocoder: SafeGeocoder,
    scope: CoroutineScope,
    onResult: (Location?, String?) -> Unit
) {
    val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
    
    fusedLocationClient.lastLocation
        .addOnSuccessListener { location ->
            if (location != null) {
                onResult(location, null)
                
                // 주소 변환
                scope.launch {
                    geocoder.getAddressFromLocation(location.latitude, location.longitude)
                        .onSuccess { address ->
                            onResult(location, address)
                        }
                        .onFailure { error ->
                            Log.e("AddressLookup", "주소 변환 실패", error)
                            onResult(location, "주소 변환 실패: ${error.message}")
                        }
                }
            } else {
                onResult(null, "위치를 가져올 수 없습니다")
            }
        }
        .addOnFailureListener { exception ->
            Log.e("AddressLookup", "위치 가져오기 실패", exception)
            onResult(null, "위치 가져오기 실패: ${exception.message}")
        }
}

9. 위치 서비스 통합 예제

모든 구성 요소를 통합한 완전한 위치 서비스 예제를 살펴보자.

/**
 * 완전한 위치 서비스 관리 클래스
 */
class ComprehensiveLocationManager(private val context: Context) {
    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
    private val geocoder = SafeGeocoder(context)
    private var locationCallback: LocationCallback? = null
    private var isTracking = false
    
    private val _locationState = MutableStateFlow<LocationState>(LocationState.Initial)
    val locationState: StateFlow<LocationState> = _locationState.asStateFlow()
    
    /**
     * 위치 추적 시작
     */
    @SuppressLint("MissingPermission")
    suspend fun startLocationTracking(
        priority: Int = Priority.PRIORITY_BALANCED_POWER_ACCURACY,
        updateInterval: Long = 30000L
    ): Result<Unit> {
        return try {
            if (!checkLocationPermissions(context)) {
                return Result.failure(SecurityException("위치 권한이 필요합니다"))
            }
            
            if (isTracking) {
                return Result.success(Unit)
            }
            
            val locationRequest = LocationRequest.Builder(priority, updateInterval)
                .setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
                .build()
            
            locationCallback = object : LocationCallback() {
                override fun onLocationResult(locationResult: LocationResult) {
                    locationResult.lastLocation?.let { location ->
                        handleLocationUpdate(location)
                    }
                }
                
                override fun onLocationAvailability(locationAvailability: LocationAvailability) {
                    if (!locationAvailability.isLocationAvailable) {
                        _locationState.value = LocationState.Error("위치 서비스를 사용할 수 없습니다")
                    }
                }
            }
            
            fusedLocationClient.requestLocationUpdates(
                locationRequest,
                locationCallback!!,
                Looper.getMainLooper()
            )
            
            isTracking = true
            _locationState.value = LocationState.Loading
            
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    /**
     * 위치 추적 중지
     */
    fun stopLocationTracking() {
        locationCallback?.let { callback ->
            fusedLocationClient.removeLocationUpdates(callback)
            locationCallback = null
            isTracking = false
            _locationState.value = LocationState.Stopped
        }
    }
    
    /**
     * 마지막 알려진 위치 가져오기
     */
    @SuppressLint("MissingPermission")
    suspend fun getLastKnownLocation(): Result<LocationData> {
        return try {
            if (!checkLocationPermissions(context)) {
                return Result.failure(SecurityException("위치 권한이 필요합니다"))
            }
            
            suspendCancellableCoroutine { continuation ->
                fusedLocationClient.lastLocation
                    .addOnSuccessListener { location ->
                        if (location != null) {
                            CoroutineScope(Dispatchers.IO).launch {
                                val locationData = processLocation(location)
                                continuation.resume(Result.success(locationData))
                            }
                        } else {
                            continuation.resume(
                                Result.failure(Exception("마지막 위치를 찾을 수 없습니다"))
                            )
                        }
                    }
                    .addOnFailureListener { exception ->
                        continuation.resume(Result.failure(exception))
                    }
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    private suspend fun handleLocationUpdate(location: Location) {
        val locationData = processLocation(location)
        _locationState.value = LocationState.Success(locationData)
    }
    
    private suspend fun processLocation(location: Location): LocationData {
        val address = geocoder.getAddressFromLocation(
            location.latitude, 
            location.longitude
        ).getOrNull()
        
        return LocationData(
            latitude = location.latitude,
            longitude = location.longitude,
            accuracy = location.accuracy,
            address = address,
            timestamp = System.currentTimeMillis(),
            provider = location.provider,
            altitude = if (location.hasAltitude()) location.altitude else null,
            bearing = if (location.hasBearing()) location.bearing else null,
            speed = if (location.hasSpeed()) location.speed else null
        )
    }
    
    fun cleanup() {
        stopLocationTracking()
    }
}

/**
 * 위치 서비스의 다양한 상태를 나타내는 sealed class
 */
sealed class LocationState {
    object Initial : LocationState()
    object Loading : LocationState()
    object Stopped : LocationState()
    data class Success(val locationData: LocationData) : LocationState()
    data class Error(val message: String) : LocationState()
}

/**
 * 포괄적인 위치 정보를 담는 데이터 클래스
 */
data class LocationData(
    val latitude: Double,
    val longitude: Double,
    val accuracy: Float,
    val address: String?,
    val timestamp: Long,
    val provider: String?,
    val altitude: Double?,
    val bearing: Float?,
    val speed: Float?
) {
    /**
     * 위치 정보를 사용자 친화적인 문자열로 변환
     */
    fun toDisplayString(): String {
        return buildString {
            appendLine("위치: ${String.format("%.6f", latitude)}, ${String.format("%.6f", longitude)}")
            appendLine("정확도: ${String.format("%.1f", accuracy)}m")
            address?.let { appendLine("주소: $it") }
            provider?.let { appendLine("위치 제공자: $it") }
            altitude?.let { appendLine("고도: ${String.format("%.1f", it)}m") }
            bearing?.let { appendLine("방향: ${String.format("%.1f", it)}°") }
            speed?.let { appendLine("속도: ${String.format("%.1f", it * 3.6f)}km/h") }
        }.trimEnd()
    }
}

실제 앱에서의 활용 예제

이제 우리가 구축한 모든 구성 요소를 실제 앱 화면에서 어떻게 활용하는지 살펴보자. 이는 마치 모든 악기가 조화롭게 연주되어 아름다운 교향곡을 만들어내는 것과 같다.

@Composable
fun LocationServiceDemoScreen() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    
    // 위치 관리자를 생성하고 기억한다
    val locationManager = remember { ComprehensiveLocationManager(context) }
    
    // 위치 상태를 관찰한다
    val locationState by locationManager.locationState.collectAsState()
    
    // 권한 상태를 관리한다
    var permissionState by remember { mutableStateOf(PermissionState.NotRequested) }
    
    // 권한 요청을 위한 launcher를 설정한다
    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        val fineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
        val coarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false
        
        permissionState = when {
            fineLocationGranted -> PermissionState.Granted
            coarseLocationGranted -> PermissionState.PartiallyGranted
            else -> PermissionState.Denied
        }
    }
    
    // 컴포넌트가 제거될 때 위치 추적을 정리한다
    DisposableEffect(Unit) {
        onDispose {
            locationManager.cleanup()
        }
    }
    
    // 초기 권한 상태를 확인한다
    LaunchedEffect(Unit) {
        permissionState = if (checkLocationPermissions(context)) {
            PermissionState.Granted
        } else {
            PermissionState.NotRequested
        }
    }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // 상단 제목
        Text(
            text = "위치 서비스 데모",
            style = MaterialTheme.typography.h4,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 24.dp)
        )
        
        // 권한 상태에 따른 UI 렌더링
        when (permissionState) {
            PermissionState.NotRequested, PermissionState.Denied -> {
                LocationPermissionCard(
                    onRequestPermission = {
                        permissionLauncher.launch(
                            arrayOf(
                                Manifest.permission.ACCESS_FINE_LOCATION,
                                Manifest.permission.ACCESS_COARSE_LOCATION
                            )
                        )
                    }
                )
            }
            
            PermissionState.Granted, PermissionState.PartiallyGranted -> {
                LocationControlCard(
                    locationState = locationState,
                    onStartTracking = {
                        scope.launch {
                            locationManager.startLocationTracking()
                        }
                    },
                    onStopTracking = {
                        locationManager.stopLocationTracking()
                    },
                    onGetLastLocation = {
                        scope.launch {
                            locationManager.getLastKnownLocation()
                        }
                    }
                )
                
                Spacer(modifier = Modifier.height(16.dp))
                
                LocationInfoCard(locationState = locationState)
            }
        }
    }
}

@Composable
fun LocationPermissionCard(onRequestPermission: () -> Unit) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = 8.dp
    ) {
        Column(
            modifier = Modifier.padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Icon(
                imageVector = Icons.Default.LocationOff,
                contentDescription = "위치 권한 필요",
                modifier = Modifier.size(64.dp),
                tint = MaterialTheme.colors.primary
            )
            
            Spacer(modifier = Modifier.height(16.dp))
            
            Text(
                text = "위치 권한이 필요합니다",
                style = MaterialTheme.typography.h6,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.Center
            )
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Text(
                text = "위치 기반 서비스를 제공하기 위해\n위치 접근 권한이 필요합니다.",
                style = MaterialTheme.typography.body1,
                textAlign = TextAlign.Center,
                color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
            )
            
            Spacer(modifier = Modifier.height(24.dp))
            
            Button(
                onClick = onRequestPermission,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("위치 권한 허용하기")
            }
        }
    }
}

@Composable
fun LocationControlCard(
    locationState: LocationState,
    onStartTracking: () -> Unit,
    onStopTracking: () -> Unit,
    onGetLastLocation: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = 4.dp
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "위치 서비스 제어",
                style = MaterialTheme.typography.h6,
                fontWeight = FontWeight.Bold
            )
            
            Spacer(modifier = Modifier.height(16.dp))
            
            // 추적 상태 표시 및 제어 버튼
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Button(
                    onClick = onStartTracking,
                    enabled = locationState !is LocationState.Loading,
                    modifier = Modifier.weight(1f)
                ) {
                    if (locationState is LocationState.Loading) {
                        CircularProgressIndicator(
                            modifier = Modifier.size(16.dp),
                            color = MaterialTheme.colors.onPrimary
                        )
                    } else {
                        Text("추적 시작")
                    }
                }
                
                Spacer(modifier = Modifier.width(8.dp))
                
                Button(
                    onClick = onStopTracking,
                    modifier = Modifier.weight(1f)
                ) {
                    Text("추적 중지")
                }
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Button(
                onClick = onGetLastLocation,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("마지막 위치 가져오기")
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            // 현재 상태 표시
            when (locationState) {
                is LocationState.Initial -> {
                    StatusChip(
                        text = "대기 중",
                        color = Color.Gray
                    )
                }
                is LocationState.Loading -> {
                    StatusChip(
                        text = "위치 검색 중...",
                        color = Color.Blue
                    )
                }
                is LocationState.Success -> {
                    StatusChip(
                        text = "위치 업데이트됨",
                        color = Color.Green
                    )
                }
                is LocationState.Stopped -> {
                    StatusChip(
                        text = "추적 중지됨",
                        color = Color.Orange
                    )
                }
                is LocationState.Error -> {
                    StatusChip(
                        text = "오류: ${locationState.message}",
                        color = Color.Red
                    )
                }
            }
        }
    }
}

@Composable
fun LocationInfoCard(locationState: LocationState) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = 4.dp
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "위치 정보",
                style = MaterialTheme.typography.h6,
                fontWeight = FontWeight.Bold
            )
            
            Spacer(modifier = Modifier.height(16.dp))
            
            when (locationState) {
                is LocationState.Success -> {
                    val locationData = locationState.locationData
                    
                    LocationInfoRow(
                        label = "위도",
                        value = String.format("%.6f", locationData.latitude)
                    )
                    
                    LocationInfoRow(
                        label = "경도",
                        value = String.format("%.6f", locationData.longitude)
                    )
                    
                    LocationInfoRow(
                        label = "정확도",
                        value = "${String.format("%.1f", locationData.accuracy)}m"
                    )
                    
                    locationData.address?.let { address ->
                        LocationInfoRow(
                            label = "주소",
                            value = address
                        )
                    }
                    
                    locationData.provider?.let { provider ->
                        LocationInfoRow(
                            label = "위치 제공자",
                            value = provider
                        )
                    }
                    
                    locationData.altitude?.let { altitude ->
                        LocationInfoRow(
                            label = "고도",
                            value = "${String.format("%.1f", altitude)}m"
                        )
                    }
                    
                    locationData.speed?.let { speed ->
                        LocationInfoRow(
                            label = "속도",
                            value = "${String.format("%.1f", speed * 3.6f)}km/h"
                        )
                    }
                    
                    LocationInfoRow(
                        label = "업데이트 시간",
                        value = formatTimestamp(locationData.timestamp)
                    )
                }
                
                else -> {
                    Text(
                        text = "위치 정보가 없습니다",
                        style = MaterialTheme.typography.body1,
                        color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
                    )
                }
            }
        }
    }
}

@Composable
fun LocationInfoRow(label: String, value: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            text = "$label:",
            style = MaterialTheme.typography.body2,
            fontWeight = FontWeight.Medium,
            modifier = Modifier.weight(1f)
        )
        
        Text(
            text = value,
            style = MaterialTheme.typography.body2,
            modifier = Modifier.weight(2f),
            textAlign = TextAlign.End
        )
    }
}

@Composable
fun StatusChip(text: String, color: Color) {
    Surface(
        color = color.copy(alpha = 0.1f),
        shape = RoundedCornerShape(16.dp),
        border = BorderStroke(1.dp, color)
    ) {
        Text(
            text = text,
            style = MaterialTheme.typography.caption,
            color = color,
            modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
        )
    }
}

private fun formatTimestamp(timestamp: Long): String {
    val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
    return sdf.format(Date(timestamp))
}

 

10. 성능 최적화와 베스트 practice

위치 서비스를 효율적으로 사용하기 위한 핵심 원칙들을 살펴보자. 이는 마치 자동차를 운전할 때 연료 효율성을 고려하면서도 목적지에 안전하게 도착하는 것과 같은 균형을 맞추는 작업이다.

배터리 최적화 전략

/**
 * 배터리 사용량을 최적화하는 스마트 위치 관리자
 */
class BatteryOptimizedLocationManager(private val context: Context) {
    private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
    private var currentLocationRequest: LocationRequest? = null
    private var locationCallback: LocationCallback? = null
    
    /**
     * 앱의 사용 패턴에 따라 동적으로 위치 요청 설정을 조정한다
     */
    fun createAdaptiveLocationRequest(
        appState: AppState,
        userActivity: UserActivity
    ): LocationRequest {
        return when {
            // 사용자가 앱을 활발히 사용 중이고 내비게이션 기능을 사용하는 경우
            appState == AppState.FOREGROUND && userActivity == UserActivity.NAVIGATING -> {
                LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
                    .setMinUpdateDistanceMeters(10f)
                    .setWaitForAccurateLocation(true)
                    .build()
            }
            
            // 앱이 포그라운드에 있지만 위치 정보를 가끔만 필요로 하는 경우
            appState == AppState.FOREGROUND && userActivity == UserActivity.BROWSING -> {
                LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 30000L)
                    .setMinUpdateDistanceMeters(50f)
                    .setWaitForAccurateLocation(false)
                    .build()
            }
            
            // 백그라운드에서 위치 추적이 필요한 경우 (매우 제한적으로 사용)
            appState == AppState.BACKGROUND -> {
                LocationRequest.Builder(Priority.PRIORITY_LOW_POWER, 300000L) // 5분마다
                    .setMinUpdateDistanceMeters(200f)
                    .setWaitForAccurateLocation(false)
                    .build()
            }
            
            // 기본적인 위치 정보만 필요한 경우
            else -> {
                LocationRequest.Builder(Priority.PRIORITY_PASSIVE, 600000L) // 10분마다
                    .build()
            }
        }
    }
    
    /**
     * 앱의 생명주기에 따라 위치 추적을 자동으로 조정한다
     */
    @SuppressLint("MissingPermission")
    fun adjustLocationTrackingForLifecycle(
        lifecycleState: Lifecycle.State,
        userActivity: UserActivity
    ) {
        when (lifecycleState) {
            Lifecycle.State.RESUMED -> {
                // 앱이 활성 상태일 때는 더 정확한 위치 추적
                val request = createAdaptiveLocationRequest(AppState.FOREGROUND, userActivity)
                startLocationUpdates(request)
            }
            
            Lifecycle.State.STARTED -> {
                // 앱이 보이지만 포커스가 없을 때는 위치 추적 빈도 감소
                val request = createAdaptiveLocationRequest(AppState.BACKGROUND, userActivity)
                startLocationUpdates(request)
            }
            
            Lifecycle.State.CREATED, Lifecycle.State.DESTROYED -> {
                // 앱이 백그라운드에 있거나 종료된 경우 위치 추적 중지
                stopLocationUpdates()
            }
            
            else -> {
                // 기타 상태에서는 최소한의 위치 추적만 수행
                val request = createAdaptiveLocationRequest(AppState.BACKGROUND, UserActivity.IDLE)
                startLocationUpdates(request)
            }
        }
    }
    
    @SuppressLint("MissingPermission")
    private fun startLocationUpdates(locationRequest: LocationRequest) {
        // 기존 요청과 동일한 경우 중복 요청을 방지한다
        if (currentLocationRequest?.let { it.priority == locationRequest.priority && 
                                           it.intervalMillis == locationRequest.intervalMillis } == true) {
            return
        }
        
        // 기존 위치 업데이트를 중지하고 새로운 설정으로 시작한다
        stopLocationUpdates()
        
        locationCallback = object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult) {
                locationResult.lastLocation?.let { location ->
                    handleLocationUpdate(location)
                }
            }
        }
        
        fusedLocationClient.requestLocationUpdates(
            locationRequest,
            locationCallback!!,
            Looper.getMainLooper()
        )
        
        currentLocationRequest = locationRequest
    }
    
    private fun stopLocationUpdates() {
        locationCallback?.let { callback ->
            fusedLocationClient.removeLocationUpdates(callback)
            locationCallback = null
            currentLocationRequest = null
        }
    }
    
    private fun handleLocationUpdate(location: Location) {
        // 위치 업데이트 처리 로직
        // 필요에 따라 데이터베이스 저장, UI 업데이트 등을 수행한다
    }
}

enum class AppState {
    FOREGROUND,
    BACKGROUND
}

enum class UserActivity {
    NAVIGATING,    // 내비게이션 사용 중
    BROWSING,      // 일반적인 앱 사용
    IDLE           // 비활성 상태
}

메모리 누수 방지와 리소스 관리

 
/**
 * 생명주기를 고려한 안전한 위치 서비스 컴포넌트
 */
@Composable
fun LifecycleAwareLocationComponent() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    
    // 위치 관리자를 생성하되, 컴포넌트가 제거될 때 정리되도록 한다
    val locationManager = remember {
        BatteryOptimizedLocationManager(context)
    }
    
    // 앱의 생명주기 상태를 관찰한다
    val lifecycleState by lifecycleOwner.lifecycle.currentStateFlow.collectAsState()
    
    // 생명주기 변화에 따라 위치 추적을 조정한다
    LaunchedEffect(lifecycleState) {
        when (lifecycleState) {
            Lifecycle.State.RESUMED -> {
                if (checkLocationPermissions(context)) {
                    locationManager.adjustLocationTrackingForLifecycle(
                        lifecycleState, 
                        UserActivity.BROWSING
                    )
                }
            }
            
            Lifecycle.State.STARTED -> {
                locationManager.adjustLocationTrackingForLifecycle(
                    lifecycleState, 
                    UserActivity.IDLE
                )
            }
            
            else -> {
                locationManager.adjustLocationTrackingForLifecycle(
                    lifecycleState, 
                    UserActivity.IDLE
                )
            }
        }
    }
    
    // 컴포넌트가 제거될 때 반드시 리소스를 정리한다
    DisposableEffect(Unit) {
        onDispose {
            locationManager.cleanup()
        }
    }
}

/**
 * 메모리 누수를 방지하는 안전한 위치 콜백 래퍼
 */
class WeakReferenceLocationCallback(
    target: Any,
    private val onLocationUpdate: (Location) -> Unit
) : LocationCallback() {
    private val weakReference = WeakReference(target)
    
    override fun onLocationResult(locationResult: LocationResult) {
        // WeakReference를 통해 대상 객체가 아직 존재하는지 확인한다
        if (weakReference.get() != null) {
            locationResult.lastLocation?.let { location ->
                onLocationUpdate(location)
            }
        } else {
            // 대상 객체가 가비지 컬렉션된 경우 콜백을 무시한다
            Log.w("LocationCallback", "대상 객체가 제거되었습니다. 위치 업데이트를 무시합니다.")
        }
    }
}

11. 디버깅과 문제 해결

위치 서비스 개발 중 자주 발생하는 문제들과 해결 방법을 살펴보자. 이는 마치 의사가 환자의 증상을 파악하고 적절한 치료법을 제시하는 것과 같은 체계적인 접근이 필요하다.

일반적인 문제와 해결책

 
/**
 * 위치 서비스 문제 진단 및 해결을 위한 유틸리티 클래스
 */
class LocationServiceDiagnostics(private val context: Context) {
    
    /**
     * 위치 서비스의 전반적인 상태를 진단한다
     */
    fun diagnoseLocationService(): LocationServiceDiagnosis {
        val diagnosis = LocationServiceDiagnosis()
        
        // 1. 권한 상태 확인
        diagnosis.permissionStatus = checkPermissionStatus()
        
        // 2. 위치 서비스 활성화 상태 확인
        diagnosis.locationServiceEnabled = isLocationServiceEnabled()
        
        // 3. Google Play Services 가용성 확인
        diagnosis.googlePlayServicesAvailable = checkGooglePlayServices()
        
        // 4. GPS 제공자 상태 확인
        diagnosis.gpsProviderEnabled = isGpsProviderEnabled()
        
        // 5. 네트워크 제공자 상태 확인
        diagnosis.networkProviderEnabled = isNetworkProviderEnabled()
        
        // 6. Geocoder 서비스 가용성 확인
        diagnosis.geocoderAvailable = Geocoder.isPresent()
        
        return diagnosis
    }
    
    private fun checkPermissionStatus(): PermissionDiagnosis {
        val fineLocation = ContextCompat.checkSelfPermission(
            context, Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
        
        val coarseLocation = ContextCompat.checkSelfPermission(
            context, Manifest.permission.ACCESS_COARSE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
        
        val backgroundLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            ContextCompat.checkSelfPermission(
                context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
            ) == PackageManager.PERMISSION_GRANTED
        } else true
        
        return PermissionDiagnosis(
            fineLocationGranted = fineLocation,
            coarseLocationGranted = coarseLocation,
            backgroundLocationGranted = backgroundLocation
        )
    }
    
    private fun isLocationServiceEnabled(): Boolean {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
               locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
    }
    
    private fun checkGooglePlayServices(): Boolean {
        val googleApiAvailability = GoogleApiAvailability.getInstance()
        val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context)
        return resultCode == ConnectionResult.SUCCESS
    }
    
    private fun isGpsProviderEnabled(): Boolean {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
    }
    
    private fun isNetworkProviderEnabled(): Boolean {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
    }
    
    /**
     * 진단 결과를 바탕으로 사용자에게 제공할 해결책을 생성한다
     */
    fun generateSolutions(diagnosis: LocationServiceDiagnosis): List<LocationSolution> {
        val solutions = mutableListOf<LocationSolution>()
        
        if (!diagnosis.permissionStatus.fineLocationGranted && 
            !diagnosis.permissionStatus.coarseLocationGranted) {
            solutions.add(
                LocationSolution(
                    problem = "위치 권한이 부여되지 않음",
                    solution = "앱 설정에서 위치 권한을 허용해주세요",
                    action = SolutionAction.REQUEST_PERMISSION
                )
            )
        }
        
        if (!diagnosis.locationServiceEnabled) {
            solutions.add(
                LocationSolution(
                    problem = "위치 서비스가 비활성화됨",
                    solution = "기기 설정에서 위치 서비스를 활성화해주세요",
                    action = SolutionAction.ENABLE_LOCATION_SERVICE
                )
            )
        }
        
        if (!diagnosis.googlePlayServicesAvailable) {
            solutions.add(
                LocationSolution(
                    problem = "Google Play Services를 사용할 수 없음",
                    solution = "Google Play Services를 업데이트하거나 설치해주세요",
                    action = SolutionAction.UPDATE_PLAY_SERVICES
                )
            )
        }
        
        if (!diagnosis.gpsProviderEnabled && !diagnosis.networkProviderEnabled) {
            solutions.add(
                LocationSolution(
                    problem = "GPS와 네트워크 위치 제공자가 모두 비활성화됨",
                    solution = "정확한 위치를 위해 GPS를 활성화하거나, 네트워크 위치라도 활성화해주세요",
                    action = SolutionAction.ENABLE_LOCATION_PROVIDERS
                )
            )
        }
        
        return solutions
    }
}

data class LocationServiceDiagnosis(
    var permissionStatus: PermissionDiagnosis = PermissionDiagnosis(),
    var locationServiceEnabled: Boolean = false,
    var googlePlayServicesAvailable: Boolean = false,
    var gpsProviderEnabled: Boolean = false,
    var networkProviderEnabled: Boolean = false,
    var geocoderAvailable: Boolean = false
)

data class PermissionDiagnosis(
    val fineLocationGranted: Boolean = false,
    val coarseLocationGranted: Boolean = false,
    val backgroundLocationGranted: Boolean = false
)

data class LocationSolution(
    val problem: String,
    val solution: String,
    val action: SolutionAction
)

enum class SolutionAction {
    REQUEST_PERMISSION,
    ENABLE_LOCATION_SERVICE,
    UPDATE_PLAY_SERVICES,
    ENABLE_LOCATION_PROVIDERS
}

진단 UI 컴포넌트

 
@Composable
fun LocationDiagnosticsScreen() {
    val context = LocalContext.current
    val diagnostics = remember { LocationServiceDiagnostics(context) }
    
    var diagnosis by remember { mutableStateOf<LocationServiceDiagnosis?>(null) }
    var solutions by remember { mutableStateOf<List<LocationSolution>>(emptyList()) }
    var isLoading by remember { mutableStateOf(false) }
    
    LaunchedEffect(Unit) {
        isLoading = true
        try {
            diagnosis = diagnostics.diagnoseLocationService()
            diagnosis?.let { diag ->
                solutions = diagnostics.generateSolutions(diag)
            }
        } catch (e: Exception) {
            Log.e("LocationDiagnostics", "진단 중 오류 발생", e)
        } finally {
            isLoading = false
        }
    }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(
            text = "위치 서비스 진단",
            style = MaterialTheme.typography.h4,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 24.dp)
        )
        
        if (isLoading) {
            Box(
                modifier = Modifier.fillMaxWidth(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        } else {
            diagnosis?.let { diag ->
                LazyColumn {
                    item {
                        DiagnosisCard(
                            title = "권한 상태",
                            items = listOf(
                                DiagnosisItem("정밀 위치", diag.permissionStatus.fineLocationGranted),
                                DiagnosisItem("대략적 위치", diag.permissionStatus.coarseLocationGranted),
                                DiagnosisItem("백그라운드 위치", diag.permissionStatus.backgroundLocationGranted)
                            )
                        )
                    }
                    
                    item { Spacer(modifier = Modifier.height(16.dp)) }
                    
                    item {
                        DiagnosisCard(
                            title = "서비스 상태",
                            items = listOf(
                                DiagnosisItem("위치 서비스", diag.locationServiceEnabled),
                                DiagnosisItem("Google Play Services", diag.googlePlayServicesAvailable),
                                DiagnosisItem("GPS 제공자", diag.gpsProviderEnabled),
                                DiagnosisItem("네트워크 제공자", diag.networkProviderEnabled),
                                DiagnosisItem("Geocoder", diag.geocoderAvailable)
                            )
                        )
                    }
                    
                    if (solutions.isNotEmpty()) {
                        item { Spacer(modifier = Modifier.height(16.dp)) }
                        
                        item {
                            SolutionsCard(
                                solutions = solutions,
                                onActionClicked = { action ->
                                    handleSolutionAction(context, action)
                                }
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun DiagnosisCard(
    title: String,
    items: List<DiagnosisItem>
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = 4.dp
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = title,
                style = MaterialTheme.typography.h6,
                fontWeight = FontWeight.Bold
            )
            
            Spacer(modifier = Modifier.height(12.dp))
            
            items.forEach { item ->
                DiagnosisItemRow(item = item)
                Spacer(modifier = Modifier.height(8.dp))
            }
        }
    }
}

@Composable
fun DiagnosisItemRow(item: DiagnosisItem) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = item.name,
            style = MaterialTheme.typography.body1
        )
        
        Icon(
            imageVector = if (item.isAvailable) Icons.Default.CheckCircle else Icons.Default.Cancel,
            contentDescription = if (item.isAvailable) "사용 가능" else "사용 불가",
            tint = if (item.isAvailable) Color.Green else Color.Red
        )
    }
}

@Composable
fun SolutionsCard(
    solutions: List<LocationSolution>,
    onActionClicked: (SolutionAction) -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = 4.dp
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "해결 방법",
                style = MaterialTheme.typography.h6,
                fontWeight = FontWeight.Bold,
                color = Color.Red
            )
            
            Spacer(modifier = Modifier.height(12.dp))
            
            solutions.forEach { solution ->
                SolutionItem(
                    solution = solution,
                    onActionClicked = { onActionClicked(solution.action) }
                )
                Spacer(modifier = Modifier.height(12.dp))
            }
        }
    }
}

@Composable
fun SolutionItem(
    solution: LocationSolution,
    onActionClicked: () -> Unit
) {
    Column {
        Text(
            text = "문제: ${solution.problem}",
            style = MaterialTheme.typography.body2,
            fontWeight = FontWeight.Medium,
            color = Color.Red
        )
        
        Spacer(modifier = Modifier.height(4.dp))
        
        Text(
            text = "해결책: ${solution.solution}",
            style = MaterialTheme.typography.body2,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        Button(
            onClick = onActionClicked,
            size = ButtonDefaults.SmallButtonSize
        ) {
            Text("해결하기")
        }
    }
}

data class DiagnosisItem(
    val name: String,
    val isAvailable: Boolean
)

private fun handleSolutionAction(context: Context, action: SolutionAction) {
    when (action) {
        SolutionAction.REQUEST_PERMISSION -> {
            // 권한 요청은 상위 컴포넌트에서 처리
            Toast.makeText(context, "권한 요청 버튼을 사용해주세요", Toast.LENGTH_SHORT).show()
        }
        
        SolutionAction.ENABLE_LOCATION_SERVICE -> {
            try {
                val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
                context.startActivity(intent)
            } catch (e: Exception) {
                Toast.makeText(context, "설정을 열 수 없습니다", Toast.LENGTH_SHORT).show()
            }
        }
        
        SolutionAction.UPDATE_PLAY_SERVICES -> {
            try {
                val intent = Intent(Intent.ACTION_VIEW).apply {
                    data = Uri.parse("market://details?id=com.google.android.gms")
                }
                context.startActivity(intent)
            } catch (e: Exception) {
                Toast.makeText(context, "Play Store를 열 수 없습니다", Toast.LENGTH_SHORT).show()
            }
        }
        
        SolutionAction.ENABLE_LOCATION_PROVIDERS -> {
            try {
                val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
                context.startActivity(intent)
            } catch (e: Exception) {
                Toast.makeText(context, "위치 설정을 열 수 없습니다", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

12. 보안 고려사항과 사용자 개인정보 보호

위치 정보는 매우 민감한 개인정보이기 때문에 이를 다룰 때는 특별한 주의가 필요하다. 이는 마치 귀중한 보물을 다루는 것처럼 신중하고 책임감 있는 접근이 필요하다.

개인정보 보호 모범 사례

 
/**
 * 개인정보 보호를 고려한 안전한 위치 데이터 관리 클래스
 */
class PrivacyAwareLocationManager(private val context: Context) {
    private val securePrefs = EncryptedSharedPreferences.create(
        "location_prefs",
        MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
        context,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    
    /**
     * 위치 데이터의 정밀도를 사용자 설정에 따라 조정한다
     */
    fun getLocationWithUserConsent(
        precisionLevel: LocationPrecisionLevel,
        onSuccess: (Location) -> Unit,
        onError: (Exception) -> Unit
    ) {
        if (!hasValidConsent()) {
            onError(SecurityException("사용자 동의가 필요합니다"))
            return
        }
        
        getCurrentLocation { location ->
            val adjustedLocation = adjustLocationPrecision(location, precisionLevel)
            onSuccess(adjustedLocation)
        }
    }
    
    /**
     * 사용자가 설정한 정밀도 레벨에 따라 위치 데이터를 조정한다
     */
    private fun adjustLocationPrecision(
        location: Location,
        precisionLevel: LocationPrecisionLevel
    ): Location {
        return when (precisionLevel) {
            LocationPrecisionLevel.EXACT -> location
            
            LocationPrecisionLevel.APPROXIMATE -> {
                // 위치를 대략적으로 조정 (예: 100m 반경 내의 임의 지점)
                val adjustedLocation = Location(location.provider)
                adjustedLocation.latitude = location.latitude + (Math.random() - 0.5) * 0.001
                adjustedLocation.longitude = location.longitude + (Math.random() - 0.5) * 0.001
                adjustedLocation.accuracy = maxOf(location.accuracy, 100f)
                adjustedLocation
            }
            
            LocationPrecisionLevel.CITY_LEVEL -> {
                // 도시 레벨로만 제공 (km 단위로 조정)
                val adjustedLocation = Location(location.provider)
                adjustedLocation.latitude = Math.round(location.latitude * 100.0) / 100.0
                adjustedLocation.longitude = Math.round(location.longitude * 100.0) / 100.0
                adjustedLocation.accuracy = 1000f
                adjustedLocation
            }
        }
    }
    
    /**
     * 사용자 동의 상태를 확인한다
     */
    private fun hasValidConsent(): Boolean {
        val consentTimestamp = securePrefs.getLong("consent_timestamp", 0L)
        val currentTime = System.currentTimeMillis()
        val thirtyDaysInMillis = 30L * 24 * 60 * 60 * 1000 // 30일
        
        return (currentTime - consentTimestamp) < thirtyDaysInMillis
    }
    
    /**
     * 사용자 동의를 기록한다
     */
    fun recordUserConsent(consentDetails: LocationConsentDetails) {
        securePrefs.edit()
            .putLong("consent_timestamp", System.currentTimeMillis())
            .putString("consent_version", consentDetails.consentVersion)
            .putString("consent_purposes", consentDetails.purposes.joinToString(","))
            .putBoolean("data_sharing_allowed", consentDetails.dataSharing)
            .apply()
    }
    
    /**
     * 위치 데이터를 안전하게 저장한다 (임시 저장만, 영구 저장 지양)
     */
    private val locationCache = mutableMapOf<String, CachedLocationData>()
    
    fun cacheLocationTemporarily(
        key: String, 
        location: Location, 
        ttlMinutes: Int = 5
    ) {
        val expiryTime = System.currentTimeMillis() + (ttlMinutes * 60 * 1000)
        locationCache[key] = CachedLocationData(location, expiryTime)
        
        // 만료된 캐시 정리
        cleanExpiredCache()
    }
    
    fun getCachedLocation(key: String): Location? {
        val cached = locationCache[key]
        return if (cached != null && cached.expiryTime > System.currentTimeMillis()) {
            cached.location
        } else {
            locationCache.remove(key)
            null
        }
    }
    
    private fun cleanExpiredCache() {
        val currentTime = System.currentTimeMillis()
        locationCache.entries.removeIf { it.value.expiryTime <= currentTime }
    }
    
    @SuppressLint("MissingPermission")
    private fun getCurrentLocation(callback: (Location) -> Unit) {
        val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
        fusedLocationClient.lastLocation.addOnSuccessListener { location ->
            location?.let { callback(it) }
        }
    }
}

enum class LocationPrecisionLevel {
    EXACT,        // 정확한 위치
    APPROXIMATE,  // 대략적인 위치 (100m 범위)
    CITY_LEVEL    // 도시 레벨 (km 범위)
}

data class LocationConsentDetails(
    val consentVersion: String,
    val purposes: List<String>,
    val dataSharing: Boolean,
    val timestamp: Long = System.currentTimeMillis()
)

data class CachedLocationData(
    val location: Location,
    val expiryTime: Long
)

사용자 동의 UI 구현

 
@Composable
fun LocationConsentScreen(
    onConsentGiven: (LocationConsentDetails) -> Unit,
    onConsentDenied: () -> Unit
) {
    var selectedPrecision by remember { mutableStateOf(LocationPrecisionLevel.APPROXIMATE) }
    var allowDataSharing by remember { mutableStateOf(false) }
    var showDetailedInfo by remember { mutableStateOf(false) }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp)
    ) {
        Text(
            text = "위치 정보 사용 동의",
            style = MaterialTheme.typography.h4,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 24.dp)
        )
        
        // 위치 정보 사용 목적 설명
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = 4.dp
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(
                    text = "위치 정보 사용 목적",
                    style = MaterialTheme.typography.h6,
                    fontWeight = FontWeight.Bold
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                val purposes = listOf(
                    "현재 위치 표시 및 지도 서비스 제공",
                    "주변 정보 및 맞춤형 콘텐츠 제공",
                    "위치 기반 알림 및 서비스 개선",
                    "사용 통계 분석 (익명화된 데이터만 사용)"
                )
                
                purposes.forEach { purpose ->
                    Row(
                        modifier = Modifier.padding(vertical = 4.dp),
                        verticalAlignment = Alignment.Top
                    ) {
                        Text(
                            text = "• ",
                            style = MaterialTheme.typography.body1
                        )
                        Text(
                            text = purpose,
                            style = MaterialTheme.typography.body1,
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // 정밀도 선택
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = 4.dp
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(
                    text = "위치 정보 정밀도 선택",
                    style = MaterialTheme.typography.h6,
                    fontWeight = FontWeight.Bold
                )
                
                Spacer(modifier = Modifier.height(12.dp))
                
                LocationPrecisionLevel.values().forEach { level ->
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 4.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        RadioButton(
                            selected = selectedPrecision == level,
                            onClick = { selectedPrecision = level }
                        )
                        
                        Column(
                            modifier = Modifier.padding(start = 8.dp)
                        ) {
                            Text(
                                text = when (level) {
                                    LocationPrecisionLevel.EXACT -> "정확한 위치"
                                    LocationPrecisionLevel.APPROXIMATE -> "대략적인 위치 (권장)"
                                    LocationPrecisionLevel.CITY_LEVEL -> "도시 레벨만"
                                },
                                style = MaterialTheme.typography.body1,
                                fontWeight = FontWeight.Medium
                            )
                            
                            Text(
                                text = when (level) {
                                    LocationPrecisionLevel.EXACT -> "정확한 위치 (미터 단위)"
                                    LocationPrecisionLevel.APPROXIMATE -> "100m 범위 내 대략적 위치"
                                    LocationPrecisionLevel.CITY_LEVEL -> "킬로미터 단위 위치"
                                },
                                style = MaterialTheme.typography.caption,
                                color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
                            )
                        }
                    }
                }
            }
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // 데이터 공유 선택
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = 4.dp
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Column(
                        modifier = Modifier.weight(1f)
                    ) {
                        Text(
                            text = "익명화된 위치 데이터 공유",
                            style = MaterialTheme.typography.body1,
                            fontWeight = FontWeight.Medium
                        )
                        
                        Text(
                            text = "서비스 개선을 위한 통계 목적으로만 사용됩니다",
                            style = MaterialTheme.typography.caption,
                            color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
                        )
                    }
                    
                    Switch(
                        checked = allowDataSharing,
                        onCheckedChange = { allowDataSharing = it }
                    )
                }
            }
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // 상세 정보 보기
        TextButton(
            onClick = { showDetailedInfo = true },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("개인정보 처리방침 자세히 보기")
        }
        
        Spacer(modifier = Modifier.height(24.dp))
        
        // 동의/거부 버튼
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            Button(
                onClick = { onConsentDenied() },
                modifier = Modifier.weight(1f),
                colors = ButtonDefaults.buttonColors(
                    backgroundColor = MaterialTheme.colors.surface
                )
            ) {
                Text("거부", color = MaterialTheme.colors.onSurface)
            }
            
            Button(
                onClick = {
                    val consentDetails = LocationConsentDetails(
                        consentVersion = "1.0",
                        purposes = listOf(
                            "location_display",
                            "nearby_content",
                            "notifications",
                            "analytics"
                        ),
                        dataSharing = allowDataSharing
                    )
                    onConsentGiven(consentDetails)
                },
                modifier = Modifier.weight(1f)
            ) {
                Text("동의")
            }
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Text(
            text = "동의는 언제든지 앱 설정에서 철회할 수 있습니다.",
            style = MaterialTheme.typography.caption,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
            textAlign = TextAlign.Center,
            modifier = Modifier.fillMaxWidth()
        )
    }
    
    // 상세 정보 다이얼로그
    if (showDetailedInfo) {
        AlertDialog(
            onDismissRequest = { showDetailedInfo = false },
            title = { Text("개인정보 처리방침") },
            text = {
                LazyColumn {
                    item {
                        Text(
                            text = buildString {
                                appendLine("1. 수집하는 개인정보")
                                appendLine("• 위치 정보 (GPS, 네트워크 기반)")
                                appendLine("• 기기 정보 (모델, OS 버전)")
                                appendLine()
                                appendLine("2. 이용 목적")
                                appendLine("• 위치 기반 서비스 제공")
                                appendLine("• 서비스 개선 및 통계 분석")
                                appendLine()
                                appendLine("3. 보관 기간")
                                appendLine("• 위치 정보: 임시 저장 후 즉시 삭제")
                                appendLine("• 통계 데이터: 익명화 후 1년간 보관")
                                appendLine()
                                appendLine("4. 제3자 제공")
                                appendLine("• 사용자 동의 없이 제3자에게 제공하지 않음")
                                appendLine("• 법적 요구 시에만 예외적으로 제공")
                            },
                            style = MaterialTheme.typography.body2
                        )
                    }
                }
            },
            confirmButton = {
                TextButton(onClick = { showDetailedInfo = false }) {
                    Text("확인")
                }
            }
        )
    }
}

 


 

핵심 요약

  1. 권한 관리: AndroidManifest 설정부터 런타임 권한 요청까지 체계적으로 관리
  2. Context 이해: Android의 핵심 개념인 Context의 역할과 올바른 사용법
  3. FusedLocationProviderClient: Google Play Services를 활용한 효율적인 위치 서비스
  4. Geocoder: 좌표와 주소 간의 변환을 통한 사용자 친화적인 위치 정보 제공
  5. 성능 최적화: 배터리 효율성과 메모리 관리를 고려한 구현
  6. 디버깅: 문제 진단과 해결을 위한 체계적인 접근
  7. 보안과 개인정보: 사용자의 위치 정보를 안전하게 다루는 방법