[Flutter + Firebase로 북마크 앱 만들기 #3] Firebase Authentication 연동 및 로그인 구현
Oct 08, 2025
![[Flutter + Firebase로 북마크 앱 만들기 #3] Firebase Authentication 연동 및 로그인 구현](https://image.inblog.dev?url=https%3A%2F%2Finblog.ai%2Fapi%2Fog%3Ftitle%3D%255BFlutter%2520%252B%2520Firebase%25EB%25A1%259C%2520%25EB%25B6%2581%25EB%25A7%2588%25ED%2581%25AC%2520%25EC%2595%25B1%2520%25EB%25A7%258C%25EB%2593%25A4%25EA%25B8%25B0%2520%25233%255D%2520Firebase%2520Authentication%2520%25EC%2597%25B0%25EB%258F%2599%2520%25EB%25B0%258F%2520%25EB%25A1%259C%25EA%25B7%25B8%25EC%259D%25B8%2520%25EA%25B5%25AC%25ED%2598%2584%26logoUrl%3Dhttps%253A%252F%252Finblog.ai%252Finblog_logo.png%26blogTitle%3DGyeongwon%27s%2520blog&w=2048&q=75)
Contents
🎯 이번 글에서 다룰 내용🔐 Firebase Authentication이란?📁 구현할 기능 구조1️⃣ Firebase Auth Service 구현2️⃣ Riverpod Provider로 상태 관리3️⃣ 로그인 화면 구현4️⃣ 회원가입 화면 구현5️⃣ go_router 라우팅 설정6️⃣ main.dart 라우터 적용7️⃣ 홈 화면 (임시) 구현🧪 테스트 시나리오🔍 Firebase Console 확인📊 최종 프로젝트 구조💡 구현하면서 배운 점🐛 트러블슈팅📈 성능 최적화 포인트🎨 UI/UX 개선 포인트🔒 보안 고려사항📝 코드 품질 개선🎯 다음 단계 예고📦 현재 패키지 목록🎓 학습 정리💭 회고🎉 마무리🎯 이번 글에서 다룰 내용
- Firebase Authentication Service 구현
- Riverpod으로 인증 상태 관리
- 로그인/회원가입 화면 개발
- go_router로 인증 기반 라우팅 구현
- 자동 리다이렉트 및 세션 관리
🔐 Firebase Authentication이란?
Firebase Authentication은 사용자 인증을 쉽게 구현할 수 있는 백엔드 서비스입니다.
주요 기능:
- 이메일/비밀번호 인증
- 소셜 로그인 (Google, Facebook 등)
- 전화번호 인증
- 익명 로그인
장점:
- 서버 구축 불필요
- 보안 처리 자동화
- 세션 관리 자동화
- 무료 tier로 충분한 사용량
📁 구현할 기능 구조
Authentication Flow
├── Service Layer (auth_service.dart)
│ └── Firebase와 직접 통신
├── State Management (auth_provider.dart)
│ └── Riverpod Provider로 상태 관리
├── UI Layer
│ ├── LoginScreen (로그인 화면)
│ └── SignupScreen (회원가입 화면)
└── Routing (app_router.dart)
└── 인증 상태에 따른 자동 리다이렉트
1️⃣ Firebase Auth Service 구현
Service Layer의 역할
위치:
lib/features/auth/services/auth_service.dart
Service Layer는 Firebase와 직접 통신하는 계층입니다. 비즈니스 로직을 담당하며, UI와 Firebase를 분리합니다.
import 'package:firebase_auth/firebase_auth.dart';
import '../models/user_model.dart';
class AuthService {
final FirebaseAuth _auth = FirebaseAuth.instance;
// 현재 사용자 가져오기
User? get currentUser => _auth.currentUser;
// 사용자 스트림 (실시간 인증 상태)
Stream<User?> get authStateChanges => _auth.authStateChanges();
// 이메일/비밀번호 회원가입
Future<UserModel?> signUpWithEmail({
required String email,
required String password,
}) async {
try {
final UserCredential userCredential =
await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
if (userCredential.user != null) {
return UserModel.fromFirebaseUser(userCredential.user!);
}
return null;
} on FirebaseAuthException catch (e) {
throw _handleAuthException(e);
} catch (e) {
throw '회원가입 중 오류가 발생했습니다: $e';
}
}
// 이메일/비밀번호 로그인
Future<UserModel?> signInWithEmail({
required String email,
required String password,
}) async {
try {
final UserCredential userCredential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
if (userCredential.user != null) {
return UserModel.fromFirebaseUser(userCredential.user!);
}
return null;
} on FirebaseAuthException catch (e) {
throw _handleAuthException(e);
} catch (e) {
throw '로그인 중 오류가 발생했습니다: $e';
}
}
// 로그아웃
Future<void> signOut() async {
try {
await _auth.signOut();
} catch (e) {
throw '로그아웃 중 오류가 발생했습니다: $e';
}
}
// Firebase Auth 에러 처리
String _handleAuthException(FirebaseAuthException e) {
switch (e.code) {
case 'weak-password':
return '비밀번호가 너무 약합니다.';
case 'email-already-in-use':
return '이미 사용 중인 이메일입니다.';
case 'invalid-email':
return '유효하지 않은 이메일입니다.';
case 'user-not-found':
return '사용자를 찾을 수 없습니다.';
case 'wrong-password':
return '잘못된 비밀번호입니다.';
case 'too-many-requests':
return '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.';
case 'user-disabled':
return '비활성화된 계정입니다.';
default:
return '인증 오류가 발생했습니다: ${e.message}';
}
}
}
설계 포인트
1. 단일 책임 원칙:
- AuthService는 오직 인증 관련 로직만 처리
- UI 로직과 완전히 분리
2. 에러 처리:
String _handleAuthException(FirebaseAuthException e) {
switch (e.code) {
case 'weak-password':
return '비밀번호가 너무 약합니다.';
// ... 사용자 친화적 메시지로 변환
}
}
- Firebase의 영문 에러 코드를 한글 메시지로 변환
- 사용자 경험 향상
3. 비동기 처리:
Future<UserModel?> signInWithEmail({...}) async {
// Firebase 통신은 비동기
final UserCredential userCredential =
await _auth.signInWithEmailAndPassword(...);
}
2️⃣ Riverpod Provider로 상태 관리
Provider의 역할
위치:
lib/features/auth/services/auth_provider.dart
Provider는 앱 전체에서 인증 상태를 공유하고 관리합니다.
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_service.dart';
import '../models/user_model.dart';
// AuthService Provider
final authServiceProvider = Provider<AuthService>((ref) {
return AuthService();
});
// 인증 상태 스트림 Provider
final authStateProvider = StreamProvider<User?>((ref) {
final authService = ref.watch(authServiceProvider);
return authService.authStateChanges;
});
// 현재 사용자 Provider
final currentUserProvider = Provider<User?>((ref) {
final authService = ref.watch(authServiceProvider);
return authService.currentUser;
});
// 로그인 상태 확인 Provider
final isLoggedInProvider = Provider<bool>((ref) {
final authState = ref.watch(authStateProvider);
return authState.value != null;
});
Provider 종류 설명
1. Provider:
final authServiceProvider = Provider<AuthService>((ref) {
return AuthService();
});
- 불변 객체 제공
- 싱글톤처럼 동작
- AuthService 인스턴스 재사용
2. StreamProvider:
final authStateProvider = StreamProvider<User?>((ref) {
final authService = ref.watch(authServiceProvider);
return authService.authStateChanges;
});
- 실시간 데이터 스트림 제공
- 로그인/로그아웃 시 자동 업데이트
- UI가 자동으로 반응
3. 계산된 Provider:
final isLoggedInProvider = Provider<bool>((ref) {
final authState = ref.watch(authStateProvider);
return authState.value != null;
});
- 다른 Provider 값을 기반으로 계산
authStateProvider
를 watch해서 boolean 반환
Riverpod 사용 장점
기존 방식 | Riverpod |
setState로 수동 관리 | 자동 상태 업데이트 |
BuildContext 필요 | 어디서든 접근 가능 |
런타임 에러 가능 | 컴파일 타임 체크 |
테스트 어려움 | Mock 작성 쉬움 |
3️⃣ 로그인 화면 구현
UI 설계 원칙
위치:
lib/features/auth/screens/login_screen.dart
디자인 고려사항:
- 간결하고 직관적인 UI
- Material Design 3 가이드라인 준수
- 로딩 상태 표시
- 유효성 검사
- 사용자 친화적 에러 메시지
핵심 코드 분석
1. State 관리
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
// ...
}
포인트:
ConsumerState
: Riverpod 사용을 위한 State
GlobalKey<FormState>
: Form 유효성 검사용
TextEditingController
: 입력값 관리
dispose()
: 메모리 누수 방지
2. 로그인 로직
Future<void> _handleLogin() async {
// 1. 유효성 검사
if (!_formKey.currentState!.validate()) return;
// 2. 로딩 시작
setState(() => _isLoading = true);
try {
// 3. Firebase 로그인
final authService = ref.read(authServiceProvider);
await authService.signInWithEmail(
email: _emailController.text.trim(),
password: _passwordController.text,
);
// 4. 성공 메시지
if (mounted) {
Fluttertoast.showToast(
msg: AppStrings.loginSuccess,
backgroundColor: AppColors.success,
);
}
} catch (e) {
// 5. 에러 처리
if (mounted) {
Fluttertoast.showToast(
msg: e.toString(),
backgroundColor: AppColors.error,
);
}
} finally {
// 6. 로딩 종료
if (mounted) {
setState(() => _isLoading = false);
}
}
}
흐름 설명:
- Form 유효성 검사 (이메일, 비밀번호)
- 로딩 인디케이터 시작
- AuthService를 통해 Firebase 로그인
- 성공 시 토스트 메시지
- 실패 시 에러 메시지
- 로딩 종료
mounted
체크 이유:if (mounted) {
// UI 업데이트
}
- 비동기 작업 중 화면이 닫힐 수 있음
- 닫힌 화면에 setState 호출하면 에러 발생
mounted
로 화면이 아직 존재하는지 확인
3. 이메일 입력 필드
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: AppStrings.email,
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: AppColors.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return AppStrings.fieldsRequired;
}
if (!value.contains('@')) {
return AppStrings.invalidEmail;
}
return null;
},
)
유효성 검사:
- 빈 값 체크
- 이메일 형식 체크 (
@
포함 여부)
- 실시간 에러 메시지 표시
4. 비밀번호 입력 필드 (표시/숨김 기능)
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: AppStrings.password,
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
// ...
),
validator: (value) {
if (value == null || value.isEmpty) {
return AppStrings.fieldsRequired;
}
if (value.length < 6) {
return AppStrings.passwordTooShort;
}
return null;
},
)
기능:
obscureText
: 비밀번호 숨김
- suffixIcon: 표시/숨김 토글 버튼
- 6자 이상 검증
5. 로그인 버튼 (로딩 상태)
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
AppStrings.login,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
)
로딩 상태 처리:
_isLoading
이 true면 버튼 비활성화
- 로딩 중에는 CircularProgressIndicator 표시
- 중복 클릭 방지
4️⃣ 회원가입 화면 구현
위치:
lib/features/auth/screens/signup_screen.dart
로그인 화면과의 차이점
비밀번호 확인 필드 추가:
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: AppStrings.confirmPassword,
// ...
),
validator: (value) {
if (value == null || value.isEmpty) {
return AppStrings.fieldsRequired;
}
if (value != _passwordController.text) {
return AppStrings.passwordsDoNotMatch;
}
return null;
},
)
비밀번호 일치 검증:
_passwordController.text
와 비교
- 불일치 시 에러 메시지
회원가입 후 처리:
Future<void> _handleSignup() async {
// ... 회원가입 로직
if (mounted) {
Fluttertoast.showToast(
msg: AppStrings.signupSuccess,
backgroundColor: AppColors.success,
);
context.pop(); // 로그인 화면으로 돌아가기
}
}
5️⃣ go_router 라우팅 설정
인증 기반 라우팅의 핵심
위치:
lib/routes/app_router.dart
final goRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
initialLocation: '/login',
redirect: (context, state) {
final isLoggedIn = authState.value != null;
final isLoggingIn = state.matchedLocation == '/login' ||
state.matchedLocation == '/signup';
// 로그인 안 했는데 로그인 페이지가 아니면 -> 로그인 페이지로
if (!isLoggedIn && !isLoggingIn) {
return '/login';
}
// 로그인 했는데 로그인 페이지에 있으면 -> 홈으로
if (isLoggedIn && isLoggingIn) {
return '/';
}
return null; // 리다이렉트 없음
},
routes: [
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/signup',
builder: (context, state) => const SignupScreen(),
),
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
],
);
});
redirect 로직 상세 분석
1. 인증 상태 확인:
final authState = ref.watch(authStateProvider);
final isLoggedIn = authState.value != null;
authStateProvider
를 watch해서 실시간 감지
- 로그인 상태가 변경되면 자동으로 재평가
2. 현재 위치 확인:
final isLoggingIn = state.matchedLocation == '/login' ||
state.matchedLocation == '/signup';
- 현재 로그인/회원가입 페이지인지 확인
3. 리다이렉트 로직:
상태 | 현재 위치 | 동작 |
비로그인 | 로그인 페이지 | 그대로 유지 |
비로그인 | 다른 페이지 | /login 으로 이동 |
로그인 | 로그인 페이지 | / (홈)으로 이동 |
로그인 | 다른 페이지 | 그대로 유지 |
4. 자동 리다이렉트 예시:
시나리오 1: 로그인 성공
1. 로그인 완료
2. authState 변경 감지
3. redirect 실행
4. 로그인 상태 + 로그인 페이지 = 홈으로 이동
시나리오 2: 로그아웃
1. 로그아웃 완료
2. authState 변경 감지
3. redirect 실행
4. 비로그인 상태 + 홈 = 로그인 페이지로 이동
go_router 네비게이션 방법
context.push() vs context.go():
// push: 스택에 추가 (뒤로가기 가능)
context.push('/signup');
// go: 현재 화면 대체 (뒤로가기 불가)
context.go('/home');
// pop: 뒤로가기
context.pop();
사용 예시:
// 로그인 → 회원가입
TextButton(
onPressed: () => context.push('/signup'),
// 뒤로가기로 로그인 화면 복귀 가능
)
// 회원가입 완료 후
context.pop(); // 로그인 화면으로
6️⃣ main.dart 라우터 적용
위치:
lib/main.dart
MaterialApp.router 사용
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(goRouterProvider);
return MaterialApp.router(
title: 'Bookmark Manager',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
routerConfig: router,
debugShowCheckedModeBanner: false,
);
}
}
변경사항:
MaterialApp
→MaterialApp.router
routerConfig
에 goRouter 연결
StatelessWidget
→ConsumerWidget
(Riverpod 사용)
7️⃣ 홈 화면 (임시) 구현
위치:
lib/features/bookmarks/screens/home_screen.dart
기본 구조
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authService = ref.read(authServiceProvider);
final currentUser = authService.currentUser;
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.myBookmarks),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await authService.signOut();
// go_router가 자동으로 로그인 화면으로 리다이렉트
},
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('환영합니다!'),
Text(currentUser?.email ?? ''),
Text(AppStrings.noBookmarks),
],
),
),
);
}
}
기능:
- 사용자 이메일 표시
- 로그아웃 버튼
- 북마크 추가 플로팅 버튼 (다음 단계 구현 예정)
🧪 테스트 시나리오
1. 회원가입 플로우
1. 앱 실행
↓
2. 로그인 화면 표시 (자동)
↓
3. "계정이 없으신가요? 회원가입" 클릭
↓
4. 회원가입 화면 이동
↓
5. 이메일/비밀번호 입력
- test@example.com
- password123
↓
6. "회원가입" 버튼 클릭
↓
7. ✅ "회원가입 성공!" 토스트
↓
8. 로그인 화면으로 자동 복귀
2. 로그인 플로우
1. 로그인 화면
↓
2. 이메일/비밀번호 입력
↓
3. "로그인" 버튼 클릭
↓
4. ✅ "로그인 성공!" 토스트
↓
5. 홈 화면으로 자동 이동
↓
6. 사용자 이메일 표시 확인
3. 로그아웃 플로우
1. 홈 화면에서 로그아웃 버튼 클릭
↓
2. Firebase 로그아웃 실행
↓
3. authState 변경 감지
↓
4. ✅ 로그인 화면으로 자동 리다이렉트
4. 유효성 검사 테스트
이메일 검증:
- ❌ 빈 값: "모든 필드를 입력해주세요"
- ❌
@
없음: "유효하지 않은 이메일입니다"
- ✅
test@example.com
: 통과
비밀번호 검증:
- ❌ 빈 값: "모든 필드를 입력해주세요"
- ❌ 5자 이하: "비밀번호는 최소 6자 이상이어야 합니다"
- ✅
password123
: 통과
비밀번호 확인 (회원가입):
- ❌ 불일치: "비밀번호가 일치하지 않습니다"
- ✅ 일치: 통과
5. Firebase 에러 처리 테스트
이미 존재하는 이메일:
1. 이미 가입된 이메일로 회원가입 시도
↓
2. ❌ "이미 사용 중인 이메일입니다" 토스트
잘못된 비밀번호:
1. 올바른 이메일 + 잘못된 비밀번호
↓
2. ❌ "잘못된 비밀번호입니다" 토스트
존재하지 않는 사용자:
1. 가입하지 않은 이메일로 로그인 시도
↓
2. ❌ "사용자를 찾을 수 없습니다" 토스트
🔍 Firebase Console 확인
Authentication 탭에서 확인 가능한 정보
- Users 목록:
- UID (고유 식별자)
- 이메일
- 생성일
- 마지막 로그인
- 로그인 제공업체
- 활동 로그:
- 로그인 시도
- 성공/실패 기록
- IP 주소
- 보안 설정:
- 비밀번호 강도 설정
- 이메일 인증 활성화
- 계정 복구 옵션
📊 최종 프로젝트 구조
lib/
├── main.dart ✅
├── firebase_options.dart ✅
│
├── core/
│ └── constants/
│ ├── app_colors.dart ✅
│ └── app_strings.dart ✅
│
├── features/
│ ├── auth/
│ │ ├── models/
│ │ │ └── user_model.dart ✅
│ │ ├── screens/
│ │ │ ├── login_screen.dart ✅
│ │ │ └── signup_screen.dart ✅
│ │ └── services/
│ │ ├── auth_service.dart ✅
│ │ └── auth_provider.dart ✅
│ │
│ └── bookmarks/
│ ├── models/
│ │ └── bookmark.dart ✅
│ └── screens/
│ └── home_screen.dart ✅
│
└── routes/
└── app_router.dart ✅
💡 구현하면서 배운 점
1. Service Layer 분리의 중요성
Before: UI에 Firebase 로직 직접 작성
// ❌ 화면에 직접 작성
await FirebaseAuth.instance.signInWithEmailAndPassword(...);
After: Service로 분리
// ✅ Service 사용
final authService = ref.read(authServiceProvider);
await authService.signInWithEmail(...);
장점:
- UI와 비즈니스 로직 분리
- 테스트 용이
- 재사용성 증가
- 에러 처리 일원화
2. Riverpod의 실시간 상태 관리
StreamProvider의 강력함:
final authStateProvider = StreamProvider<User?>((ref) {
return authService.authStateChanges;
});
로그인/로그아웃 시:
- Firebase 상태 변경
- Stream이 새 값 emit
- Provider가 자동 업데이트
- UI가 자동 리빌드
- go_router의 redirect가 자동 실행
직접 관리할 필요 없음 - 모든 것이 자동!
3. go_router의 선언적 라우팅
기존 Navigator 방식의 문제:
// ❌ 명령형, 복잡함
if (isLoggedIn) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => HomeScreen()),
);
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => LoginScreen()),
);
}
go_router 방식:
// ✅ 선언적, 간단함
redirect: (context, state) {
if (!isLoggedIn && !isLoggingIn) return '/login';
if (isLoggedIn && isLoggingIn) return '/';
return null;
}
장점:
- 라우팅 로직이 한 곳에 집중
- 인증 상태 변경 시 자동 리다이렉트
- URL 기반 라우팅 (웹 친화적)
4. Form 유효성 검사의 중요성
사용자 경험 개선:
validator: (value) {
if (value == null || value.isEmpty) {
return AppStrings.fieldsRequired;
}
if (!value.contains('@')) {
return AppStrings.invalidEmail;
}
return null;
}
효과:
- Firebase 요청 전 클라이언트에서 검증
- 불필요한 네트워크 요청 감소
- 즉각적인 피드백
- 사용자 친화적
5. 에러 처리의 세심함
mounted 체크의 중요성:
if (mounted) {
setState(() => _isLoading = false);
}
왜 필요한가?:
- 비동기 작업 중 화면이 닫힐 수 있음
- 닫힌 화면에 setState 호출 시 에러
- 앱 크래시 방지
Firebase 에러 메시지 한글화:
String _handleAuthException(FirebaseAuthException e) {
switch (e.code) {
case 'weak-password':
return '비밀번호가 너무 약합니다.';
// ...
}
}
효과:
- 사용자가 이해하기 쉬운 메시지
- 더 나은 사용자 경험
🐛 트러블슈팅
문제 1: Navigator.onGenerateRoute 에러
에러 메시지:
Navigator.onGenerateRoute was null, but the route named "/signup" was referenced.
원인:
// ❌ go_router 사용 중인데 Navigator 사용
Navigator.pushNamed(context, '/signup');
해결:
// ✅ go_router 방식 사용
context.push('/signup');
교훈: 라우팅 패키지를 사용할 때는 일관되게 해당 패키지의 API 사용
문제 2: 로그인 후 화면 전환 안 됨
원인: go_router의 redirect 로직 오류
해결:
// authStateProvider를 watch
final authState = ref.watch(authStateProvider);
// redirect 로직 개선
redirect: (context, state) {
final isLoggedIn = authState.value != null;
// ...
}
포인트: Provider를 watch해야 상태 변경 감지
📈 성능 최적화 포인트
1. TextEditingController dispose
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
왜 중요한가?:
- Controller는 리소스를 점유
- dispose하지 않으면 메모리 누수
- 앱 성능 저하 원인
2. const 생성자 사용
const Icon(Icons.email) // ✅
Icon(Icons.email) // ❌
효과:
- 컴파일 타임에 객체 생성
- 런타임 오버헤드 감소
- 불필요한 재빌드 방지
3. Provider의 적절한 사용
// ❌ 매번 새로운 인스턴스
ref.read(authServiceProvider)
// ✅ watch: 변경 감지 필요할 때
ref.watch(authStateProvider)
// ✅ read: 한 번만 읽을 때
ref.read(authServiceProvider)
🎨 UI/UX 개선 포인트
1. 로딩 인디케이터
_isLoading
? const CircularProgressIndicator(...)
: Text(AppStrings.login)
효과:
- 사용자에게 진행 상황 표시
- 중복 클릭 방지
- 신뢰감 향상
2. 비밀번호 표시/숨김
suffixIcon: IconButton( icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility), onPressed: () { setState(() => _obscurePassword = !_obscurePassword); }, )
효과:
- 사용자 편의성
- 비밀번호 확인 용이
3. Toast 메시지
Fluttertoast.showToast( msg: AppStrings.loginSuccess, backgroundColor: AppColors.success, );
장점:
- 방해하지 않는 피드백
- 자동으로 사라짐
- 색상으로 성공/실패 구분
4. 라운드 디자인
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
효과:
- 모던한 디자인
- Material Design 3 스타일
- 시각적 통일성
🔒 보안 고려사항
1. 비밀번호 최소 길이
if (value.length < 6) {
return AppStrings.passwordTooShort;
}
이유: Firebase의 최소 요구사항 준수
2. 이메일 trim 처리
email: _emailController.text.trim(),
이유: 공백으로 인한 로그인 실패 방지
3. 에러 메시지 노출 제한
// ❌ 보안 위험
return 'Database error: ${e.message}';
// ✅ 일반적 메시지
return '로그인 중 오류가 발생했습니다';
이유: 시스템 정보 노출 방지
📝 코드 품질 개선
1. 상수 사용
// ✅ 상수 사용
Text(AppStrings.login)
// ❌ 하드코딩
Text('로그인')
장점:
- 오타 방지
- 일관성 유지
- 다국어 대응 준비
2. 색상 관리
// ✅ 색상 상수
backgroundColor: AppColors.primary
// ❌ 직접 지정
backgroundColor: Color(0xFF2196F3)
장점:
- 디자인 통일성
- 색상 변경 시 한 곳만 수정
3. 파일 구조화
features/
auth/
models/ # 데이터
screens/ # UI
services/ # 로직
장점:
- 찾기 쉬움
- 유지보수 편함
- 확장 용이
🎯 다음 단계 예고
#4편에서 다룰 내용
1. Firestore 연동:
- Bookmark Service 구현
- CRUD Provider 작성
2. 북마크 목록 화면:
- 실시간 북마크 목록 표시
- StreamProvider로 자동 업데이트
- 카테고리별 필터링
3. 북마크 추가 화면:
- URL 입력 및 검증
- 메타데이터 자동 추출 (선택)
- 카테고리 설정
4. 북마크 수정/삭제:
- 상세 화면
- 수정 다이얼로그
- 삭제 확인
5. 검색 기능:
- 북마크 검색
- 실시간 필터링
📦 현재 패키지 목록
dependencies:
flutter:
sdk: flutter
# Firebase
firebase_core: ^4.1.1
firebase_auth: ^6.1.0
cloud_firestore: ^6.0.2
# State Management
flutter_riverpod: ^3.0.3
# Routing
go_router: ^16.2.4
# Utilities
url_launcher: ^6.3.2
flutter_spinkit: ^5.2.2
fluttertoast: ^9.0.0
🎓 학습 정리
이번 글에서 배운 것
기술적 학습:
- ✅ Firebase Authentication 연동
- ✅ Riverpod 상태 관리 실전 적용
- ✅ go_router 인증 기반 라우팅
- ✅ Form 유효성 검사
- ✅ 비동기 에러 처리
- ✅ StreamProvider 활용
아키텍처 학습:
- ✅ Service Layer 분리
- ✅ Provider 계층 구조
- ✅ 선언적 라우팅
- ✅ 상태 중심 설계
실무 스킬:
- ✅ 사용자 친화적 에러 메시지
- ✅ 로딩 상태 관리
- ✅ 메모리 누수 방지
- ✅ 보안 고려사항
💭 회고
잘한 점
- 체계적인 폴더 구조로 확장성 확보
- Riverpod으로 상태 관리 자동화
- 에러 처리를 세심하게 구현
- 사용자 경험 고려한 UI
개선할 점
- 테스트 코드 작성 (다음 단계에서 추가 예정)
- 로딩 상태 전역 관리 고려
- 에러 핸들링 더 세분화
느낀 점
Firebase와 Riverpod의 조합이 정말 강력했습니다. 특히 StreamProvider를 통한 실시간 상태 관리는 마법 같았어요. 로그인하면 자동으로 홈으로, 로그아웃하면 자동으로 로그인 화면으로 - 이 모든 게 선언적으로 처리됩니다!
처음에는 Riverpod이 어렵게 느껴졌지만, 직접 사용해보니 Provider보다 훨씬 직관적이고 안전하다는 것을 알게 되었습니다. 타입 안전성 덕분에 실수를 사전에 방지할 수 있었어요.
🎉 마무리
드디어 사용자가 로그인하고 회원가입할 수 있는 앱이 완성되었습니다! 🎊
다음 편에서는 본격적으로 북마크를 추가하고 관리하는 핵심 기능을 구현하겠습니다. Firestore와의 실시간 연동, 아름다운 UI, 그리고 실용적인 기능들이 추가될 예정입니다!
Share article