Flutter + Firebase 3편

Firebase Social Login

Posted by Minki on March 25, 2021

들어가기

App을 만들때 가장 기본이 되는 요소 중 하나는 ‘로그인’이다. 다행이 Firebase를 이용하면 이메일 로그인과 각종 소셜 로그인 기능을 비교적 쉽게 구현할 수 있다. 지금부터 차례대로 알아보자.

1. 구글로그인.

먼저 연동시킨 firebase console창에 들어가서 각종 로그인 기능을 활성화 시켜주어야 한다. 이를 위해 Firebase의 Authentication Tab에서 Sign-in method로 이동한뒤 Google을 활성화해야한다.

이렇게 활성화를 했으면 firebase에서 준비해야할 것은 다 했다. 이제 에디터로 돌아와서 코드를 짜보자.

2. Firebase Initialize와 경우의 수 대응.

먼저 이번 포스팅에서 사용할 package부터 알려드리고 가는것이 맞을듯 싶다. 이번 포스팅에서 사용할 package는 다음과 같다.

1
2
3
4
5
6
firebase_core: ^0.7.0
firebase_auth: ^0.20.1
cloud_firestore: ^0.16.0+1
firebase_storage: ^7.0.0
google_sign_in: ^4.5.9
get: ^3.26.0

flutter에서 firebase를 사용하기 위해서는 제일 먼저 firebase initialize 해주어야 한다. 이후 simulator를 시작했을때 build에 성공하면 이제 firebase가 해당 앱에서 무사히 실행되는 것이다. 그리고 이번 포스팅에서 state management로 get을 활용할 것이므로 가장 윗단에 getMaterial을 등록해주는것도 잊지말자.

2-1. Firebase Initialize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'app.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // GetX 등록
    return GetMaterialApp(
      debugShowCheckedModeBanner: false,
      // GetX Controller 등록
      initialBinding: BindingsBuilder(() {
      }),
      title: 'Flutter Basic',
      home: App(),
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import 'package:firebase_core/firebase_core.dart';
import 'package:firebasebasic/src/home.dart';
import 'package:flutter/material.dart';

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: Firebase.initializeApp(),
      builder: (BuildContext context, AsyncSnapshot snapshot) {
        if (snapshot.hasError) {
          return Center(
            child: Text('Firebase load fail'), // 에러 대응
          );
        }
        if (snapshot.connectionState == ConnectionState.done) {
          return Home();
        } else {
          return Center(
            child: CircularProgressIndicator(),
          );
        }
      },
    );
  }
}

위의 코드를 보면 Firebase.initializeApp()의 Type이 Future이므로 FutureBuilder를 이용해 Firebase를 initialize해주었다. 그리고 이 FutureBuild의 데이터가 snpashot으로 전달되는데 snapshot의 경우의 수에 따라 page 전개가 달라진다.

- if (snapshot.hasError)

snapshot.hasError가 true라는 것은 Firebase.initializeApp()과정 중에 에러가 발생했다는 것이다. 따라서 해당 경우의 수에서는 에러에 대응할 수 있는 Ui/UX가 나와야 한다.

- if (snapshot.connectionState == ConnectionState.done)

이번 경우의 수는 firebase 연동이 완료됐다는 것이다. 따라서 에러에 대응할 필요가 없고 바로 다음 페이지로 넘어가면 된다. 이 두 가지 경우가 아니면 나머지는 그냥 아직 로딩중이므로 CircularProgressIndicator로 쉽게 처리할 수 있다.

3. 로그인 경우의 수

firebase 연동을 끝내고 home화면으로 넘어왔다고 가정하자. 그럼 여기서 또 유저가 로그인 된 경우와 그렇지 않은 경우의 수로 나뉜다. 따라서 home화면에서는 firebase에 유저 데이터를 호출해서 유저 데이터가 불러와지면 로그인된거, 그렇지 않으면 로그인 안된거라고 판단해야 유저에게 적절한 페이지를 안내해줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebasebasic/src/login_page.dart';
import 'package:flutter/material.dart';
import 'main_page.dart';

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: FirebaseAuth.instance
          .authStateChanges(), //firebase 상태가 바뀌었는지 아닌지 체크하는 stream.
      builder: (BuildContext context, AsyncSnapshot<User> snapshot) {
        if (!snapshot.hasData) {
          return LoginPage(); //data가 없으므로 로그인 페이지로
        } else {
          return MainPage(); // data가 있으므로 바로 메인 페이지로
        }
      },
    );
  }
}

위에 주석을 달아놨으니 살펴보면 될것이다. 여기까지가 firebase의 공통된 뼈대이다. 이렇게 뼈대를 만들고 난 뒤에 로그인 페이지에서 원하는 소셜 로그인을 구현하면 된다. 지금까지의 과정을 요약하면 다음과 같다.

3-1. 과정 요약

  1. GetX State Management를 위한 GetMaterialApp 등록.
  2. FutureBuilder를 통해 Firebase Initiallize
  3. FirebaseAuth.instance.authStateChanges()를 통해 Firebase 로그인 상태 체크
  4. 로그인된 상태면 MainPage로 그렇지 않으면 LoginPage로 유저 안내
  5. LoginPage에서 로그인 기능 구현 및 유저가 로그인하도록 유도
  6. 유저가 로그인하면 Firebase.instance.authStateChanges()에서 상태 변화 감지
  7. 로그인이 정상적으로 됐으면 MainPage로 안내

이 과정이 이해가 됐으면 소셜로그인을 구현해볼 차례이다. 해당 포스팅에서는 구글로그인만 다루도록 하겠다.

4. LoginPage에서 구글 로그인 구현.

구글 로그인 구현은 FlutterFire페이지에 매우 잘 나와있다. 따라서 LoginPage 코드만 공유하도록 하겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';

class LoginPage extends StatelessWidget {
  //google sign_in
  Future<UserCredential> signInWithGoogle() async {
    // Trigger the authentication flow
    final GoogleSignInAccount googleUser = await GoogleSignIn().signIn();
    // Obtain the auth details from the request
    final GoogleSignInAuthentication googleAuth =
        await googleUser.authentication;
    // Create a new credential
    final GoogleAuthCredential credential = GoogleAuthProvider.credential(
      accessToken: googleAuth.accessToken,
      idToken: googleAuth.idToken,
    );
    // Once signed in, return the UserCredential
    return await FirebaseAuth.instance.signInWithCredential(credential);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Google Login'),
        backgroundColor: Colors.black,
        elevation: 0.0,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            FlatButton(
              color: Colors.black,
              child: Text(
                'Google Login',
                style: TextStyle(color: Colors.white),
              ),
              onPressed: () {
                signInWithGoogle(); // 해당 버튼을 누르면 구글로그인 페이지가 나온다.
              },
            )
          ],
        ),
      ),
    );
  }
}

이 과정을 통해 구글 로그인이 정상적으로 실행되었다고 가정하자. 그럼 위에서 말한데로 FirebaseAuth.instance.authStateChanges()에서 변화를 감지하고, 유저 데이터를 snapshot.data로 보냄과 동시에 유저를 MainPage로 안내할 것이다. 앱이 정상적으로 MainPage로 이동했다면 구글 로그인은 성공한 것이다.

5. 유저 데이터를 Firebase에 보내기

지금부터는 구글로그인에서 받아온 snpashot.data를 Firebase Database로 보내 유저 데이터를 저장하고, 여기에 더해 유저 데이터를 GetXController로 등록해 프론트 전체에서 공유할 수 있는 구조를 만들어야 한다. 이를 위해 GetXController를 만들고 이를 앱 상단에 등록해주어야 한다.

5-1. GetX Controller 등록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 'package:firebasebasic/src/controller/profile_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'app.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false,
      initialBinding: BindingsBuilder(() {
        Get.lazyPut<ProfileController>(() => ProfileController()); //이 부분을 추가하면 된다.
      }),
      title: 'Flutter Basic',
      home: App(),
    );
  }
}

5-2. 유저데이터 Firebase에 저장.

이 과정이 이번 포스팅에서 제일 중요하므로 좀 자세히 설명하도록 하겠다. 먼저 해당 과정을 요약하면 다음과 같다.

  1. snapshot.data를 받으면 snapshot.data.uid를 통해 해당 유저가 Firebase Database에 등록이 됐는지 안됐는지 파악.
  2. 등록이 안된 유저면 Database에 추가 OR 등록이 된 유저면 최종 로그인 시각 업데이트.

지금부터 하나씩 살펴보자. 전체 코드 뼈대는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// firebase storage에 데이터를 보내는 과정.
class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: FirebaseAuth.instance
          .authStateChanges(),
      builder: (BuildContext context, AsyncSnapshot<User> snapshot) {
        // snapshot.data를 GetX Controller에 보냄
        ProfileController.to.authStateChanges(snapshot.data);
        if (!snapshot.hasData) {
          return LoginPage(); //data가 없으면 로그인으로
        } else {
          return MainPage(); // data가 있으면 MainPage로
        }
      },
    );
  }
}

class ProfileController extends GetxController {
  // firebaseUser = snapshot.dart이고 User는 firebaseAuth 라이브러리의 Type 변수 중 하나.
  void authStateChanges(User firebaseUser) async {
    if (firebaseUser != null) {
      // firebase uid를 이용해 user의 등록 여부를 파악
      firebaseUserdata = await findUserByUid(firebaseUser.uid);
      if (firebaseUserdata == null) {
        // firebaseUserData가 null이면 firebase database에 등록이 안된 유저
        // 이런 유저들은 새롭게 등록해주어야 함.
        saveUserToFirebase(firebaseUser);
      } else {
        // 이미 등록된 유저이므로 최종 로그인 시간만 업데이트 해주면됨.
        updateLoginTime()
      }
    }
  }
}

5-3. snapshot.data.uid를 이용해 Firebase Database에서 User찾기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Future<Map<String, dynamic>> findUserByUid(String uid) async {
    // users collection에 있는 모든 user들을 users에 담음.
    CollectionReference users = FirebaseFirestore.instance.collection('users');
    // users collection에서 현재 firebaseUser.uid인 user만 가져와서 이를 data에 옮김
    QuerySnapshot data = await users.where('uid', isEqualTo: uid).get();
    // 여기서 data.size가 0이면 결국 같은 uid를 가진 user가 없다는 뜻.
    if (data.size == 0) {
      return null;
    } else {
      // 같은 uid를 가진 여러명의 data중에서 첫 번째것만 필요. 그리고 return은 Map<String,dynamic>으로 받음
      // 결과적으로 userData가 현재 로그인된 userData이므로 이를 전체 front에서 공유하고 firebase에 업로드 하면됨.
      Map<String, dynamic> userData = data.docs[0].data();
      // docId는 users collection 하위에 있는 user의 key값과 같다. 이어지는 포스팅에서 쓰임새를 확인할 수 있다.
      docId = data.docs[0].id;
      return userData;
    }
  }

CollectionReference users = FirebaseFirestore.instance.collection('users'); 이 코드는 users라는 콜렉션에 있는 모든 데이터를 가져와서 users라는 CollectionReference type을 가진 데이터에 저장하라는 뜻이다.

위의 스크린샷에는 이미 user라는 collection이 있고 하위 데이터도 있지만 처음 실행하면 당연히 저 collection은 존재하지 않는다. 그래서 위의 findUserByUid함수의 return 값이 null이 나온다. null이 나왔으므로 우리가 해야할 일은 snapshot.dart를 firebase에 저장하는 일이다.

5-4. snapshot.data를 Firebase Database에 저장하기.

저장하는 과정은 매우 간단하다. 바로 코드를 보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 Future<Map<String, dynamic>> fromMap(User firebaseUser, String uid) async {
    firebaseUserdata = {
      'uid': uid,
      'name': firebaseUser.displayName,
      'email': firebaseUser.email,
      'created_time': DateTime.now().toIso8601String(),
      'last_login_time': DateTime.now().toIso8601String(),
    };
    return firebaseUserdata;
  }

  void saveUserToFirebase(User firebaseUser) async {
    CollectionReference users = FirebaseFirestore.instance.collection('users');
    // firebase의 users collection에 data를 추가하는 것.
    users.add(await fromMap(firebaseUser, firebaseUser.uid));
  }

근데 여기서 주의해야할 것은 firebase의 data type은 Map이다 따라서 우리가 받아온 snpashot.dart(=firebaseUser)를 Map Type으로 바꿔줘야 한다. 따라서 해당 코드를 실행하면 비어있던 firebase에 ‘users’ collection이 생기고 위의 스크린샷처럼 구글로그인을 통해 얻은 user data가 저장될 것이다.

5-5. 이미 등록된 유저 최종 로그인 시간 업데이트하기.

업데이트 과정도 매우 간단하다. 바로 코드를 보겠다.

1
2
3
4
5
6
7
void updateLoginTime() {
    // userdata가 있다면 마지막 로그인 시간을 업데이트 해줘야함.
    CollectionReference users = FirebaseFirestore.instance.collection('users');
    users
        .doc(docId)
        .update({'last_login_time': DateTime.now().toIso8601String()});
  }

먼저 users.doc(docId)를 통해 users라는 전체 collection에서 docId를 가지고 있는 딱 하나의 user만 찾는다. 그리고 .update({'last_login_time': DateTime.now().toIso8601String()});를 통해 최종 로그인 시간만 업데이트 해주면 된다.

이렇게 하면 firebase google login 과정은 무사히 끝났다고 볼 수 있다. 포스팅을 읽고 해당 부분 전체 코드를 보면 더 이해가 잘 갈 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:get/get.dart';

// 전체 과정 //
// firebaseUser를 받으면 일단 firebaseUser를.uid를 이용해서 이미 데이터베이스에 있는지 없는지 확인
// 없으면 'users' collection을 생성한 뒤에 여기다 해당 정볼르 add 해줘야함.
// 근데 firebase에 업로드를 하기 위해서는 Map 형식이여야 하는데, 여기서 현재 firebase는 User type이므로
// 이를 Map으로 바꿔서 해줘야함.
// 그래서 UserModle을 만든 다음에 toMap함수를 넣어서 이를 Map으로 바꿔준다음에 firebase DB에 업로드

class ProfileController extends GetxController {
  // Get.find<ProfileController>()대신에 ProfileController.to ~ 라고 쓸 수 있음
  static ProfileController get to => Get.find();
  Map<String, dynamic> firebaseUserdata = {};
  String docId;

  Future<Map<String, dynamic>> findUserByUid(String uid) async {
    // users collection에 있는 모든 user들을 users에 담음.
    CollectionReference users = FirebaseFirestore.instance.collection('users');
    // users collection에서 현재 firebaseUser.uid인 user만 가져와서 이를 data에 옮김
    QuerySnapshot data = await users.where('uid', isEqualTo: uid).get();
    // 여기서 data.size가 0이면 결국 같은 uid를 가진 user가 없다는 뜻.
    if (data.size == 0) {
      return null;
    } else {
      // 같은 uid를 가진 여러명의 data중에서 첫 번째것만 필요. 그리고 return은 Map<String, dynamic>으로 받음
      // 결과적으로 userData가 현재 로그인된 userData이므로 이를 전체 front에서 공유하고 firebase에 업로드 하면됨.
      Map<String, dynamic> userData = data.docs[0].data();
      docId = data.docs[0].id;
      return userData;
    }
  }

  Future<Map<String, dynamic>> fromMap(User firebaseUser, String uid) async {
    firebaseUserdata = {
      'uid': uid,
      'name': firebaseUser.displayName,
      'email': firebaseUser.email,
      'created_time': DateTime.now().toIso8601String(),
      'last_login_time': DateTime.now().toIso8601String(),
    };
    return firebaseUserdata;
  }

  void saveUserToFirebase(User firebaseUser) async {
    CollectionReference users = FirebaseFirestore.instance.collection('users');
    // firebase의 users collection에 data를 추가하는 것.
    users.add(await fromMap(firebaseUser, firebaseUser.uid));
  }

  void updateLoginTime() {
    // userdata가 있다면 마지막 로그인 시간을 업데이트 해줘야함.
    CollectionReference users = FirebaseFirestore.instance.collection('users');
    users
        .doc(docId)
        .update({'last_login_time': DateTime.now().toIso8601String()});
  }

  // firebase storage에 데이터를 보내는 과정.
  void authStateChanges(User firebaseUser) async {
    if (firebaseUser != null) {
      firebaseUserdata = await findUserByUid(firebaseUser.uid);
      // firebaseUserData가 null이면 firebase database에 등록이 안된 유저
      if (firebaseUserdata == null) {
        saveUserToFirebase(firebaseUser);
      } else {
        updateLoginTime();
      }
    }
  }
}

끝으로 전체적인 리팩토링을 끝낸 코드를 깃허브에 올려놓았다. 여기서확인할 수 있다. 또한 해당 포스팅에서 데이터베이스의 기본인 CRUD 중 C(create), R(read), U(update)를 살짝 맛봤는데 또 기회가 된다면 이 세가지를 더 자세하게 설명하겠다.


reference

  • https://www.youtube.com/channel/UCbMGBIayK26L4VaFrs5jyBw
  • https://firebase.flutter.dev/docs/auth/social
  • https://firebase.flutter.dev