쿠팡 메시지 패싱 메커니즘의 진화

LiveData를 활용해 확장 및 커스터마이징 가능한 메시지 버스를 만들다

쿠팡 엔지니어링
Coupang Engineering Blog
15 min readAug 4, 2022

--

By Ju Cai

본 포스트는 영문으로도 제공됩니다.

2010년대 후반, 쿠팡의 성장이 가속화되면서 쿠팡 모바일 애플리케이션의 복잡도 역시 배가되었습니다. 비즈니스 요건이 많아지면서 코드베이스가 확대되고, 클래스 간의 커플링이 함께 복잡해지면서 클래스 간 의존성도 높아졌습니다. 신규 비즈니스 요건을 만족시키기 위해 상충하는 로직이 존재하는 모듈을 수정하고 병합(merge)해야 했으며, 이는 기능 하나의 작은 수정 작업도 여러 코드에 영향을 주는 상황으로 이어졌습니다. 에러가 흔히 발생할 수 있는 프로세스였습니다. 그래서 단순한 변경에도 신뢰성을 위한 전체 회귀 테스트(regression test)가 필요했습니다.

모듈 간, 그리고 각 모듈 내 페이지 간의 디커플링이 시급했습니다. 그래서 앱의 모듈과 페이지가 인터페이스를 통해 상호작용하고 데이터를 교환할 수 있도록 메시지 패싱(message passing) 시스템을 설계했습니다.

메시지 패싱은 내부 모듈들이 이벤트 데이터를 이용한 단순하고 직접적인 방식으로 서로 소통할 수 있게 만듭니다. 또한 높은 응집도(cohesion)와 낮은 결합도(coupling)를 갖는 모듈 개발 시 필수적인 요소입니다. 게다가, 메시지 패싱 인터페이스(Message Passing Interface, MPI)를 통한 독립적인 기능 개발 및 유지관리도 지원됩니다.

이번 포스트에서는, 쿠팡 안드로이드 앱의 메시지 패싱 메커니즘이 어떻게 진화해 왔는지를 소개하겠습니다.

목차

· 배경
· 근대시대
· 구현
· 사용 예시
· 결론

배경

쿠팡 앱의 메시지 패싱 방식은 세 단계에 걸쳐 발전했습니다. 쿠팡에서는 이단계들을 석기시대(2012초~2018), 철기시대(2018~2019), 그리고 근대시대(2019~현재)로 부르고 있으며, 이 시기 동안 메시지 패싱 컴포넌트들은 보다 더 세련된 수준으로 진화했습니다. 우선 첫 두 시기를 간략히 소개하겠습니다.

석기시대

이 시기의 쿠팡 앱에는 홈, 장바구니, 마이쿠팡, 제품 목록, 그리고 검색결과 이렇게 다섯가지 종류의 페이지만 존재했고, 페이지 별로 단 하나의 액티비티(activity)가 연결되어 있었습니다.

당시에는 메시지 패싱을 위해 안드로이드의 Handler와 브로드캐스팅(broadcasting) 메커니즘을 사용했습니다. 상당히 단순한 방식으로 메시지 커뮤니케이션이 이루어졌기에 이 시기를 석기시대라 부릅니다. 메시지 트래킹도 쉬웠습니다. 메시지는 변수로 정의되었고, 호출자(caller) 또는 응답자(responder)를 IDE의 찾기 기능으로도 찾을 수 있었습니다.

하지만 모듈들 간의 결합도가 높고 및 메모리 누수 위험이 크다는 단점이 존재했습니다. 또한, 당시 시스템은 메시지 관리 및 수명 주기(lifecycle)에 대한 인식이 부재했습니다.

철기시대

성장하는 비즈니스 니즈를 처리하기에 다섯 개의 액티비티로는 한없이 부족했습니다. 그래서 다음으로 이벤트 버스와 유사한 액티비티 기반의 ViewEventManager를 도입하게 되었습니다.

쿠팡 ViewEventManager 아키텍처
그림 1. 철기시대에 메시지 전달을 위해 사용한 ViewEventManager 아키텍처

위 그림에서 볼 수 있듯이 View에서 eventSender로 이벤트를 보내고, eventSender가 해당 이벤트를 이벤트에 상응하는 eventHandler로 보내는 구조입니다. 이후 메시지 버스가 등록되어 있는 모든 이벤트 핸들러를 순회하면서 일치하는 이벤트 유형을 찾아 처리합니다.

그러나 비즈니스의 실행 및 조정 속도가 점점 빨라지며 이러한 방식의 한계점이 눈에 띄기 시작했습니다. 우선, 메시지 트래킹 난이도가 너무 높아져 디버깅이 불가능에 가까워졌습니다. 메시지 응답자를 찾으려면 등록된 모든 이벤트를 상세히 살펴야 했고, 개발 효율이 떨어졌습니다.

또한, eventHandlerView를 붙들고 있어서 메모리 누수에 매우 취약했습니다. 특히 다수의 이벤트가 동시 처리되고 있는 상황에서는 해당 위험성이 더욱 컸습니다. 마지막으로, 스티키 메시지(sticky message) 전달을 지원하지 않고, 메시지 관리 시스템이 부재했습니다.

이러한 한계점을 해결하고 점차 까다로워지는 비즈니스 요건을 만족시키기 위해 완전히 새로운 메시지 패싱 메커니즘을 개발하게 됐고, 그렇게 근대시대로의 전환이 이루어졌습니다.

근대시대

이번 섹션에서는 근대시대의 메시지 패싱 메커니즘을 어떻게 설계하고 구현했는지와 패싱 매커니즘의 사용 예시를 설명하겠습니다.

시스템 설계

급성장하는 비즈니스를 뒷받침하려면 개발 효율을 개선해야 했고, 아래의 세 가지 요건이 충족되어야 했습니다. 첫째, 리치 메시지(Rich Message)의 지원이 필요했습니다. 둘째, 트래킹 기능이 강화되어야했습니다. 메시지 트래킹의 전체 프로세스를 효율적으로 간소화할 필요가 있었습니다. 예를 들어, 메시지의 송신자(sender)와 수신자(receiver)를 쉽게 찾을 수 있어야 했습니다. 마지막으로, 메시지 수신자와 송신자의 수명 주기를 인식할 수 있어야 했습니다. 예를 들어, 수명 주기의 비활성화(inactive) 단계에 있는 메시지 수신자는 메시지를 처리할 수 없어야 했습니다.

쿠팡 ElegantLiveDataBus 아키텍처
그림 2. ElegantLiveDataBus 아키텍처에서의 데이터 흐름이다. ElegantLiveDataBus는 동적인 프록시 기술을 활용하여 단일 메시지를 상응하는 메시지 채널에 바인딩한다.

모든 요구 사항들을 충족시키기 위해, ElegantLiveDataBus라는 메시지 버스 시스템을 제안하고 이를 바탕으로 새로운 메시지 패싱 메커니즘을 설계했습니다. 이 메커니즘은 안드로이드의 LiveData 클래스를 활용하며 다음과 같은 특징이 있습니다.

  • 다양한 메시지 유형 지원. ElegantLiveDataBus는 스트링(String) 등의 자바 시스템 내장 유형과 사용자 정의 유형을 포함, 모든 메시지 유형을 지원합니다.
  • 제약은 있지만 커스터마이징 가능한 메시지 정의. 메시지는 인터페이스를 통해 정의되며, 게시자(Publisher) 및 구독자(Subscriber) 사이에 강한 제약을 설정합니다. 동시에, 동일한 비즈니스에서 발생한 메시지는 수월한 관리를 위해 하나의 인터페이스에서 다 정의되고 집계(aggregate)합니다.
  • 메시지 채널 격리. ElegantLiveDataBus는 동적 프록시 기술을 이용해 메시지를 메시지 채널에 바인딩합니다.
  • 확장 및 호환 가능한 게시자와 구독자. 게시자는 시스템 API를 사용하고 LiveData는 호환 가능한 setValue(T)postValue(T) 메서드를 제공합니다. 구독자는 시스템 API 그리고 Observer 클래스를 사용합니다.
  • 스티키(Sticky) 및 논-스티키(Non-Sticky) 메시지 패싱 모드. 쿠팡의 시스템은 실시간 스티키/논-스티키 메시지 패싱을 모두 지원합니다.

위의 기능에 더해, 쿠팡의 이벤트 버스 시스템은 안전하고 수명 주기를 인식할 수 있습니다. 메모리 누수 리스크가 작고 onCreate()부터 onDestroy()까지 전체 수명 주기 내내 메시지를 실시간으로 받을 수 있게 되었습니다.

다음의 표를 통해 쿠팡 메시지 패싱의 세 시기를 요약해보았습니다.

구현

ElegantLiveDataBus의 코드 구현은 어떻게 이루어졌는지 기술적 세부 내용을 살펴보겠습니다.

인터페이스

아래에서 볼 수 있듯이 이벤트 버스는 기본적으로 각 LiveData가 채널에 바인딩되어 있는 HashMap입니다. 인터페이스에 정의된 메시지는 동적 프록시를 통해 적절한 채널로 매핑됩니다.

인터페이스를 사용하면 다음과 같은 여러 가지의 장점이있습니다. 우선 에러를 메시지 패싱 프로세스가 런타임에 실행될 때가 아닌 컴파일 중 타입 검사(type-checking)를 통해 미리 찾아낼 수 있습니다. 또한, 메시지 정의 및 관리가 인터페이스를 통해 이루어지기 때문에 String 기반의 메시지 정의에서 발생할 수 있는 모호성을 방지할 수 있습니다. 게시자와 구독자는 String을 사용해 정의한 메시지 스펙을 계속 사후검사(post-checking) 메커니즘의 일환으로 추적(follow)할 수 있습니다.

수명 주기 인식

LiveData의 수명 주기 인식은 LifecycleOwnerLifecycleBoundObserver가 함께 바인딩되어 있기 때문에 가능합니다. 아래 LiveData 연관 클래스(association class)의 UML 도표에서 내용을 확인할 수 있습니다.

LiveData의 연관 클래스
그림 3. LiveData의 연관 클래스

이 바인딩 메커니즘의 세부 호출 및 구현 코드는 아래와 같습니다. LiveDataobserve() 메서드를 호출하면 LiveData 객체 내에 수명 주기 옵저버가 추가되어 특정 앱 구성요소의 수명 주기를 관찰하게 됩니다.

논-스티키

현시점에서 ElegantLiveDataBus는 스티키 모드만 지원합니다. 옵저버는 구독 전에 발신된 데이터를 수신할 수 있는데, 이는 몇몇 경우에 있어 저희가 권장하는 사용방식은 아닙니다. LiveDatasetValue(T) 함수를 추적하면 이러한 일이 왜 발생하는지 알 수 있습니다.

일련의 호출(chain of call) 관계성이 setValue(T)에서 시작해 dispatchingValue(), considerNotify()로 이어집니다. LiveDatasetValue(T)을 사용해 데이터 버전을 수정합니다. 이후 dispatchingValue()을 사용해 옵저버에 변경된 데이터 값을 발송합니다. 마지막으로 옵저버는 considerNotify()를 사용해 변경된 데이터 값을 처리할지 확인합니다. 이러한 일련의 명령(chain of command)을 통해 옵저버는 메시지 알림이 중복되지 않도록 하면서 구독자에게 관찰 중인 객체의 신규 데이터 업데이트만을 알리게 됩니다.

그렇다면 어째서 옵저버가 이전에 릴리스된 메시지를 수신할 수 있는 걸까요? 각 옵저버에는 ObserverWrapper가 존재합니다. 여기에는 생성 시에 -1로 초기 설정된 mLastVersion이 있습니다. 또한, LiveData의 초기 mVersion 역시 -1입니다. LiveData 객체의 setValue(T) 또는 postValue(T) 메서드를 호출하면 해당 mVersion이 1씩 증가합니다. 새로운 옵저버의 mLastVersion이 -1이고 옵저버가 관찰하는 LiveDatamVersion이 -1보다 크다면, LiveDataobserver.mObserver.onChanged((T) mData)를 호출하여 옵서버에 기존 메시지를 발송합니다.

이러한 상황을 방지하려면 LiveData와 등록된 옵저버의 버전 번호에 동일한 값을 수동으로 설정해야 합니다.

데이터 누락 방지

다음으로 해결했던 이슈는, postValue(T) 사용 시 메시지 전달 과정에서 발생할 수 있는 데이터의 누락을 방지하는 것이었습니다. 코드를 보면서 데이터 누락 원인을 찾아보겠습니다.

데이터가 누락된 이유는 postValue(T)가 수신 데이터를 mPendingData에 저장하고 메인 스레드에 Runnable을 던지기 때문이었습니다. 해당 Runnable에서는 setValue(T)를 우선 호출하고, 옵저버 콜백을 수행했습니다. Runnable 이전에 postValue(T)가 여러 번 실행된다면, 임시 mPendingData만이 변경되고 기타 Runnable 객체는 삭제됩니다. 그 결과, 차후에 설정되는 값이 직전 값을 덮어쓰게 되고, 이벤트 누락이 발생하게 되는 것이었습니다.

데이터 누락을 방지하려면 postValue(T)이 호출될 때마다 Runnable을 메인 스레드에 던져야 합니다.

이벤트 계층 처리

ElegantLiveDataBus를 구현하며 마주한 새로운 도전 과제 중 하나는 ElegantLiveDataBus의 적용 범위를 정의하는 것이었습니다. 다수의 액티비티가 동시다발적으로 존재하는 상황을 가정해보겠습니다. ElegantLiveDataBus의 범위가 전역적(global scope)이고 모든 액티비티가 동일한 메시지 유형을 관찰하고 있다면, 새로운 이벤트 발생 시 모든 액티비티가 응답하게 됩니다. 최신 액티비티만이 최신 이벤트에 응답해야 하는데, 이는 문제가 있는 상황입니다.

이 문제에는 보통 다음과 같은 두 가지 솔루션을 적용할 수 있습니다.

  1. 로컬 범위: 메시지 버스의 범위를 로컬로 정의하고 범위를 해당 액티비티로 제한한다.
  2. 글로벌 범위: 메시지 버스의 범위를 글로벌로 정의하여 범위를 애플리케이션 전체가 되도록 한다. 이 경우, 액티비티의 태그로 메시지를 구별한다.

다만, 이벤트를 분리하고 계층적으로 처리하면서 위의 방식보다 더 편리한 솔루션을 찾아냈습니다. 메시지 모니터링을 다음과 같은 카테고리로 구분하고, 사용자가 프로덕트 요건에 따라 사용할 카테고리를 정하는 방식입니다.

  • observeWhenFront(@NonNull Observer<T> observer)는 onResume()부터 onStop()까지의 이벤트를 관찰.
  • observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer)는 onCreate()부터 onDestroy()까지의 이벤트를 관찰.
  • observeSticky(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer)는 onCreate() 이전의 이벤트와 onCreate()부터 onDestroy()까지의 이벤트를 관찰.
  • observeForever(@NonNull Observer<T> observer)는 onCreate()부터 removeObserver()까지의 이벤트를 관찰.
쿠팡 이벤트를 관찰하여 커스터마이징
그림 4. 서로 다른 관찰 메서드가 서로 다른 일련의 이벤트를 관찰하여 커스터마이징이 가능하다

사용 예시

ElegantLiveDataBus를 어떻게 호출하고 사용하는지를 자세히 알아보겠습니다.

메시지 정의

신규 메시지의 기본 정의는 다음과 같습니다:

쿠팡 앱에서는 각 도메인이 하나의 액티비티(activity)와 다수의 프래그먼트(fragment)와 관계를 맺고 있습니다(follow). 예를 들어, 제품 상세 페이지에 하나의 액티비티가 있지만 해당 액티비티에는 3개의 프래그먼트가 있다고 가정해보겠습니다. 이런 경우 메시지 응답 처리 페이지(액티비티 또는 프래그먼트) 영역 그리고 메시지에 포함된 비즈니스 영역, 이렇게 두 개의 영역으로 메시지를 구조화합니다.

제품 상세 페이지에서 결제 페이지로 이동하는 함수를 예로 들어보겠습니다. 이동 메시지에 대한 응답은 제품 상세 페이지에서는 완료되지만, 해당 메시지는 체크아웃 비즈니스 영역에 포함됩니다. 그러므로 해당 메시지는 다음과 같이 정의해야 합니다.

메시지 등록

메시지 등록의 예시 코드는 다음과 같습니다:

메시지 송신

postValue(T)를 사용한 메시지 송신을 위한 예시 코드는 다음과 같습니다.

결론

ElegantLiveDataBus의 정의된 인터페이스를 통해 메시지를 관리함으로써 디버깅 효율성을 크게 개선할 수 있었고, 메시지 버스의 계층적 처리를 지원할 수 있었습니다. 액티비티 간의 상호 작용을 개발하는 데 소요되는 시간을 50% 줄이고, 코드베이스를 60% 축소했습니다.

쿠팡의 엔지니어 팀은 ElegantLiveDataBus와 같은 혁신적인 방식을 통해 안드로이드 프로그래밍의 효율을 열정적으로 개선하고 있습니다. 차후에는 IPC 기술을 사용한 교차 프로세스 메시지 패싱 방식의 개발을 시도해보려고 합니다.

쿠팡 같은 거대한 이커머스 기업의 크고 도전적인 엔지니어링 프로젝트에 관심이 있는 안드로이드 엔지니어라면, 이 곳에서 새로운 기회를 찾아보세요.

--

--

쿠팡 엔지니어링
Coupang Engineering Blog

쿠팡의 엔지니어들은 매일 쿠팡 이커머스, 이츠, 플레이 서비스를 만들고 발전시켜 나갑니다. 그 과정과 결과를 이곳에 기록하고 공유합니다.