2025년 10월 24일
웹 표준 성장통과 함께한 Flutter 하이브리드 앱 개발기
안녕하세요. 지금은 백엔드 개발을 메인으로 하지만 이전에는 안드로이드 앱을 만들고, 웹 프론트엔드도 경험한 개발자입니다. 다양한 플랫폼을 경험해 본 덕분에, 새로운 프로젝트를 시작할 때면 늘 여러 선택지를 두고 저울질하는 습관이 생겼습니다.
최근 팀에 'iOS와 Android 전기차 충전기 앱을 최대한 빠르게 런칭하고, 이후에도 기민하게 업데이트해야 한다'는 미션이 주어졌습니다. 이 미션의 핵심은 '속도'였습니다. 단순히 개발 속도가 아니라, 고객 피드백을 반영하는 '배포 속도' 인 것이죠.
이 관점에서 '앱 스토어 심사'는 가장 큰 병목이었습니다. 그래서 처음부터 웹을 중심으로 한 하이브리드 아키텍처를 선택했습니다.
배포의 자율성을 확보하고 작업 속도를 극대화하기 위한 전략적인 결정이었습니다.
핵심 전략: Flutter는 '네이티브 API 게이트웨이'
이 아키텍처에서 Flutter의 역할은 명확했습니다. 앱의 모든 UI를 그리는 프레임워크가 아니라, 실시간으로 업데이트되는 웹 콘텐츠를 담아주는 '고성능 네이티브 셸(Shell)' 이자 웹이 OS의 기능에 접근할 수 있도록 도와주는 'API 게이트웨이' 였습니다.

- 웹: UI, 비즈니스 로직, 기능 등 앱의 95%를 차지합니다. 웹 서버에 배포하는 순간, 모든 사용자의 앱은 즉시 업데이트됩니다.
- Flutter (Native Shell): 권한 처리, 푸시 알림, ML Kit 같은 고성능 연산 등 웹 혼자서는 할 수 없는 네이티브 기능만 담당합니다. 앱 스토어 업데이트는 이 부분에 큰 변경이 있을 때만 진행합니다.
이 아키텍처 덕분에 우리는 치명적인 버그 수정이나 긴급 기능 배포를 앱 스토어 심사 없이, 서버 배포 한 번으로 몇 분 만에 해결할 수 있는 강력한 무기를 손에 쥐게 되었습니다.
최전선에서 겪은 웹 표준의 성장통 (feat. MediaStream)
이 아키텍처의 성패는 '웹이 얼마나 네이티브 기능을 대체할 수 있는가'에 달려 있었습니다. 마침 신분증 인증에 카메라가 필요했는데, 이때 제 눈에 들어온 것이 바로 MediaStream Image Capture 라는 W3C의 최신 웹 표준이었습니다. 이론상 네이티브 코드 없이 웹 기술만으로 카메라와 플래시 제어가 가능했죠. "이거다!" 싶었습니다.
하지만 현실은 녹록지 않았습니다.
- iOS: 웹 표준 선두주자 답게 최신 웹 표준을 착실히 지원하며 완벽하게 동작합니다.
- Android: 당시 최신 모바일 크롬에서도 웹표준을 완벽하게 지원하지 않는 문제가 있었습니다. 특정 디바이스의 경우 카메라가 전면으로 표시되거나, 가장 중요했던 플래시(Torch) 기능이 전혀 동작하지 않았습니다.
그야말로 올해 막 나온 따끈따끈한 웹 표준이 자리를 잡아가는 과정을 최전선에서 온몸으로 부딪힌 셈이었습니다. 포기하는 대신, PoC(기술 검증)를 통해 가장 현실적인 해법을 찾아 나섰습니다.
"카메라 영상 스트림은 웹으로, 플래시 제어만 네이티브로!"
자바스크립트 브릿지를 이용해 웹에서는 카메라 화면만 보여주고, 사용자가 플래시 버튼을 누르면 네이티브의 Torch 기능을 호출하는 하이브리드의 하이브리드(?) 방식을 구현했습니다. 이 우여곡절 끝에 안정적인 QR 코드 스캐너를 완성할 수 있었습니다.
재미있는 점은, 몇 달 후 안드로이드 크롬이 업데이트되면서 이 모든 문제가 거짓말처럼 해결되었다는 것입니다. 지금은 은행권 신분증 인증조차 네이티브 코드 없이 이 웹 표준으로 처리할 정도가 되었죠. 웹 표준의 변천사를 직접 겪으며, 기술적으로 가능한 변화의 최전선을 가장 먼저 확인할 수 있었던 짜릿한 경험이었습니다.
웹 클라이언트와 앱의 약속: 브릿지 통신 규약
서버 개발에는 API 명세를 위한 Swagger나 OpenAPI가 있지만, 웹 클라이언트(웹뷰 내부)와 앱(Flutter) 간의 통신에는 그런 표준이 없습니다. "이 기능은 앱에서 호출 가능한가요?", "어떤 파라미터를 보내야 하죠?" 와 같은 질문들이 오가기 시작하면 협업은 금세 엉망이 되기 십상입니다.
그래서 우리만의 '웹 클라이언트-앱 통신 규약 명세'를 만들기로 했습니다. 자바스크립트 브릿지를 통해 호출할 수 있는 모든 네이티브 기능을 API처럼 정의하고 문서화하여 관리하는 것이었죠.
1. 통신 규약 정의: 약속된 JSON 형식과 앱 버전 명시
가장 먼저 한 일은 웹과 Flutter가 주고받을 메시지 형식을 표준화하는 것이었습니다. 모든 요청은 type과 data를 가진 JSON 객체로 정의했습니다.
그리고 중요한 것은, 각 기능이 앱의 몇 버전부터 지원되는지를 명시했습니다. 이를 통해 웹 개발자는 특정 기능을 사용하기 전, 현재 사용자의 앱 버전에서 해당 기능이 지원되는지 안전하게 확인할 수 있습니다.
[웹 클라이언트-앱 통신 규약 명세 예시]
기능 | Type | Data (Payload) | 앱 버전 | 설명 |
|---|---|---|---|---|
네이티브 권한 설정 화면 열기 | open_permission_settings | ["notification", "camera"] (배열, 복수 선택 가능) | 76 | 지정된 권한의 모바일 OS 설정 화면으로 직접 이동시킵니다. |
ML Kit QR 스캐너 실행 (Blob) | qr_decode | "data:image/png;base64,iVBORw..." (Base64 문자열) | 81 | 웹에서 전달한 이미지 Blob을 네이티브 ML Kit으로 스캔합니다. |
... | ... | ... | ... | ... |
이 간단한 문서만으로도 웹과 앱 개발팀 사이의 커뮤니케이션을 효율적으로 진행할 수 있었습니다.
2. 웹과 네이티브 연동
앱에서는 사용자가 어떤 권한을 허용하지 않았을 때, 해당 설정창으로 바로 이동시켜야 합니다. 이를 위해 웹에서는 다음과 같이 약속된 JSON 메시지를 만들어 VoltUpChannel이라는 브릿지로 전송하기만 하면 됩니다.
// '알림'과 '카메라' 권한 설정 페이지를 열도록 요청
const message = {
type: "open_permission_settings",
data: ["notification", "camera"] // OS 설정창으로 보낼 권한 목록
};
// 약속된 채널로 메시지 전송
window.VoltUpChannel.postMessage(JSON.stringify(message));Flutter에 정의된 채널 핸들러가 이 메시지를 받아 적절한 네이티브 설정 화면을 열어주는 로직을 수행합니다.
3. 고성능 QR 스캔 기능 구현 (ML Kit 연동)
이 아키텍처의 진정한 힘은 무거운 연산을 네이티브에 위임할 때 드러납니다. 웹에서 카메라 스트림을 받아 순수 JS로 QR 코드를 처리하는 것은 성능 저하가 심각했죠. 그래서 '분석'이라는 연산 자체를 Flutter의 기능으로 정의하고, 웹에서는 이를 호출하는 방식으로 구현했습니다.
웹은 카메라 화면에서 프레임을 추출해 Base64 문자열(Blob)로 변환한 뒤, Flutter에 요청합니다.
// 카메라 스트림에서 얻은 이미지 프레임을 Base64로 변환
const imageBlobBase64 = canvas.toDataURL('image/png');
const message = {
type: "qr_decode",
data: imageBlobBase64
};
// Flutter에 이미지 분석 요청
window.VoltUpChannel.postMessage(JSON.stringify(message));
// Flutter가 결과를 돌려줄 콜백 함수를 미리 정의
function onQrDecode(result) {
console.log("ML Kit 스캔 결과:", result);
}그러면 Flutter는 이 Base64 문자열을 받아 google_mlkit_barcode_scanning 모듈을 이용해 순식간에 처리합니다.
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
import 'dart:io'; // 파일 처리를 위한 import
import 'dart:convert'; // Base64 디코딩을 위한 import
import 'package:path_provider/path_provider.dart'; // 임시 파일 저장을 위한 import
// 웹에서 받은 Base64 문자열로 바코드를 스캔하는 함수
Future<List<Barcode>> scanBarcodeByBlob(String base64String) async {
final barcodeScanner = BarcodeScanner(formats: const [BarcodeFormat.qrCode]);
// Base64 문자열에서 "data:image/png;base64," 부분을 제거하고 디코딩
final decodedBytes = base64Decode(base64String.split(',').last);
// 임시 디렉토리에 파일 저장
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/qr_image_${DateTime.now().microsecondsSinceEpoch}.png');
await file.writeAsBytes(decodedBytes);
final inputImage = InputImage.fromFile(file);
final barcodes = await barcodeScanner.processImage(inputImage);
await barcodeScanner.close();
await file.delete(); // 임시 파일 삭제
return barcodes;
}분석이 끝나면 Flutter는 다시 JS 브릿지를 통해 웹의 onQrDecode 함수를 호출하여 결과를 전달합니다. 이처럼 저희는 웹뷰를 단순한 콘텐츠 표시창이 아닌, 네이티브 기능을 자유롭게 호출하고 연산 결과를 주고받는 채널로 활용할 수 있었습니다.
껍데기는 거들 뿐: 현실적인 하이브리드 앱 운영기
'웹을 중심으로 개발하면 편하겠지'라고 생각하면 오산입니다. 네이티브 셸(Shell)은 생각보다 많은 관리가 필요했고, 플랫폼별 미세한 차이점들이 계속해서 발목을 잡았습니다.
- 공식 문서의 배신 (feat. 해상도 문제): 웹뷰의 텍스트 줌(Text Zoom) 기능 문서에는 분명 이렇게 적혀 있었습니다. /// The default is 100. 당연히 모든 플랫폼에서 기본값이 100%일 것이라 믿고 개발을 진행했습니다.
iOS에서는 주석 그대로 완벽하게 동작했습니다. 하지만 안드로이드에서는 사용자가 설정한 시스템 폰트/디스플레이 크기에 따라 UI가 멋대로 확대/축소되며 레이아웃이 깨지는 이슈가 발생했습니다.
/// Sets the text zoom of the page in percent.
///
/// The default is 100.
Future setTextZoom(int textZoom) =>
_webView.settings.setTextZoom(textZoom);
- 원인은 간단했습니다. 공식 문서의 주석은 iOS에만 해당하는 반쪽짜리 진실이었고, 안드로이드는 유저의 기기 설정에 따라 동작하고 있었던 것입니다. 결국 플랫폼을 직접 확인하여 안드로이드일 경우에만 명시적으로 100%로 고정하는 코드를 추가해야 했습니다.
// controller는 WebView 컨트롤러
if (controller.platform is AndroidWebViewController) {
final androidController = controller.platform as AndroidWebViewController;
// 안드로이드 웹뷰의 텍스트 줌을 100%로 강제 고정
await androidController.setTextZoom(100);
}
- 이 경험을 통해 뼈저리게 깨달았습니다. 멀티플랫폼 환경에서는 공식 문서에 적힌 주석조차 의심하고, 모든 기능을 각 플랫폼에서 직접 검증해야 한다는 것입니다.
- 🍪 쿠키 시스템의 파편화: iOS와 안드로이드 네이티브 웹뷰는 쿠키를 다루는 방식이 미묘하게 달랐습니다. 플랫폼 구분 없이 서드파티 쿠키를 제어하기 위해, 결국 webview_cookie_manager_plus 같은 외부 라이브러리의 도움을 받아야 했습니다.
- 피할 수 없는 네이티브의 숙제: 웹이 아무리 발전해도 앱의 '기반'은 네이티브입니다. 최근 안드로이드 정책에 맞춰 16KB 페이지 사이즈를 지원해야 하거나, 보안을 위해 최소 SDK 버전을 주기적으로 올려야 하는 등, 퍼스트파티 스토어의 지침을 꾸준히 따라가야 하는 숙제는 피할 수는 없었습니다.
이 경험을 통해 뼈저리게 깨달았습니다. 멀티플랫폼 환경에서는 공식 문서에 적힌 주석조차 의심하고, 모든 기능을 각 플랫폼에서 직접 검증해야 한다는 것입니다.
최적의 아키텍처를 찾아서
이번 프로젝트는 '최고의 기술'이 아닌 '최적의 아키텍처'에 대한 깊은 고민의 연속이었습니다.
네이티브 앱을 개발한 경험이 있었지만, 배포 속도를 위해 Flutter와 웹뷰를 선택했고, 웹 표준의 불안정성을 마주했을 땐 네이티브 기능을 접목하는 유연성을 발휘했습니다. 그 과정에서 수많은 예외처리와 플랫폼별 분기 로직을 마주해야 했지만, 이제는 앱 마켓플레이스에 얽매이지 않고 우리만의 속도로 제품을 개선해나갈 수 있는 강력한 시스템을 손에 넣었습니다.
만약 당신의 팀이 '속도'를 가장 중요하게 생각한다면, 정석적인 앱 개발 방식에서 벗어나 웹과 네이티브의 장점을 기민하게 조합하는 아키텍처를 진지하게 고민해 보시길 바랍니다. 그 험난한 여정 끝에, 기술의 경계를 허무는 개발자로서 한 뼘 더 성장한 자신을 발견하게 될 것입니다.
긴 글 읽어주셔서 감사합니다.
참고 문헌
Flutter 공식 문서
- Flutter Documentation
- webview_flutter 패키지
- webview_cookie_manager_plus 패키지
- AndroidWebViewController 소스 코드 (setTextZoom 관련)
웹 표준 (Web Standards)
- W3C MediaStream Image Capture API (MDN Web Docs)
- HTML canvas.toDataURL() (MDN Web Docs) - Base64 이미지 변환 관련
