-
채팅 기능 구현하기(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 로 진행했습니다. (채팅창이 시스템 바까지 침범하는 문제, 입력창이 끝까지 올라가지 않는 부분은 나중에 해결하면 글을 수정하겠습니다)
오늘은 챗봇과 채팅을 하는 기능을 알아보았습니다. 읽어주셔서 감사합니다!
'Android' 카테고리의 다른 글
사용자 수면 시간 추적하기 (2) 2025.08.31 Bottom Sheet Dialog, Slider, ProgressBar (2) 2025.06.08 네이버 지도 SDK 사용하기 (4) 2025.06.01 카카오 로그인 기능 구현하기 (7) 2025.05.11 하단 내비게이션 바(BottomNavigationView) (2) 2025.05.04