[Flutter] 쉽게 배우는 플러터 앱 개발 실무 08강
정보
- 업무명 : [Flutter] 쉽게 배우는 플러터 앱 개발 실무 08강
- 작성자 : 이상호
- 작성일 : 2025.02.08
- 설 명 :
- 수정이력 :
플랫폼 클라우드 연동 I (Firebase)
[학습목표]
- 플랫폼 클라우드 서비스(PAAS,Platform As A Service)와 연동하여 Flutter 앱을 개발하는 방법을 이해합니다.
- Firebase Authentication 클라우드 서비스를 사용하여 로그인과 회원가입 기능을 개발해 봅니다.
- Firestore Database 클라우드 서비스를 사용하여 추가와 조회 기능을 개발해 봅니다.
[플랫폼 클라우드(PAAS, Platform As A Service)]
- PAAS 클라우드 서비스는 Backend 서버의 많은 기능들을 대체할 수 있습니다. Firebase는 프로그램 개발을 위한 클라우드 서비스로 사용량이 적은 개발단계에서는 특별히 많은 데이터를 저장하거나(5GB 초과시 과금) 사용량이 폭증하지 않는 한 무상으로 사용할 수 있습니다. 그러나 사용량이 늘어나면 사용하는 만큼 비용을 지불하여야 하니 주의하여야 합니다.
Firebase 클라우드 요금제
[Firebase Authentication]
- Firebase 클라우드의 Authentication 서비스를 사용하여 회원가입, 로그아웃 및 로그인과 같은 Backend 기능들을 구현해 보겠습니다.
Firebase 클라우드 프로젝트 만들기
- console.firebase.google.com에 접속하고 구글 계정으로 로그인 합니다.
로그인 - Google 계정
이메일 또는 휴대전화
accounts.google.com
- 프로젝트 만들기를 클릭합니다.
- 프로젝트 이름을 flutter firebase로 지정합니다.
- 프로젝트의 고유 이름을 기억해 둡시다.
- Google 애널리틱스 사용을 설정합니다.
- Google 애널리틱스 계정을 선택하거나 추가합니다.
Firebase Authentication 서비스 시작하기
- 빌드 > Authentication 메뉴를 클릭합니다.
- 시작하기를 클릭합니다.
- 원하는 인증방법을 선택합니다. 교육 목적상 이메일/비밀번호를 선택합니다.
Firebase CLI 설치
- Firebase를 사용하여 개발을 하기 위해서는 개발자 컴퓨터에 Firebase CLI(Command Level Interface,명령어를 사용하는 도구)를 설치하여야 합니다.
- Firebase CLI 참조 | Firebase 문서로 이동합니다. 그런데 독립 실행형 바이너리로 설치하면 나중에 Firebase 실행 파일을 찾지 못하는 문제가 있으니 npm 사용하여 설치하는 방법을 선택합시다.
Firebase CLI 참조 | Firebase 문서
firebase.google.com
- 아래 화면에서 Node.js 링크를 눌러 Node.js 설치 사이트로 이동합니다.
- Download Node.js (LTS) 버튼을 눌러 설치 파일을 다운로드받습니다.
- 다운로드 받은 파일을 기본 설정을 수용하며 설치합니다.
- 명령 프롬프트를 새로 기동시켜 npm install -g firebase-tools 명령을 수행하여 Firebase CLI를 설치합니다.
Flutter 앱 프로젝트에 Firebase 클라우드 프로젝트 연동하기
- 안드로이드 스튜디오를 다시 닫았다 연후 로그인과 회원가입 기능을 개발해 둔 bottom_navigation_bar 프로젝트를 오픈합니다.
- 안드로이드 스튜디오에서 터미널 창을 열고 dart pub global activate flutterfire_cli 명령어를 실행하면 Flutter 프로젝트에 Firebase를 연결하기 위한 FlutterFire CLI(Command Level Interface, 명령어를 사용하여 원하는 작업을 원활하게 수행하도록 설계된 도구)를 설치할 수 있습니다. C:\flutter\bin\mingit\cmd가 PATH 시스템 변수에 추가되어 있어야 합니다. 이전 버전의 Git에서는 Git 설치로 해결하였으나 2025년 2월 8일 버전의 Git은 버전 차이로 오류가 발생합니다.
- PATH 환경 변수에 C:\Users\user\AppData\Local\Pub\Cache\bin (교육장 PC에 맞는 경로로 변경) 경로를 추가합니다. PATH 환경 변수를 적용하기 위하여 안드로이드 스튜디오를 다시 닫았다 연후 bottom_navigation_bar 프로젝트를 다시 오픈합니다.
- 참조) idx를 사용하여 개발하는 경우 위와 같이 Firebase CLI를 설치하기 위한 절차가 생략됩니다.
- 만들어둔 flutter-firebase Firebase 프로젝트를 bottom_navigation_bar Flutter 앱 프로젝트에 연결하기 위하여 flutterfire configure --project=your-firebase-project-id 명령을 실행해줍니다. your-firebase-project-id은 Firebase 클라우드 프로젝트를 만들때 기억하라고 했던 고유번호입니다. 프로젝트 이름을 사용하지 않고 프로젝트 고유번호를 사용한다는 것에 주의하기 바랍니다. 프로젝트 고유 번호를 웹 브라우저의 주소창에서 쉽게 확인할 수 있습니다.
- 혹시 아래와 같은 오류가 발생한다면
- firebase에 로그인하는 절차를 거쳐야 합니다.
- 참조) idx를 사용하여 개발하는 경우 Firebase에 로긴하는 절차가 생략됩니다.
- Firebase 인증이 완료된 후 설정할 플랫폼을 선택하라는 메세지가 나타나면 화살표와 스페이스키를 이용하여 android와 ios를 선택한 후 Enter 키를 칩니다. 이때 프로젝트에 포함된 플랫폼만 선택하여야 합니다. 그렇지 않으면 설정 작업을 하다가 오류가 발생합니다.
- 연동하고자 하는 앱 ID를 입력해줍니다.
- android > app > build.gradle 파일의 applicationId 값을 참조하여 입력합니다. —> 최근에 입력하지 않도록 변경된 것 같습니다.
- flutterfire configure --project=your-firebase-project-id 명령의 실행이 정상적으로 완료되면 아래의 첫번째 화면을 볼 수 있는데 flutter-firebase Firebase 프로젝트와 bottom_navigation_bar Flutter 앱 프로젝트가 연결된 것입니다. 아래 두번째 화면과 같이 Firebase의 프로젝트에도 앱이 등록되어 나타나는 것을 확인할 수 있습니다.
- 최상위 폴더에 firebase.json 파일이 생기고 lib 폴더에 firebase_options.dart 파일이 생성된 후 firebase_options.dart 파일에 오류가 발생하는 것을 확인할 수 있습니다.
- 이 오류는 pubspec.yaml 파일에 firebase_core: ^3.6.0 의존성을 추가하여 해결할 수 있습니다. 그리고 Firebase Authorization 서비스를 사용하기 위하여 firebase_auth: ^5.3.1 의존성을 함께 추가합니다.
- Build > Flutter > Build APK 메뉴를 클릭하여 apk 파일을 빌드할 때 아래 두번째 화면과 같은 오류가 발생합니다. 이를 예방하기 위하여 아래 세번째 화면에서 알려 주는 것과 같이 android\app\build.gradle 파일에 minSdkVersion을 23으로 설정합니다. 아래 화면을 참조바랍니다.
- 아래는 Build시의 오류 화면입니다.
Firebase 초기화 및 회원가입
- 먼저 Firebase 초기화를 위하여 main.dart 파일에서 main() 함수를 아래와 같이 수정합니다.
// main.dart 파일의 코드
... 생략 ...
import 'package:firebase_core/firebase_core.dart'; // Firebase 초기화 import
import 'firebase_options.dart'; // Firebase Option import
void main() async { // 비동기 호출을 위하여 async 추가
WidgetsFlutterBinding.ensureInitialized(); // 비동기 함수 호출 전에 초기화 확인
await Firebase.initializeApp( // 비동기적으로 Firebase 초기화
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
... 생략 ...
- Firebase의 Authorization 서비스를 사용하여 회원가입을 구현하기 위하여 signup_page.dart 파일에서 import, addUser 함수 및 화면 구성을 아래와 같이 수정합니다.
// signup_page.dart 파일의 코드
import 'package:flutter/material.dart';
//import 'package:http/http.dart' as http; // 사용하지 않게 되어 삭제
// Firebase Authorization import
import 'package:firebase_auth/firebase_auth.dart';
... 생략 ...
/*
addUser(String userId, String password, String name) async {
String serverUri = "http://10.0.2.2:8087/add-user";
var response = await http.post(
Uri.parse(serverUri),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
'userId': userId,
'password': password,
'name': name,
},
);
print(response.body);
if (response.statusCode == 200) {
var addUserStatus = response.body;
if (addUserStatus == 'success') {
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const MyHomePage(title: 'Flutter Demo Home Page')));
} else {
showAlertDialog("사용자 추가 실패", "Flutter Server 관리자에게 문의하세요.");
}
} else {
showAlertDialog("서버 오류", "Flutter Server의 정상 동작 여부를 점검하세요.(${response.statusCode})");
}
}
*/
addUser(String userId, String password, String name) async {
try {
// Firebase Authentication을 사용하여 회원가입 처리
final UserCredential userCredential = await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: userId,
password: password,
);
// 이름 업데이트 / ?는 null일 수 있는 값일때 붙여줌
await userCredential.user?.updateDisplayName(name);
// 홈화면으로 이동
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const MyHomePage(title: 'Flutter Demo Home Page')));
} catch (e) {
showAlertDialog("사용자 추가 실패", "$e"); // 오류 메세지
}
}
... 중략 ...
Container(
width: 200,
child: TextField(
controller: userIdController,
keyboardType: TextInputType.emailAddress, // userId를 email로 받아 들임
maxLines: 1,
decoration: InputDecoration(
labelText: "아이디",
border: OutlineInputBorder(),
//hintText: "8자 이상 입력해 주세요."
hintText: "이메일 ID를 입력해 주세요." // userId를 email로 받아 들임
),
),
),
... 생략 ...
- 회원가입을 해 보겠습니다. main() 함수가 수정된 것은 단순한 화면 변경이 아니기 때문에 재시작해 주어야 합니다. 메일 ID를 User ID로 입력한 후 동일한 방식으로 회원가입을 하면 회원 가입이 되어 홈화면으로 이동하는 것을 확인할 수 있습니다.
- Firebase에 만들어 놓은 flutter firebase 프로젝트의 Authorization 화면으로 이동해 보면 입력한 사용자 ID가 등록되어 있는 것을 확인할 수 있습니다.
- 이번에는 한번 등록한 메일 ID를 User ID로 다시 한번 회원가입을 시도해 보겠습니다. [firebase_auth/email-already-in-use] The email address is already in use by another account. 메세지를 보여주면 사용자 추가에 실패합니다.
로그인과 로그아웃
- Firebase의 Authorization 서비스로 로그인된 상태이면 홈화면으로 바로 이동하고 로그아웃된 상태이면 로그인 화면으로 이동하기 위하여 main.dart 파일을 아래와 같이 수정합니다.
// main.dart 파일의 코드
... 생략 ...
// Firebase Authorization import
import 'package:firebase_auth/firebase_auth.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
//runApp(const MyApp()); // user 변수가 정의되어 const를 해제합니다.
runApp(MyApp());
}
class MyApp extends StatelessWidget {
//const MyApp({super.key}); // user 변수가 정의되어 const를 해제합니다.
MyApp({super.key});
// FirebaseAuth로부터 현재 로그인한 사용자 정보를 가져옵니다.
// 로그인된 상태이면 user 정보를 가지고 오고, 그렇지 않으면 null 값이 넘어 옵니다.
// User?와 같이 자료형 뒤에 ?가 붙으면 null인 값과 그렇지 않은 값을 가질 수 있다는 의미입니다.
User? user = FirebaseAuth.instance.currentUser;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
//home: const MyHomePage(title: 'Flutter Demo Home Page'),
//home: LoginPage(),
// user가 null 즉 로그아웃된 상태이면 로그인 화면으로 그렇지 않으면 홈화면으로
home: user == null ? LoginPage() : MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
... 생략 ...
- Firebase의 Authorization 서비스는 로그아웃을 하지 않으면 로그인 상태를 유지하기 때문에 앱을 다시 시작해도 로그인 화면으로 이동하지 않고 홈화면으로 이동합니다.
- Firebase의 Authorization 서비스로 로그인된 상태에서 로그아웃하기 위하여 main.dart 파일에서 _MyHomePageState 클래스를 아래와 같이 수정합니다. 로그인 화면으로 이동하기 전에 FirebaseAuth.instance.signOut() 함수를 비동기적으로 호출하면 로그아웃이 됩니다.
// main.dart 파일의 코드
... 생략 ...
class _MyHomePageState extends State<MyHomePage> {
var pages = [ HttpListviewPage(),
ImageListViewPage(),
SimpleListViewPage(),
MyThirdPage(),
];
int _selectedNaviIndex = 0;
_onBottomNavigationItemTapped(index) {
setState(() {
_selectedNaviIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
actions: [
IconButton(
icon: Icon(Icons.logout),
onPressed: () async { // 비동기적으로 FirebaseAuth에서 로그아웃
await FirebaseAuth.instance.signOut();
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => LoginPage()));
}
),
]
),
... 생략 ...
- 이제 Appbar에서 로그아웃 아이콘을 누르면 로그아웃되어 로그인 화면이 나타납니다.
- Firebase의 Authorization 서비스로 로그인하는 기능을 구현하기 위하여 login_page.dart 파일에서 import, loginCheck 함수 및 화면 구성을 아래와 같이 수정합니다.
// login_page.dart 파일의 코드
... 생략 ...
// Firebase Authorization import
import 'package:firebase_auth/firebase_auth.dart';
... 중략 ...
/*
loginCheck(String userId, String password) async {
String serverUri = "http://10.0.2.2:8087/login-check";
var response = await http.post(
Uri.parse(serverUri),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
'userId': userId,
'password': password,
},
);
print(response.body);
if (response.statusCode == 200) {
var parseJson = jsonDecode(response.body);
bool loginSuccess = parseJson['loginSuccess'];
if (loginSuccess) {
saveUserId(userId); // 로그인 성공시 성공한 User Id 문자열을 저장
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => const MyHomePage(title: 'Flutter Demo Home Page')));
} else {
showAlertDialog("로그인 실패", "아이디가 존재하지 않거나 비밀번호가 일치하지 않습니다.");
}
} else {
showAlertDialog("서버 오류", "Flutter Server의 정상 동작 여부를 점검하세요.(${response.statusCode})");
}
}
*/
loginCheck(String userId, String password) async {
try {
// Firebase Authentication을 사용하여 로그인 처리
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: userId,
password: password,
);
// 홈화면으로 이동
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const MyHomePage(title: 'Flutter Demo Home Page')));
} catch (e) {
showAlertDialog("사용자 로긴 실패", "$e"); // 오류 메세지
}
}
... 중략 ...
SizedBox(
width: 200,
child: TextField(
controller: userIdController,
keyboardType: TextInputType.emailAddress, // userId를 email로 받아 들임
decoration: InputDecoration(
labelText: "아이디",
border: OutlineInputBorder(),
hintText: "이메일 ID를 입력해 주세요." // userId를 email로 받아 들임
),
),
),
... 생략 ...
- 정상적인 로그인을 시도해 보겠습니다. 로그인이 잘되어 홈화면으로 이동합니다.
- 로그아웃후 로그인 ID나 비밀번호를 틀리게 하여 로그인을 시도해 보겠습니다. ID가 잘못된 경우 [firebase_auth/channel-error] "dev.flutter.pigeon.firebase_auth_platform_interface.FirebaseAuthHostApi.signInWithEmailAndPassword” 메세지와 암호가 잘못된 경우 [firebase_auth/invalid-credential] The supplied auth credential is incorrect, malformed or has expired 메세지가 나타나며 사용자 로긴이 실패하는 것을 확인할 수 있습니다.
Snackbar 메세지
- 이제 다이어로그(Dialog) 형태로 나타나는 오류 메세지를 스낵바(SnackBar) 형식의 오류 메시지로 다듬어 볼 차례입니다.
- signup_page.dart 파일의 addUser() 함수를 아래와 같이 수정합니다.
// signup_page.dart 파일의 addUser() 함수 코드
... 생략 ...
addUser(String userId, String password, String name) async {
try {
final UserCredential userCredential = await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: userId,
password: password,
);
await userCredential.user?.updateDisplayName(name);
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const MyHomePage(title: 'Flutter Demo Home Page')));
} catch (e) {
//showAlertDialog("사용자 추가 실패", "$e");
ScaffoldMessenger.of(context).showSnackBar( // SnackBar 메세지로 변경
SnackBar(
content: Text("${userId} 사용자를 추가할 수 없습니다. 입력한 메일 ID가 올바른지 점검해 보세요."),
),
);
}
}
... 생략 ...
- login_page.dart 파일의 loginCheck() 함수를 아래와 같이 수정합니다.
// login_page.dart 파일의 loginCheck() 함수 코드
... 생략 ...
loginCheck(String userId, String password) async {
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: userId,
password: password,
);
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const MyHomePage(title: 'Flutter Demo Home Page')));
} catch (e) {
//showAlertDialog("사용자 로긴 실패", "$e");
ScaffoldMessenger.of(context).showSnackBar( // SnackBar 메세지로 변경
SnackBar(
content: Text("사용자 로긴에 실패하였습니다. 입력한 메일 ID와 암호를 점검해 보세요."),
),
);
}
}
... 생략 ...
- 코드 수정 후 실행해 보면 로그인 오류와 회원가입 오류가 발생할 때 스낵바(SnackBar) 메세지가 하단에 잠시 나타났다가 사라지는 것을 확인할 수 있습니다.
[Firestore Database]
- Firebase 클라우드의 Firestore Database 서비스를 사용하여 ImageListView 페이지의 이미지 URL과 책제목을 프로그램에 하드코딩하지 않고 백앤드 서버와 연동해 보겠습니다. Firestore Database는 NoSQL 기반의 문서형 데이터베이스(Document-based database)입니다.
Firestore Database 서비스 시작하기
- Firebase의 flutter firebase 프로젝트로 이동하여 빌드 > Firestore Database 메뉴를 클릭한 후 데이터베이스 만들기를 클릭합니다.
- 데이터베이스를 생성할 위치를 선택합니다. 우리는 asia-northeast3 (Seoul)을 선택합니다.
- 프로덕션 모드에서 시작을 선택한 후 만들기 버튼을 클릭합니다.
- Firestore Database가 만들어지면 아래와 같은 화면이 나타납니다.
- 규칙 탭을 클릭하여 보안규칙을 설정하러 갑시다. 초기에 설정되어 있는 보안규칙은 아래와 같은데 allow read, write: if false;는 모든 읽기와 쓰기를 차단하는 규칙입니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
}
}
- 아래와 같이 설정합시다. 그리고 게시 버튼을 누릅니다. 이 설정을 적용하면, 로그인된 사용자만 Firestore에 접근할 수 있습니다. 게시 버튼을 누른 후 규칙의 적용에 1분 정도의 시간이 소요된다고 하니 조금 대기했다가 사용합시다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}
- 규칙에 대한 자세한 설명은 생략하니 아래 링크를 참조하기 바랍니다.
[Firebase] 파이어베이스 보안규칙 (Firestore Security Rules ) 작성 방법
파이어베이스에서 제공하는 보안규칙은 코드가 간단하며 보안규칙을 위해 인프라를 관리하거나 복잡한 서버측 인증 및 인증 코드를 작성할 필요 없다. 하지만 보안규칙을 적용하지 않으면 파
seizemymoment.tistory.com
Firestore Database에 책정보 데이터 올리기
- pubspec.yaml 파일에 cloud_firestore: ^5.4.4를 디펜던시로 등록하고 pub get 메뉴를 클릭합니다. 패키지의 이름이 firestore_database가 아니고 cloud_firestore인 것에 주의바랍니다.
- Firestore Database에 ImageListView에 하드코딩해 놓은 책의 정보를 저장하기 위하여 main.dart 파일에 필요한 import 문장을 추가하고 Appbar에 save 아이콘을 추가한 후 클릭할 때 Firestore Database에 추가하는 로직을 추가합니다.
// main.dart 파일의 코드
... 생략 ...
// Firestore Database import
import 'package:cloud_firestore/cloud_firestore.dart';
... 중략 ...
saveBooksToFirestore() async {
var imgList = [ "https://image.aladin.co.kr/product/34207/82/cover200/896088457x_1.jpg",
"https://image.aladin.co.kr/product/31794/10/cover200/k362833219_1.jpg",
"https://image.aladin.co.kr/product/3422/90/cover200/8966260993_1.jpg",
"https://image.aladin.co.kr/product/57/79/cover200/8991268072_2.jpg",
"https://image.aladin.co.kr/product/34274/43/coversum/scm9462999557200.jpg",
"https://image.aladin.co.kr/product/26031/38/coversum/k702737950_1.jpg",
];
var imgNameList = [ "파이썬 + AI", "점프 투 파이썬", "생각하는 프로그래밍",
"실용주의 프로그래머", "프로그래밍 심리학", "UWP 퀵스타트",
];
// String?와 같이 자료형 뒤에 ?가 붙으면 null인 값과 그렇지 않은 값을 가질 수 있다는 의미입니다.
// currentUser?와 같이 변수명 뒤에 ?가 붙으면 변수가 null이 아닌 경우에만 뒤의 따라오는 값을 사용한다는 의미입니다.
String? uid = FirebaseAuth.instance.currentUser?.uid;
String? username = FirebaseAuth.instance.currentUser?.displayName;
Timestamp createdAt = Timestamp.now();
for (int i = 0; i < imgList.length; i++) {
// dynamic은 여러가지 자료형을 동시에 처리할 수 있다는 의미입니다.
// Firestore Database는 일종의 NoSQL로 Map(Dictionary) 즉 json의 형식으로 저장할 수 있습니다.
Map<String, dynamic> data = {
"uid": uid,
"username": username,
"imgUrl": imgList[i],
"bookName": imgNameList[i],
"createdAt": createdAt,
};
try {
// Firestore의 books 컬렉션에 게시물 추가하기
await FirebaseFirestore.instance.collection("books").add(data);
} catch (e) {
print(e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("책 정보 업로드에 실패했습니다: ${imgNameList[i]}")),
);
return;
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("책 정보 업로드에 성공했습니다."),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
actions: [
IconButton( // 저장 아이콘 추가
icon: Icon(Icons.save),
onPressed: saveBooksToFirestore, // 클릭 발생시 저장 함수 지정
),
IconButton(
icon: Icon(Icons.logout),
onPressed: () async {
await FirebaseAuth.instance.signOut();
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => LoginPage()));
}
),
]
),
... 생략 ...
- 위의 코드를 완성한 후 앱을 완전히 재시작한 후 저장버튼을 눌러 봅시다. 그러면 Firestore Database에 책정보가 저장된 것을 알 수 있습니다. Firestore Database는 NoSQL 기반의 문서형 데이터베이스(Document-based database)로 컬렉션(Collection)은 DBMS의 테이블(Table)에 해당하고, 문서(Document)는 행(Row) 혹은 레코드(Record)에 해당하며, 필드(Field)는 열 혹은 컬럼(Column)에 해당합니다.
Firestore Database에 저장된 책정보를 사용하여 ImageListView 보여 주기
- Firestore Database에서 필드 수정 기능을 사용하여 책의 이름 앞에 FF(Firebase Firestore)를 추가합시다. 그러면 책의 이름으로 이전에 하드코딩했던 ImageListVIew인지 Firestore Database에서 가져온 ImageListView인지 책의 이름을 보고 판단할 수 있을 것입니다.
- Firestore Database에서 ImageListView로 책 정보를 가져오기 위하여 image_listview_page.dart 파일에 필요한 import 문장을 추가하고 ImageListViewPage Widget이 생성될 때 하드코딩되어 있던 imgList 변수와 imgNameList 변수를 초기화하도록 프로그램의 코드를 아래와 같이 수정합니다 .
- StatelessWidget을 StatefulWidget으로 변경해야 하는데 class ImageListViewPage extends StatelessWidget 문장 위에 마우스 커서를 두고 Alt-Enter 키를 친 후 Convert to StatefulWidget 메뉴를 선택합니다. 반대의 경우 즉 StatelessWidget을 StatefulWidget으로 변경해야 하는 경우에도 동일한 방법을 사용합니다.
// image_listview_page.dart 파일의 코드
import 'package:flutter/material.dart';
// Firestore Database import
import 'package:cloud_firestore/cloud_firestore.dart';
import 'image_listview_detail_page.dart';
// StatelessWidget을 StatefulWidget으로 변경합니다.
// class ImageListViewPage extends StatelessWidget 문장 위에 마우스 커서를 두고
// Alt-Enter 키를 친 후 Convert to Stateful Widget 메뉴를 선택하면 됩니다.
class ImageListViewPage extends StatefulWidget {
const ImageListViewPage({super.key});
@override
State<ImageListViewPage> createState() => _ImageListViewPageState();
}
class _ImageListViewPageState extends State<ImageListViewPage> {
/* 하드코딩된 리스트들을 삭제하고
var imgList = [ "https://image.aladin.co.kr/product/34207/82/cover200/896088457x_1.jpg",
"https://image.aladin.co.kr/product/31794/10/cover200/k362833219_1.jpg",
"https://image.aladin.co.kr/product/3422/90/cover200/8966260993_1.jpg",
"https://image.aladin.co.kr/product/57/79/cover200/8991268072_2.jpg",
"https://image.aladin.co.kr/product/34274/43/coversum/scm9462999557200.jpg",
"https://image.aladin.co.kr/product/26031/38/coversum/k702737950_1.jpg",
];
var imgNameList = [ "파이썬 + AI", "점프 투 파이썬", "생각하는 프로그래밍",
"실용주의 프로그래머", "프로그래밍 심리학", "UWP 퀵스타트",
];
*/
// 빈 리스트를 만듭니다.
List<String> imgList = [];
List<String> imgNameList = [];
@override
void initState() {
// TODO: implement initState
super.initState();
fetchBooks(); // Widget을 초기화할 때 Firestore Database에서 책정보를 조회
}
fetchBooks() async {
try {
// FirebaseFirestore로부터 데이터를 받아옵니다.
var snapshot = await FirebaseFirestore.instance.collection("books").get();
//var snapshot = await FirebaseFirestore.instance.collection("books").orderBy("createdAt").limit(2).get();
/*
var snapshot = await FirebaseFirestore.instance
.collection("books")
.where("bookName", isEqualTo: "FF 파이썬 + AI")
.get();
*/
var documents = snapshot.docs;
/* // 특정 문자열을 포함하는 데이터만 조회
var documents = snapshot.docs.where((doc) {
return (doc['bookName'] as String).contains("FF");
}).toList();
*/
// 문서별로 imgList와 imgNameList에 이미지 URL과 책이름을 저장합니다.
for (var doc in documents) {
var data = doc.data();
imgList.add(data['imgUrl']);
imgNameList.add(data['bookName']);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("책 정보 다운로드에 실패했습니다.")),
);
return;
}
// 리스트의 변경을 화면에 반영해 줍니다.
// setState() 함수는 build() 함수를 다시 호출하여 화면에 상태 변화를 반영하도록 합니다.
// 화면에 표시될 상태 변수가 꼭 setState 함수 안에서 변경될 필요는 없으며,
// setState 호출 시점에 이미 변경된 상태가 있으면 이를 반영하여 화면이 갱신됩니다.
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
// Firestore Database에서 값을 가져오는 동안 프로그래스 바를 보여 줍니다.
child: imgList.length == 0 ? LinearProgressIndicator() : ListView.builder(
itemCount: imgList.length,
itemBuilder: (context, index) {
... 생략 ...
- 위의 코드를 완성한 후 앱을 재시작하고 ImageListView로 이동해 봅시다. 그러면 Firestore Database에서 가져온 책정보가 나타나는 것을 알 수 있습니다. 책의 이름 앞에 FF가 붙어 있어서 쉽게 확인할 수 있습니다.
[실습 V] 자신의 가족을 소개하는 family_intro2 앱을 개선해 보세요.
- family_intro2 프로젝트에 로그인 기능과 회원가입 기능을 Firebase 클라우드를 이용하도록 수정해 보세요.
플랫폼 클라우드 연동 I (Firebase)
[학습목표]
- 스마트폰의 갤러리에 저장된 사진이나 동영상을 가져다 사용하는 방법을 이해합니다.
- 스마트폰에서 사진이나 동영상을 촬영하여 사용하는 방법을 이해합니다.
- Flutter App의 이미지를 Flutter Server에 저장하는 방법을 이해합니다.
- Flutter Server에 저장된 이미지를 Flutter App에서 보여주는 방법을 이해합니다.
[갤러리와 카메라]
- 테스트 코드를 작성하기 위하여 gallery_camera 프로젝트를 새로 만듭시다.
- 스마트폰의 갤러리에 저장된 사진은 ImagePicker Widget을 사용하여 파일에 저장된 일반 이미지처럼 가져다 사용할 수 있습니다. ImagePicker Widget을 사용하려면 image_picker 라이브러리를 가져다 사용하여야 합니다. https://pub.dev/ 사이트를 확인해 보니 image_picker의 최신 버전이 1.1.2인데 안드로이드 이뮬레이터에서 동작하지 않습니다. 그래서 안드로이드 이뮬레이터에서 동작하는 버전인 0.8.7 버전을 사용하도록 하겠습니다. pubspec.yaml에 등록한 후 pub get 명령어를 실행합시다.
# pubspec.yaml 파일
... 생략 ...
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
#image_picker: 1.1.2 # 이뮬레이터에서 동작하지 않음
image_picker: 0.8.7 # 이뮬레이터에서 동작함
... 생략 ...
- 안드로이드 앱에서 갤러리와 카메라를 사용하기 위해서는 android/app/src/main/AndroidManifest.xml 파일에 아래와 같은 권한 설정을 해야 한다고 하는데 최근 버전의 Flutter에서 아래와 같은 설정이 없이 정상적으로 동작하고 있습니다. 혹시 권한 문제가 발생하면 아래의 uses-permission 설정 3줄을 추가하기 바랍니다.
<!-- AndroidManifest.xml 파일 -->
... 생략 ...
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.your_app">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:label="your_app"
android:icon="@mipmap/ic_launcher">
... 생략 ...
iOS 앱에서 갤러리와 카메라를 사용하기 위해서는 ios/Runner/Info.plist 파일에 아래와 같은 권한 설정을 해야 합니다.
<!-- Info.plist 파일 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>gallery_camera 앱에서 갤러리와 카메라를 사용합니다.</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Gallery Camera</string>
... 생략 ...
- 아래 코드에서 눈여겨 볼 부분은 picker.pickImage(source: ImageSource.gallery); 문장에 의하여 갤러리에서 선택되는 이미지와 picker.pickImage(source: ImageSource.camera); 문장에 의하여 사진기로 촬영되는 이미지가 파일에 저장된다는 것입니다. 저장된 이미지 파일을 image = File(pickedImage.path); 문장으로 image 변수에 저장한 후 Image.file((image, width: 300, height: 300) 문장으로 화면에 보여 주게 됩니다. 갤러리와 사진촬영의 경우도 시간을 많이 소비하는 작업이기 때문에 pickImage() async나 takePicture() async와 같이 비동기 함수로 만드는 부분은 매우 직관적입니다.
// main.dart 파일 코드
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; // image_picker 라이브러리 import
import 'dart:io'; // File IO를 위해 import
... 중략 ...
class _MyHomePageState extends State<MyHomePage> {
var image; // 이미지 경로를 저장하는 변수
pickImage() async {
var picker = ImagePicker(); // 갤러리에서 이미지 한장을 가져 오는 코드
var pickedImage = await picker.pickImage(source: ImageSource.gallery);
if (pickedImage != null) { // 갤러리에서 이미지를 정상적으로 가져온 경우
setState(() {
image = File(pickedImage.path); // 갤러리에서 가져온 이미지 경로를 변수에 저장
});
}
}
takePicture() async {
var picker = ImagePicker(); // 사진기로 이미지 한장을 촬영하는 코드로
var pickedImage = await picker.pickImage(source: ImageSource.camera);
// ImageSource.gallery가 ImageSource.camera로
if (pickedImage != null) { // 바뀐 것만 달라짐
setState(() {
image = File(pickedImage.path);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
image == null // 가져온 이미지나 촬영한 이미지가 없으면
? Text('사진을 선택하세요.')
: Image.file( // 파일에 저장된 이미지를 보여 줌
image,
width: 300,
height: 300,
),
SizedBox(height: 20),
ElevatedButton(
onPressed: pickImage, // 갤러리에서 사진을 가져 오는 이벤트 핸들러 기동
child: Text('갤러리에서 사진 선택'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: takePicture, // 사진을 촬영하는 이벤트 핸들러 기동
child: Text('사진 촬영'),
),
],
),
),
);
}
}
- 갤러리와 카메라 기능을 이뮬레이터에서 테스트하려면 몇가지 절차를 거쳐야 합니다. “Flutter FAQ와 Tips”에 정리된 “Android 에뮬레이터에 사진 추가하기”와 “안드로이드 에뮬레이터 카메라 활성화”를 참조하기 바랍니다. 그런데 강사의 경우에도 이뮬레이터에서 갤러리와 카메라를 정상적으로 동작시킬 때 많은 어려움들을 겪었습니다. 때로는 앱 자체가 정상적으로 동작하지 않아 에뮬레이터를 초기화한 후 다시 수행하여야 하였고, 어떤 경우에는 매우 오래 기다린 뒤에 - 정상 동작을 포기할 즈음 - 자동으로 정상적으로 동작하였습니다. 그러다가 다시 동작하지 않기도 하였습니다. 가능하면 이상 동작이 발생하지 않도록 간편하게 스마트폰을 사용하여 테스트하기 바랍니다.
- 이미지 대신 동영상을 가져다 사용하려면 picker.pickVideo(source: ImageSource.gallery) 혹은 picker.pickVideo(source: ImageSource.camera) 함수를 사용합니다.
// main.dart 파일 코드
... 생략 ...
class _MyHomePageState extends State<MyHomePage> {
var image;
var video; // 동영상을 저장할 변수
pickVideo() async {
var picker = ImagePicker(); // picker.pickImage가 picker.pickVideo로 바뀜
var pickedVideo = await picker.pickVideo(source: ImageSource.gallery);
// 나머지 코드들은 image가 video로 바뀐것 정도가 다름
if (pickedVideo != null) {
setState(() {
video = File(pickedVideo.path);
});
}
}
takeVideo() async {
var picker = ImagePicker(); // picker.pickImage가 picker.pickVideo로 바뀜
var pickedVideo = await picker.pickVideo(source: ImageSource.camera);
if (pickedVideo != null) {
setState(() {
video = File(pickedVideo.path);
});
}
}
... 중략 ...
children: <Widget>[
image == null
? Text('사진을 선택하세요.')
: Image.file(
image,
width: 300,
height: 300,
),
SizedBox(height: 20),
ElevatedButton(
onPressed: pickImage,
child: Text('갤러리에서 사진 선택'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: takePicture,
child: Text('사진 촬영'),
),
video == null // image가 video로 바뀌는 것을
? Text('동영상을 선택하세요.') // 코드가 image의 경우와 거의 동일함
: Text('비디오 파일: ${video.path}'), // Video의 경로를 출력함
SizedBox(height: 20),
ElevatedButton(
onPressed: pickVideo,
child: Text('갤러리에서 동영상 선택'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: takeVideo,
child: Text('동영상 촬영'),
),
],
),
),
);
}
}
- 동영상을 갤러리에서 선택하거나 촬영하면 스마트폰에 저장된 동영상의 경로가 나타납니다. 이미지도 동영상과 같이 스마트폰에 저장된 경로가 image 변수에 저장되는 것이며 Image.file() 메소드를 사용해 화면에 보여 주는 것입니다.
- 가져온 동영상을 화면에 보여 주려면 video_player 라이브러리의 VideoPlayerController를 사용하여야 합니다. 관심있는 분들은 노션에 올려 놓은 “Flutter FAQ와 Tips” 문서의 “동영상을 재생하는 코드”를 참조하여 직접 구현해 보기 바랍니다.
- 동영상과 이미지의 구분없이 가져다 사용하려면 picker.pickMedia() 함수를 사용합니다.
- 여러장의 이미지를 동시에 가져다 사용하려면 picker.pickMultiImage() 함수를 사용합니다.
- gallery_camera 프로젝트의 화면이 가득찼으니 multi_image 프로젝트를 만들겠습니다.
- pubspec.yaml 파일의 설정을 잊지 맙시다.
# pubspec.yaml 파일
... 생략 ...
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
#image_picker: 1.1.2 # 이뮬레이터에서 동작하지 않음
image_picker: 0.8.7 # 이뮬레이터에서 동작함
... 생략 ...
- 아래 코드를 주석을 중심으로 살펴 봅시다.
// main.dart 파일 코드
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
... 중략 ...
class _MyHomePageState extends State<MyHomePage> {
var images; // 여러장의 이미지를 저장하기 위한 변수
pickMultipleImages() async {
var picker = ImagePicker();
var pickedImages = await picker.pickMultiImage(); // 기존 코드와 함수가 달라짐
if (pickedImages.isNotEmpty) { // 가져온 사진이 있으면
setState(() { // 가져온 사진의 파일 Pass를 리스트로 변환
images = pickedImages.map((pickedFile) => File(pickedFile.path)).toList();
});
} else { // 가져온 사진이 없으면
setState(() {
images = null; // null을 저장하여 비어 있음을 알림
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
images == null
? Text('여러 장의 사진을 선택하세요.')
: Expanded(
child: GridView.builder( // ListView.builder에 준하여 이해
// GridView의 모양을 결정해 줌
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 가로로 세칸
crossAxisSpacing: 10, // 가로 간격
mainAxisSpacing: 10, // 세로 간격
),
itemCount: images.length,
itemBuilder: (context, index) {
return Image.file(
images[index],
fit: BoxFit.cover, // 이미지의 크기를 동일하게 가져가기 위해 필요
);
},
),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: pickMultipleImages,
child: Text('갤러리에서 여러 사진 선택'),
),
],
),
),
);
}
}
[Flutter App의 이미지와 동영상을 Flutter Server에 저장하기]
- Flutter Server에서 Flutter App의 요청을 받아 파일을 저장하기 위하여 src\main\resources\application.properties 파일에 Upload 용량의 제한을 50MB 수준으로 완화해 줍니다.
# application.properties 파일
spring.application.name=flutter_server
server.port=8087
# 최대 파일 크기 (예: 50MB)
spring.servlet.multipart.max-file-size=50MB
# 최대 요청 크기 (예: 50MB, 여러 파일 업로드를 포함할 경우 적용)
spring.servlet.multipart.max-request-size=50MB
- 그리고 UploadController 클래스를 아래 화면과 같은 위치에 아래 코드와 같이 생성합니다.
// UploadController 클래스
package com.example.flutter_server;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
@RestController
@RequestMapping("/upload")
public class UploadController {
// 업로드할 경로 설정 (예: 로컬 서버에서 저장할 폴더 경로)
String projectDir = System.getProperty("user.dir");
String uploadDir = projectDir + "/uploads/";
@PostMapping
public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file) {
try {
String filePath = uploadDir + file.getOriginalFilename();
File dest = new File(filePath);
// 폴더가 존재하지 않으면 생성
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
// 파일 저장
file.transferTo(dest);
return new ResponseEntity<>("이미지 업로드 성공: " + filePath, HttpStatus.OK);
} catch (IOException e) {
e.printStackTrace();
return new ResponseEntity<>("이미지 업로드 실패", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
- 이미지와 동영상을 Flutter Server에 저장하는 코드를 구현하기 위하여 다시 gallery_camera 프로젝트로 이동하겠습니다.
- 먼저 pubspec.yaml 파일에 이미지/동영상 업로드를 위한 라이브러리인 dio를 설정합니다.
# pubspec.yaml 파일
... 생략 ...
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
#image_picker: 1.1.2 # 이뮬레이터에서 동작하지 않음
image_picker: 0.8.7 # 이뮬레이터에서 동작함
dio: ^5.2.1 # 이미지/동영상 업로드를 위한 라이브러리
... 생략 ...
- 아래 코드를 주석을 중심으로 살펴 봅시다.
// main.dart 파일 코드
... 생략 ...
import 'package:dio/dio.dart'; // 이미지/동영상 Upload를 위한 라이브러리 import
... 중략 ...
class _MyHomePageState extends State<MyHomePage> {
... 중략 ...
upload(media) async { // 동영상 혹은 이미지 upload를 위한 비동기 함수 추가
if (media != null) {
String fileName = media.path.split('/').last;
var formData = FormData.fromMap({ // 이 코드가 upload할 데이터를 만드는 핵심입니다.
"file": await MultipartFile.fromFile(media.path, filename: fileName),
});
try { // dio는 status code가 200이 아닌 경우 Exception을 발생시킵니다
var dio = Dio(); // Flutter Server 통신시 설명한 것들로 http대신 dio를 사용합니다.
String serverUri = "http://localhost:8087/upload";
if (Platform.isAndroid) {
serverUri = "http://10.0.2.2:8087/upload";
}
var response = await dio.post(serverUri, data: formData);
print("미디어(이미지/비디오) 업로드 성공");
} catch(e) {
print("미디어(이미지/비디오) 업로드 실패: ${e}");
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
image == null
? Text('사진을 선택하세요.')
: Column(
children: [
Image.file(
image,
width: 300,
height: 300,
),
ElevatedButton( // 이미지가 있는 경우 업로드 버튼을 활성화하고
onPressed: () {upload(image);}, // upload 함수에 image를 넘겨
child: Text('이미지 업로드'), // 업로드합니다.
),
],
),
SizedBox(height: 20),
ElevatedButton(
onPressed: pickImage,
child: Text('갤러리에서 사진 선택'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: takePicture,
child: Text('사진 촬영'),
),
video == null
? Text('동영상을 선택하세요.')
: Column(
children: [
Text('비디오 파일: ${video.path}'),
ElevatedButton( // 동영상이 있는 경우 업로드 버튼을 활성화하고
onPressed: () {upload(video);}, // upload 함수에 video를 넘겨
child: Text('동영상 업로드'), // 업로드합니다.
),
],
),
SizedBox(height: 20),
ElevatedButton(
onPressed: pickVideo,
child: Text('갤러리에서 동영상 선택'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: takeVideo,
child: Text('동영상 촬영'),
),
],
),
),
);
}
}
- 이뮬레이터로 테스할 때 말썽을 부리지 않는다면 괜찮은데 이뮬레이터가 말썽을 부리면 스마트폰으로 테스트해야 합니다.
- 다행히 테스트할 스마트폰과 Flutter Server가 연결된 Network 대역이 같다면 즉 연결된 무선 LAN AP가 동일하다면 스마트폰의 앱에서 Flutter Server에 접속할 수 있습니다. 먼저 Flutter Server가 수행되는 PC에서 ipconfig 명령을 발행하여 PC의 IP 주소를 알아냅시다. 아래 화면의 경우에는 172.30.1.95입니다. Network 대역이 같지 않다면 ngrok를 사용하여 연결합니다.
- Flutter Server의 주소를 확인한 후 Flutter 앱에서 접속할 주소를 아래와 같은 형식으로 수정해 주어야 합니다.
// main.dart 파일의 uploadImage() async 함수 코드
... 생략 ...
//String serverUri = "http://localhost:8087/upload";
//if (Platform.isAndroid) {
//serverUri = "http://10.0.2.2:8087/upload-image";
//}
String serverUri = "http://172.30.1.95:8087/upload";
... 생략 ...
- 실행해 보니 아래 화면과 같이 이미지와 동영상이 Flutter Server의 uplods 폴더에 잘 저장이 됩니다. 예전에는 이미지를 저장하기 위해 image/jpg, 동영상을 저장하기 위해 video/mp4와 같은 형식의 MIME 타입(Multipurpose Internet Mail Extensions Type)을 지정해 주어야 했습니다. 그러나 현재는 Spring Boot나 Firebase 같은 백엔드 서버가 파일의 MIME 타입을 자동으로 감지하여 처리해 주는 경우가 많아, 개발자가 수동으로 설정할 필요가 줄어들었습니다. MIME 타입은 원래 이메일 파일 형식을 정의하기 위해 시작되었으나, 현재는 HTTP에서도 서버가 전송하는 콘텐츠의 종류를 정의하고 클라이언트가 이를 이해하도록 돕는 중요한 표준으로 사용되고 있습니다.
[Flutter Server의 이미지와 동영상을 Flutter App에 보여 주기]
- Flutter Server에 저장된 이미지와 동영상을 가져다 사용하려면 uploads 폴더를 웹사이트에 노출시켜 주어야 합니다. WebConfig 클래스를 아래와 같이 설정하면 됩니다.
// WebConfig 클래스
package com.example.flutter_server;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 업로드된 파일을 URL로 접근할 수 있도록 설정
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + System.getProperty("user.dir") + "/uploads/");
}
}
- 그러면 웹브라우저에서 http://localhost:8087/uploads/1000003729.jpg나 http://localhost:8087/uploads/1000003121.mp4와 같이 조회해 볼 수 있고,
- Image.network(”http://localhost:8087/uploads/1000003729.jpg”)
- Image.network(”http://10.0.2.2:8087/uploads/1000003729.jpg”)
- Image.network(”http://172.30.1.95:8087/uploads/1000003729.jpg”) 와 같은 방식으로 Flutter App에서 보여 줄 수 있습니다.
플랫폼 클라우드 연동 II
[학습목표]
- Firebase Storage 클라우드 서비스를 사용하여 이미지 데이터를 저장하고 조회하는 기능을 개발해 봅니다.
[Firebase Storage]
- Firebase 클라우드의 Storage 서비스를 사용하여 스마트폰에 있는 이미지와 동영상을 백앤드 서버에 저장해 보겠습니다. 이미지와 동영상은 저장공간을 많이 차지하게 되니 요금이 발생하지 않도록 주의합시다.
- 최근에 Firebase Storage의 경우 Spark 요금제를 적용할 수 없어 Blaze 요금제를 적용할 수 밖에 없습니다. 개발 단계에서도 비용이 발생할 수 있는 위험이 커졌습니다. 아래의 공지와 같이 클라우드의 요금제는 수시로 변경되기 때문에 예측하지 않은 과금이 발생할 수 있어 매우 주의하여야 합니다.
Firebase Storage 서비스 시작하기
- Firebase의 flutter firebase 프로젝트로 이동하여 빌드 > Storage 메뉴를 클릭한 후 프로젝트 업그레이드를 클릭합니다. 요금제를 Spark 요금제에서 Blaze 요금제로 올리는 것인데 1GB의 저장 용량과 다운로드 트래픽을 발생시키지 않고 동시 사용자가 많아지지 않도록 주의바랍니다.
- Cloud Billing 계정 만들기를 선택합니다. 그리고 필요한 정보들을 입력한 후 구매확인 버튼을 누릅니다.
- 결제 예산 설정 화면에서 예산 금액을 입력하고 계속 버튼을 누릅니다.
- Cloud Billing 계정 연결 버튼을 누릅니다.
- 시작하기 버튼을 누릅니다.
- 버킷(Bucket)을 생성할 위치를 선택합니다. 버킷(Bucket)은 이미지나 동영상을 저장할 저장소로 이해하면 됩니다. 우리는 무료 등급 버킷인 US-CENTRAL1을 선택합니다.
- 프로덕션 모드에서 시작을 선택한 후 만들기 버튼을 클릭합니다.
- Firebase Storage Bucket이 만들어지면 아래와 같은 화면이 나타납니다.
- 규칙 탭을 클릭하여 보안규칙을 설정하러 갑시다. 아래와 같이 설정합시다. 그리고 게시 버튼을 누릅니다. 이 설정을 적용하면, 로그인된 사용자만 Firestore에 접근할 수 있습니다. 게시 버튼을 누른 후 규칙의 적용에 1분 정도의 시간이 소요된다고 하니 조금 대기했다가 사용합시다. → Firebase Authorization 서비스의 도움이 없이 사용하기 위하여 잠시 보안을 풀어 놓겠습니다. 아래와 같이 설정하면 Firebase Authorization으로 로그인되지 않아도 Firebase Store에 모든 사용자가 이미지와 동영상 등을 업로드할 수 있습니다. → 테스트후 바로 권한을 정상화합시다.
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
//allow read, write: if request.auth != null;
allow read, write: if true; // 프로그램을 간결하게 보여 주기 위하여
} // 전체 권한을 오픈합니다.
} // 테스트 후 바로 권한을 정상화합시다.
}
Flutter 앱 프로젝트에 Firebase 클라우드 프로젝트 연동하기
- 안드로이드 스튜디오로 gallery_camera 프로젝트를 오픈합니다.
- 만들어둔 flutter-firebase Firebase 프로젝트를 gallery_camera Flutter 앱 프로젝트에 연결하기 위하여 flutterfire configure --project=your-firebase-project-id 명령을 실행해줍니다. your-firebase-project-id은 Firebase 클라우드 프로젝트를 만들때 기억하라고 했던 고유번호입니다. 프로젝트 이름을 사용하지 않고 프로젝트 고유번호를 사용한다는 것에 주의하기 바랍니다. 프로젝트 고유 번호를 웹 브라우저의 주소창에서 쉽게 확인할 수 있습니다.
- 그후 “Chapter 7. 플랫폼 클라우드 연동 I”의 “Flutter 앱 프로젝트에 Firebase 클라우드 프로젝트 연동하기”에서 설명하는 것과 동일한 방법으로 설정을 진행합니다.
- flutterfire configure --project=your-firebase-project-id 명령의 실행이 정상적으로 완료되면 안드로이드 스튜디오의 gallery_camera 프로젝트에 아래 첫번째 화면과 같이 최상위 폴더에 firebase.json 파일이 생기고 lib 폴더에 firebase_options.dart 파일이 생성된 것을 확인할 수 있으며, 아래 두번째 화면과 같이 Firebase의 프로젝트에도 앱이 등록되어 나타나는 것을 확인할 수 있습니다.
- pubspec.yaml 파일에 firebase_core: ^3.6.0 와 firebase_storage: ^12.3.4를 디펜던시로 등록하고 pub get 메뉴를 클릭합니다.
- android\app\build.gradle 파일에 minSdkVersion을 23으로 설정합니다.
Firebase Storage에 이미지와 동영상 올리기
- Firebase Storage에 이미지와 동영상을 업로드할 수 있도록 main.dart 파일에 필요한 import 문장을 추가하고 main() 함수와 upload() 함수를 아래와 같이 수정합니다 .
// main.dart 파일의 코드
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
//import 'package:dio/dio.dart'; // Firebase Store 사용으로 삭제
// Firebase 라이브러리 import
import 'package:firebase_core/firebase_core.dart'; // Core import
import 'package:firebase_storage/firebase_storage.dart'; // Storage import
import 'firebase_options.dart'; // Options import
void main() async { // Firebase 초기화 시작
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
); // Firebase 초기화의 끝
runApp(const MyApp());
}
... 중략 ...
/*
upload(media) async {
if (media != null) {
String fileName = media.path.split('/').last;
var formData = FormData.fromMap({
"file": await MultipartFile.fromFile(media.path, filename: fileName),
});
try {
var dio = Dio();
//String serverUri = "http://localhost:8087/upload";
String serverUri = "http://192.168.0.5:8087/upload";
if (Platform.isAndroid) {
//serverUri = "http://10.0.2.2:8087/upload";
serverUri = "http://192.168.0.5:8087/upload";
}
var response = await dio.post(serverUri, data: formData);
print("미디어(이미지/비디오) 업로드 성공");
} catch(e) {
print("미디어(이미지/비디오) 업로드 실패: ${e}");
}
}
}
*/
upload(media) async {
if (media != null) {
// 이미지를 저장할 Firebase Storage의 경로명을 지정합니다.
String fileName = media.path.split('/').last;
String pathName = '/uploads/$fileName';
try {
// 선택한 미디어를 Storage에 업로드 할 수 있는 File로 변환해줍니다.
File imageFile = File(media.path);
// File 객체로 변환한 미디어를 Firebase Storage에 업로드합니다.
await FirebaseStorage.instance.ref(pathName).putFile(imageFile);
// Firebase Storage에 업로드된 파일의 URL 가져오기
// Spring Boot와의 연동에서는 생략한 로직으로 여기서는 간단히 추가할 수 있습니다.
String imageUrl = await FirebaseStorage.instance.ref(pathName).getDownloadURL();
// 이미지의 URL은 파일이나 Database 등의 저장소에 저장되어 활용하게 됩니다.
print("업로드된 미디어(이미지/비디오)의 URL : $imageUrl}");
} catch(e) {
print("미디어(이미지/비디오) 업로드 실패: ${e}");
}
}
}
... 생략 ...
- 이미지를 Firebase Storage에 올리는 테스트는 이뮬레이터가 아니라 스마트폰으로 하겠습니다. 갤러리에서 사진 선택 버튼을 클릭한 후 이미지 업로드 버튼을 클릭합니다.
- 그러면 아래 화면과 같이 안드로이드 스튜디오 하단에 Firebase Authorization을 사용하지 않았기때문에 W/StorageUtil(23282): no auth token for request와 같은 경고가 나타나지만 I/flutter ( 5030): 업로드된 미디어(이미지/비디오)의 URL : https://firebasestorage.googleapis.com/v0/b/totemic-fulcrum-420006.firebasestorage.app/o/uploads%2F1000004288.png?alt=media&token=d367e371-9624-418f-a3f4-5f3b7a49dc8a}와 같은 출력이 나타나 파일이 정상적으로 업로드되었다는 것을 알려 줍니다.
- Firebase Storage 사이트에 가서 확인하면 아래 화면과 같이 지정된 폴더 위치에 선택한 이미지(유형이 image/png)가 저장되어 있는 것을 확인할 수 있습니다.
- 이번에는 이미지와 동일한 방법으로 동영상을 업로드해 보겠습니다. 앱을 다시 시작한 후 스마트폰에서 갤러리에서 동영상 선택 버튼을 클릭한 후 동영상 업로드 버튼을 클릭합니다.
- 그러면 동영상 업로드를 위한 코드의 변경이나 추가없이 I/flutter ( 5030): 업로드된 미디어(이미지/비디오)의 URL : https://firebasestorage.googleapis.com/v0/b/totemic-fulcrum-420006.firebasestorage.app/o/uploads%2F1000002759.mp4?alt=media&token=e958a190-d6c8-4ee6-a54f-be0ec79883fa}와 같은 출력이 나타나 파일이 정상적으로 업로드되었다는 것을 알려 줍니다.
- Firebase Storage 사이트에 가서 확인하면 아래 화면과 같이 지정된 폴더 위치에 선택한 동영상(유형이 video/mp4)이 저장되어 있는 것을 확인할 수 있습니다.
- 규칙 탭을 클릭하여 보안규칙을 설정을 원복하러 갑시다. 저는 강의 목적을 달성했으니 만약을 대비하여 권한의 정상화를 넘어 모든 권한을 막아 놓겠습니다. 아래와 같이 설정하면 권한을 모두 회수하고 아무런 권한도 주지 않게 됩니다. 게시 버튼을 누르는 것도 잊지 맙시다.
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
//allow read, write: if request.auth != null;
//allow read, write: if true;
allow read, write: if false; // 모든 권한을 회수
}
}
}
[배포]
안드로이드 앱 배포 파일 만들기
- 안드로이드 스튜디오에서 File > Build > Flutter > Build APK 메뉴를 클릭하면 확장자가 apk인 안드로이드 앱의 배포 파일을 만들 수 있습니다.
- 빌드가 끝나면 프로젝트 폴더 하부의 build\app\outputs\apk\release 폴더에 app-release.apk 배포 파일이 생긴 것을 확인할 수 있습니다.
- apk 확장자를 가진 안드로이드 앱의 배포 파일을 안드로이드 폰에 저장한 후 아래 링크에서 설명하는 방법으로 앱을 설치할 수 있습니다.
출처를 알 수 없는 앱 설치 방법(안드로이드13)
안녕하세요 이번에는 출처를 알수 없는 앱 설치 방법을 포스팅 하도록 할께요 간혹 출처를 알 수 없는 앱을...
blog.naver.com
테스트 앱과 실전 앱 배포
- 안드로이드 폰은 Play Console을 통하여 테스트 앱을 배포할 수 있습니다.
- iOS는 출처를 알 수 없는 앱 설치를 허용하지 않으며 TestFlight를 통하여 테스트 앱을 배포할 수 있으나 TestFlight는 Apple Developer Program에 가입한 유료 계정에서만 사용할 수 있습니다.
- 그외 안드로이드 앱을 Google Play Store에, iOS 앱을 Apple App Store를 통하여 실전 배포할 수 있습니다. 앱 배포 방법은 플랫폼 정책에 따라 수시로 변경될 수 있으므로, 배포할 일이 있을 때 최신 절차를 배우고 적용하는 것이 좋습니다.
참고 문헌
[논문]
- 없음
[보고서]
- 없음
[URL]
- 없음
문의사항
[기상학/프로그래밍 언어]
- sangho.lee.1990@gmail.com
[해양학/천문학/빅데이터]
- saimang0804@gmail.com