Android/Kotlin

<정리> SelfCheckout & BoxReading

re트 2025. 1. 31. 17:12
728x90

산업용 PDA 회사에 들어가서 앱 만들 일은 없겠다 했는데 생각외로 수습기간 중에 외부 전시회 시연 앱을 맡게 되었다.

혼자 만들거나 학교나 학원 팀플이 아닌 회사에서 진행하는 것이다보니 긴장이 많이 되었지만 생각보다 진행에 큰 문제가 없었다.

 

내가 맡은 앱의 역할은 다른 단말기에 받은 데이터를 서버를 통해 받아와서 화면에 보여주고 버튼을 클릭 시 포맷에 맞는 데이터를 서버로 넘겨주는 것이었다.

세부적으로 들어가면 여러 제한사항이나 요구사항들이 있었지만 간단하게는 저렇게 말할 수 있다.

 

두 앱 모두 비슷한 로직을 사용했고 UI도 비슷했다.

그렇기에 짧은 시간에 두 개의 앱을 완성할 수 있었다.


데이터 통신에 사용된 것은 MQTT였다.

*MQTT : 낮은 대역폭과 리소스가 제한된 환경에서 안정적으로 통신을 할 수 있는 경량 메시지 프로토콜

MQTT 통신은 클라이언트와 브로커로 구성되어있는데, 앱 단에서는 클라이언트 쪽의 Topic 메세지를 발행하는 것과 Topic 메세지를 수신하는 것, 수신하기 원하는 Topic 구독하는 것을 처리하면 됐다.

일단 클라이언트를 선언해야하기 때문에 MqttClient 클래스를 이용하여 초기화했다.

private val client: MqttClient by lazy {
    MqttClient("tcp://" + mainViewModel.ipAddress, MqttClient.generateClientId(), null)
}

첫번째 인자는 서버 IP, 두번째 인자는 클라이언트 ID, 세번째 인자는 클라이언트가 메시지를 유지할 방식을 지정하는 건데 null이면 메세지를 지속적으로 저장하지 않게 된다.

 

클라이언트 연결은 connect 메서드로 간단하게 가능했고 그 전에 연결 완료, 연결 끊김, 메세지 도착, 발행 완료에 대한 콜백을 setCallback 메서드를 통해 진행해줬다.

private fun connectMqttServer() {
    client.setCallback(object : MqttCallbackExtended {
        override fun connectComplete(reconnect: Boolean, serverURI: String?) {
            // 클라이언트와 브로커가 연결됐을 때 수행할 코드
        }
        override fun connectionLost(cause: Throwable?) {
            // 클라이언트와 브로커의 연결이 끊겼을 때 수행할 코드
        }
        override fun messageArrived(topic: String?, message: MqttMessage?) {
            // 구독 중인 Topic의 메세지가 도착했을 때 수행할 코드
        }
        override fun deliveryComplete(token: IMqttDeliveryToken?) {
            // 특정 Topic으로 보낸 메세지가 브로커에게 도착했을 때 수행할 코드
        }
    })
    
    client.connect()
}

 

연결을 끊을 때는 disconnect 메서드를 사용했고 구독도 그 전에 끊었다.

private fun disconnectMqttServer() {
    if (client.isConnected) {
        client.unsubscribe(Topic명)
        client.disconnect()
    }
}

 

Topic은 클라이언트에 대한 메세지를 필터링하기 위해 /로 구분되어있는 문자열이면 되고 서버를 담당하고 있는 분께서 주시는대로 사용했다.

프로젝트에서는 Topic을 관리하는 클래스를 만들어 사용했다.

class Topic {
    companion object {
        const val TAG_DATA = "/상위Topic/하위Topic"
        const val RFID_COM = "/상위Topic/하위Topic"
        const val RFID_RES = "/상위Topic/하위Topic"
        const val PAY_COMMAND = "/상위Topic/하위Topic"
        const val PAY_RESPONSE = "/상위Topic/하위Topic"
    }
}

 

Topic에 맞는 메세지를 보내는 방법은 JSON 형태로 정해졌기 때문에 JSONArray 객체와 JSONObject 객체, MqttMessage 객체를 사용했다.

일반 값이면 JSONObject에서 put하는 것으로 추가했고, 배열같이 여러값이 함께 묶여야한다면 JSONArray에 JSONObject를 추가했다.

그리고 모든 값을 가지고 있는 JSONObject 객체를 toString 메서드를 통해 문자열로 변경한 다음 MqttMessage 객체에 ByteArray 형태로 넣어줬다.

publish 메서드에 Topic과 MqttMessage 객체를 넣어 실행하면 서버로 해당 메세지가 넘어간다.

private fun publishMessageWithItems() {
    val jsonArray = JSONArray()
    for (item in mainViewModel.listInfo) {
        val innerObj = JSONObject()
        innerObj.put("name", item.name)
        innerObj.put("price", item.price)
        innerObj.put("quantity", item.quantity)
        jsonArray.put(innerObj)
    }
    
    val obj = JSONObject()
    obj.put("transaction_id", getTransactionIdWithToday())
    obj.put("type", "Sale")
    obj.put("amount", mainViewModel.listInfo.sumOf { it.price * it.quantity })
    obj.put("items", jsonArray)
    val reqPayload = obj.toString()
    val reqMessage = MqttMessage(reqPayload.toByteArray())
    reqMessage.qos = 2
    reqMessage.isRetained = false
    client.publish(Topic.PAY_COMMAND, reqMessage)
}

여기서 qos와 isRetained라는게 있는데 qos는 QoS(Quality of Service)로 3단계가 있다.

0

> 메세지를 한번만 전달하고 수신 과정은 체크하지 않음

> 메세지를 받지 못하는 클라이언트가 있을 수도 있음

1

> 메세지를 한번 이상 전달하고 수신 과정을 널널히 체크함

> 중복 수신되는 클라이언트가 있을 수도 있음

2

> 메세지를 한번만 전달하고 수신 과정을 확실히 체크함

> 정확히 딱 1번 클라이언트로 전송됨을 보장함

단계가 높아질수록 품질은 좋아지지만 성능저하가 높아질 수 있으므로 필요한 수준을 선택해서 사용해야한다.

 

isRetained는 브로커가 클라이언트로부터 온 메세지를 유지할지 말지를 결정하는 속성이다.

true이면 브로커에 의해 저장되고, 이후 해당 Topic을 구독하는 모든 클라이언트는 최신 메세지를 즉시 받을 수 있다.

false이면 브로커에 의해 저장되지 않고, 해당 Topic을 메세지 발행 이후에 구독하는 클라이언트는 해당 메세지를 받을 수 없다.

 

수신한 메세지의 내용을 꺼내 사용할 때는 payload를 문자열로 바꾸고 그걸 JSONObject 객체로 변경하는 걸 먼저 해야한다

그 다음에 상호간에 정해놓은 이름을 가지고 get 메서드를 사용하면 된다.(getString, getBoolean 등) 

val jsonPayload = JSONObject(String(message.payload))
val command = jsonPayload.getString("command")
val response = jsonPayload.getString("response")
val errorCode = jsonPayload.getString("error_code")

JSONArray 객체를 사용한 것은 다음과 같이 뽑아내면 된다.

val jsonPayload = JSONObject(String(message.payload))
val code = jsonPayload.getJSONObject("data").getString("epc")

 

뒤로가기 두 번 눌러서 앱을 종료하는 건 onBackPressed 메서드를 사용하지 않고 OnBackPressedCallback을 onBackPressedDispatcher에 추가하는 방식으로 진행했다.

액티비티에서 하는 것이 아니라 프래그먼트에서 진행하는것이었기 때문에 activity와 requireActivity 메서드를 사용했다.

프래그먼트의 onViewCreated에서 콜백을 추가하고 onDestroyView에서 콜백을 제거했다. 

private var lastBackPressedTime = System.currentTimeMillis()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    addBackPressedCallback()
}

private fun addBackPressedCallback() {
    onBackPressedCallback = object  : OnBackPressedCallback(true) {
        override fun handleOnBackPressed() {
            if (lastBackPressedTime > System.currentTimeMillis() - 2000) {
                activity?.finish()
            } else {
                Toast.makeText(mContext, "Please press back again to exit the app.", Toast.LENGTH_SHORT).show()
                lastBackPressedTime = System.currentTimeMillis()
            }
        }
    }
    requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
}

override fun onDestroyView() {
    super.onDestroyView()
    onBackPressedCallback.remove()
}

 

가장 중요했던 부분들만 정리를 해봤는데 이 프로젝트를 통해서 IoT 통신에서 많이 사용하는 MQTT 프로토콜을 이용해 앱을 만들고 다른 하드웨어에서 서버로 보내는 데이터를 받아 사용해볼 수 있었다. 또한 JSON 형식에 맞춰 데이터를 보낼 수 있게 됐다. 이외에도 Room, Coroutine, ViewModel 등을 다시 사용해보며 이전에 했던 Skill들을 까먹지 않을 수 있었다.

 

진행하면서 조금 이상했던 것은 ListAdapter를 가지고 리사이클러뷰를 구성했는데 빠르게 들어오는 데이터를 완벽하게 업데이트하지 못하는 이슈가 있었다. 이를 해결하기 위해 여러 방법을 사용했지만 최종적으로 나온 해결책은 notifyItemSetChanged 메서드였다... 아마 ListAdapter의 업데이트 처리 과정에서 뭔가 무시되며 진행되는 부분이 있어 그런 거 같지만 제대로 파악할수는 없었다. 코드 상으로 100개를 한번에 보낼 때는 잘 되는데 다른 하드웨어에서 서버로 보낸 데이터를 가져와 사용하려고 할 때 그랬기 때문이다.

이 부분은 이후에 기회가 된다면 분석을 해봐야겠다.

반응형