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

안드로이드 jetpack compose 공부 정리 12강 (google maps api)

sobal 2025. 6. 28. 22:23

1. Google Maps API 키 설정 - 완벽한 시작

Google Maps를 Android 애플리케이션에 통합하는 첫 번째 단계는 API 키를 올바르게 설정하는 것이다. 이 과정은 Google Cloud Platform의 여러 단계를 거쳐야 하므로 차근차근 살펴보겠다.

Google Cloud 프로젝트 생성

먼저 Google Cloud Console에 접속하여 새로운 프로젝트를 만들어야 한다. Google Cloud Console에 접속한 후, 상단의 프로젝트 선택 드롭다운을 클릭하고 "새 프로젝트"를 선택한다. 프로젝트 이름을 입력할 때는 나중에 식별하기 쉬운 의미 있는 이름을 사용하는 것이 좋다. 예를 들어 "MyMapsApp-Android"와 같은 형태로 명명하면 된다.

Maps SDK for Android API 활성화

프로젝트가 생성되면 Google Maps 기능을 사용하기 위해 필요한 API를 활성화해야 한다. 좌측 메뉴에서 "API 및 서비스" -> "라이브러리"로 이동한다. 검색창에 "Maps SDK for Android"를 입력하여 해당 API를 찾고 "사용 설정" 버튼을 클릭한다.

만약 Places API나 Directions API 등 추가 기능을 사용할 계획이라면 이들도 함께 활성화하는 것이 좋다. 각 API는 특정 기능을 담당하므로 필요에 따라 선택적으로 활성화할 수 있다.

API 키 생성 및 제한 설정

API를 활성화한 후에는 "API 및 서비스" > "사용자 인증 정보"로 이동하여 "사용자 인증 정보 만들기" → "API 키"를 선택한다. 생성된 API 키는 즉시 사용할 수 있지만, 보안을 위해 반드시 제한을 설정해야 한다.

API 키를 클릭하여 설정 페이지로 이동한 후 "애플리케이션 제한사항"에서 "Android 앱"을 선택한다. 이때 애플리케이션의 패키지 이름과 SHA-1 인증서 지문이 필요하다.

SHA-1 인증서 지문 획득

SHA-1 지문을 얻는 가장 간단한 방법은 Android Studio의 Gradle 패널을 사용하는 것이다. Android Studio 우측의 Gradle 탭을 열고, 프로젝트 이름 → Tasks → android → signingReport를 더블클릭한다. 실행 결과에서 SHA1 값을 찾아 복사한다.

# 터미널을 사용하는 경우
./gradlew signingReport

# Windows의 경우
gradlew.bat signingReport

실행 결과는 다음과 같은 형태로 나타난다:

Variant: debug
Config: debug
Store: /Users/username/.android/debug.keystore
Alias: AndroidDebugKey
MD5: A7:89:5A:E2:C6:B2:D2:32:A5:E8:43
SHA1: BB:0D:AC:74:D3:21:E1:43:67:71:9B:62:91:AF:A1:66:6E:44:5D:75
SHA-256: EF:49:30:9A:A6:C1:E0:BB:A2:29:F0:B2:C8:AA

AndroidManifest.xml 설정

API 키를 얻었다면 이를 Android 프로젝트에 추가해야 한다. app/src/main/AndroidManifest.xml 파일을 열고 <application> 태그 내부에 다음과 같이 추가한다:

<application
    android:allowBackup="true"
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:fullBackupContent="@xml/backup_rules"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/Theme.MyMapsApp">
    
    <!-- Google Maps API 키 -->
    <meta-data
        android:name="com.google.android.geo.API_KEY"
        android:value="YOUR_API_KEY_HERE" />
        
    <!-- 활동들... -->
</application>

보안을 위해 API 키를 직접 하드코딩하는 대신 local.properties 파일에 저장하고 빌드 시 참조하는 방법을 권장한다:

# local.properties
MAPS_API_KEY=your_actual_api_key_here
// build.gradle.kts (Module: app)
android {
    defaultConfig {
        // 다른 설정들...
        
        // BuildConfig에서 API 키 접근 가능하도록 설정
        buildConfigField("String", "MAPS_API_KEY", "\"${project.findProperty("MAPS_API_KEY")}\"")
        
        // Manifest에서 사용할 수 있도록 설정
        manifestPlaceholders["MAPS_API_KEY"] = project.findProperty("MAPS_API_KEY") ?: ""
    }
}

그리고 AndroidManifest.xml에서는 다음과 같이 참조한다:

<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="${MAPS_API_KEY}" />

2. Jetpack Compose에서 Google Maps 구현

Jetpack Compose에서 Google Maps를 사용하려면 먼저 필요한 의존성을 추가해야 한다.

의존성 추가

// build.gradle.kts (Module: app)
dependencies {
    implementation("com.google.maps.android:maps-compose:2.15.0")
    implementation("com.google.android.gms:play-services-maps:18.2.0")
    implementation("com.google.android.gms:play-services-location:21.0.1")
    
    // Compose 기본 의존성들
    implementation("androidx.compose.ui:ui:$compose_version")
    implementation("androidx.compose.ui:ui-tooling-preview:$compose_version")
    implementation("androidx.compose.material3:material3:1.1.2")
    implementation("androidx.activity:activity-compose:1.8.1")
}

기본 지도 구현

가장 간단한 형태의 지도부터 시작해보자:

@Composable
fun SimpleMapScreen() {
    // 지도의 초기 카메라 위치 설정 (서울 시청)
    val seoulCityHall = LatLng(37.5666805, 126.9784147)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(seoulCityHall, 15f)
    }

    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState
    ) {
        // 기본 마커 추가
        Marker(
            state = MarkerState(position = seoulCityHall),
            title = "서울시청",
            snippet = "대한민국의 수도 서울의 중심지"
        )
    }
}

고급 지도 기능 구현

실제 애플리케이션에서는 더 많은 기능이 필요하다. 사용자 위치 추적, 여러 마커 표시, 클릭 이벤트 처리 등을 포함한 어느정도 완성도 있는 예제를 살펴보자:

@Composable
fun AdvancedMapScreen() {
    var userLocation by remember { mutableStateOf<LatLng?>(null) }
    var mapProperties by remember { mutableStateOf(MapProperties(isMyLocationEnabled = true)) }
    var mapUiSettings by remember { mutableStateOf(MapUiSettings(zoomControlsEnabled = true)) }
    
    // 여러 장소 정보
    val places = remember {
        listOf(
            PlaceInfo(LatLng(37.5666805, 126.9784147), "서울시청", "서울특별시 중구"),
            PlaceInfo(LatLng(37.5759, 126.9768), "경복궁", "조선 왕조의 정궁"),
            PlaceInfo(LatLng(37.5662, 126.9779), "명동", "서울의 대표 쇼핑 거리"),
            PlaceInfo(LatLng(37.5173, 127.0473), "강남역", "서울의 비즈니스 중심지")
        )
    }
    
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(places.first().location, 12f)
    }

    Column(modifier = Modifier.fillMaxSize()) {
        // 지도 컨트롤 버튼들
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            Button(
                onClick = { 
                    mapProperties = mapProperties.copy(
                        mapType = if (mapProperties.mapType == MapType.NORMAL) 
                            MapType.SATELLITE else MapType.NORMAL
                    )
                }
            ) {
                Text(if (mapProperties.mapType == MapType.NORMAL) "위성" else "일반")
            }
            
            Button(
                onClick = { 
                    mapUiSettings = mapUiSettings.copy(
                        myLocationButtonEnabled = !mapUiSettings.myLocationButtonEnabled
                    )
                }
            ) {
                Text("내 위치")
            }
        }
        
        // 지도
        GoogleMap(
            modifier = Modifier.weight(1f),
            cameraPositionState = cameraPositionState,
            properties = mapProperties,
            uiSettings = mapUiSettings,
            onMapClick = { latLng ->
                // 지도 클릭 시 해당 위치로 카메라 이동
                cameraPositionState.move(CameraUpdateFactory.newLatLng(latLng))
            }
        ) {
            // 모든 장소에 마커 추가
            places.forEach { place ->
                Marker(
                    state = MarkerState(position = place.location),
                    title = place.name,
                    snippet = place.description,
                    onClick = { marker ->
                        // 마커 클릭 시 카메라를 해당 위치로 이동
                        cameraPositionState.animate(
                            CameraUpdateFactory.newLatLngZoom(place.location, 16f),
                            1000 // 애니메이션 시간 (밀리초)
                        )
                        false // false를 반환하면 기본 정보창이 표시됨
                    }
                )
            }
        }
    }
}

data class PlaceInfo(
    val location: LatLng,
    val name: String,
    val description: String
)

권한 처리

위치 기반 기능을 사용하려면 적절한 권한을 요청해야 한다:

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@Composable
fun MapWithPermissions() {
    val context = LocalContext.current
    var hasLocationPermission by remember { mutableStateOf(false) }
    
    val locationPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
                permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
    }
    
    LaunchedEffect(Unit) {
        when {
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.ACCESS_FINE_LOCATION
            ) == PackageManager.PERMISSION_GRANTED -> {
                hasLocationPermission = true
            }
            else -> {
                locationPermissionLauncher.launch(
                    arrayOf(
                        Manifest.permission.ACCESS_FINE_LOCATION,
                        Manifest.permission.ACCESS_COARSE_LOCATION
                    )
                )
            }
        }
    }
    
    if (hasLocationPermission) {
        AdvancedMapScreen()
    } else {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text("위치 권한이 필요합니다")
                Spacer(modifier = Modifier.height(16.dp))
                Button(
                    onClick = {
                        locationPermissionLauncher.launch(
                            arrayOf(
                                Manifest.permission.ACCESS_FINE_LOCATION,
                                Manifest.permission.ACCESS_COARSE_LOCATION
                            )
                        )
                    }
                ) {
                    Text("권한 요청")
                }
            }
        }
    }
}

3. LatLng 객체와 좌표 시스템 이해

LatLng 객체는 지구상의 특정 위치를 나타내는 핵심 데이터 구조다. 위도(Latitude)와 경도(Longitude)라는 두 개의 좌표 값으로 구성된다.

좌표 시스템의 이해

위도는 적도를 기준으로 북쪽과 남쪽 방향의 각도를 나타내고 -90도(남극)에서 +90도(북극) 사이의 값을 가진다. 경도는 영국 그리니치 천문대를 지나는 본초 자오선을 기준으로 동쪽과 서쪽 방향의 각도를 나타내며, -180도에서 +180도 사이의 값을 가진다.

// 주요 도시들의 좌표 예제
val cities = mapOf(
    "서울" to LatLng(37.5666805, 126.9784147),
    "부산" to LatLng(35.1796, 129.0756),
    "도쿄" to LatLng(35.6762, 139.6503),
    "뉴욕" to LatLng(40.7128, -74.0060),
    "런던" to LatLng(51.5074, -0.1278),
    "시드니" to LatLng(-33.8688, 151.2093)
)

@Composable
fun CityMapDemo() {
    var selectedCity by remember { mutableStateOf("서울") }
    val cameraPositionState = rememberCameraPositionState()
    
    LaunchedEffect(selectedCity) {
        cities[selectedCity]?.let { location ->
            cameraPositionState.animate(
                CameraUpdateFactory.newLatLngZoom(location, 12f)
            )
        }
    }
    
    Column {
        LazyRow(
            modifier = Modifier.padding(8.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(cities.keys.toList()) { city ->
                FilterChip(
                    onClick = { selectedCity = city },
                    label = { Text(city) },
                    selected = selectedCity == city
                )
            }
        }
        
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            cities.forEach { (cityName, location) ->
                Marker(
                    state = MarkerState(position = location),
                    title = cityName,
                    alpha = if (cityName == selectedCity) 1.0f else 0.6f
                )
            }
        }
    }
}

거리 계산 및 기하학적 연산

두 LatLng 점 사이의 거리를 계산하거나 특정 영역을 표시해야 할 때가 있다:

import kotlin.math.*

object MapUtils {
    /**
     * 두 지점 사이의 거리를 계산 (하버사인 공식 사용)
     * @return 거리 (미터 단위)
     */
    fun calculateDistance(point1: LatLng, point2: LatLng): Double {
        val earthRadius = 6371000.0 // 지구 반지름 (미터)
        
        val lat1Rad = Math.toRadians(point1.latitude)
        val lat2Rad = Math.toRadians(point2.latitude)
        val deltaLatRad = Math.toRadians(point2.latitude - point1.latitude)
        val deltaLngRad = Math.toRadians(point2.longitude - point1.longitude)
        
        val a = sin(deltaLatRad / 2).pow(2) +
                cos(lat1Rad) * cos(lat2Rad) * sin(deltaLngRad / 2).pow(2)
        val c = 2 * atan2(sqrt(a), sqrt(1 - a))
        
        return earthRadius * c
    }
    
    /**
     * 중심점과 반지름으로 원형 영역의 경계 점들을 생성
     * @param center 중심점
     * @param radiusInMeters 반지름 (미터)
     * @param numberOfPoints 경계를 구성할 점의 개수
     * @return 경계 점들의 리스트
     */
    fun createCircle(center: LatLng, radiusInMeters: Double, numberOfPoints: Int = 50): List<LatLng> {
        val points = mutableListOf<LatLng>()
        val earthRadius = 6371000.0
        
        for (i in 0 until numberOfPoints) {
            val angle = 2.0 * PI * i / numberOfPoints
            val dx = radiusInMeters * cos(angle)
            val dy = radiusInMeters * sin(angle)
            
            val deltaLat = dy / earthRadius
            val deltaLng = dx / (earthRadius * cos(Math.toRadians(center.latitude)))
            
            val lat = center.latitude + Math.toDegrees(deltaLat)
            val lng = center.longitude + Math.toDegrees(deltaLng)
            
            points.add(LatLng(lat, lng))
        }
        
        return points
    }
}

@Composable
fun GeometryMapDemo() {
    val centerPoint = LatLng(37.5666805, 126.9784147) // 서울시청
    val radius = 1000.0 // 1km
    
    val circlePoints = remember { MapUtils.createCircle(centerPoint, radius) }
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(centerPoint, 14f)
    }
    
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState
    ) {
        // 중심점 마커
        Marker(
            state = MarkerState(position = centerPoint),
            title = "중심점",
            snippet = "반지름 ${radius.toInt()}m 영역"
        )
        
        // 원형 영역 표시
        Polygon(
            points = circlePoints,
            fillColor = Color.Blue.copy(alpha = 0.3f),
            strokeColor = Color.Blue,
            strokeWidth = 2.dp
        )
        
        // 반지름을 나타내는 선
        Polyline(
            points = listOf(
                centerPoint,
                LatLng(centerPoint.latitude + 0.009, centerPoint.longitude) // 약 1km 북쪽
            ),
            color = Color.Red,
            width = 3.dp
        )
    }
}

4. Retrofit을 이용한 네트워크 통신

Google Maps와 함께 외부 API를 사용하는 경우가 많다. 예를 들어 장소 정보를 가져오거나 경로를 계산하는 등의 작업에서 Retrofit을 사용한 네트워크 통신이 필요하다.

Retrofit 설정 및 기본 구조

// build.gradle.kts (Module: app)
dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}

API 인터페이스 정의

// Places API 응답 데이터 클래스
data class PlacesResponse(
    val results: List<Place>,
    val status: String,
    val next_page_token: String?
)

data class Place(
    val place_id: String,
    val name: String,
    val geometry: Geometry,
    val vicinity: String?,
    val rating: Double?,
    val types: List<String>
)

data class Geometry(
    val location: Location
)

data class Location(
    val lat: Double,
    val lng: Double
) {
    fun toLatLng(): LatLng = LatLng(lat, lng)
}

// API 인터페이스
interface PlacesApiService {
    @GET("nearbysearch/json")
    suspend fun getNearbyPlaces(
        @Query("location") location: String,
        @Query("radius") radius: Int,
        @Query("type") type: String,
        @Query("key") apiKey: String
    ): Response<PlacesResponse>
    
    @GET("details/json")
    suspend fun getPlaceDetails(
        @Query("place_id") placeId: String,
        @Query("fields") fields: String = "name,rating,geometry,formatted_phone_number",
        @Query("key") apiKey: String
    ): Response<PlaceDetailsResponse>
}

data class PlaceDetailsResponse(
    val result: PlaceDetail,
    val status: String
)

data class PlaceDetail(
    val name: String,
    val rating: Double?,
    val geometry: Geometry,
    val formatted_phone_number: String?
)

Retrofit 인스턴스 생성

object NetworkModule {
    private const val BASE_URL = "https://maps.googleapis.com/maps/api/place/"
    
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }
    
    private val httpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build()
    
    val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(httpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    
    val placesApi: PlacesApiService = retrofit.create(PlacesApiService::class.java)
}

Repository 패턴으로 데이터 관리

class PlacesRepository {
    private val api = NetworkModule.placesApi
    
    suspend fun getNearbyRestaurants(
        location: LatLng,
        radiusInMeters: Int = 1500
    ): Result<List<Place>> {
        return try {
            val locationString = "${location.latitude},${location.longitude}"
            val response = api.getNearbyPlaces(
                location = locationString,
                radius = radiusInMeters,
                type = "restaurant",
                apiKey = BuildConfig.MAPS_API_KEY
            )
            
            if (response.isSuccessful) {
                val places = response.body()?.results ?: emptyList()
                Result.success(places)
            } else {
                Result.failure(Exception("API 호출 실패: ${response.code()}"))
            }
        } catch (e: Exception) {
            Log.e("PlacesRepository", "네트워크 오류", e)
            Result.failure(e)
        }
    }
    
    suspend fun getPlaceDetails(placeId: String): Result<PlaceDetail> {
        return try {
            val response = api.getPlaceDetails(
                placeId = placeId,
                apiKey = BuildConfig.MAPS_API_KEY
            )
            
            if (response.isSuccessful) {
                response.body()?.result?.let { detail ->
                    Result.success(detail)
                } ?: Result.failure(Exception("응답 데이터가 없습니다"))
            } else {
                Result.failure(Exception("API 호출 실패: ${response.code()}"))
            }
        } catch (e: Exception) {
            Log.e("PlacesRepository", "네트워크 오류", e)
            Result.failure(e)
        }
    }
}

ViewModel에서 데이터 관리

class MapViewModel : ViewModel() {
    private val repository = PlacesRepository()
    
    private val _places = mutableStateOf<List<Place>>(emptyList())
    val places: State<List<Place>> = _places
    
    private val _isLoading = mutableStateOf(false)
    val isLoading: State<Boolean> = _isLoading
    
    private val _errorMessage = mutableStateOf<String?>(null)
    val errorMessage: State<String?> = _errorMessage
    
    fun loadNearbyRestaurants(location: LatLng) {
        viewModelScope.launch {
            _isLoading.value = true
            _errorMessage.value = null
            
            repository.getNearbyRestaurants(location)
                .onSuccess { restaurantList ->
                    _places.value = restaurantList
                }
                .onFailure { exception ->
                    _errorMessage.value = exception.message
                    Log.e("MapViewModel", "장소 로딩 실패", exception)
                }
            
            _isLoading.value = false
        }
    }
    
    fun clearError() {
        _errorMessage.value = null
    }
}

통합된 지도 화면

@Composable
fun IntegratedMapScreen(
    viewModel: MapViewModel = viewModel()
) {
    val places by viewModel.places
    val isLoading by viewModel.isLoading
    val errorMessage by viewModel.errorMessage
    
    val seoulCenter = LatLng(37.5666805, 126.9784147)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(seoulCenter, 14f)
    }
    
    // 오류 메시지 표시
    errorMessage?.let { message ->
        LaunchedEffect(message) {
            // 스낵바나 토스트로 오류 표시
            Log.e("MapScreen", message)
            viewModel.clearError()
        }
    }
    
    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState,
            onMapClick = { latLng ->
                // 지도 클릭 시 해당 위치 주변의 레스토랑 검색
                viewModel.loadNearbyRestaurants(latLng)
            }
        ) {
            // API에서 가져온 장소들을 마커로 표시
            places.forEach { place ->
                Marker(
                    state = MarkerState(position = place.geometry.location.toLatLng()),
                    title = place.name,
                    snippet = "평점: ${place.rating ?: "정보 없음"} | ${place.vicinity ?: ""}",
                    icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_ORANGE)
                )
            }
        }
        
        // 로딩 인디케이터
        if (isLoading) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Black.copy(alpha = 0.3f)),
                contentAlignment = Alignment.Center
            ) {
                Card(
                    modifier = Modifier.padding(16.dp)
                ) {
                    Row(
                        modifier = Modifier.padding(16.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        CircularProgressIndicator(
                            modifier = Modifier.size(24.dp)
                        )
                        Spacer(modifier = Modifier.width(16.dp))
                        Text("주변 레스토랑을 검색하는 중...")
                    }
                }
            }
        }
        
        // 사용법 안내
        Card(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .padding(16.dp)
        ) {
            Text(
                text = "지도를 터치하여 주변 레스토랑을 검색하세요", modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium ) }
                        // 검색 결과 개수 표시
        if (places.isNotEmpty()) {
            Card(
                modifier = Modifier
                    .align(Alignment.BottomStart)
                    .padding(16.dp)
            ) {
                Text(
                    text = "찾은 레스토랑: ${places.size}개",
                    modifier = Modifier.padding(12.dp),
                    style = MaterialTheme.typography.bodySmall
                )
            }
        }
    }
}

 

5. 고급 지도 기능 구현

커스텀 마커 및 정보창

@Composable
fun CustomMarkerMapScreen() {
    val restaurants = remember {
        listOf(
            Restaurant(
                LatLng(37.5666805, 126.9784147),
                "한국식당",
                4.5f,
                "한식",
                R.drawable.ic_korean_food
            ),
            Restaurant(
                LatLng(37.5659, 126.9784),
                "이탈리안 레스토랑",
                4.2f,
                "이탈리안",
                R.drawable.ic_italian_food
            ),
            Restaurant(
                LatLng(37.5673, 126.9785),
                "일본식당",
                4.7f,
                "일식",
                R.drawable.ic_japanese_food
            )
        )
    }
    
    var selectedRestaurant by remember { mutableStateOf<Restaurant?>(null) }
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(restaurants.first().location, 16f)
    }
    
    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            restaurants.forEach { restaurant ->
                Marker(
                    state = MarkerState(position = restaurant.location),
                    title = restaurant.name,
                    snippet = "평점: ${restaurant.rating} | ${restaurant.cuisine}",
                    onClick = { marker ->
                        selectedRestaurant = restaurant
                        true // true를 반환하면 기본 정보창이 표시되지 않음
                    }
                )
            }
        }
        
        // 커스텀 정보창
        selectedRestaurant?.let { restaurant ->
            RestaurantInfoCard(
                restaurant = restaurant,
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(16.dp),
                onDismiss = { selectedRestaurant = null }
            )
        }
    }
}

@Composable
fun RestaurantInfoCard(
    restaurant: Restaurant,
    modifier: Modifier = Modifier,
    onDismiss: () -> Unit
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = restaurant.name,
                    style = MaterialTheme.typography.headlineSmall,
                    fontWeight = FontWeight.Bold
                )
                IconButton(onClick = onDismiss) {
                    Icon(Icons.Default.Close, contentDescription = "닫기")
                }
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Row(
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    Icons.Default.Star,
                    contentDescription = "평점",
                    tint = Color(0xFFFFD700)
                )
                Spacer(modifier = Modifier.width(4.dp))
                Text(
                    text = restaurant.rating.toString(),
                    style = MaterialTheme.typography.bodyMedium
                )
                Spacer(modifier = Modifier.width(16.dp))
                Chip(
                    onClick = { },
                    label = { Text(restaurant.cuisine) }
                )
            }
            
            Spacer(modifier = Modifier.height(12.dp))
            
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                Button(
                    onClick = { /* 전화걸기 */ },
                    modifier = Modifier.weight(1f)
                ) {
                    Icon(Icons.Default.Phone, contentDescription = null)
                    Spacer(modifier = Modifier.width(4.dp))
                    Text("전화")
                }
                
                Spacer(modifier = Modifier.width(8.dp))
                
                Button(
                    onClick = { /* 길찾기 */ },
                    modifier = Modifier.weight(1f)
                ) {
                    Icon(Icons.Default.Directions, contentDescription = null)
                    Spacer(modifier = Modifier.width(4.dp))
                    Text("길찾기")
                }
            }
        }
    }
}

data class Restaurant(
    val location: LatLng,
    val name: String,
    val rating: Float,
    val cuisine: String,
    val iconResource: Int
)

 

경로 표시 및 네비게이션

@Composable
fun NavigationMapScreen() {
    val startPoint = LatLng(37.5666805, 126.9784147) // 서울시청
    val endPoint = LatLng(37.5759, 126.9768) // 경복궁
    var routePoints by remember { mutableStateOf<List<LatLng>>(emptyList()) }
    var distance by remember { mutableStateOf("") }
    var duration by remember { mutableStateOf("") }
    
    val cameraPositionState = rememberCameraPositionState()
    
    // 경로 데이터를 가져오는 함수
    LaunchedEffect(startPoint, endPoint) {
        // 실제로는 Directions API를 호출해야 하지만, 여기서는 직선으로 시뮬레이션
        routePoints = createSampleRoute(startPoint, endPoint)
        distance = "약 ${MapUtils.calculateDistance(startPoint, endPoint).toInt()}m"
        duration = "도보 약 15분"
        
        // 경로 전체가 보이도록 카메라 조정
        val bounds = LatLngBounds.builder()
            .include(startPoint)
            .include(endPoint)
            .build()
        
        cameraPositionState.move(
            CameraUpdateFactory.newLatLngBounds(bounds, 100)
        )
    }
    
    Column {
        // 경로 정보 카드
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Icon(Icons.Default.StraightLine, contentDescription = "거리")
                    Text(distance, style = MaterialTheme.typography.bodySmall)
                }
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Icon(Icons.Default.AccessTime, contentDescription = "시간")
                    Text(duration, style = MaterialTheme.typography.bodySmall)
                }
            }
        }
        
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            // 출발점 마커
            Marker(
                state = MarkerState(position = startPoint),
                title = "출발지",
                snippet = "서울시청",
                icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN)
            )
            
            // 도착점 마커
            Marker(
                state = MarkerState(position = endPoint),
                title = "도착지",
                snippet = "경복궁",
                icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED)
            )
            
            // 경로 표시
            if (routePoints.isNotEmpty()) {
                Polyline(
                    points = routePoints,
                    color = Color.Blue,
                    width = 8.dp,
                    pattern = listOf(
                        Dot(),
                        Gap(10.dp)
                    )
                )
            }
        }
    }
}

fun createSampleRoute(start: LatLng, end: LatLng): List<LatLng> {
    // 실제 환경에서는 Google Directions API를 사용해야 한다
    val points = mutableListOf<LatLng>()
    val steps = 20
    
    for (i in 0..steps) {
        val ratio = i.toDouble() / steps
        val lat = start.latitude + (end.latitude - start.latitude) * ratio
        val lng = start.longitude + (end.longitude - start.longitude) * ratio
        
        // 약간의 곡선을 추가하여 더 현실적인 경로 만들기
        val offset = sin(ratio * PI) * 0.001
        points.add(LatLng(lat + offset, lng))
    }
    
    return points
}

6. 성능 최적화 및 모범 사례

지도 성능 최적화

@Composable
fun OptimizedMapScreen() {
    val markers = remember { mutableStateListOf<MarkerData>() }
    var mapProperties by remember {
        mutableStateOf(
            MapProperties(
                isMyLocationEnabled = true,
                mapType = MapType.NORMAL,
                // 성능 향상을 위한 설정
                isTrafficEnabled = false,
                isBuildingEnabled = false,
                isIndoorEnabled = false
            )
        )
    }
    
    val cameraPositionState = rememberCameraPositionState()
    
    // 카메라 이동이 완료될 때만 마커를 업데이트
    LaunchedEffect(cameraPositionState.isMoving) {
        if (!cameraPositionState.isMoving) {
            // 현재 화면에 보이는 영역에 대해서만 마커 로드
            updateMarkersForVisibleRegion(cameraPositionState.position, markers)
        }
    }
    
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
        properties = mapProperties,
        uiSettings = MapUiSettings(
            zoomControlsEnabled = false, // 커스텀 컨트롤 사용
            myLocationButtonEnabled = false, // 커스텀 버튼 사용
            mapToolbarEnabled = false // 불필요한 툴바 숨김
        )
    ) {
        // 화면에 보이는 마커만 렌더링
        markers.forEach { markerData ->
            if (isMarkerVisible(markerData.position, cameraPositionState)) {
                Marker(
                    state = MarkerState(position = markerData.position),
                    title = markerData.title,
                    tag = markerData.id // 마커 식별을 위한 태그
                )
            }
        }
    }
}

fun isMarkerVisible(markerPosition: LatLng, cameraPositionState: CameraPositionState): Boolean {
    val projection = cameraPositionState.projection
    return projection?.visibleRegion?.latLngBounds?.contains(markerPosition) ?: true
}

suspend fun updateMarkersForVisibleRegion(
    cameraPosition: CameraPosition,
    markers: MutableList<MarkerData>
) {
    // 실제로는 현재 보이는 영역에 대해서만 API 호출
    withContext(Dispatchers.IO) {
        // API 호출 로직
        val newMarkers = fetchMarkersForRegion(cameraPosition)
        withContext(Dispatchers.Main) {
            markers.clear()
            markers.addAll(newMarkers)
        }
    }
}

data class MarkerData(
    val id: String,
    val position: LatLng,
    val title: String
)

메모리 관리

@Composable
fun MemoryEfficientMapScreen() {
    val context = LocalContext.current
    
    // 큰 비트맵 이미지를 효율적으로 관리
    val markerIcon = remember {
        BitmapDescriptorFactory.fromResource(R.drawable.custom_marker)
    }
    
    // DisposableEffect를 사용하여 리소스 정리
    DisposableEffect(Unit) {
        onDispose {
            // 지도 관련 리소스 정리
            markerIcon?.let {
                // 필요한 경우 비트맵 리소스 해제
            }
        }
    }
    
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        onMapLoaded = {
            // 지도 로딩 완료 후 초기화 작업
            Log.d("MapScreen", "지도 로딩 완료")
        }
    ) {
        // 마커 구현
    }
}

7. 오류 처리 및 디버깅

로깅 및 디버깅

object MapLogger {
    private const val TAG = "MapDebug"
    
    fun logCameraPosition(position: CameraPosition) {
        Log.d(TAG, "카메라 위치: ${position.target}, 줌: ${position.zoom}")
    }
    
    fun logNetworkError(error: Throwable) {
        Log.e(TAG, "네트워크 오류: ${error.message}", error)
    }
    
    fun logMarkerClick(markerTitle: String) {
        Log.d(TAG, "마커 클릭: $markerTitle")
    }
}

@Composable
fun DebuggableMapScreen() {
    val cameraPositionState = rememberCameraPositionState()
    var errorState by remember { mutableStateOf<String?>(null) }
    
    // 카메라 위치 변경 로깅
    LaunchedEffect(cameraPositionState.position) {
        MapLogger.logCameraPosition(cameraPositionState.position)
    }
    
    if (errorState != null) {
        AlertDialog(
            onDismissRequest = { errorState = null },
            title = { Text("오류 발생") },
            text = { Text(errorState ?: "") },
            confirmButton = {
                TextButton(onClick = { errorState = null }) {
                    Text("확인")
                }
            }
        )
    }
    
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState,
        onMapClick = { latLng ->
            Log.d("MapDebug", "지도 클릭: $latLng")
        }
    ) {
        // 마커 구현
    }
}

API 키 검증

object ApiKeyValidator {
    fun validateMapsApiKey(context: Context): Boolean {
        return try {
            val ai = context.packageManager.getApplicationInfo(
                context.packageName,
                PackageManager.GET_META_DATA
            )
            val apiKey = ai.metaData?.getString("com.google.android.geo.API_KEY")
            
            when {
                apiKey.isNullOrEmpty() -> {
                    Log.e("ApiValidator", "API 키가 설정되지 않았습니다")
                    false
                }
                apiKey.length < 20 -> {
                    Log.e("ApiValidator", "API 키 형식이 올바르지 않습니다")
                    false
                }
                else -> {
                    Log.d("ApiValidator", "API 키 검증 성공")
                    true
                }
            }
        } catch (e: Exception) {
            Log.e("ApiValidator", "API 키 검증 중 오류 발생", e)
            false
        }
    }
}

8. 실전 응용: 완전한 지도 애플리케이션

모든 개념을 종합하여 완전한 지도 애플리케이션을 만들어보자.

@Composable
fun CompleteMapApplication() {
    val viewModel: MapViewModel = viewModel()
    val places by viewModel.places
    val isLoading by viewModel.isLoading
    val errorMessage by viewModel.errorMessage
    
    var showSearchDialog by remember { mutableStateOf(false) }
    var selectedPlace by remember { mutableStateOf<Place?>(null) }
    
    val seoulCenter = LatLng(37.5666805, 126.9784147)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(seoulCenter, 12f)
    }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("지도 앱") },
                actions = {
                    IconButton(onClick = { showSearchDialog = true }) {
                        Icon(Icons.Default.Search, contentDescription = "검색")
                    }
                }
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = {
                    // 현재 위치로 이동
                    cameraPositionState.move(
                        CameraUpdateFactory.newLatLngZoom(seoulCenter, 15f)
                    )
                }
            ) {
                Icon(Icons.Default.MyLocation, contentDescription = "내 위치")
            }
        }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            GoogleMap(
                modifier = Modifier.fillMaxSize(),
                cameraPositionState = cameraPositionState,
                properties = MapProperties(isMyLocationEnabled = true),
                onMapClick = { latLng ->
                    viewModel.loadNearbyPlaces(latLng)
                }
            ) {
                places.forEach { place ->
                    Marker(
                        state = MarkerState(position = place.geometry.location.toLatLng()),
                        title = place.name,
                        snippet = place.vicinity,
                        onClick = { marker ->
                            selectedPlace = place
                            true
                        }
                    )
                }
            }
            
            // 로딩 인디케이터
            if (isLoading) {
                Card(
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(16.dp)
                ) {
                    Row(
                        modifier = Modifier.padding(16.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        CircularProgressIndicator(modifier = Modifier.size(24.dp))
                        Spacer(modifier = Modifier.width(16.dp))
                        Text("검색 중...")
                    }
                }
            }
            
            // 장소 정보 카드
            selectedPlace?.let { place ->
                PlaceInfoBottomSheet(
                    place = place,
                    onDismiss = { selectedPlace = null },
                    modifier = Modifier.align(Alignment.BottomCenter)
                )
            }
        }
    }
    
    // 검색 다이얼로그
    if (showSearchDialog) {
        SearchDialog(
            onDismiss = { showSearchDialog = false },
            onSearch = { query ->
                // 검색 로직 구현
                showSearchDialog = false
            }
        )
    }
    
    // 오류 처리
    errorMessage?.let { message ->
        LaunchedEffect(message) {
            Log.e("MapApp", message)
            viewModel.clearError()
        }
    }
}

@Composable
fun PlaceInfoBottomSheet(
    place: Place,
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(
                    text = place.name,
                    style = MaterialTheme.typography.headlineSmall,
                    modifier = Modifier.weight(1f)
                )
                IconButton(onClick = onDismiss) {
                    Icon(Icons.Default.Close, contentDescription = "닫기")
                }
            }
            
            if (place.rating != null) {
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = Modifier.padding(vertical = 8.dp)
                ) {
                    Icon(
                        Icons.Default.Star,
                        contentDescription = "평점",
                        tint = Color(0xFFFFD700)
                    )
                    Spacer(modifier = Modifier.width(4.dp))
                    Text(place.rating.toString())
                }
            }
            
            place.vicinity?.let { address ->
                Text(
                    text = address,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            Button(
                onClick = { /* 상세 정보 보기 */ },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("상세 정보")
            }
        }
    }
}

@Composable
fun SearchDialog(
    onDismiss: () -> Unit,
    onSearch: (String) -> Unit
) {
    var searchText by remember { mutableStateOf("") }
    
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("장소 검색") },
        text = {
            OutlinedTextField(
                value = searchText,
                onValueChange = { searchText = it },
                label = { Text("검색어를 입력하세요") },
                modifier = Modifier.fillMaxWidth()
            )
        },
        confirmButton = {
            TextButton(
                onClick = { onSearch(searchText) },
                enabled = searchText.isNotBlank()
            ) {
                Text("검색")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("취소")
            }
        }
    )
}

 

여기서는 Android Jetpack Compose에서 Google Maps API를 사용하는 완전한 방법을 살펴보았다. API 키 설정부터 시작하여 기본적인 지도 표시, LatLng 객체 활용, Retrofit을 이용한 네트워크 통신, 그리고 실전에서 사용할 수 있는 완전한 애플리케이션 구현까지 다뤘다.

핵심 포인트를 정리하자면:

보안: API 키는 반드시 제한을 설정하고 안전하게 관리해야 한다. 소스 코드에 직접 하드코딩하지 말고 local.properties나 환경 변수를 활용해야한다.

성능: 마커가 많은 경우 화면에 보이는 영역에 대해서만 렌더링하고, 불필요한 지도 기능은 비활성화하여 성능을 최적화해야한다.

사용자 경험: 적절한 로딩 상태 표시, 오류 처리, 그리고 직관적인 인터페이스를 제공하여 사용자가 쉽게 사용할 수 있도록 해야한다.

데이터 관리: Repository 패턴과 ViewModel을 활용하여 지도 데이터를 체계적으로 관리하고, Retrofit을 통해 안정적인 네트워크 통신을 구현해야한다.