ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 채팅 기능 구현하기(with 챗봇)
    Android 2025. 6. 22. 21:44

     

    안녕하세요~ 일요일 주말 저녁이네요. 요즘 낮엔 더운데 저녁엔 바람이 불어서 산책하기 좋은 것 같아요 😆

    오늘은 챗봇과 함께 채팅을 하는 기능을 한번 알아보고자 합니다. 참고로 제가 제목 옆에 괄호로 'with 챗봇' 이라고 한 이유는 다른 실제 유저랑 채팅하는 경우도 있기 때문인데요. 이 부분은 추후에 한번 다뤄보겠습니다!

    채팅 기능 원리

    채팅 기능의 기본적인 원리는 바로 리사이클러뷰 (RecyclerView) 입니다. 특히 리사이클러뷰에서 뷰타입을 여러개 사용하는 Multi-viewType RecyclerView 가 핵심입니다. 

    채팅 기능 구현

    1. UI (XML 기반)

    첫 번째로, UI 를 작성합니다. RecyclerView 를 배치한 다음에 하위에 사용자가 직접 채팅을 칠 수 있는 대화창을 만들면 됩니다. 중요한 부분 위주로 다루고자 리사이클러뷰 외의 UI 관련 코드는 생략했습니다. (이에 대해 궁금한 점 있으시면 댓글 남겨주세요!) 

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview_chat"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"/>
    
        <LinearLayout ..>
            <EditText ... />
            <com.google.android.material.card.MaterialCardView .. >
                <ImageView .. />
            </com.google.android.material.card.MaterialCardView>
        </LinearLayout>
    
    </LinearLayout>

     

    UI (XML)

     

    2. Adapter (어댑터) 

    가장 중요한 부분이죠. 어댑터는 한마디로 리사이클러뷰를 컨트롤하는 내부 설계도라고 보시면 됩니다. 어댑터로는 RecyclerView Adapter 와 ListAdapter 을 사용할 수 있는데요. RecyclerViewAdapter 는 notifyDataSetChanged 메소드처럼 데이터 변경을 수동으로 감지해야 하지만, ListAdapter 같은 경우에는 diffUtil 이 데이터 변경을 자동으로 감지하기 때문에 성능이 더욱 좋습니다. 저는 이러한 점에서 ListAdapter 을 사용했습니다.

    2-1. DiffUtil 과 Model(DTO)

    class ChatAdapter: ListAdapter<ChatModel, RecyclerView.ViewHolder>(differ) {
        companion object {
            val differ = object: DiffUtil.ItemCallback<ChatModel>() {
                override fun areItemsTheSame(
                    oldItem: ChatModel,
                    newItem: ChatModel
                ): Boolean {
                    return oldItem === newItem
                }
                override fun areContentsTheSame(
                    oldItem: ChatModel,
                    newItem: ChatModel
                ): Boolean {
                    return oldItem == newItem
                }
            }
        }
    }

     

    여기서 ChatModel 은 DTO 입니다. 데이터의 구조를 담고있는 클래스라고 보면 됩니다. differ 은 위와 같이 정의한 DTO 을 토대로 작성합니다. 이때 기존 아이템과 새로운 아이템이 같은 객체인지, 아니면 내용이 같은지를 통해 데이터 변경을 자동으로 감지할 수 있는 것이 ListAdapter 의 강력한 장점입니다. 

     

    ChatModel 에 대한 구조는 아래와 같습니다.

    sealed class ChatModel {
        data class UserMessage(
            val profile: String?, // 프로필 이미지 url
            val nickname: String?, // 사용자 닉네임
            val message: String, // 사용자 메세지
        ) : ChatModel()
        
        data class ChatBotMessage(
            val type: Int, // 답변 유형
            val message: String, // 챗봇 메세지
            val accommodationInfo: List<AccommodationInfo>? = null // 숙소 리스트 정보
        ) : ChatModel()
    }

     

    대화창에서 유저가 보낸 메세지와 챗봇이 보낸 메세지가 담아내는 내용이 다르기 때문에 data class 가 아닌 sealed class 로 묶어보았는데요. 이렇게 하면 서로 다른 data class 를 복잡한 분기 처리없이 한번에 같이 사용할 수 있다는 장점이 있습니다. 

    2-2. 여러 개의 뷰홀더

    뷰홀더를 얘기하기 전에 아래의 채팅 상황을 먼저 보겠습니다!

     

    채팅 상황을 보면 몇 개의 뷰홀더가 필요한 지 이해할 수 있습니다. 챗봇이 숙소 정보를 추천해주는 채팅뷰, 사용자가 챗봇에게 질문을 하는 채팅뷰, 챗봇이 단순히 텍스트로 사용자의 질문에 응답하는 채팅뷰 총 3개가 필요해보이네요. 총 3개의 xml 파일을 각각의 레이아웃에 맞게 작성해주시면 됩니다. (실수로 숙소 추천해주는 뷰의 챗봇 닉네임과 단순 답변을 하는 뷰의 챗봇 닉네임을 동일하게 못했네요) xml 레이아웃까지 만들었다면 뷰홀더를 만들어봅시다!

    // 유저 채팅
    inner class UserViewHolder(private val binding: ItemChatUserBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: ChatModel.UserMessage) {
            binding.tvMessage.text = item.message
            binding.tvNickname.text = item.nickname
            Glide.with(binding.ivProfile).load(item.profile).into(binding.ivProfile)
        }
    }
    
    // 챗봇 단순 답변 
    inner class ChatBotTextViewHolder(private val binding: ItemChatBotTextBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: ChatModel.ChatBotMessage) {
            binding.tvMessage.text = item.message
        }
    }
    
    // 챗봇 숙소 추천 답변
    inner class ChatBotRecommendViewHolder(private val binding: ItemChatBotRecommendationBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: ChatModel.ChatBotMessage) {
            binding.tvMessage.text = item.message
            Glide.with(binding.ivStayImage).load(item.image).into(binding.ivStayImage)
            binding.tvStayName.text = item.accommodationInfo?.name
            binding.tvStayAddress.text = item.accommodationInfo?.address
            binding.tvStayPrice.text = item.accommodationInfo?.pricePerNight.toString()
        }
    }

     

    이렇게 총 3개의 뷰홀더를 만들었습니다. bind 메서드를 통해 각 항목이 가지고 있는 데이터를 뷰와 연결해줍니다.

    2-3. 뷰홀더 생성

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when(viewType) {
            VIEW_TYPE_USER -> {
                val binding = ItemChatUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                UserViewHolder(binding)
            }
            VIEW_TYPE_CHATBOT_TEXT -> {
                val binding = ItemChatBotTextBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                ChatBotTextViewHolder(binding)
            }
            VIEW_TYPE_CHATBOT_RECOMMEND -> {
                val binding = ItemChatBotRecommendationBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                ChatBotRecommendViewHolder(binding)
            }
        }
    }
    
    companion object {
            private const val VIEW_TYPE_USER = 0
            private const val VIEW_TYPE_CHATBOT_TEXT = 1
            private const val VIEW_TYPE_CHATBOT_RECOMMEND = 2
    }

     

    각각의 뷰홀더를 설계했으니 실제로 뷰홀더를 만들어야겠네요. 근데, 어떤 상황에서 어떤 뷰를 보여줄 지 어댑터가 어떻게 판단할 수 있을까요? 이를 구별하기 위해 매개변수로 뷰타입(viewType) 을 받는데요. onCreateViewHolder 는 전달받은 뷰타입의 종류를 보고 각각 다른 뷰홀더를 생성합니다. 한번 뷰타입을 전달하는 쪽을 살펴봅시다.

    2-4. 서로 다른 뷰타입 전달

    override fun getItemViewType(position: Int): Int {
        return when(val item = getItem(position)) {
            is ChatModel.UserMessage -> {
                VIEW_TYPE_USER
            }
            is ChatModel.ChatBotMessage -> {
                when(item.type) {
                    0 -> VIEW_TYPE_CHATBOT_TEXT
                    1 -> VIEW_TYPE_CHATBOT_RECOMMEND
                }
            }
        }
    }

     

    item 은 어댑터가 아닌 뷰(액티비티, 프래그먼트)에서 전달하는데요. 어댑터는 전달받은 item 이 무슨 DTO 타입인지 구분하여 그에 맞는 뷰타입을 반환합니다. data class 였으면 처리가 복잡했을텐데 그 상위를 sealed class 로 한번 더 묶어주어 비교적 가독성 좋게 처리할 수 있었습니다. 실제 뷰에서 어댑터에 어떻게 데이터를 전달하는 지도 알아봐야겠네요!

    2-5. onBindViewHolder

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val item = getItem(position)
        when(holder) {
            is UserViewHolder -> holder.bind(item as ChatModel.UserMessage)
            is ChatBotTextViewHolder -> holder.bind(item as ChatModel.ChatBotMessage)
            is ChatBotRecommendViewHolder -> holder.bind(item as ChatModel.ChatBotMessage)
        }
    }

     

    마지막으로 onBindViewHolder 입니다. onCreateViewHolder 을 통해 뷰홀더를 파라미터로 전달받습니다. holder 를 구분하여 각각각의 뷰홀더에 실제 데이터를 전달해줍니다. 이때 데이터 타입이 sealed class 이기 때문에 as 를 통해 캐스팅을 하여 실제 data class 타입으로 전달해줍니다. 

    3. 리사이클러뷰 어댑터 연결 

    어댑터를 만들었으니 이제 리사이클러뷰에 연결하면 됩니다. 다시 뷰(액티비티, 프래그먼트) 로 넘어가보겠습니다!

    private lateinit var chatAdapter: ChatAdapter
    private lateinit var linearLayoutManager: LinearLayoutManager
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        linearLayoutManager = LinearLayoutManager(applicationContext)
        chatAdapter = ChatAdapter()
        
        binding.recyclerviewChat.apply {
            layoutManager = linearLayoutManager
            adapter = chatAdapter
        }
    }

     

    관련 변수를 lateinit var 를 통해 지연초기화를 해주어 onCreate 생명주기에서 초기화를 해주었습니다. 리사이클러뷰에 layoutManager, adapter 을 연결해줍니다.

    4. 데이터 전달 과정

    private val chatItemList = mutableListOf<ChatModel>()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.cvSend.setOnClickListener {
        
            // 유저가 보낸 채팅으로 UI 업데이트
            val myText = binding.etMessage.text.toString()
    
            chatItemList.add(
                ChatModel.UserMessage(
                    profile = userImage, 
                    nickname = userNickname,
                    message = myText
                )
            )
    
            chatAdapter.submitList(chatItemList.toList())
            
    }

     

    먼저 채팅 UI 로 받아온 사용자 텍스트와 profile, nickname 을 UserMessage 라는 data class 객체로 묶은 다음에 채팅 아이템 리스트 변수에 담아줍니다. 이후 리사이클러뷰의 어댑터에 해당 리스트를 submitList 메서드를 통해 보내면 아까 구현한 어댑터 내용 가지고 자동으로 유저 관련 UI 가 업데이트됩니다. 이때, diffUtil 이 데이터의 변경을 감지하기 위해서는 반드시 toList() 메서드를 통해 새로운 리스트를 보내줘야 합니다

    // 서버 쪽에 보내서 챗봇 UI 업데이트
    val root = JSONObject().apply {
        put("user_id", userId)
        put("message", myText)
    }
    
    val userInput = JSONObject().apply {
        put("성별코드", userInfo.gender)
        put("나이", userInfo.age)
        put("직업분류명", userInfo.job)
        put("결혼여부코드", userInfo.marriage)
        put("자녀여부", userInfo.children)
        put("가족유형명", userInfo.familyCount) 
        put("구성원 1인당 수익", userInfo.income) 
        put("동행자여부", userInfo.isCompanionExist)
    }
    
    root.put("user_input", userInput)
    
    val requestBody = root.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
    val okHttpClient = OkHttpClient()
    val request = Request.Builder().url("${BASE_URL}receive").post(requestBody)
            .build()
    
    okHttpClient.newCall(request).enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            if (response.isSuccessful) {
                val response = response.body?.string() 
                val gson = Gson()
                val chatBotMessage = gson.fromJson(response, ChatModel.ChatBotMessage::class.java)
                chatItemList.add(chatBotMessage)
                chatAdapter.submitList(chatItemList.toList())
            } else { .. }
        }
        override fun onFailure(call: Call, e: IOException) { .. }
    })

    서버가 원하는 요청 예시(request body)

     

    이후, 챗봇 관련 UI 를 업데이트하기 위해서는 dummy data 를 사용하는게 아니라면 실제 서버에 보내줘야하는데요. 저는 서버 통신 방식으로 retrofit 보다 가볍게 사용할 수 있는 okHttpClient 를 사용했습니다. 서버(백엔드)가 원하는 형식으로 Json 객체를 생성하고 이후 Request Body 로 변환합니다. 변환한 Request Body 를 서버 base url 과 method (POST) 방식에 맞추어서 요청을 보냅니다. 만약에 요청이 실패하면 로그를 찍어보면 원인을 파악할 수 있습니다. 

     

    서버에서 요청에 대한 응답이 성공하면 json 형식으로 응답을 보내주는데요. 이를 실제 사용할 수 있는 객체로 다시 변환해야합니다. 이때는 gson, moshi 를 사용할 수 있는데, 저는 전통적인 방식에 가까운 gson 을 사용했습니다. 받아온 json 을 Gson 을 통해  원하는 data class 로 변환하여 변수에 담아줍니다. 변수 값을 리스트에 넣고 리스트를 또 다시 어댑터에 전달하면 챗봇 UI 도 마찬가지로 업데이트가 됩니다. 

    4. 채팅 기능 추가 보완

    마지막으로, 채팅에서 추가적으로 보완할만한 기능을 설명하겠습니다. 

    chatAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
        override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
            super.onItemRangeInserted(positionStart, itemCount)
            linearLayoutManager.smoothScrollToPosition(
                binding.recyclerviewChat,
                null,
                chatAdapter.itemCount - 1
            )
        }
    }

     

    첫 번째로,  채팅이 새로 들어올 때마다 위치를 가장 마지막 위치로 이동하는 것입니다. 어댑터에 옵져버를 등록하여 아이템이 등록될 때 위치를 마지막으로 이동할 수 있습니다. 

    val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
    val view = this.currentFocus ?: return@setOnClickListener
    imm.hideSoftInputFromWindow(view.windowToken, 0)

     

    두 번째로, 사용자가 채팅을 입력한 후에 키보드를 자동으로 내릴 수 있습니다.

     

    시연 영상

     

    시연 영상

     

    백엔드 연결 당시 찍은 시연 영상으로 올리고 싶었는데 (실제 숙소 추천이 되는 경우) 사용한 사진이 문제가 될까봐 올리기 힘들 것 같아요. 영상 편집이 어려워 블로그 시연영상은 dummy data 로 진행했습니다. (채팅창이 시스템 바까지 침범하는 문제, 입력창이 끝까지 올라가지 않는 부분은 나중에 해결하면 글을 수정하겠습니다)

     

    오늘은 챗봇과 채팅을 하는 기능을 알아보았습니다. 읽어주셔서 감사합니다!

Designed by Tistory.