LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


Flutter 패키지로 공통 모듈 리팩토링하기

안녕하세요. LINE+ ABC Studio 팀에서 앱을 개발하고 있는 김종식입니다. 저희 팀은 현재 일본에서 운영하고 있는 배달 서비스 '데마에칸(Demaecan, 出前館)' 프로덕트를 개발하고 있으며, 저는 그중에서 DriverApp을 개발하고 있습니다. 현재 Flutter를 이용해 클라이언트를 개발하고 있는데요. 최근 진행한 과제 중에서 서버에서 발송한 메시지를 클라이언트에서 처리하는 로직을 공통으로 사용할 수 있도록 패키징한 messaging-hub-sdk 이야기를 드리려고 합니다(데마에칸 서비스에 대해 더 알고 싶으시다면 먼저 발행된 오래된 프로덕트 디자인 리뉴얼하기를 읽어주세요!).

MessagingHub란?

MessagingHub는 ABC Studio 팀에서 개발해서 현재 데마에칸의 여러 프로덕트에서 사용하고 있는 메시징 플랫폼입니다. 웹소켓 프로토콜 기반 메시징 플랫폼이며 FCM(Firebase Cloud Messaging) 푸시와 이메일, SMS 등 다양한 방법으로 사용자에게 신속하게 메시지를 전달해 주는 효율적인 시스템입니다(더욱 자세한 내용은 Tech-Verse 2022 - An Independent and Universal All-in-one Messaging Platform에서 확인할 수 있습니다).

출처: Tech-Verse 2022 - An Independent and Universal All-in-one Messaging Platform

DriverApp과 MessagingHub 관계

DriverApp은 DeliveryEngine이라고 부르는 서버와 연계해서 작동합니다. DeliveryEngine에서는 배달 가능한 새로운 주문이 있거나, 현재 배달 중인 주문 정보가 변경 혹은 취소되거나, 음식 배달 준비가 완료됐을 때 관련 사항을 사용자에게 안내해야 하는데요. DeliveryEngine은 이때 MessagingHub를 이용해 각 클라이언트로 메시지를 발송합니다.

MessagingHub에 접속하려면 인증 토큰이 필요합니다. DriverApp이 DeliveryEngine에 인증 토큰 발급을 요청하면 DeliveryEngine은 이 요청을 MessagingHub로 전달하고 MessagingHub에서 인증 토큰을 생성합니다. 생성한 토큰은 다시 DeliveryEngine을 통해 DriverApp으로 전달됩니다. 이 인증 토큰을 이용해 DriverApp에서 웹소켓으로 MessagingHub에 연결 후 미리 약속된 'CONNECT'라는 메시지를 전달합니다.

인증 토큰은 클라이언트가 인증된 것임을 보장해야 하므로 인증 토큰을 발급 받기 전에 DriverApp이 로그인돼 있어야 합니다. 로그인 후 DriverApp이 포어그라운드(foreground)로 전환되면 연결을 시도하며, DriverApp이 백그라운드로 이동하면 웹소켓 연결을 해제하고 FCM을 받습니다.

출처: Tech-Verse 2022 - An Independent and Universal All-in-one Messaging Platform

아래는 DriverApp을 코드 레벨에서 살펴본 그림입니다. AS-IS를 보면 UI 표시를 담당하는 상태 클래스에서 Repository를 통해 인증 토큰을 발급받아 웹소켓으로 연결하고 있는 것을 알 수 있는데요. UI 계층과 도메인 계층 간 데이터 모델과 로직 구분이 명확하지 않아 코드 정리가 필요하다고 판단했습니다(아래 그림에서 Repository는 Android 개발 권장 아키텍처 가이드라인 중 데이터 레이어로 이해하시면 좋을 것 같습니다). 

MessagingHub는 왜 클라이언트에서 작동하는 공통 모듈이 필요했을까?

출처: Tech-Verse 2022 - An Independent and Universal All-in-one Messaging Platform

MessagingHub는 ABC Studio 팀에서 만드는 데마에칸 제품 곳곳에서 사용하고 있습니다. 이 중에서 DriverApp과 MerchantApp, RetailApp은 모두 Flutter로 개발하고 있으며, 각 시스템에서는 MessagingHub를 이용해 클라이언트로 메시지를 발송합니다. 그런데 최근 네트워크 연결 상태가 불안정할 때 MessagingHub에서 메시지를 전송했지만 클라이언트에서 이를 수신하지 못하는 이슈가 발생한 적이 있습니다(이 이슈와 관련된 자세한 내용이 궁금하시다면 먼저 발행된 messaging-hub 트러블 슈팅을 함께 읽어보세요!).

이 문제를 해결하기 위해 MessagingHub로부터 받은 메시지에 ACK 인증 토큰 정보가 있다면 클라이언트에서 REST API로 서버를 호출해 메시지 수신 여부를 기록하는 작업이 필요해졌습니다. MessagingHub를 이용하는 모든 프로덕트에 적용해야 하는 요구 사항이었는데요. 각 제품 클라이언트 담당자가 각자 이슈 사항을 파악해 요구 사항에 맞춰 개발했습니다. 이에 따라 공통적으로 필요한 처리 로직을 개발하기 위해 리소스가 중복으로 사용됐고, 프로덕트별로 요청 사항 반영이 누락되는 경우가 발생하기도 했습니다. 

MessagingHub에서 전송한 메시지를 클라이언트에서 수신하면 ACK를 MessagingHub로 전달해 클라이언트의 메시지 수신 여부를 기록

또한 각 프로덕트가 각자 MessagingHub와 연동하는 기능을 구현하면서 이에 대응하는 서버 엔지니어 역시 공통적인 요구 사항을 이해하고 관련 이해관계를 조정하며 배포 일정을 체크하는 등의 작업을 진행하면서 적지 않은 추가 비용이 발생했습니다. 

MessagingHub에서는 클라이언트 SDK가 있으면 좋겠다고 생각했지만 직접 클라이언트 SDK를 제공하기에는 기술 배경과 리소스가 부족해 쉽지 않은 상황이었습니다. 클라이언트 엔지니어 역시 모두가 동일하고 안정적인 코드를 패키지 단위로 사용할 수 있으면 좋겠다고 생각했습니다.

결국 서버와 클라이언트 엔지니어 모두 MessagingHub와 연동하기 위한 클라이언트 SDK가 필요하다고 인식했고, 빠르고 긴밀하게 협업을 시작했습니다.

messaging-hub-sdk 패키지 살펴보기

messaging-hub-sdk 패키지 분리하기

DriverApp에서 messaging-hub-sdk 패키지를 새로 만들고 공통으로 필요한 코드를 분리해 내기 시작했습니다. 패키지를 분리할 때는 아래와 같은 사항에 특히 신경 쓰면서 작업을 진행했습니다. 

  1. 패키지 사용자가 별도 전처리기를 설정하지 않아도 즉시 사용할 수 있도록 한다.
  2. 단위 테스트를 최대한 꼼꼼하게 작성한다.

첫 번째 사항과 관련해서, MessagingHub와 데이터 통신할 때 JSON 형식을 이용하는데요. Flutter JSON 직렬화 모델 클래스를 생성하기 위해 코드 생성 유틸리티를 실행하는 방법이 있지만(참고), 모듈을 사용하기 위해 알아야 하는 배경지식을 최소화하기 위해 직렬화 방식을 사용하지 않았습니다.

두 번째 사항과 관련해서 MessagingHub와의 연동 규격을 최대한 준수해 구현하는 것이 중요하다고 판단했습니다. 이 연동 규격과 구현체를 얼마나 잘 구현하는지가 중요한 패키지이므로, 구현할 때 외부 패키지를 직접 참조하는 경우를 제외한 다른 모든 함수와 동작에 대한 테스트 케이스를 만들며 코드를 작성할 수 있도록 고민했습니다. 

Flutter 테스트 실행 결과는 lcov 파일 형식으로 생성되며, genhtml을 이용하면 실행 결과를 좀 더 가시적으로 생성할 수 있습니다(자세한 내용은 lcov GitHub을 참고하세요).

messaging-hub-sdk 패키지 테스트 코드 실행 결과

처음 패키지로 분리할 때는 DriverApp 프로젝트의 하위 디렉터리로 구성해서 내부 패키지 참조 방식으로 분리했습니다. 이후 정기 배포 건에 포함해 안정성을 확보하고 난 후 Git에 새롭게 리포지터리를 등록해 Flutter의 Git 종속성 추가 방식으로 변경했습니다. 

messaging-hub-sdk 패키지 사용하기

현재 Flutter에서 메타데이터 종속성을 관리하는 pubspec.yaml에 아래와 같이 내용을 추가하면 패키지를 사용할 수 있으며, Git 리포지토리에서 버전별로 배포를 관리한다면 아래처럼 타깃 버전으로 사용할 수 있습니다(Flutter에서 내부 패키지를 경로 및 Git 종속성으로 추가하는 방법은 공식 문서를 참고하세요!).

how to add messaging-hub-sdk package
messaging_hub_sdk:
    git:
      url: git@git-dev.linecorp.com:demaecan/messaging-hub-sdk-flutter.git
      ref: 0.1.6

messaging-hub-sdk API 개요

messaging-hub-sdk를 사용하려면 앱 초기 구동 시 아래 코드와 같이 MessagingHubManager.init 함수를 호출해야 합니다. 함수를 호출하면 MessagingHub의 엔드포인트와 앱 버전, 플랫폼 및 사용 언어가 설정됩니다. 이때 HttpApiDelegate에는 REST API 실제 구현체를 앱에서 전달해야 하며, showLog 옵션을 이용해 messaging-hub-sdk가 작동할 때 발생하는 로그를 출력할 수도 있습니다(logger 패키지를 이용했습니다).

messaging-hub-sdk initialize
void main() {
  final options = MessagingHubOptions(
    endPoint: EndPoint.dev,                 /// Messaging Hub EndPoint
    version: '1.0.0',                       /// Current application version
    platform: Platform.android,             /// Which platform is operating on
    language: Language.en,                  /// Set the language
    showLog: true,                          /// if true, messaging-hub-connector print log
    httpApiDelegate: HttpApiDelegateImpl(),    /// An REST API implementation object
  );
  MessagingHubManager.init(options);
}

초기화 후 MessagingHubConnector와 MessagingHubAnalytics를 생성하면 다음과 같은 기능을 활용할 수 있습니다.

구분 주요 기능
MessagingHubConnector

MessagingHub와 웹소켓으로 연결하고 수신된 메시지를 처리할 수 있습니다.

  • MessagingHub와 연결된 상태에서 오류가 발생하면 자동으로 재연결을 수행합니다.
  • MessagingHub와 연결 중 토큰 정보가 만료될 경우 다시 연결하는 과정을 진행합니다.
  • MessagingHub로부터 PING을 수신하면 PONG을 응답합니다.
  • MessagingHub로부터 전달받은 메시지에 ACK가 있다면 내부에서 ACK 응답을 수행합니다.
  • 각 클라이언트에서 필요한 콜백이 있다면 별도로 정의해서 전달합니다. 
MessagingHubAnalytics

MessagingHub를 사용할 때 분석하기 위해 필요한 인터페이스를 제공합니다.

  • ACK 메시지를 수신했을 때 MessagingHub로 응답할 수 있는 인터페이스를 제공합니다.

MessagingHub와 연결할 때는 '연결에 필요한 토큰 발급 → 웹소켓 연결 → CONNECT 명령어로 해당 토큰 전달 완료'까지 끝내야 온전히 연결 작업이 완료된 상태가 됩니다. MessagingHubConnector는 내부 상태를 enum으로 정의하고 있으며 각 상태에 따라 어떻게 작동할지 판단하도록 구현돼 있습니다. 이 상태 정보는 특히 테스트 코드에서 잘 활용하고 있는데요. 예를 들어 connect() 함수의 테스트 코드는 아래와 같습니다. 

messaging_hub_connector_impl_test
void main() {
  /// MessagingHubConnector Setup for test
 
  group("test connect()", () {
    test("when state is idle, then state change to ConnectionState.connecting", () async {
      final result = await connector.connect();
      expect(result, true);
      expect(connector.state, ConnectionState.connecting);
    });
 
    test("when state already connected, then state not change", () async {
      connector.state = ConnectionState.connected;
      final result = await connector.connect();
      expect(result, false);
      expect(connector.state, ConnectionState.connected);
    });
  });
}

MessagingHubConnector에서는 web_socket_channel 패키지를 이용해 웹소켓 연결을 구현했습니다. 

void initSocketConnect(String webSocketUrl) {
  /// (중략)
  webSocketDelegate.connect(webSocketUrl);
  subscription = webSocketDelegate.receivedStream?.listen((event) => listen(event), onDone: onDone, onError: onError);
}
 
void onDone() {
  /// (중략)
  retryConnection(ReconnectingReason.webSocketChannelOnDone);
}
 
void onError(Error error) {
  /// (중략)
  retryConnection(ReconnectingReason.webSocketChannelOnError);
}
 
void retryConnection(ReconnectingReason reason) {
  /// (중략)
  timerRetryConnect?.cancel();
  timerRetryConnect = retryTimerTask(reason, exponentialBackoffSeconds(retryConnectionCount++).toInt() + Random().nextInt(20));
}
 
num exponentialBackoffSeconds(int retryCount) => 10 * pow(2, min(retryCount, maxRetryConnectionExponentialValue));
 
Timer retryTimerTask(ReconnectingReason reason, int seconds) {
  /// (중략)
  return Timer.periodic(Duration(seconds: seconds), (timer) {
    if (!isConnected) {
      retry(reason);
    }
  });
}

앞서 소개한 연결 과정 중 오류가 발생하거나, 웹소켓 연결 후 재연결이 필요한 메시지를 수신하거나, 웹소켓 연결 중 문제가 발생할 경우 재연결을 수행합니다. 예를 들어 위 코드에서 웹소켓 연결 중 문제가 발생해 재연결을 시도하는 코드를 따라가 보면 Timer.period으로 Timer 객체를 생성해서 일정 시간 지연 후 재연결을 시도하는 것을 확인할 수 있습니다.

어떤 동작을 수행하고 그 결과 재연결이 필요한 경우를 테스트할 때는 timerRetryConnect 객체 생성 여부를 테스트 코드 작성에 활용합니다. 이때 중요한 것은 재연결이 필요할 때 '어느 정도 지연한 후 연결 과정을 실행할 것인가'이며, 여기에는 두 가지 중요한 고려 사항이 있습니다.

  1. 서버나 네트워크 상태 등으로 인해 짧은 주기로 반복해서 재연결을 시도하면 안 됩니다. 불안정한 상태가 장기간 지속될 때 동일한 주기로 계속 재연결을 시도하면 서버 부담을 가중시켜 장애가 발생할 수 있기 때문입니다. 현재 재연결 시도 횟수에 제한을 두지는 않았지만 재연결 시도 횟수에 따라 필요한 지연 시간을 증가시키고 있습니다.
  2. 지연 후 재연결을 시도할 때 고정된 시간값으로 지연하면 안 됩니다. 시간값을 고정하면 만약 웹소켓 연결이 어떤 이유로 동시에 끊어졌을 때 모두 동일한 지연 시간 후 재연결 과정을 시도하게 됩니다. 이는 일시적으로 서버에 부하를 주고 잠재적으로 이슈가 발생할 수 있습니다. 이런 문제를 방지하기 위해 앞서 정의한 재연결에 필요한 지연 시간에 랜덤 시간값을 추가해서 한 번에 재연결을 시도하지 않게 합니다.

위 두 가지 사항에 현실적인 상황까지 고려해 서버와 합의한 정책을 messaging-hub-sdk에서 지원하고 있습니다. 현재 messaging-hub-sdk 패키지에서 재연결을 시도할 때 지연하는 시간은 `[10, 20, 40, 80, 160, 160, 160 .... ](by number of attempts) + 0~20 secs`입니다. 만약 MessagingHub가 클라이언트로 PING 메시지를 발송한 뒤 PONG 응답을 받지 못해서 연결을 해제한다고 해도 위와 같은 지연 시간이 지난 후 재연결을 시도합니다. 

messaging-hub-sdk 패키지 분리 후 거둔 성과

messaging-hub-sdk를 패키지로 분리하는 작업으로 아래와 같은 성과를 얻었습니다.

  • 리팩토링과 패키지 분리를 통해 재사용성 및 안정성 확보
  • 서버와 클라이언트 간 업무 협업 관점에서 효율화 

현재 DriverApp은 물론 MerchantApp과 RetailApp에서도 messaging-hub-sdk를 사용하고 있으며, MessagingHub와 앱 개발자 간 전담 커뮤니케이션 역할이 생기면서 커뮤니케이션 비용이 감소했고, 앱과 서버 개발자 분들 모두 업무 진행이 효율적으로 개선됐다는 피드백을 주셨습니다. 또한 MessagingHub 연계에 필요한 코드를 모듈화하고 테스트 코드를 작성하면서 코드 품질도 좋아졌습니다. 

적절한 수준의 과제를 수행하면서 개인 역량도 많이 성장했습니다. Dart 언어 문법을 좀 더 숙지하며 코드를 빠르게 읽을 수 있는 능력을 키웠고, 기존 프로젝트 구조를 파악하는 데에도 도움이 됐으며, Flutter 환경에서의 모듈화와 단위 테스트 작성 등을 경험하며 조금 더 성장한 Flutter 개발자가 된 것 같습니다. 이번 작업을 수행하면서 공통으로 사용할 수 있도록 코드를 모듈화해 배포하는 게 중요하다고 느꼈습니다. 또한 과제 진행 과정에서 PR 리뷰를 받았을 때 동료분들께 들은 칭찬과 각 프로덕트를 개발하시는 분들이 업무 효율이 좋아졌다는 피드백을 주셨을 때 보람을 느꼈습니다.

향후 계획

MessagingHub는 외부 시스템에서 전송한 메시지를 사용자에게 전송하는 것뿐 아니라 사용자 간에 커뮤니케이션할 수 있는 채팅 인터페이스도 제공합니다. messaging-hub-sdk 모듈의 다음 과제는 채팅 인터페이스를 구현하고 각 클라이언트에서 손쉽게 사용할 수 있도록 모범 사례를 만드는 것입니다.

출처: Tech-Verse 2022 - An Independent and Universal All-in-one Messaging Platform

마치며

MessagingHub는 데마에칸 프로덕트에서 주문부터 배송 완료까지 고객 요구 사항이 전달되는 과정 곳곳에서 매우 중요하게 활용하고 있는 시스템입니다. ABC Studio 팀은 좋은 프로덕트를 만들기 위해 기술과 역할에 제약을 두지 않고 유기적으로 협업하고 있습니다. 서버 엔지니어로서 좀 더 도전적이고, 다양한 도메인을 접할 수 있는 팀이라고 생각합니다. MessagingHub 시스템을 함께 만들어 가실 분을 찾고 있으니 많은 관심 부탁드립니다.