[Flutter 교육]
Flutter Web 앱을 React에 통합하기 - 완전 실행 가이드
Flutter 웹 앱을 React에 임베드하기: 단계별 가이드
준비사항
- Node.js 설치 (v14 이상)
- Flutter SDK 설치 (최신 안정 버전)
- Git 설치
1. 프로젝트 초기 설정
1.1 프로젝트 디렉토리 생성
# 메인 프로젝트 디렉토리 생성
mkdir flutter-react-chat
cd flutter-react-chat
# Flutter 및 React 프로젝트용 디렉토리 생성
mkdir flutter-chat
mkdir react-chat
1.2 Flutter 프로젝트 생성
# Flutter 프로젝트 생성
cd flutter-chat
flutter create .
# web 플랫폼 활성화
flutter config --enable-web
1.3 React 프로젝트 생성
# React 프로젝트 생성
cd ../react-chat
npx create-react-app . --template typescript
# 필요한 패키지 설치
npm install @types/node @types/react @types/react-dom @types/jest
2. Flutter 프로젝트 구성
2.1 Flutter 프로젝트 구조 생성
cd ../flutter-chat
mkdir -p lib/src/services lib/src/widgets
# 필요한 파일 생성
touch lib/src/services/js_bridge.dart
touch lib/src/widgets/chat_widget.dart
touch lib/src/models/message_model.dart
2.2 pubspec.yaml 수정
name: flutter_chat
description: Flutter chat application for web embedding
environment:
sdk: ">=2.17.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
js: ^0.6.3
json_annotation: ^4.8.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.6
json_serializable: ^6.7.1
2.3 파일 내용 작성
lib/src/models/message_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'message_model.g.dart';
@JsonSerializable()
class ChatMessage {
final String id;
final String content;
final String sender;
final DateTime timestamp;
ChatMessage({
required this.id,
required this.content,
required this.sender,
required this.timestamp,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
_$ChatMessageFromJson(json);
Map<String, dynamic> toJson() => _$ChatMessageToJson(this);
}
lib/src/services/js_bridge.dart
import 'dart:js' as js;
import 'package:js/js.dart';
import 'dart:html' as html;
import '../models/message_model.dart';
import 'dart:convert';
@JS('window.flutterChatBridge')
external set _flutterChatBridge(void Function(dynamic) f);
class JsBridge {
static final JsBridge _instance = JsBridge._internal();
factory JsBridge() {
return _instance;
}
JsBridge._internal() {
_initializeBridge();
}
void _initializeBridge() {
_flutterChatBridge = allowInterop((dynamic data) {
try {
if (data is String) {
final jsonData = json.decode(data);
_handleMessage(jsonData);
}
} catch (e) {
print('Error processing message: $e');
}
});
}
void _handleMessage(Map<String, dynamic> data) {
final type = data['type'] as String?;
final payload = data['payload'];
switch (type) {
case 'newMessage':
_handleNewMessage(payload);
break;
case 'styleUpdate':
_handleStyleUpdate(payload);
break;
}
}
void _handleNewMessage(dynamic payload) {
if (payload is Map<String, dynamic>) {
final message = ChatMessage.fromJson(payload);
// 메시지 처리 로직
}
}
void _handleStyleUpdate(dynamic payload) {
if (payload is Map<String, dynamic>) {
final element = html.document.getElementById('flutter-chat-container');
if (element != null) {
if (payload.containsKey('width')) {
element.style.width = payload['width'];
}
if (payload.containsKey('height')) {
element.style.height = payload['height'];
}
if (payload.containsKey('transform')) {
element.style.transform = payload['transform'];
}
}
}
}
void sendToReact(String type, dynamic payload) {
final event = html.CustomEvent(
'flutterMessage',
detail: {
'type': type,
'payload': payload,
},
);
html.window.dispatchEvent(event);
}
}
lib/src/widgets/chat_widget.dart
import 'package:flutter/material.dart';
import '../services/js_bridge.dart';
import '../models/message_model.dart';
class ChatWidget extends StatefulWidget {
const ChatWidget({Key? key}) : super(key: key);
@override
_ChatWidgetState createState() => _ChatWidgetState();
}
class _ChatWidgetState extends State<ChatWidget> {
final List<ChatMessage> _messages = [];
final TextEditingController _controller = TextEditingController();
final JsBridge _jsBridge = JsBridge();
void _sendMessage() {
if (_controller.text.isEmpty) return;
final message = ChatMessage(
id: DateTime.now().toIso8601String(),
content: _controller.text,
sender: 'Flutter',
timestamp: DateTime.now(),
);
setState(() {
_messages.add(message);
});
_jsBridge.sendToReact('newMessage', message.toJson());
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return ListTile(
title: Text(message.content),
subtitle: Text(message.sender),
trailing: Text(
message.timestamp.toLocal().toString().split('.')[0],
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: '메시지를 입력하세요',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
),
],
),
),
],
);
}
}
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/chat_widget.dart';
void main() {
runApp(const ChatApp());
}
class ChatApp extends StatelessWidget {
const ChatApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Chat',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const Scaffold(
body: ChatWidget(),
),
);
}
}
3. React 프로젝트 구성
3.1 React 프로젝트 구조 생성
cd ../react-chat
mkdir -p src/components src/services src/types
# 필요한 파일 생성
touch src/services/flutter-bridge.ts
touch src/components/FlutterChat.tsx
touch src/components/MultiView.tsx
touch src/types/index.ts
3.2 패키지 설치
npm install @types/node @types/react @types/react-dom @types/jest
3.3 파일 내용 작성
src/types/index.ts
export interface ChatMessage {
id: string;
content: string;
sender: string;
timestamp: string;
}
export interface StyleUpdate {
width?: string;
height?: string;
transform?: string;
}
src/services/flutter-bridge.ts
import { ChatMessage, StyleUpdate } from '../types';
type MessageHandler = (message: ChatMessage) => void;
type StyleHandler = (style: StyleUpdate) => void;
export class FlutterBridge {
private static instance: FlutterBridge | null = null;
private messageHandlers: Set<MessageHandler>;
private styleHandlers: Set<StyleHandler>;
private constructor() {
this.messageHandlers = new Set();
this.styleHandlers = new Set();
this.initializeMessageListener();
}
static getInstance(): FlutterBridge {
if (!FlutterBridge.instance) {
FlutterBridge.instance = new FlutterBridge();
}
return FlutterBridge.instance;
}
private initializeMessageListener() {
window.addEventListener('flutterMessage', ((event: CustomEvent) => {
const { type, payload } = event.detail;
switch (type) {
case 'newMessage':
this.messageHandlers.forEach(handler => handler(payload));
break;
case 'styleUpdate':
this.styleHandlers.forEach(handler => handler(payload));
break;
}
}) as EventListener);
}
sendMessage(message: ChatMessage) {
if ((window as any).flutterChatBridge) {
(window as any).flutterChatBridge(JSON.stringify({
type: 'newMessage',
payload: message,
}));
}
}
updateStyle(style: StyleUpdate) {
if ((window as any).flutterChatBridge) {
(window as any).flutterChatBridge(JSON.stringify({
type: 'styleUpdate',
payload: style,
}));
}
}
onMessage(handler: MessageHandler) {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
onStyleUpdate(handler: StyleHandler) {
this.styleHandlers.add(handler);
return () => this.styleHandlers.delete(handler);
}
}
src/components/FlutterChat.tsx
import React, { useEffect, useRef, useState } from 'react';
import { FlutterBridge } from '../services/flutter-bridge';
import { ChatMessage } from '../types';
interface FlutterChatProps {
width?: number;
height?: number;
rotation?: { x: number; y: number; z: number };
onMessage?: (message: ChatMessage) => void;
}
export const FlutterChat: React.FC<FlutterChatProps> = ({
width = 400,
height = 600,
rotation = { x: 0, y: 0, z: 0 },
onMessage,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isLoaded, setIsLoaded] = useState(false);
const bridge = FlutterBridge.getInstance();
useEffect(() => {
if (onMessage) {
return bridge.onMessage(onMessage);
}
}, [onMessage]);
useEffect(() => {
if (isLoaded) {
bridge.updateStyle({
width: `${width}px`,
height: `${height}px`,
transform: `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg) rotateZ(${rotation.z}deg)`,
});
}
}, [width, height, rotation, isLoaded]);
useEffect(() => {
const loadFlutter = async () => {
if (!containerRef.current) return;
try {
// Flutter 앱 로드
setIsLoaded(true);
} catch (error) {
console.error('Failed to load Flutter app:', error);
}
};
loadFlutter();
}, []);
return (
<div
ref={containerRef}
id="flutter-chat-container"
style={{
width: `${width}px`,
height: `${height}px`,
transform: `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg) rotateZ(${rotation.z}deg)`,
transition: 'all 0.3s ease',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
/>
);
};
src/components/MultiView.tsx
import React, { useState } from 'react';
import { FlutterChat } from './FlutterChat';
import { ChatMessage } from '../types';
export const MultiView: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [rotation, setRotation] = useState({ x: 0, y: 0, z: 0 });
const handleMessage = (message: ChatMessage) => {
setMessages(prev => [...prev, message]);
};
return (
<div className="multi-view-container">
<div className="controls">
<div>
<label>X 회전: </label>
<input
type="range"
min="-180"
max="180"
value={rotation.x}
onChange={e => setRotation(prev => ({ ...prev, x: +e.target.value }))}
/>
</div>
<div>
<label>Y 회전: </label>
<input
type="range"
min="-180"
max="180"
value={rotation.y}
onChange={e => setRotation(prev => ({ ...prev, y: +e.target.value }))}
/>
</div>
<div>
<label>Z 회전: </label>
<input
type="range"
min="-180"
max="180"
value={rotation.z}
onChange={e => setRotation(prev => ({ ...prev, z: +e.target.value }))}
/>
</div>
</div>
<div className="views" style={{ display: 'flex', gap: '20px' }}>
<FlutterChat
width={400}
height={600}
rotation={rotation}
onMessage={handleMessage}
/>
<FlutterChat
width={400}
height={600}
rotation={rotation}
onMessage={handleMessage}
/>
</div>
<div className="messages" style={{ marginTop: '20px' }}>
<h3>All Messages</h3>
<div style={{ maxHeight: '200px', overflow: 'auto' }}>
{messages.map(message => (
<div key={message.id} style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
<strong>{message.sender}:</strong> {message.content}
<small style={{ float: 'right' }}>
{new Date(message.timestamp).toLocaleTimeString()}
</small>
</div>
))}
</div>
</div>
</div>
);
};
src/App.tsx
import React from 'react';
import { MultiView } from './components/MultiView';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<h1>Flutter Chat Integration</h1>
</header>
<main>
<MultiView />
</main>
</div>
);
}
export default App;
src/App.css
.App {
text-align: center;
padding: 20px;
}
.App-header {
background-color: #282c34;
padding: 20px;
color: white;
margin-bottom: 20px;
}
.controls {
margin-bottom: 20px;
padding: 20px;
background-color: #f5f5f5;
border-radius: 8px;
}
.controls > div {
margin: 10px 0;
}
.controls label {
margin-right: 10px;
min-width: 80px;
display: inline-block;
text-align: right;
}
.controls input[type="range"] {
width: 200px;
}
.multi-view-container {
max-width: 1200px;
margin: 0 auto;
}
.views {
justify-content: center;
flex-wrap: wrap;
}
4. 통합 및 실행
4.1 Flutter 프로젝트 빌드
# Flutter 프로젝트 디렉토리로 이동
cd flutter-chat
# Flutter 웹 빌드
flutter build web --web-renderer html --release
# 빌드된 파일을 React 프로젝트의 public 폴더로 복사
mkdir -p ../react-chat/public/flutter
cp -r build/web/* ../react-chat/public/flutter/
4.2 index.html 수정
React 프로젝트의 public/index.html 파일을 수정하여 Flutter 앱을 로드하는 스크립트를 추가합니다:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Flutter Chat Integration with React"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Flutter Chat Integration</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- Flutter 앱 로드 -->
<script src="%PUBLIC_URL%/flutter/flutter.js" defer></script>
<script>
window.addEventListener('load', function() {
// Flutter 초기화
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: async function(engineInitializer) {
const appRunner = await engineInitializer.initializeEngine();
await appRunner.runApp();
}
});
});
</script>
</body>
</html>
4.3 React 앱 실행
# React 프로젝트 디렉토리로 이동
cd ../react-chat
# 의존성 설치
npm install
# 개발 서버 실행
npm start
5. 개발 프로세스
5.1 Flutter 개발
- Flutter 코드 수정
flutter run -d chrome
으로 테스트- 변경사항 확인 후 빌드
- 빌드된 파일을 React 프로젝트로 복사
5.2 React 개발
- React 컴포넌트 수정
- 브라우저에서 변경사항 확인
- Flutter 통신 테스트
6. 문제 해결
6.1 Flutter 웹 로딩 실패
다음 사항을 확인하세요:
- public/flutter 디렉토리에 모든 필요 파일이 있는지 확인
- 브라우저 콘솔에서 오류 메시지 확인
- Flutter 빌드 옵션이 올바른지 확인
6.2 통신 오류
- 브라우저 콘솔에서 이벤트 발생 여부 확인
- JS 브릿지 초기화 상태 확인
- 이벤트 핸들러가 제대로 등록되었는지 확인
6.3 스타일 적용 실패
- DOM 요소 ID가 올바른지 확인
- 스타일 업데이트 이벤트가 제대로 전달되는지 확인
- CSS 변환이 올바른 형식인지 확인
7. 배포
7.1 빌드
# Flutter 빌드
cd flutter-chat
flutter build web --web-renderer html --release
# React 빌드
cd ../react-chat
npm run build
7.2 서버 설정
- CORS 설정 추가
- 정적 파일 서빙 설정
- 라우팅 설정
8. 성능 최적화
8.1 Flutter 최적화
- 웹 렌더러 선택 (html vs CanvasKit)
- 자산 최적화
- 초기 로딩 최적화
8.2 React 최적화
- 컴포넌트 메모이제이션
- 이벤트 쓰로틀링/디바운싱
- 코드 스플리팅
9. 보안 고려사항
- 메시지 유효성 검사
- XSS 방지
- CORS 설정
- CSP 설정
이 가이드를 따라 구현하면 Flutter 웹 앱이 React 애플리케이션에 성공적으로 통합될 것입니다. 문제가 발생하면 각 섹션의 문제 해결 가이드를 참조하세요.
출처 : https://cheddar-sparrow-aba.notion.site/Flutter-Web-React-13897e0a8ff380be883cf30b9c02324e