[Froppy 개발기 2편] Flutter로 Web과 Mobile 동시 개발하기 — 이론과 현실의 차이
3일 전
▣Froppy3개 기록▲ 목록 접기
Flutter로 Web과 Mobile 동시 개발하기
"Write once, run anywhere."
모든 크로스플랫폼 프레임워크가 꿈꾸는 말이다.
현실은 조금 달랐다.
왜 Flutter를 선택했는가
1인 개발에서 Android, iOS, Web 세 벌의 코드를 따로 관리하는 건 처음부터 포기했다.
React Native는 웹을 제대로 지원하지 않았고,
React는 모바일 네이티브 경험을 포기해야 했다.
Flutter는 단일 코드베이스로 세 플랫폼을 커버한다는 약속이 있었다.
그 약속은 대부분 지켜졌다. 하지만 "플랫폼을 타는" 기능들에서는 직접 분기 처리를 해야 했다.
Froppy에서 플랫폼 분기가 필요했던 것들:
| 기능 | 문제 |
|---|---|
| JWT 토큰 저장 | 웹은 localStorage, 모바일은 SharedPreferences |
| OAuth 로그인 | 웹은 location.assign, 모바일은 url_launcher |
| 결제 처리 | 웹은 JS SDK + iframe, 모바일은 flutter_inappwebview |
| URL 전략 | 웹은 path URL, 모바일은 deep link |
이것들을 어떻게 해결했는지 하나씩 정리한다.
핵심 패턴 — 조건부 import (Conditional Import)
Flutter에서 플랫폼을 분기하는 가장 깔끔한 방법이다.
import 'token_service_web.dart'
if (dart.library.io) 'token_service_mobile.dart';
dart.library.io는 모바일/데스크톱 환경에서 true,
dart.library.html은 웹 환경에서 true가 된다.
이 한 줄이 컴파일 타임에 플랫폼에 맞는 파일을 선택한다.
런타임 분기가 아니기 때문에 트리 쉐이킹도 제대로 된다.
Froppy에서 이 패턴을 사용한 파일들:
services/
├── token_service.dart ← 퍼사드 (공통 인터페이스)
├── token_service_web.dart ← 웹 구현체 (dart:html)
├── token_service_mobile.dart ← 모바일 구현체 (SharedPreferences)
│
├── oauth_service.dart ← 퍼사드
├── oauth_service_web.dart ← 웹 구현체
├── oauth_service_mobile.dart ← 모바일 구현체
│
screens/
├── payment_web_helper.dart ← 웹 전용 (dart:html + dart:ui_web)
├── payment_web_helper_stub.dart← 모바일용 빈 구현체
1. JWT 토큰 저장 — localStorage vs SharedPreferences
가장 기본적인 분기다.
웹은 브라우저의 localStorage에, 모바일은 SharedPreferences에 토큰을 저장한다.
공통 인터페이스 (token_service.dart)
import 'token_service_web.dart'
if (dart.library.io) 'token_service_mobile.dart';
class TokenService {
static Future<void> saveToken(String token) async {
await TokenServicePlatform.saveToken(token);
}
static Future<String?> getToken() async {
return await TokenServicePlatform.getToken();
}
static Future<void> removeToken() async {
await TokenServicePlatform.removeToken();
}
}
앱 어디서든 TokenService.saveToken(token)만 호출하면 된다.
플랫폼이 뭔지 신경 쓸 필요가 없다.
웹 구현체 (token_service_web.dart)
import 'dart:html' as html;
class TokenServicePlatform {
static const String _tokenKey = 'jwt_token';
static Future<void> saveToken(String token) async {
html.window.localStorage[_tokenKey] = token;
}
static Future<String?> getToken() async {
final token = html.window.localStorage[_tokenKey];
if (token == null || token.isEmpty) return null;
return token;
}
static Future<void> removeToken() async {
html.window.localStorage.remove(_tokenKey);
}
}
모바일 구현체 (token_service_mobile.dart)
import 'package:shared_preferences/shared_preferences.dart';
class TokenServicePlatform {
static const String _tokenKey = 'jwt_token';
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
static Future<void> removeToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
}
}
두 파일 모두 같은 클래스 이름 TokenServicePlatform을 사용한다.
조건부 import가 파일을 교체하는 방식이므로, 클래스 이름이 동일해야 한다.
2. 웹 결제 처리 — dart:html과 iframe
결제 플랫폼 분기가 가장 복잡했다.
웹에서 PortOne V2 결제를 처리하는 방법은 세 가지였다:
- JavaScript SDK를 직접 호출하는 방식 (
dart:js_interop) - iframe에 결제 HTML을 로드하고
postMessage로 통신하는 방식 - 결제 페이지로 redirect했다가 돌아오는 방식
Froppy는 세 방식을 상황에 따라 모두 사용했다.
JS SDK 직접 호출 (portone_web_impl.dart)
import 'dart:js_interop';
// PortOne JS 전역 객체 바인딩
extension type PortOneSDK(JSObject _) implements JSObject {
external JSPromise<JSAny?> requestPayment(JSAny? options);
}
@JS('PortOne')
external PortOneSDK? get _portOne;
Future<PaymentResponse?> startWebPayment(PaymentRequest request) async {
if (_portOne == null) {
// index.html에 PortOne SDK 스크립트가 없는 경우
return PaymentResponse(code: 'SDK_NOT_LOADED', ...);
}
final optionsMap = {
'storeId': request.storeId,
'channelKey': request.channelKey,
'paymentId': request.paymentId,
'orderName': request.orderName,
'totalAmount': request.totalAmount,
'currency': 'KRW',
};
// Dart Map → JS Object 변환
final result = await _portOne!
.requestPayment(optionsMap.jsify())
.toDart;
// JS Object → Dart Map 변환
final response = (result as JSObject).dartify() as Map<dynamic, dynamic>;
return PaymentResponse(paymentId: response['paymentId'], ...);
}
iframe + postMessage 방식 (payment_web_helper.dart)
결제창이 팝업 차단에 걸리거나, 모바일 웹에서 새 탭으로 열어야 할 때는
iframe에 HTML을 직접 주입하고 postMessage로 결과를 받는 방식을 사용했다.
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
void registerIframeView(
String viewId,
String htmlContent,
Function(dynamic) onMessage,
) {
// Flutter 플랫폼 뷰로 iframe 등록
ui_web.platformViewRegistry.registerViewFactory(viewId, (int id) {
final blob = html.Blob([htmlContent], 'text/html');
final url = html.Url.createObjectUrl(blob);
final iframe = html.IFrameElement()
..src = url
..style.border = 'none'
..width = '100%'
..height = '100%';
// iframe → Flutter 메시지 수신
html.window.onMessage.listen((event) {
onMessage(event.data);
});
return iframe;
});
}
Flutter 위젯에서는 이렇게 사용한다:
// Flutter 위젯 트리에서 iframe 렌더링
HtmlElementView(viewType: viewId)
모바일용 stub (payment_web_helper_stub.dart)
모바일에서는 아무것도 하지 않는 빈 껍데기만 있으면 된다.
void clearPaymentResult() {}
void redirectToPaymentUrl(String url) {}
void registerIframeView(String viewId, String htmlContent, Function(dynamic) onMessage) {}
이 파일이 없으면 모바일 빌드에서 dart:html을 import할 수 없어 컴파일 에러가 난다.
stub 파일은 반드시 같은 함수 시그니처를 가져야 한다.
3. URL 전략 — 해시 없는 경로
Flutter 웹은 기본적으로 /#/home 같은 해시 기반 URL을 사용한다.
일반 웹사이트처럼 /home으로 접근하려면 path URL 전략을 설정해야 한다.
import 'package:flutter_web_plugins/url_strategy.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 해시(#) 없는 URL 전략
usePathUrlStrategy();
runApp(const FroppyApp());
}
한 줄이지만 배포 시 웹 서버 설정도 함께 바꿔야 한다.
모든 경로 요청을 index.html로 fallback하지 않으면
새로고침 시 404가 난다.
Cloudflare Pages 기준으로는 _redirects 파일에 다음 한 줄을 추가했다:
/* /index.html 200
4. 반응형 레이아웃 — 하단 내비게이션 vs 사이드 내비게이션
웹과 모바일의 네비게이션 패턴이 다르다.
모바일은 하단 탭 바, 웹(데스크톱)은 사이드 내비게이션이 일반적이다.
class MainFeedPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isWeb = kIsWeb;
final isWideScreen = MediaQuery.of(context).size.width > 768;
return Scaffold(
body: Row(
children: [
// 웹 + 넓은 화면: 사이드 내비게이션
if (isWeb && isWideScreen)
const CommonSideNavigationBar(),
// 메인 콘텐츠 영역
Expanded(child: _buildCurrentPage()),
],
),
// 모바일 또는 좁은 화면: 하단 탭 바
bottomNavigationBar: (!isWeb || !isWideScreen)
? const CommonBottomNavigationBar()
: null,
);
}
}
kIsWeb은 Flutter가 제공하는 상수로, 웹 빌드일 때 true가 된다.
MediaQuery로 화면 너비를 추가로 체크해서 태블릿 화면 대응도 했다.
삽질 모음
삽질 1: dart:html import 에러
Error: dart:html is not supported on this platform
dart:html을 직접 import한 파일이 모바일 빌드에 포함될 때 나는 에러다.
해결책은 항상 조건부 import + stub 패턴을 사용하는 것이다.
직접 dart:html을 import하는 파일은 절대로 앱 전체에서 직접 import해선 안 되고,
반드시 조건부 import의 대상 파일로만 사용해야 한다.
삽질 2: iframe postMessage 타이밍 이슈
iframe이 로드되기 전에 메시지를 수신하려 하면 아무것도 받지 못한다.
iframe onLoad 이벤트를 기다린 뒤에 메시지 리스너를 붙여야 한다.
iframe.onLoad.listen((_) {
// 이제 postMessage 수신 준비 완료
html.window.onMessage.listen((event) {
onMessage(event.data);
});
});
삽질 3: GoRouter와 OAuth redirect
OAuth 로그인 후 백엔드가 /auth/callback?token=xxx로 redirect하는데,
Flutter 웹에서는 이 URL 변경을 GoRouter가 잡아야 한다.
OAuthCallbackHandler를 별도로 만들어 앱 초기화 시점에
window.location.href를 체크하도록 처리했다.
// 조건부 import로 웹에서만 동작
import 'services/oauth_callback_handler_stub.dart'
if (dart.library.html) 'services/oauth_callback_handler.dart';
삽질 4: 웹에서 video_player가 안 된다
video_player 패키지는 웹에서 <video> 태그를 사용하는데,
Cloudflare Stream의 HLS 스트리밍 URL이 웹에서 직접 재생이 안 됐다.
결국 웹에서는 Cloudflare Stream의 iframe embed 방식으로 전환했다.
조건부 import 패턴 정리
Froppy에서 결론적으로 정착한 패턴은 다음과 같다.
공통 인터페이스 파일
├── import 웹 구현체
└── if (dart.library.io) 모바일 구현체
웹 구현체
└── dart:html / dart:js_interop / dart:ui_web 사용 가능
모바일 구현체
└── shared_preferences / url_launcher 등 사용
stub 파일 (웹 전용 기능의 모바일 더미)
└── 함수 시그니처만 동일하게, 내부는 비움
이 패턴을 처음부터 알았다면 삽질의 절반은 없었을 것이다.
Flutter Web의 현실
Flutter Web은 분명히 강력하다.
단일 코드베이스로 모바일 앱과 거의 동일한 UI를 웹에서 보여줄 수 있다.
하지만 솔직히 말하면, "Flutter로 웹을 만드는 것"과 "React로 웹을 만드는 것"은 다르다.
- SEO는 기대하기 어렵다 (Canvas 기반 렌더링)
- 초기 로딩이 느리다 (Wasm 빌드로 개선되고 있지만)
- 웹 표준 API를 써야 할 때마다
dart:html분기가 필요하다 - 웹 개발자가 기대하는 DOM 조작이 안 된다
Froppy처럼 모바일이 주 플랫폼이고 웹이 보조인 경우에는 충분히 만족스러웠다.
반대로 웹이 주 플랫폼이라면 Flutter Web보다 Next.js 같은 웹 특화 프레임워크가 낫다.
다음 편 예고
다음 편에서는 백엔드 이야기다.
NestJS로 15개 이상의 도메인을 어떻게 모듈로 나눴는지,
JWT Guard를 어떻게 설계했는지,
환경별 설정을 어떻게 분리했는지를 다룬다.
이 시리즈는 총 8편으로 구성됩니다.
- [1편] 기획 & 아키텍처 — Flutter + NestJS + Cloudflare를 선택한 이유
- [2편] Flutter로 Web과 Mobile 동시 개발하기 ← 현재 글
- [3편] NestJS로 스타트업 백엔드 설계하기
- [4편] 소셜 로그인 3종 세트 — Google / Kakao / Naver OAuth 연동 실전
- [5편] 결제 시스템 구축 — Toss Payments & PortOne 이중 연동기
- [6편] Supabase + Cloudflare로 저비용 인프라 구성하기
- [7편] 실시간 채팅 — WebSocket Gateway + Cloudflare R2
- [8편] 피벗 — 1년간 만든 서비스를 접는 결정

