데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      신입 개발자의 A.(에이닷) 개발기: JT 프로젝트

      Bubble 24.08.19
      2,680 17 4
      DEVOTEE 요약
      처음 맡은 프로젝트인 "Adot Simple Assistant App" 개발 경험을 공유하며, 특히 신입 개발자들이 첫 프로젝트를 시작할 때 겪는 어려움과 고민을 덜어주기 위해 이 글을 작성했습니다. 프로젝트 과정을 통해 배운 점과 성장 과정을 나누며, 다른 신입 개발자들에게 도움이 되기를 바랍니다.
      DEVOTEE 추천 블로그

      들어가며

      안녕하세요.

      ’24 JT로 AI Connectivity Client 팀의 신입 개발자로 합류한 정재윤입니다.


      팀에 합류한 신입 개발자로서 처음 맡은 프로젝트인 "Adot Simple Assistant App" 개발의 경험을 공유하고자 합니다.

      이 글은 특히 저와 같은 신입 개발자들이 처음 프로젝트를 시작할 때 겪는 어려움과 고민을 조금이나마 덜어줄 수 있기를 바라며 작성했습니다.

      저 역시 신입으로서 많은 시행착오를 겪었고, 이 과정에서 배운 점과 느낀 점을 성장 일기처럼 전하고자 합니다.


      첫 프로젝트를 진행하면서 많은 기술적 도전과 문제들을 마주했습니다.

      이 글을 통해 제가 배운 점과 성장한 과정을 나누고자 합니다.

      팀에서의 첫 프로젝트를 통해 겪은 저의 여정이 다른 신입 개발자들에게 조금이나마 도움이 되기를 바랍니다.


      Simple Assistant를 만들어가는 과정

      준비

      프로젝트를 처음 마주하고 가장 먼저 해야 할 일은 ‘요건이 무엇인가’를 파악하는 것이었습니다.


      이번 프로젝트는 기존 Adot App의 컴포넌트를 재사용하여 Adot Agent와의 대화를 통해 도움을 받을 수 있는 Simple Assistant App를 개발하는 것이었고,

      이에 가장 먼저 요구사항을 간략하게 정리했습니다.

      1. Adot 인증

        • 최초 로그인 시, 아이디와 비밀번호를 입력하여 TID 로그인을 진행합니다.

        • 이후 로그인 시, 자동 로그인이 됩니다.

        • 로그인 성공 시, 메인 화면으로 이동합니다.

      2. Adot Agent와 대화

        • 채팅방 입장과 동시에 선톡 메시지가 보입니다.

        • 음성 입력을 실행하면 보이스 크롬 화면에 상태에 따른 UI와 발화 내용이 보입니다.

        • 입력이 완료되면 SDK를 통해 서버로 요청을 보냅니다.

        • 서버로부터 Adot Agent의 응답을 받고 그 내용이 음성과 화면으로 나타납니다.

      3. 미디어 플레이어

        • “w. 음악 틀어줘” 발화 시, 음악이 플레이됩니다.

        • 재생, 정지, 다음 곡, 이전 곡 등 다양한 이벤트를 처리합니다.

      약 한 달 정도의 시간이 주어졌기 때문에 각 요구사항에 따라 개발 요건을 바탕으로 기능별로 일정을 산출하고 계획을 수립했습니다.

      이제 준비가 끝났습니다.


      설계

      요건이 정리되면 본격적으로 개발하기에 앞서 설계를 준비해야 합니다.

      집을 지으라는 요구사항을 만족시키기 위해 움막을 지을 수도, 주택을 지을 수도, 아파트를 지을 수도 있습니다.


      마찬가지로, 앱 개발에서도 다양한 아키텍처와 기술 스택을 선택할 수 있습니다.

      제가 참고한 기존 Adot App는 클린 아키텍처와 Hilt를 사용한 ‘호화로운 타워 팰리스’ 수준의 집이었습니다.


      소프트웨어가 가진 본연의 목적을 추구하려면 소프트웨어는 반드시 ‘부드러워’ 야 한다. 다시 말해 변경하기 쉬워야 한다.

      이해관계자가 기능에 대한 생각을 바꾸면, 이러한 변경 사항을 쉽게 적용할 수 있어야 한다. (로버트 C. 마틴, 클린 아키텍처)


      예를 들어, 프로젝트에서 네트워크 통신을 위해 OkHttp 라이브러리를 사용하고 있다고 가정해 봅시다.

      어느 날, 기술 지침에 따라 OkHttp를 Retrofit으로 변경하라는 요구가 생긴다면,

      기존 구조에서는 프로젝트 내의 모든 네트워크 통신 코드를 일일이 찾아서 수정해야 하는 번거로움이 있을 수 있습니다.


      반면에, 깨끗한 아키텍처를 적용하면 네트워크 통신을 처리하는 특정 Layer만 수정하면 됩니다.

      이는 시스템의 다른 부분에 영향을 주지 않고도 쉽게 라이브러리를 교체할 수 있게 하여, 실수의 가능성을 최소화하고 작업 효율성을 대폭 향상합니다.


      그러나 이러한 타워 팰리스가 항상 최선의 선택은 아닙니다. 중요한 것은 현재 상황과 요구사항에 가장 적합한 선택지를 찾는 것입니다.


      저는 다음과 같은 요소들을 고려해야 했습니다.

      1. 상대적으로 작은 규모의 앱

      2. 약 한 달이라는 짧은 기간

      3. 클린 아키텍처와 Hilt에 대한 러닝 커브

      4. 기존 Adot App의 Compose 컴포넌트 재사용 필요

      이러한 상황을 종합적으로 고려했을 때, 저에게 맞는 집은 무엇일까요? 이 과정을 통해 저만의 최적의 설계를 찾아가고자 했습니다.


      모든 것을 혼자 다 할 수는 없다

      처음에는 가장 기본적인 형태로 시작했습니다. 모든 로직을 액티비티에 구현하는 단순한 구조였습니다.

      이는 빠르게 개발을 할 수 있는 방법이었습니다.

      이러한 구조의 장점은 빠르고 간단하게 구현하여 액티비티가 마치 만능인 일당백처럼 보이게 만듭니다.

      하지만 여기서 이상한 부분이 있습니다. ‘마치 만능인 일당백처럼 보이게 만든다.’.

      맞습니다.

      // LoginActivity
      ...
      override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityLoginBinding.inflate(layoutInflater)
              setContentView(binding.root)
      		...
      
      		binding.btnLogin.setOnClickListener {
                  startLogin()
              }
      		...
      }
      
      // T ID 로그인
      private fun startLogin() {
      	...
      	requestAuth(...)
      }
      
      // Adot 인증
      private fun requestAuth(...) {
      	...
      	getAuthorize(...)
      }
      
      // 서버와 연동
      private fun getAuthorize(...) {
      	...
      	// 네트워크 통신을 위한 Retrofit 활용
      	...
      }
      
      
      interface AuthApiService {
      	...
      }

      개발이 진행되면서 이러한 단순한 접근 방식의 한계가 드러났습니다.

      하나의 액티비티가 모든 역할을 수행하는 이 방식은 마치 한 사람이 모든 일을 처리하는 것처럼 효율적일 수 있지만,

      규모가 커지면 커질수록 코드의 복잡성은 높아지고 유연함은 줄어들게 됩니다.

      결과적으로 액티비티는 비대해졌고, Compose 컴포넌트의 통합이 필요해지면서 구조적 변화가 필요했습니다.


      나눌수록 가벼워진다

      먼저, 액티비티의 부담을 줄이고, Compose 컴포넌트를 재사용하기 위한 작업이 필요했습니다.

      이를 위해 액티비티 내에 프래그먼트를 추가하여 Compose UI를 그릴 수 있도록 구조를 개선했습니다.


      우선, activity_main.xml 파일에 다음과 같이 FragmentContainerView를 추가하여 프래그먼트를 배치했습니다.

      <androidx.fragment.app.FragmentContainerView
          android:id="@+id/fragment_container_view"
          android:name="com.example.myapp.ChatFragment"
          android:layout_width="match_parent"
          android:layout_height="match_parent" />

      이 설정을 통해 ChatFragment가 activity_main.xml에 포함되도록 했습니다.


      다음으로, fragment_chat.xml 파일을 생성하여 Compose UI를 렌더링할 수 있도록 구성했습니다.

      이를 위해 ComposeView를 사용하여 Compose 컴포넌트를 렌더링할 수 있는 프레임을 만들었습니다.

      <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools"
          android:layout_width="match_parent"
          android:layout_height="match_parent">
      
          <androidx.compose.ui.platform.ComposeView
              android:id="@+id/compose_view"
              android:layout_width="match_parent"
              android:layout_height="match_parent"/>
      </FrameLayout>

      이러한 구조를 통해 액티비티 내의 복잡한 로직을 프래그먼트로 분리하고, Compose UI를 재사용할 수 있었습니다.

      하지만 액티비티, 프래그먼트 간의 데이터 전달과 Compose UI 내에서의 데이터 관리에서 어려움을 겪게 되었습니다.


      중앙 통제 센터의 필요성

      Adot Simple Assistant를 이루는 기본 화면은 AI Agent와의 대화입니다.

      대화방에 들어가고, 대화 내용을 불러오고, 입력하면 Agent의 응답이 보이기까지 여러 가지 작업을 수행하고 그 결과를 처리해 줘야 합니다.

      이러한 작업을 모두 처리하기 위해서는 데이터의 흐름과 상태를 효율적으로 관리할 필요가 있습니다.


      서버로부터 대화 내용을 가져온 후, 이를 프래그먼트에서 Compose UI로 넘기게 됩니다.

      채팅창 전체 화면에서 채팅 버블, 입력창 등 다양한 UI 컴포넌트와 이벤트들이 상호작용을 해야 하는데,

      이 모든 데이터 흐름을 파라미터로 직접 전달하는 것은 비효율적이고 구조적으로 뻣뻣해지게 됩니다.

      예를 들어,프래그먼트 -> 채팅 화면 UI -> 채팅창 UI -> 입력창 UI -> 마이크 호출 버튼 UI 까지 메서드가 전달되어야 마이크 호출 버튼을 클릭하면 음성 입력이 가능하고,

      입력된 데이터와 함께 context 정보를 서버로 보내야 AI Agent의 응답을 화면에 그리기 위해서는 또다시 프래그먼트에서 Compose UI로 넘겨줘야 합니다.

      또한, 액티비티에서 프래그먼트로 코드를 옮겼을 뿐 프래그먼트가 다시 비대해지는 문제가 발생했습니다.


      이때, 중앙 통제 센터의 필요성을 느꼈습니다.

      데이터를 중앙에서 관리하고, UI 로직과 비즈니스 로직을 분리할 수 있는 ViewModel을 도입하기로 했습니다.

      ViewModel을 사용하면 데이터의 일관성을 유지하면서 UI를 효율적으로 업데이트할 수 있습니다.


      ViewModel을 통해 데이터는 중앙에서 관리되며, 프래그먼트는 ViewModel에 로는 데이터를 관찰하여 UI를 업데이트합니다.

      복잡한 로직을 ViewModel에게 위임하여 프래그먼트를 단순화하고, 데이터의 일관성을 유지와 데이터 변화에 따라 효율적인 UI 업데이트가 가능해졌습니다.


      프래그먼트 내에서 ViewModel을 만들어서 채팅 화면을 담당하는 컴포넌트로 넘기면, 해당 화면에서 ViewModel의 프로퍼티와 메서드를 넘겨 처리될 수 있도록 만듭니다.

      // [ChatFragment]
      private lateinit var viewModel: ChatViewModel
      private val remoteConfigManager = RemoteConfigManagerImpl()
      
      override fun onCreate(savedInstanceState: Bundle?) {
      	super.onCreate(savedInstanceState)
      	val context = requireActivity().applicationContext
      	val factory = ViewModelFactory(context, remoteConfigManager)
      
      	activity.run {
                  viewModel = ViewModelProvider(
                     this@ChatFragment, factory)[ChatViewModel::class.java]
      	}
      
      	...
      }
      
      
      override fun onCreateView(
              inflater: LayoutInflater, container: ViewGroup?,
              savedInstanceState: Bundle?,
          ): View? {
      		val content: @Composable () -> Unit = {
      			...
      
      			MainChatScreen(
      				viewModel,
      				...
      			)
      		}
      		return ComposeView(requireContext()).apply {
      			setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
              	setContent(content)
          	}
      }
      `
      // [MainChatScreen]
      @Composable
      fun MainChatScreen(
      	chatViewModel: ChatViewModel,
      	...
      ) {
      	val message by chatViewModel.messages.collectAsStateWithLifeCycle()
      	...
      
      	ChatSceen(
      		...
      		messages = messages,
      		onVoiceClick = chatViewModel::onVoiceClick,
      		...
      	)
      	...
      }

      서버로부터 새로운 메시지를 받아오면 ViewModel이 이를 처리하고, Compose UI는 ViewModel을 관찰하여 자동으로 UI를 업데이트합니다.

      이 접근 방식은 데이터 흐름과 상태 관리를 명확하게 분리함으로써, UI 컴포넌트 간의 의존성을 줄이고 코드 유연성을 높였습니다.


      하지만 이제는 ViewModel이 무거워졌습니다.


      역할 분담의 힘

      ViewModel을 도입함으로써 데이터의 중앙 관리와 UI 업데이트가 가능해졌지만, 여전히 데이터 접근 로직과 비즈니스 로직이 혼재되어 있었습니다.

      서버와 로컬 데이터베이스 간의 데이터 동기화, 그리고 데이터 변화에 따른 UI 업데이트의 일관성을 유지하는 과정에서 새로운 문제가 나타났습니다.


      이러한 문제를 해결하기 위해서는 Model 계층을 도입하여 데이터 접근 로직을 분리하고, ViewModel의 책임을 덜어줘야 했습니다.

      Model은 데이터베이스 접근 객체(DAO)와 서버와의 통신부 등을 포함하여 데이터 소스와의 상호작용을 관리합니다.

      이렇게 함으로써 ViewModel은 비즈니스 로직만 다루도록 만들 수 있었습니다.

      // [ChatViewModel]
      private var observeNewMessagesJob: Job? = null
      private val _messages = MutableStateFlow<List<Message>>(emptyList())
      val messages = _messages.asStateFlow()
      ...
      
      // 메시지 관찰
      fun observeMessages() {
      	observeNewMessagesJob?.cancel()
          observeNewMessagesJob = coroutineScope.launch(Dispatchers.IO) {
      		listenNewMessages(...)
      		...	
      		.collect { messages -> 
      			...
      		}
      	}
      }
      
      // LocalDataSource에 접근
      private fun listenNewMeesages(...): Flow<List<Message>> {
      	return listenNewMessages(...).map { messages -> 
      		val result = mutableListOf<Message>()
      		messages.forEach { message -> 
      			...
      		}
      		result.toList()
      	}
      }
       
      // RemoteDataSource에 접근
      private fun listenNewMeesages(...): Flow<List<RawMessage>> {
      	val reactiveFlow = AdotClientManager.adotAgent.reactiveFlow
      		...
      		.map {
      			listOf(RawMessage(...))
      		}
      	...
      }
      
      // Message 업데이트
      private fun updateMessage(block: (MutableList<Message>) -> List<Message>) {
      	_messages.update {
      		...
      	{
      }

      결국, 데이터 접근 로직과 비즈니스 로직의 분리를 위해 Model 계층을 도입했고, Model을 통해 데이터 접근과 관련된 모든 작업을 캡슐화하고,

      ViewModel은 비즈니스 로직과 UI 상호작용에만 집중할 수 있도록 하게 되었습니다.


      이로써 ViewModel과 Model을 통합함으로써, 자연스럽게 MVVM 아키텍처를 구현하게 되었습니다. 이를 통해 데이터 로직과 UI 로직이 명확하게 분리되었습니다.


      정리하자면,

      초기에는 모든 로직을 액티비티에 몰아넣는 단순한 구조로 시작했지만, 개발이 진행됨에 따라 발생한 문제들을 해결하기 위해 점진적으로 구조를 개선해 나갔습니다.

      이러한 과정에서 자연스럽게 MVVM 아키텍처를 도입하게 되었고, 이를 통해 데이터 로직과 UI 로직을 명확히 분리하여 효율적으로 관리할 수 있었습니다.


      SDK로 Adot과 대화하기

      스크린샷 2024-08-14 오후 6.41.15.png

      Adot App는 SDK를 활용하여 디바이스와 앱에서 Adot 플랫폼 연동을 지원하며, 이를 통해 텍스트와 음성 인터페이스 기반으로 AI Agent와 대화할 수 있습니다.

      Adot SDK에서 제공되는 API 형식에 따라 요청을 Adot 플랫폼으로 전송하고, 플랫폼의 처리 결과를 클라이언트에 전달합니다.


      Adot의 문을 열기

      Adot의 AI Agent와 대화하려면 어떻게 해야할까요?

      SDK를 사용하기에 앞서 전제되어야 하는 조건이 있습니다.

      바로 Adot 플랫폼과의 연동하는데 사용될 access token이 필요합니다.

      따라서, Adot 인증 과정을 반드시 거쳐야 합니다.


      Adot 연동을 위해서는 플랫폼에서 사용되는 POC ID와 Adot용 인증서를 .keystore 파일을 통해 등록해야 합니다.

      Adot App는 T ID 서비스를 통해 로그인을 처리하며, 클라이언트 정보를 서버로 전달하여 확인 후, access token을 발급받습니다.


      또한, Adot 인증 과정에서는 여러 단계의 보안 절차가 포함됩니다.

      예를 들어, 사용자의 로그인 정보가 암호화되어 서버에 전송되고, 서버에서 이를 검증한 후에만 access token이 발급됩니다.

      이렇게 함으로써 사용자 데이터의 안전성과 시스템의 보안성을 높일 수 있습니다.


      인증 과정이 완료되면, SDK는 이 access token을 사용하여 Adot 플랫폼과의 통신을 시작합니다.

      이를 통해 사용자는 Adot Agent와의 대화를 시작하고, 다양한 기능을 활용할 수 있게 됩니다.


      Adot의 힘을 끌어내기

      Adot의 AI Agent와 대화를 한다는 것은 단순히 음성 명령을 처리하는 것을 넘어 다양한 기능을 수행하는 것을 의미합니다.

      예를 들어, 사용자가 날씨, 시간, 일정, 음악 재생 등을 물어보면 AI Agent가 이에 대해 적절히 응답해야 합니다.

      하지만 클라이언트에서 이러한 모든 케이스를 개별적으로 처리하는 것은 비효율적입니다.


      이 문제를 해결하기 위해 SDK는 사용자의 요청을 표준화된 인터페이스를 통해 플랫폼으로 전송하고, 처리 결과에 따라 다양한 기능을 수행할 수 있도록 합니다.

      이 표준화된 인터페이스를 Capability Interface라고 합니다.

      Capability Interface

      Capability Interface는 디바이스의 기능을 제어하기 위한 규격으로, 다음과 같은 요소들로 구성됩니다:

      • Event: 사용자로부터 발생한 이벤트를 정의합니다.

      • Directive: 플랫폼에서 디바이스로 전달하는 명령을 정의합니다.

      • Context: 현재 상태나 정보를 담고 있는 컨텍스트 데이터를 정의합니다.

      Play에서 제공하려는 기능에 따라 여러 개의 Capability Interface를 조합하여 디바이스로 전달할 수 있으며, 이를 통해 다양한 기능을 효율적으로 처리할 수 있습니다.

      또한, Capability Interface와 1:1로 매핑되는 Capability Agent가 구현되어 있어, 미디어 재생과 같은 기능은 직접 실행하고, UI 구성과 같이 직접 실행할 수 없는 기능은 애플리케이션에 위임합니다.


      Adot의 능력을 발휘하기

      Simple Assistant에서 구현해야 하는 주요 기능은 음성 발화 입력에 대한 처리, AI Agent의 응답, 그리고 TTS 송출입니다.

      다만, ‘날씨’와 같이 위치 권한을 포함한 context 정보를 함께 전달해야 올바른 응답이 서버로부터 내려올 수 있는 경우도 있습니다.

      이처럼 각각의 Play에서 제공되는 기능을 사용하기 위해서 어떠한 Interface를 사용하면 좋을지 판단하고, 해당 Interface 규격에 맞게 Agent 내에서 다양한 로직을 수행하도록 작성하면 됩니다.

      Permission Agent 추가

      Simple Assistant에서 특정 기능을 수행하기 위해서는 사용자의 권한이 필요합니다.

      예를 들어, 날씨 정보를 제공하기 위해서는 위치 권한이 필요합니다. Adot Interface directive 수신을 위해 발화 명령 시, Location Permission이 허용된 context 정보 필요하기 때문입니다.

      이를 위해 Permission Agent를 추가하여 위치 권한 상태에 따른 설정 UI를 제공하고, 해당 context 정보를 수집할 수 있도록 했습니다.

      ...
      .enablePermission(object: PermissionDelegate {
                      override val supportedPermissions: Array<PermissionType>
                          get() = arrayOf(PermissionType.LOCATION)
      
                      override fun getPermissionState(type: PermissionType): PermissionState {
                          val result = if (PermissionUtil.hasSelfPermission(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
                              PermissionState.GRANTED
                          } else {
                              PermissionState.DENIED
                          }
                          return result
                          return PermissionState.GRANTED
                      }
      
                      override fun requestPermissions(types: Array<PermissionType>) { ... }
                  })
      Adot Agent 추가

      Adot의 경우에는 AI Agent로부터 LLM 기반의 응답을 받아와 처리해야 합니다.

      이를 위해 Adot Interface와 1:1로 매핑되는 Adot Agent를 구현하고 SDK에 추가해주면 됩니다.

      lateinit var client: AdotClient
      ...
      
      fun startService() {
      	val builder = ...
      				.addAgentFactory(
      					AdotAgentInterface.NAMESPACE,
      					object : AgentFactory<AdotAgent> { ... }
      				)
      				...
      	client = AdotClientFactory().create(builder)
      }

      여기서 중요한 것은 SDK에게 Interface 규격에 따라 정확한 context 데이터를 구성해서 전달해줘야 Adot Interface event를 수행하는 로직과 directive를 처리할 수 있습니다.

      // AdotAgent
      override fun provideState(...) {
              // sdk에서 현재 상태 정보를 처리하는 로직임.
              // context 규격에 맞게 처리할 수 있도록 던져줘야 한다.
              executor.submit {
                  ...
              }
      }
      Adot / AudioPlayer DUX 구현

      Adot Simple Assistant에서 시간, 날짜, 날씨, Q&A, 대화, 음악 등의 다양한 기능을 구현했습니다.

      Adot Interface를 통해 수신한 directive의 payload를 바탕으로 View Layer에서 직접 rendering 하도록 구현했습니다.


      이처럼 Adot SDK를 활용할 때 중요한 점은 어떤 기능을 사용할 것인지 판단하고, 해당 기능을 이루는 Capability Interface를 확인하는 것입니다.

      그리고 Interface 규격에 맞게 Agent를 구성해야 하므로, 국소적으로 볼 것이 아니라 전반적으로 봐야 한다는 것입니다.


      Android Kotlin StateFlow 활용 여정

      Adot에서는 서버와 통신하는 부분이 정말 많습니다.

      위에서와 같이 사용자의 액션에 대해 컨텍스트 정보와 함께 요청 데이터를 서버로 보내면, 응답으로 Adot Interface와 같은 capability interface directive/event를 처리하는 것이 주요 로직입니다.


      기존 Adot mobile에서는 이러한 directive/event 처리를 위해 RxJava와 Flow를 사용했습니다.

      하지만 저는 러닝 커브를 고려해서 flow와 coroutine을 사용해서 대체하기로 했습니다.


      데이터를 주고받게 되면 놓이게 되는 다양한 상태가 있습니다.

      • 서버와 통신하고 있지 않을 때

      • 서버와 통신 중

      • 서버와의 통신 성공

      • 서버와의 통신 실패

      RxJava에서는 Observable과 StateSubject를 사용하여 상태 변경을 처리할 수 있습니다.

      Observable은 상태를 스스로 갱신하는 방식이라면, Subject는 다른 객체로부터 상태가 갱신됩니다.

      Observable은 구독과 동시에 실행되지만, Subject는 대기 상태에 있으며 subscriber에 상태 변경을 통지합니다.

      StateSubject를 사용하면 원하는 구간에서 상태를 변경할 수 있고, Observable이 필요한 구간에서는 .observe()를 통해 Observable을 만들 수도 있습니다.


      예를 들어, AsrResultStateSubject는 onStart(), onEach() 등을 사용하여 AsrResultState.dialog 값에 따라 VoiceChrome의 UI를 업데이트하고,

      StateSubject 자체를 Observable로 만들어 ClientManager가 초기화되는 시점에 state 값을 보고 에러를 핸들링하도록 구현할 수 있습니다.


      그렇다면 RxJava 없이 어떻게 구현하면 될까요.

      크게 StateFlow와 SharedFlow를 사용하는 방법이 있을 것 같습니다.


      StateFlow냐 SharedFlow냐 그것이 문제로다

      Flow는 Coroutine을 기반으로 하며 비동기식으로 값을 제어할 수 있는 반응형 스트림입니다.

      그러나 일반적인 Flow는 상태가 없고, 안드로이드 생명 주기를 알지 못하므로 생명 주기에 따른 추가 처리가 필요합니다.

      이를 보완하기 위해 StateFlow와 SharedFlow가 도입되었습니다.

      StateFlow

      항상 값을 가지고 있으며, 오직 하나의 값만 가집니다.

      초기값이 필요하고 여러 개의 수집자를 지원하여 항상 최신 값을 전달합니다.

      Hot Stream 방식으로 collect할 때마다 Flow가 재실행되지 않습니다.

      SharedFlow

      값을 가지지 않으며 초기값도 필요하지 않습니다.

      replayCache를 사용하여 collect 시 전달받을 이전 데이터의 개수를 설정할 수 있습니다.

      Hot Stream 방식으로 동작합니다.

      그러므로, 항상 값을 가져야하는 StateFlow의 경우 상태에 따라 UI를 업데이트 시키면서 View에 노출시킬 때 효과적으로 사용할 수 있고,

      값을 갖고 있지 않아도 되는 SharedFlow의 경우 emit시 바로 collect되기 때문에 제스쳐, 클릭 등 다양한 이벤트 핸들링에 효과적이라고 생각됩니다.


      그래서 StateFlow 어떻게 쓸건데

      이번에 저는 RxJava 없이 StateSubject를 구현하고자 StateFlow를 사용했습니다.

      StateSubject class를 생성하여 내부에 MutableStateFlow를 사용한 private property와 StateFlow를 사용한 property를 만들었습니다.

      AsrResultStateSubject의 경우 ASRAgentInterface.OnResultListener를 채택하여 필수 구현 항목을 완성해주었습니다.

      class AsrResultStateSubject :  ASRAgentInterface.OnResultListener {
          private var _state = MutableStateFlow(AsrResultState(AdotASRResult.none, "", ""))
          val state: StateFlow<AsrResultState> get() = _state
       
       
          fun createSubject(): AsrResultStateSubject {
              return this
          }
       
          override fun onCancel(cause: ASRAgentInterface.CancelCause, header: Header) {
              _state.value = AsrResultState(AdotASRResult.cancel, "", header.dialogRequestId)
          }
       
          override fun onCompleteResult(result: String, header: Header) {
              _state.value = AsrResultState(AdotASRResult.complete, result, header.dialogRequestId)
          }
       
          override fun onError(
              type: ASRAgentInterface.ErrorType,
              header: Header,
              allowEffectBeep: Boolean
          ) {
              _state.value = AsrResultState(AdotASRResult.error, type.name, header.dialogRequestId)
          }
       
          override fun onNoneResult(header: Header) {
              _state.value = AsrResultState(AdotASRResult.none, "", header.dialogRequestId)
          }
       
          override fun onPartialResult(result: String, header: Header) {
              _state.value = AsrResultState(AdotASRResult.partial, result, header.dialogRequestId)
          }
      }
       
      data class AsrResultState(
          val result: AdotASRResult,
          val text: String,
          val dialogRequestId: String
      ) {
          fun getTextWithoutError(): String {
              return if (result == AdotASRResult.partial || result == AdotASRResult.complete) text else ""
          }
      }

      VoiceChrome의 경우 AsrResultState에 따라 VoiceChromeState도 변경되어야, 상태에 맞는 Lottie 애니메이션, 텍스트 등 UI를 구현할 수 있습니다.

      그래서 ChatViewModel이 초기화되는 시점에 VoiceChrome을 observing하는 로직을 구현해두면 음성 입력을 할때마다 AsrResultState가 변하면서 voiceChrome의 상태도 변화하게 됩니다.

      // ChatViewModel
       
      private var voiceChromeInfoFlow: StateFlow<VoiceChromeInfo> =
      private val asrResultStateSubject = AsrResultStateSubject()
      val asrResultStateFlow: StateFlow<AsrResultState> =
              asrResultStateSubject.state
      private val dialogUXStateSubject = DialogUXStateSubject()
      private val dialogStateFlow: Flow<AdotDialogState> =
              dialogUXStateSubject.state
      ...
       
      init {
          ...
          observeVoiceChrome()
      }
       
       
      private fun observeVoiceChrome() {
          voiceChromeFlow = combine(
              dialogStateFlow.onStart {
                      emit(AdotDialogState.IDLE)
                  },
                  asrResultStateFlow.onStart { // 기본세팅
                      emit(
                          AsrResultState(
                              AdotASRResult.none,
                              "",
                              ""
                          )
                      )
                  }) { dialog, asr ->
                  when (dialog) {
                      AdotDialogState.EXPECTING_SPEECH -> {
                          VoiceChromeInfo(
                              VoiceChromeState.ListeningPassive,
                              asrMessage ?: "ListeningPassive"
                          )
                      }
                      AdotDialogState.IDLE,
                      AdotDialogState.SPEAKING
                      -> {
                          VoiceChromeInfo(
                              VoiceChromeState.Idle,
                              ""
                          )
                      }
      ...

      이처럼 ViewModel에서는 Flow보다 StateFlow를 사용해야 합니다.

      일반적인 Flow는 Cold Stream 방식으로 동작하여 연속해서 들어오는 데이터를 처리할 수 없고, collect할 때마다 새로운 Flow emit을 트리거하여 리소스 낭비가 발생할 수 있습니다.

      그래서 데이터를 처리하는 레이어에서 전달된 Flow를 collect하고, StateFlow로 push하여 사용하면서,

      해당 stateFlow를 화면에 업데이트 할 때에만 수집하여 시스템 리소스 낭비를 방지할 수 있지 않을까...? 하는 생각으로 작업을 하게 되었습니다.


      이 외에도 많은 부분 StateFlow를 사용했습니다.

      제일 처음 언급했던 Adot interface directive/event를 처리하는 부분에서도 AdotAgent에서 handleDirective() 라는 함수 내부적으로 Directive의 NAMESPACE에 따라 알맞게 처리하여 Flow를 emit 합니다.

      // AdotAgent
      private val ioCoroutineScope = CoroutineScope(Dispatchers.IO + Job())
      ...
       
       
      private fun handleReactive(info: DirectiveInfo) {
          runCatching {
              gson.fromJson(info.directive.payload, ReactivePayload::class.java)
          }.onFailure {
              Logger.e("failure", "$it handleReactive")
          }.getOrNull().let { payload ->
              if (payload != null) {
                  ioCoroutineScope.launch {
                      _reactiveFlow.emit(
                          Directive(
                              header = info.directive.header,
                              payload = payload
                          )
                      )
                  }
      ...

      가장 많이 사용하는 Adot.Reactive의 경우 message 라는 카테고리에 묶여 Chat을 구성하게 됩니다.

      listenNewMessage(), SyncMessage() 등 message 정보의 변화에 따라 UI를 업데이트 하는 것입니다.


      이처럼 StateFlow를 활용하여 데이터의 변화를 효과적으로 관리하고 UI를 업데이트하는 방식은 실제로 프로파일링을 통해 퍼포먼스를 체크한 것은 아니지만,

      Data 단에서 ViewModel을 거쳐 View에 노출되기까지 StateFlow를 활용하여 효율적으로 상태 관리했다는 것에 나름의 의의는 있지 않았나 생각합니다.


      마치며

      스크린샷 2024-08-14 오후 6.43.04.png

      그렇게 완성한 Adot Simple Assistant App의 최종 시스템 아키텍처입니다.

      많은 우여곡절이 있었지만, 잘 마무리한 거 같네요.

      처음 입사했을 당시를 생각해 보면 방대한 코드양에 놀랐고, 팀에서 진행하는 프로젝트 도메인과 코드에 대한 무지로 인해 난항을 겪었었습니다.

      JT 프로젝트를 하고 난 지금은 적지 않은 변화를 맞이하게 되었죠. 만족스러움과 아쉬움이 공존하고 있지만 최선을 다했다는 게 중요하지 않나 생각합니다.


      결과적으로 이번 프로젝트의 세 가지 주요 개발 요건을 모두 완료했습니다.

      이 과정에서 Adot의 명령 처리 로직에 대한 이해도가 많이 증가했으며, 검증된 Adot SDK와 Adot App의 코드를 재활용하여 코드의 안정성도 확보할 수 있었습니다.

      또한, 팀원들의 코드를 어느 정도 이해할 수 있게 되었고, 실제로 Adot App, Adot SDK 샘플 앱, Adot STB 미디어 에이전트 코드를 참고했습니다.


      하지만 Adot SDK와 플랫폼에 대한 이해 부족으로 인해 여러 가지 어려움을 겪기도 했습니다.

      T ID 서비스 로그인과 로그인 이후 에이닷 계정 로그인 시, ‘에이닷 poc ID와 에이닷용 인증서’를 제대로 등록하지 않았습니다.

      또한, Adot SDK에 디바이스 시스템 권한이 포함된 context 정보를 넘겨주어야 플랫폼 단에서 처리가 가능하다는 사실도,

      SDK에 새로운 Agent를 추가할 때는 Interface 규격에 맞는 context 데이터를 넘겨주어야 한다는 것도 알지 못하여 많은 시간이 소요되었습니다.

      실제로 처리하기 어려운 이슈가 아니었음에도 이해도 부족으로 인해 발생한 문제에 아쉬움을 느꼈습니다.

      더 적극적으로 팀원들에게 질문하고 커뮤니케이션했다면 빠르게 해결할 수 있었다는 사실이 말입니다.


      이번 프로젝트를 통해 정말 많은 것을 배웠고, 자신감도 많이 생겼습니다. 처음에는 모든 것이 낯설고 어려웠지만, 프로젝트가 진행될수록 성장하는 제 모습을 발견할 수 있었습니다.

      특히, SDK와 플랫폼에 대한 이해가 부족하여 고생했지만, 이를 해결하는 과정에서 개발자로서의 역량이 한층 더 성장했다고 느꼈습니다.


      프로젝트를 통해 얻은 경험과 교훈을 바탕으로 이제부터 더 발전된 모습을 보여드릴 것을 다짐합니다.

      긴 글 읽어주셔서 감사드리며, 앞으로도 팀에 기여할 수 있는 구성원으로 성장하기 위해 끊임없이 노력하겠습니다.


      감사합니다.

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      Bubble 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기