스토어 없이 10분만에 앱 업데이트하기

Snyong·2025년 10월 9일

flutter

목록 보기
1/1
post-thumbnail

부제: 스꾸버스의 shorebird 도입기

📌 성균관대학교 '스꾸버스'의 고민

안녕하세요, 성균관대학교 슈퍼앱을 꿈꾸는 스꾸버스 개발자 조승용입니다!
스꾸버스는 성균관대학교 버스나 건물 정보뿐만 아니라 축제 같은 실시간 이벤트 정보까지 제공하는 걸 목표로 하고 있습니다. Flutter로 개발해서 네이티브 앱의 장점을 살리면서도, 자주 바뀌는 화면들은 웹뷰로 처리하고 있습니다.

그런데 앱으로 개발하다 보면 항상 '스토어 심사'라는 장벽에 부딪힙니다.
수정사항을 빠르게 배포할 수 있는 웹과는 다르게 스토어에 업로드하고, 심사를 기다리는 시간이 필요합니다. 버스 정보처럼 실시간성이 중요하거나 축제 시즌처럼 빠른 업데이트가 필요할 때는 치명적인 문제입니다.

📌 해결책을 찾아서: SDUI와 WebView, 그리고 남겨진 과제들

스꾸버스는 기존에 다음과 같은 방법으로 문제를 해결했습니다.

  • Server-Driven UI (SDUI)

SDUI는 서버 API 응답에 따라 앱 UI를 동적으로 그리는 방식입니다. 스꾸버스에서는 API를 통해 버스 목록을 동적으로 추가하고, 목록별 클릭 액션을 지정할 수 있습니다.

[스꾸버스 구현 사례]
'메인화면' 탭 버스 목록에 "설 연휴 귀향/귀경 버스" 항목을 동적으로 추가하고,
클릭 시 버스 예매 구글 폼으로 이동하도록 구현

  • 웹뷰(WebView)

웹뷰는 앱 내부 화면에서 웹페이지를 삽입하는 방식입니다. 정적인 페이지를 보여줄 수도 있고, 앱과 웹뷰가 통신하며 상호작용할 수도 있습니다.

[스꾸버스 구현 사례]
'인사캠 건물지도' 탭에서 앱(Flutter)과 웹뷰(JS)가 상호작용
사용자가 (1) 웹뷰 속 버튼을 클릭하면 (2) 앱에서 그에 맞는 UI를 표출해주는 방식

웹뷰는 잦은 콘텐츠 업데이트에 적합하지만, 네이티브 화면과의 이질감, 성능 저하 문제가 있습니다. 또한 '인사캠 건물지도' 탭처럼 앱과 웹뷰의 상호작용이 필요한 경우, 기능을 업데이트하려면 (1) 웹뷰 코드와 (2) 앱 코드 모두 수정해야 합니다. 결국 '빠른 업데이트'라는 웹뷰의 장점을 제대로 활용하기 어렵습니다.

void onMessageReceived(JavaScriptMessage message) async {
  Map<String, dynamic> messageData = json.decode(message.message);

  String? type = messageData['type'];
  String? localplacename = messageData['placename'];
  String? localbuildingname = messageData['buildingname'];
  String? localpreviousplace = messageData['previousplace'];
  String? localafterplace = messageData['afterplace'];
  String? localplaceinfo = messageData['placeinfo'];
  String? localtime = messageData['time'];
  String? localleftcolor = messageData['leftColor'];
  String? localrightcolor = messageData['rightColor'];

  if (type == "add") {
    if (localleftcolor != null) {
      leftColor.value = localleftcolor;
    } else {
      leftColor.value = "000000";
    }
  }
}

'인사캠 건물지도' 탭 Flutter 코드

📌 축제 기간 마주한 한계

성균관대학교 축제 '에스카라'와 관련된 정보를 업데이트하던 중 한계를 마주했습니다. 축제 정보를 기존 웹뷰로 만들었는데, 스토어 업데이트 없이는 해결할 수 없는 문제가 생겼습니다.

(1) 웹뷰에서 상세 페이지로 들어가면 뒤로 갈 방법이 없었습니다. 기존 서비스는 단일 페이지라 depth를 신경 쓸 필요가 없었고, 이에 따라 브라우징 툴바가 구현되어 있지 않았습니다.

(2) 웹뷰 안에 있는 구글폼, 카카오톡 채널과 같은 외부링크를 누르면 앱 외부 브라우저로 열어줘야 했습니다. 동시에 일반 링크앱 내부 웹뷰에서 보여줘야 해서 분기처리가 필요했습니다.

웹뷰는 노코드 툴과 노션을 웹페이지로 만들어주는 OOPY를 기반으로 만들었습니다. 결국 웹뷰에서는 복잡한 분기 처리를 하기 어려웠고, 앱 단의 코드 업데이트가 필요했습니다.

📌 OTA update with Shorebird

코드 푸시는 스토어 심사 없이 앱 코드를 실시간으로 사용자에게 배포하는 기술입니다. Flutter에서 안정적으로 쓸 수 있는 코드푸시 솔루션은 shorebird인데, Flutter 창시자 Eric Seidel이 직접 만들었다는 점에서 믿을 만했습니다.

스꾸버스는 OTA 업데이트를 위해 25년 6월부터 shorebird를 도입했고, 덕분에 축제 기간에 스토어를 거치지 않고 즉각적으로 앱을 업데이트할 수 있었습니다.

(1) 앱 로딩화면과 메인화면에 축제 배너를 추가하고

(2) 앞서 언급한 '새로운 웹뷰 기획'에 따른 코드 업데이트를 진행하였습니다.

WebViewController()
  ..clearCache()
  ..setNavigationDelegate(
    NavigationDelegate(
      onNavigationRequest: (NavigationRequest request) {
        final Uri uri = Uri.parse(request.url);

        // 1. isMainFrame이 아니면서 'http/https' 프로토콜을 사용하는 요청인지 확인

        if (!request.isMainFrame &&
            (uri.scheme == 'http' || uri.scheme == 'https' )) {
          // 2. 기존 로직 유지: oopy.io가 아닌 외부 링크일 때만 외부 브라우저 실행
          if (!uri.host.contains('oopy.io')) {
            launchUrl(uri);

            // 웹뷰 내에서는 해당 URL로 이동하지 않도록 방지
            return NavigationDecision.prevent;
          }
        }

        // 3. 그 외 모든 경우 (일반적인 페이지 이동, 키보드 관련 내부 동작 등)
        // 웹뷰가 내부적으로 처리하도록 허용
        return NavigationDecision.navigate;
      },
    ),
  ),
  • URL 호스트 주소(uri.host) 확인
  • 웹뷰의 네비게이션 이벤트가 mainframe이 아닐 경우 url_launcher로 외부 브라우저를 열어주는 로직을 추가

📌 Shorebird는 어떻게 작동하는가 (how it works)

Flutter의 코드 푸시, Shorebird는 다음과 같이 동작합니다.

(1) 개발자 release → (2) 개발자 patch → (3) 유저 패치 다운로드 → (4) 유저 패치 적용

(1) shorebird release: 개발자가 스토어에 배포할 앱을 빌드하고, 이 앱을 Shorebird 서버에도 등록합니다. 개발자는 이 파일을 앱스토어와 플레이스토어에 직접 제출하여 출시합니다.
(2) shorebird patch: 개발자가 앱 코드 수정 후, 이 명령어를 통해 원본과의 차이점(diff)만 담은 '패치' 파일을 만듭니다. 이 패치는 Shorebird 서버에 업로드되어 사용자에게 배포됩니다.
(3) 자동 업데이트: 사용자가 앱을 실행하면, 앱에 내장된 Shorebird 업데이터가 자동으로 서버에서 새 패치를 확인하여 다운로드하고 다음 실행 시 적용합니다.
(4) 업데이트 완료: 사용자가 다음 앱 실행 시, 업데이트된 버전으로 실행됩니다.

이러한 기능을 구현하기 위해 Shorebird는 Flutter, Dart 레포지토리를 fork하여 직접 수정했다고 합니다.

  • Dart SDK: iOS 정책을 준수하기 위한 커스텀 인터프리터(Interpreter)와 링커(Linker)를 추가
  • Flutter Engine: 패치를 다운로드하고 적용하는 업데이터 라이브러리를 내장
  • Flutter Framework: shorebird 명령어 사용 시, 표준 Flutter가 아닌 Shorebird가 수정한 버전을 사용하도록 빌드 프로세스를 변경

📌 Point 1: 데이터 분석 함정

shorebird를 통한 코드 푸시는 편리하지만, 실제로 사용해보니 주의해야 하는 부분이 있었습니다. 먼저 데이터 분석 시, 앱 버전과 관련된 부분은 해석에 주의가 필요합니다.
구체적인 시나리오를 설정해보겠습니다.

  • 버전 상황:
    • 7일 전 스토어 버전: 1.0.0
    • 현재 최신 스토어 버전: 1.1.0
    • 코드 푸시 패치 버전: 1.2.0

시나리오 1: 신규 유저

신규 유저가 스토어에서 1.1.0 버전을 다운로드한 경우

단계사용자 행동실제 실행 버전Analytics 기록 버전비고
1최초 실행1.1.01.1.0백그라운드에서 1.2.0 패치 다운로드
2두 번째 실행1.2.01.1.0패치가 적용되었지만, 로그는 원본 버전 기준

시나리오 2: 기존 유저

기존 유저가 1.0.0 버전을 사용하던 중인 경우

단계사용자 행동실제 실행 버전Analytics 기록 버전비고
1첫 실행1.0.01.0.0백그라운드에서 1.2.0 패치 다운로드
2두 번째 실행1.2.01.0.0패치 적용, 로그는 기존 원본(1.0.0) 기준
3스토어 자동 업데이트1.1.01.1.0스토어를 통해 원본 앱 자체가 업데이트됨
4다음 실행1.2.01.1.0다시 패치가 적용, 로그는 새 원본(1.1.0) 기준

이처럼 사용자가 경험하는 기능의 버전과 Analytics에 기록되는 버전이 달라 데이터 분석에 혼란이 생길 수 있습니다. shorebird_code_push 패키지를 통해 버젼 정보를 확인하고, FirebasesetUserProperty를 활용하여 해당 정보를 추가로 기입해주는 전략이 필요합니다.

import 'package.shorebird_code_push/shorebird_code_push.dart';
final shorebirdCodePush = ShorebirdCodePush();

// 앱 시작 시 또는 특정 로직에서 호출
Future<void> logVersionToAnalytics() async {
  // 현재 스토어에 배포된 앱 버전 (예: 3.2.3+47)
  final String baseVersion = "현재 앱의 빌드 버전 정보"; // package_info_plus 등으로 가져오기

  // 현재 적용된 패치 버전 확인 (패치가 없으면 null)
  final int? patchNumber = await shorebirdCodePush.currentPatchNumber();

  // 실제 실행되고 있는 최종 버전을 조합
  final String effectiveVersion = patchNumber == null 
      ? baseVersion 
      : '$baseVersion (patch: $patchNumber)';

  // Firebase Analytics에 유저 속성으로 기록
  await FirebaseAnalytics.instance.setUserProperty(
    name: 'effective_app_version',
    value: effectiveVersion,
  );
}

📌 Point 2: 패치 적용 시점

사용자는 2번째 앱을 사용하는 시점부터 업데이트를 적용받게 됩니다. 이는 사용자는 업데이트에 대해 신경 쓸 필요가 없다는 Shorebird 팀의 철학과 안전성을 고려한 결과입니다.

Dart VM의 hot restart 기능을 이용하면 기술적으로 가능하나,
native code와 crash가 일어날 가능성이 높기 때문이라고 합니다.

스꾸버스 ios 3.2.3+47 버젼의 shorebird console 데이터를 살펴보면
'Patch Downloads'는 573명인 반면 'Patch Installs'는 197명입니다. 즉 첫번째 앱을 실행하여 573명이 패치를 다운로드 받았지만, 실제로 적용된 사용자는 197명에 불과하다는 의미입니다. 아래와 같은 가능성을 생각해볼 수 있겠습니다.

  • 앱스토에서 앱을 다음 버젼으로 자동 업데이트
  • 사용자가 3.2.3+47 버젼 1회 사용 후 이탈
  • 패치 다운로드 오류로 인한 자동 롤백

📌 Point 2: 패치 적용 시점은 📌 Point 1: 데이터 분석 함정과 맞물려 데이터의 해석과 유저 시나리오 분석에 어려움을 줍니다.

또한 치명적인 버그를 수정하는 패치를 배포했더라도, 패치를 다운로드한 사용자 중 상당수가 앱을 재실행하기 전까지는 여전히 버그를 경험하며 부정적인 인상을 가질 수 있음을 의미합니다. 결과적으로 수정이 완료된 버그에 대한 CS 문의나 부정적인 리뷰가 계속해서 유입될 수 있습니다.

정말 긴급하고 중요한 패치의 경우 사용자에게 업데이트를 유도할 수 있는 UI/UX 전략으로 보완하면 좋겠습니다. 예를 들어, 앱 내에 "새로운 기능이 준비되었어요! 앱을 완전히 종료 후 다시 시작하면 적용됩니다."와 같은 스낵바나 팝업을 띄우는 방법을 고민해볼 수 있습니다.

📌 Point 3: ios 속도 이슈

스꾸버스 사용자의 80%가 iOS 유저라서, 코드 푸시 도입 시 성능 저하를 우려했습니다. 앱스토어 정책상 실행 가능한 코드 다운로드가 금지되어 있어서, iOS에서는 새 코드를 인터프리터 방식으로 해석해서 실행해야 하기 때문입니다.

Shorebird 팀은 '링커(Linker)'로 이 문제를 영리하게 해결했습니다. 쉽게 비유하면 '부분 번역' 을 활용하는 겁니다. 책 전체를 처음부터 새로 번역하는 게 아니라, 바뀐 문단이나 단어만 골라서 번역본에 덧붙이는 방식입니다. 안 바뀐 기존 코드는 네이티브 속도로 그대로 두고, 수정된 코드만 골라서 인터프리터로 실행합니다. 이미 번역된 부분은 그대로 두니까 번역 속도가 거의 느려지지 않는 원리입니다.

실제로 스꾸버스에 적용해보니 우려했던 속도 저하는 체감되지 않았고, 결과적으로 사용자 경험에도 영향을 주지 않았습니다. 물론 기술적 한계도 있습니다. Shorebird는 Dart 코드 업데이트만 되니까 이미지 같은 Asset 파일이나 네이티브 코드는 수정할 수 없습니다. 이건 필요한 이미지를 Network Image로 불러오는 방식으로 해결했습니다. 네트워크 이미지 로딩할 때 생기는 지연 시간은 Caching과 Placeholder를 활용해 사용자 경험이 나빠지지 않게 보완했습니다.


📌 결론

Shorebird 도입에는 분명한 장단점이 있습니다. 월간 사용량에 따라 비용이 발생하며, 무료 Hobby 플랜 사용시 월 5,000건의 패치 설치까지는 무료입니다.
또한 네이티브 코드 수정처럼 스토어 릴리즈가 필요한 경우를 구분하는 업데이트 전략의 복잡성과, 최신 Flutter 버전에 대한 기술 의존성도 고려해야 하는 지점입니다.

완벽한 기획과 완벽한 코드는 없습니다. 예상 못한 버그나 긴급 수정은 언제든 생깁니다. Shorebird를 활용하면 스토어 심사 없이 사용자 피드백을 빠르게 반영할 수 있지만, 동시에 검수 없는 배포는 예상치 못한 새로운 버그를 불러오기도 합니다. 오히려 스토어 심사 과정이 없기에 철저한 QA가 필요합니다.

📌 한줄요약

SDUI나 웹뷰만으로는 네이티브 위젯의 복잡한 상태 관리나 애니메이션 변경에 한계
이를 해결하기 위한 codepush 솔류션인 Shorebird 활용

구분✅ Shorebird 패치로 가능한 업데이트❌ 스토어 릴리즈 필수 업데이트
코드Dart 로직 변경, UI 위젯 구조 수정, 버그 픽스네이티브 코드(Kotlin/Swift) 변경, pubspec.yaml 의존성 추가/삭제
에셋(네트워크 이미지로 대체)앱 아이콘 변경, assets 폴더 내 이미지/폰트 추가 및 변경
설정-권한(Permission) 추가, Info.plist / AndroidManifest.xml 수정
기타단순 텍스트 변경, 색상 코드 변경Flutter 엔진 자체의 업데이트가 필요한 경우

0개의 댓글