자유로운 개발일지/APP

[APP] 우당탕탕 앱 만들기

그낙이 2025. 5. 18. 23:04
반응형

Kotlin으로 만드는 우당탕탕 버스 도착 알림 앱 [4편]

이 글은 프론트와 백엔드 API 연동을 진행하는 과정입니다. 앞선 글에서 화면 UI, 백엔드에서 공공API로 데이터를 받아왔다면 이제 프론트와 백엔드를 연결시켜 휴대폰 화면에서 버스 정보를 조회하고, 정류소와 버스 번호를 즐겨찾기에 등록합시다. 우선 폴더 구조입니다. 

com.example.busalarm
├── api/                        // 서버 통신 (Retrofit 관련)
│   ├── BusApiService.kt        // Retrofit 인터페이스: GET/POST 정의
│   └── RetrofitInstance.kt     // Retrofit 객체 생성 및 baseUrl 설정
│
├── model/                      // 서버 응답 매핑스
│   ├── BusInfo.kt              // 개별 버스 정보 (DTO): busNumber, stationId 등
│   └── BusArrivalResponse.kt   // 전체 응답 구조: List<BusInfo> 를 감싸는 래퍼
│
├── storage/                    // 로컬 저장소 관련 (SharedPreferences, Room 등)
│   └── BusStorage.kt           // 버스 정보를 저장하거나 불러오는 클래스
│
├── viewmodel/                  // 앱의 상태 관리, 로직 처리 (MVVM 구조)
│   └── BusViewModel.kt         // 30초마다 API 요청, LiveData or State 관리 등
│
└── MainActivity.kt

 

백엔드 데이터 받아오기

3편에서 넘겨주는 데이터 형식을 한글에서 영어로 수정하여 Kotlin에서 사용하기 쉽도록 수정해줍시다. 지난 3편에서는 하나의 정류소에 2대를 받아오는 줄 알았는데, 2개의 정류소에서 1개씩 받아오는 구조였습니다. 코드도 같이 수정해줍시다. 

for item in item_list:
    if item.get("busRouteAbrv") == str(number):
        bus_arrival.append({
            "arsId": ars_id,
            "bus_no": item["busRouteAbrv"],
            "arrmsg1": item.get("arrmsg1"),
            "stationNm1": item.get("stationNm1"),
            "traTime1": item.get("traTime1"),
            "arrmsg2": item.get("arrmsg2"),
            "stationNm1": item.get("stationNm2"),
            "traTime1": item.get("traTime2"),
        })
@router.get("/fetch_bus_info")
async def get_station_by_name(name: str, number: int):
    
    ars_ids = await fetch_bus_station_id("getStationByName", {"stSrch": name})
    bus_arrival = await fetch_bus_station_info("getStationByUid", ars_ids, number)

    formatted_result = []

    for item in bus_arrival:
        time_text1, stops1 = parse_arrmsg(item.get("arrmsg1", ""))
        time_text2, stops2 = parse_arrmsg(item.get("arrmsg2", ""))
        formatted_result.append({
            "stationId": item["arsId"],
            "stationName": item["station_name"],
            "busNumber": item["bus_no"],
            "currentLocation1": item.get("stationNm1"),
            "arrivalTimeText1": time_text1,
            "remainingStops1": stops1,
            "remainingSeconds1": item.get("traTime1"),
            "currentLocation2": item.get("stationNm2"),
            "arrivalTimeText2": time_text2,
            "remainingStops2": stops2,
            "remainingSeconds2": item.get("traTime2"),
        })

    print(formatted_result)
    return {"result": formatted_result}

 

그 다음은 안드로이드 스튜디오에서 데이터를 받아올 형식인 모델을 정의해줍시다.  

package com.example.busalarm.model

data class BusInfo(
    val stationId: String,
    val stationName: String, 
    val busNumber: String,
    val currentLocation1: String,
    val arrivalTimeText1: String,
    val remainingStops1: Int?,
    val remainingSeconds1: String,
    val currentLocation2: String,
    val arrivalTimeText2: String,
    val remainingStops2: Int?,
    val remainingSeconds2: String
)
package com.example.busalarm.model

data class BusArrivalResponse(
    val result: List<BusInfo>
)

 

간단한 모델링이 끝이 났습니다. 다음은 API 호출 관련 코드입니다. Android에서도 Hilt나 Koin 같은 DI 프레임워크를 사용할 수 있지만, 이번 프로젝트에서는 구조를 단순화하기 위해 object 기반 싱글톤으로 Retrofit 인스턴스를 직접 관리했습니다.

package com.example.busalarm.api

import com.example.busalarm.model.BusArrivalResponse
import retrofit2.http.GET
import retrofit2.http.Query

interface BusApiService {
    // 백엔드: /fetch_bus_info?name=강남역&number=7016
    @GET("/fetch_bus_info")
    suspend fun getBusArrivals(
        @Query("name") name: String,
        @Query("number") number: Int
    ): BusArrivalResponse
}
package com.example.busalarm.api

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitInstance {
    private const val BASE_URL = "http://10.0.2.2:8001"

    val api: BusApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(BusApiService::class.java)
    }
}

 

이제 역을 입력하고, 버스 번호를 입력했을 때 백엔드에 호출을 보내고, UI에 전달하는 BusViewModel을 작성해줍시다. 

package com.example.busalarm.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.busalarm.api.RetrofitInstance
import com.example.busalarm.model.BusInfo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import android.util.Log

class BusViewModel : ViewModel() {
    private val _busList = MutableStateFlow<List<BusInfo>>(emptyList())
    val busList: StateFlow<List<BusInfo>> = _busList

    fun fetchBusInfo(name: String, number: Int) {
        viewModelScope.launch {
            try {
                val response = RetrofitInstance.api.getBusArrivals(name, number)
                _busList.value = response.result
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
}

 

마지막으로 MainActivity.kt에서 등록하기 버튼을 클릭 시, fetchBusInfo 함수를 호출해주면, 

@Composable
fun BusAlarmScreen(
    modifier: Modifier = Modifier,
    viewModel: BusViewModel
) {
    var stationInput by remember { mutableStateOf(TextFieldValue("")) }
    var routeInput by remember { mutableStateOf(TextFieldValue("")) }

    val busList by viewModel.busList.collectAsState()

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        Text("🚍 버스 알람 설정", style = MaterialTheme.typography.headlineSmall)

        OutlinedTextField(
            value = stationInput,
            onValueChange = { stationInput = it },
            label = { Text("정류장 입력") },
            modifier = Modifier.fillMaxWidth()
        )

        OutlinedTextField(
            value = routeInput,
            onValueChange = { routeInput = it },
            label = { Text("버스 번호 입력") },
            modifier = Modifier.fillMaxWidth()
        )

        Button(
            onClick = {
                val name = stationInput.text.trim()
                val number = routeInput.text.trim().toIntOrNull()
                if (name.isNotEmpty() && number != null) {
                    viewModel.fetchBusInfo(name, number)
                }
            },
            modifier = Modifier.align(Alignment.End)
        ) {
            Text("등록하기")
        }

        Divider(thickness = 1.dp)

        Text("🚌 실시간 도착 정보", style = MaterialTheme.typography.titleMedium)
        if (busList.isEmpty()) {
            Text("조회된 정보가 없습니다.", style = MaterialTheme.typography.bodyMedium)
        } else {
            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
                busList.forEach { bus ->
                    Text(
                        text = "${bus.busNumber}번 버스\n" +
                                "1번째 버스: ${bus.arrivalTimeText1} (${bus.remainingStops1}정거장 남음, ${bus.currentLocation1})\n" +
                                "2번째 버스: ${bus.arrivalTimeText2} (${bus.remainingStops2}정거장 남음, ${bus.currentLocation2})",
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
}

 

데이터가 잘 넘어오는 것을 볼 수 있습니다. 삼성역 & 4319 조합의 정류소가 2개라서 정류소당 2개씩 총 4개의 버스 시간표를 볼 수 있습니다. 그렇다면 나중에는 검색 시에 정류소 위치를 지도로 띄워서 정류소도 특정할 수 있게 해주는게 필수가 될 것 같습니다.


즐겨찾기 등록하기

지금은 등록하기를 눌러야 데이터를 받아오지만, 실제로 서비스를 운영하게 된다면 지도를 띄우고, 그 위에서 등록하는 구조가 되어야 할 것 같습니다. 등록하기는 검색하기로 이름을 바꾸고요. 결과적으로 지금 실시간 도착 정보에 있는 값들에는 즐겨찾기된 정류소 ID, 정류소 이름, 버스 번호가 한 묶음으로 총 2대 씩 보여지게 되겠네요. 우선은 MVP 구현을 위해서 등록하기 버튼 클릭 시 바로 로컬 스토리지에 저장되게 해줍시다. 아래는 모델입니다. 아래 정보만 있으면 계속해서 버스 위치를 추적할 수 있습니다.  

package com.example.busalarm.storage

data class BusStorage(
    val arsId: String,
    val stationName: String,
    val busNumber: Int
)

 

이걸 다시 객체로 만들어 관리합니다. Gson을 이용해 객체를 JSON 문자열로 직렬화하여 저장하고, 다시 역직렬화하여 꺼냅니다.

package com.example.busalarm.util

import android.content.Context
import com.example.busalarm.storage.BusStorage
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken

object BusStorageManager {
    private const val PREF_NAME = "bus_storage_pref"
    private const val KEY = "stored_buses"

    fun save(context: Context, list: List<BusStorage>) {
        val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
        val json = Gson().toJson(list)
        prefs.edit().putString(KEY, json).apply()
    }

    fun load(context: Context): List<BusStorage> {
        val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
        val json = prefs.getString(KEY, null) ?: return emptyList()
        val type = object : TypeToken<List<BusStorage>>() {}.type
        return Gson().fromJson(json, type)
    }
}

 

즐겨찾기에 등록하기 위해 로컬 스토리지를 사용합시다. BusViewModel에 즐겨찾기 로직을 추가합니다. 등록하기 버튼을 클릭했을 때, (원래대로라면 지도에서 정류소도 선택을 하기 때문에) 바로 저장까지 이어지게 만들어봅시다.

class BusViewModel : ViewModel() {

    // 실시간 버스 정보 리스트
    private val _busList = MutableStateFlow<List<BusInfo>>(emptyList())
    val busList: StateFlow<List<BusInfo>> = _busList

    // 즐겨찾기 저장 리스트
    private val _favorites = MutableStateFlow<List<BusStorage>>(emptyList())
    val favorites: StateFlow<List<BusStorage>> = _favorites

    // 실시간 버스 정보 가져오는 함수
    fun fetchBusInfo(name: String, number: Int, context: Context) {
        viewModelScope.launch {
            try {
                val response = RetrofitInstance.api.getBusArrivals(name, number)
                _busList.value = response.result

                val first = response.result.firstOrNull()
                if (first != null) {
                    val favorite = BusStorage(arsId = first.stationId,stationName=first.stationName,busNumber = first.busNumber)
                    addFavorite(context, favorite)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    // 즐겨찾기 불러오기
    fun loadFavorites(context: Context) {
        _favorites.value = BusStorageManager.load(context)
    }

    // 즐겨찾기 추가 (중복 방지 포함)
    fun addFavorite(context: Context, favorite: BusStorage) {
        val current = _favorites.value.toMutableList()
        val exists = current.any { it.arsId == favorite.arsId && it.busNumber == favorite.busNumber }
        if (!exists) {
            current.add(favorite)
            _favorites.value = current
            BusStorageManager.save(context, current)
        }
    }

    // 즐겨찾기 삭제 (선택)
    fun removeFavorite(context: Context, favorite: BusStorage) {
        val updated = _favorites.value.filterNot {
            it.arsId == favorite.arsId && it.busNumber == favorite.busNumber
        }
        _favorites.value = updated
        BusStorageManager.save(context, updated)
    }
}

 

MainActivity.kt도 로컬에서 즐겨찾기 등록된 정류소를 바로 가져오도록 해봅시다.

class MainActivity : ComponentActivity() {
    private val busViewModel by viewModels<BusViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        busViewModel.loadFavorites(this)
        enableEdgeToEdge()
        setContent {
            BusAlarmTheme {
                Scaffold(
                    modifier = Modifier.fillMaxSize()
                ) { innerPadding ->
                    BusAlarmScreen(
                        modifier = Modifier.padding(innerPadding),
                        viewModel = busViewModel
                    )
                }
            }
        }
    }
}

@Composable
fun BusAlarmScreen(
    modifier: Modifier = Modifier,
    viewModel: BusViewModel
) {
    var stationInput by remember { mutableStateOf(TextFieldValue("")) }
    var routeInput by remember { mutableStateOf(TextFieldValue("")) }

    val context = LocalContext.current
    val busList by viewModel.busList.collectAsState()
    val favorites by viewModel.favorites.collectAsState()

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        Text("🚍 버스 알람 설정", style = MaterialTheme.typography.headlineSmall)

        OutlinedTextField(
            value = stationInput,
            onValueChange = { stationInput = it },
            label = { Text("정류장 입력") },
            modifier = Modifier.fillMaxWidth()
        )

        OutlinedTextField(
            value = routeInput,
            onValueChange = { routeInput = it },
            label = { Text("버스 번호 입력") },
            modifier = Modifier.fillMaxWidth()
        )

        Button(
            onClick = {
                val name = stationInput.text.trim()
                val number = routeInput.text.trim().toIntOrNull()
                if (name.isNotEmpty() && number != null) {
                    viewModel.fetchBusInfo(name, number, context)
                }
            },
            modifier = Modifier.align(Alignment.End)
        ) {
            Text("등록하기")
        }

        Divider(thickness = 1.dp)

        Text("🚌 실시간 도착 정보", style = MaterialTheme.typography.titleMedium)

        if (busList.isEmpty()) {
            Text("조회된 정보가 없습니다.", style = MaterialTheme.typography.bodyMedium)
        } else {
            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
                busList.forEach { bus ->
                    Text(
                        text = "${bus.stationName} ${bus.busNumber}번 버스\n" +
                                "1번째 버스: ${bus.arrivalTimeText1} (${bus.remainingStops1}정거장 남음, ${bus.currentLocation1})\n" +
                                "2번째 버스: ${bus.arrivalTimeText2} (${bus.remainingStops2}정거장 남음, ${bus.currentLocation2})",
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }

        Divider(thickness = 1.dp)

        Text("⭐ 즐겨찾기 목록", style = MaterialTheme.typography.titleMedium)

        if (favorites.isEmpty()) {
            Text("저장된 즐겨찾기가 없습니다.", style = MaterialTheme.typography.bodyMedium)
        } else {
            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
                favorites.forEach { fav ->
                    Text(
                        text = "정류소: ${fav.stationName}, 정류소 ID: ${fav.arsId}, 버스 번호: ${fav.busNumber}",
                        style = MaterialTheme.typography.bodySmall
                    )
                }
            }
        }
    }
}

 

 

다음 글 안내

 

이번 글에서는 백엔드와 프론트를 연동해서 버스 도착 정보를 받아오고, 화면에 띄워주고, 로컬에 즐겨찾기를 저장하는 기능까지 구현해보았습니다. 사실 FCM을 이용한 푸쉬 알람 기능과 ForegroundService를 통한 상단바 표시까지 다루려 했지만, 구현 난이도와 Kotlin 생태계가 익숙하지 않아 다음 글로 미루게 됐습니다. 하지만, 이제 진짜 네이티브 영역인 FCM, 상단바를 구현할 때가 온 것 같습니다. 다음글에서 FCM, 상단바를 표시하는 과정을 진행할 예정입니다.

 

이번 편까지는 일반적인 웹개발에 가까웠다면, 다음 편은 딥한 네이티브가 되겠네요ㅠㅠ..

반응형