SidedockSidedock
로그인
Dev Log/[Froppy 개발기 2편] Flutter로 Web과 Mobile 동시 개발하기 — 이론과 현실의 차이

[Froppy 개발기 2편] Flutter로 Web과 Mobile 동시 개발하기 — 이론과 현실의 차이

#Flutter Web#flutter#사이드프로젝트#조건부 import#크로스플랫폼
froppy
@froppy

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 결제를 처리하는 방법은 세 가지였다:

  1. JavaScript SDK를 직접 호출하는 방식 (dart:js_interop)
  2. iframe에 결제 HTML을 로드하고 postMessage로 통신하는 방식
  3. 결제 페이지로 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년간 만든 서비스를 접는 결정
이 글이 도움됐다면 좋아요를 눌러주세요

댓글

로그인하면 댓글을 작성할 수 있습니다.