Flutter: 첫 번째 크로스 플랫폼 앱 구축하기
Flutter와 Dart를 사용한 크로스 플랫폼 모바일 애플리케이션 구축 완전 가이드. Widget, 상태 관리, 내비게이션, 초보자를 위한 모범 사례를 다룹니다.

Flutter는 단일 코드베이스에서 iOS와 Android 애플리케이션을 구축할 수 있게 하여 모바일 개발에 혁명을 일으키고 있습니다. Google이 개발한 이 프레임워크는 Skia 렌더링 엔진과 선언적 Widget 시스템을 통해 네이티브 수준의 성능과 뛰어난 생산성을 결합합니다. 본 가이드에서는 설치부터 프로덕션 모범 사례까지 애플리케이션 구축의 전체 과정을 다룹니다.
Flutter 3.27에는 중요한 개선 사항이 도입되었습니다. Material 3 네이티브 지원이 기본으로 활성화되고, 최적화된 Impeller 애니메이션이 추가되었으며, 실험적 매크로를 포함한 Dart 3.6과의 통합이 이루어졌습니다. 프레임워크는 최적의 성능을 위해 ARM64 네이티브로 컴파일됩니다.
환경 설정 및 설치
Flutter 설정에는 몇 가지 구성 단계가 필요합니다. Flutter SDK에는 프레임워크, 빌드 도구, Dart 패키지 매니저 등 필요한 모든 것이 포함되어 있습니다.
# terminal
# Download Flutter SDK (macOS/Linux)
git clone https://github.com/flutter/flutter.git -b stable
export PATH="$PATH:`pwd`/flutter/bin"
# Verify installation and dependencies
flutter doctor
# Create a new project
flutter create --org com.example my_app
cd my_app
# Run in development mode
flutter runflutter doctor 명령어는 Android Studio, Xcode(macOS), 에뮬레이터 설정 등 모든 종속성이 설치되었는지 확인합니다.
# pubspec.yaml
name: my_app
description: Cross-platform Flutter application
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.6.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# UI and design
cupertino_icons: ^1.0.8
google_fonts: ^6.2.1
# State management
flutter_riverpod: ^2.6.1
# Navigation
go_router: ^14.6.2
# HTTP and API
dio: ^5.7.0
# Local storage
shared_preferences: ^2.3.4
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true이 pubspec.yaml 파일은 최신 Flutter 애플리케이션에 필요한 종속성을 구성합니다.
Flutter 프로젝트 아키텍처
명확한 프로젝트 구조는 애플리케이션의 유지보수와 확장을 용이하게 합니다. 이 구성은 책임을 명확하게 분리합니다.
lib/
├── main.dart # Entry point
├── app.dart # App configuration
├── core/
│ ├── constants/ # Colors, dimensions, strings
│ ├── theme/ # Material 3 theme
│ └── utils/ # Utility functions
├── features/
│ ├── auth/ # Authentication feature
│ │ ├── data/ # Repositories, data sources
│ │ ├── domain/ # Models, use cases
│ │ └── presentation/ # Screens, widgets, providers
│ └── home/ # Home feature
│ ├── data/
│ ├── domain/
│ └── presentation/
├── shared/
│ ├── widgets/ # Reusable widgets
│ └── providers/ # Shared providers
└── routing/
└── app_router.dart # Route configuration"Feature-First" 아키텍처는 하나의 기능과 관련된 모든 코드를 같은 폴더에 그룹화하여 탐색과 리팩토링을 용이하게 합니다.
Feature-First 접근 방식은 기술적 유형이 아닌 비즈니스 기능별로 코드를 구성합니다. 각 Feature는 자체 모델, Widget, 로직을 포함하여 코드의 모듈성과 테스트 용이성을 높입니다.
Flutter Widget 이해하기
Widget은 Flutter의 기본 구성 요소입니다. 버튼, 텍스트, 레이아웃, 심지어 애플리케이션 자체까지 모든 것이 Widget입니다. Flutter는 두 가지 유형의 Widget을 제공합니다: StatelessWidget(상태 없음)과 StatefulWidget(로컬 상태 보유).
import 'package:flutter/material.dart';
/// Custom reusable button widget throughout the application.
/// Automatically handles loading and disabled states.
class CustomButton extends StatelessWidget {
// Required and optional widget parameters
final String label;
final VoidCallback? onPressed;
final bool isLoading;
final bool isOutlined;
// Constructor with named parameters for clarity
const CustomButton({
super.key,
required this.label,
this.onPressed,
this.isLoading = false,
this.isOutlined = false,
});
Widget build(BuildContext context) {
// Access theme for consistent styles
final theme = Theme.of(context);
// Conditional build based on button type
if (isOutlined) {
return OutlinedButton(
// Disable button during loading
onPressed: isLoading ? null : onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _buildChild(theme),
);
}
return FilledButton(
onPressed: isLoading ? null : onPressed,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _buildChild(theme),
);
}
/// Builds button content with loading state handling.
Widget _buildChild(ThemeData theme) {
if (isLoading) {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
);
}
return Text(label);
}
}이 Widget은 로딩 상태의 자동 처리를 포함한 버튼 표시 로직을 캡슐화합니다.
import 'package:flutter/material.dart';
import '../../features/auth/domain/models/user.dart';
/// Card displaying user information.
/// Uses Material 3 with consistent elevation and shape.
class UserCard extends StatelessWidget {
final User user;
final VoidCallback? onTap;
const UserCard({
super.key,
required this.user,
this.onTap,
});
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Material 3 Card with InkWell for ripple effect
return Card(
// Adaptive elevation based on theme
elevation: 2,
// Consistent rounded shape
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Clipping so ripple respects borders
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Avatar with image or initials
CircleAvatar(
radius: 28,
backgroundImage: user.avatarUrl != null
? NetworkImage(user.avatarUrl!)
: null,
child: user.avatarUrl == null
? Text(user.initials)
: null,
),
const SizedBox(width: 16),
// User information
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
user.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
// Navigation icon
Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
);
}
}Widget 합성을 통해 단순하고 재사용 가능한 블록으로부터 복잡한 인터페이스를 구축할 수 있습니다.
Riverpod를 활용한 상태 관리
Riverpod는 Flutter에서 상태 관리를 위한 현대적인 솔루션입니다. 이 라이브러리는 Widget 간에 상태를 공유하기 위한 선언적이고 타입 안전하며 테스트 가능한 접근 방식을 제공합니다.
/// Immutable user model with fromJson factory.
class User {
final String id;
final String email;
final String displayName;
final String? avatarUrl;
final DateTime createdAt;
const User({
required this.id,
required this.email,
required this.displayName,
this.avatarUrl,
required this.createdAt,
});
/// Generates initials from display name.
String get initials {
final parts = displayName.split(' ');
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return displayName.substring(0, 2).toUpperCase();
}
/// Creates instance from JSON (API response).
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
email: json['email'] as String,
displayName: json['display_name'] as String,
avatarUrl: json['avatar_url'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
/// Converts to JSON for API submission.
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'display_name': displayName,
'avatar_url': avatarUrl,
'created_at': createdAt.toIso8601String(),
};
}
}import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/auth_repository.dart';
import '../../domain/models/user.dart';
/// Authentication state representing different possible cases.
sealed class AuthState {
const AuthState();
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthLoading extends AuthState {
const AuthLoading();
}
class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
class AuthUnauthenticated extends AuthState {
const AuthUnauthenticated();
}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
}
/// Provider for authentication repository.
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository();
});
/// Main provider managing authentication state.
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final repository = ref.watch(authRepositoryProvider);
return AuthNotifier(repository);
});
/// Notifier handling authentication logic.
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AuthInitial()) {
// Check initial state on startup
checkAuthStatus();
}
/// Checks if a user is already logged in.
Future<void> checkAuthStatus() async {
state = const AuthLoading();
try {
final user = await _repository.getCurrentUser();
if (user != null) {
state = AuthAuthenticated(user);
} else {
state = const AuthUnauthenticated();
}
} catch (e) {
state = const AuthUnauthenticated();
}
}
/// Signs in user with email and password.
Future<void> signIn(String email, String password) async {
state = const AuthLoading();
try {
final user = await _repository.signIn(email, password);
state = AuthAuthenticated(user);
} catch (e) {
state = AuthError(e.toString());
}
}
/// Creates a new user account.
Future<void> signUp(String email, String password, String displayName) async {
state = const AuthLoading();
try {
final user = await _repository.signUp(email, password, displayName);
state = AuthAuthenticated(user);
} catch (e) {
state = AuthError(e.toString());
}
}
/// Signs out the user.
Future<void> signOut() async {
state = const AuthLoading();
await _repository.signOut();
state = const AuthUnauthenticated();
}
}Sealed Class 패턴(Dart 3.0 이상)은 Switch 표현식을 사용할 때 모든 상태 케이스가 처리되도록 보장합니다.
Flutter 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
GoRouter를 활용한 내비게이션
GoRouter는 Flutter에서의 선언적 내비게이션을 간소화합니다. 이 라이브러리는 라우트, 매개변수, 리다이렉트, 중첩 내비게이션을 관리합니다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/auth/presentation/providers/auth_provider.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/auth/presentation/screens/register_screen.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../features/home/presentation/screens/profile_screen.dart';
import '../shared/widgets/shell_scaffold.dart';
/// Provider for router with authentication handling.
final routerProvider = Provider<GoRouter>((ref) {
// Listen to authentication state changes
final authState = ref.watch(authProvider);
return GoRouter(
// Initial route
initialLocation: '/',
// Refresh router when auth changes
refreshListenable: GoRouterRefreshStream(ref, authProvider),
// Handle redirects based on authentication
redirect: (context, state) {
final isAuthenticated = authState is AuthAuthenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
// Not authenticated on protected route → login
if (!isAuthenticated && !isAuthRoute) {
return '/auth/login';
}
// Authenticated on auth route → home
if (isAuthenticated && isAuthRoute) {
return '/';
}
return null; // No redirect
},
routes: [
// Auth routes (without shell)
GoRoute(
path: '/auth/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
name: 'register',
builder: (context, state) => const RegisterScreen(),
),
// Protected routes with shell (bottom navigation)
ShellRoute(
builder: (context, state, child) {
return ShellScaffold(child: child);
},
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/profile/:userId',
name: 'userProfile',
builder: (context, state) {
// Extract route parameter
final userId = state.pathParameters['userId']!;
return ProfileScreen(userId: userId);
},
),
],
),
],
// Custom error page
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Text('Page not found: ${state.error}'),
),
),
);
});
/// Stream to trigger router refresh.
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Ref ref, StateNotifierProvider provider) {
ref.listen(provider, (previous, next) {
notifyListeners();
});
}
}인증 상태에 기반한 자동 리다이렉트를 통해 보호된 라우트가 인증되지 않은 사용자로부터 접근 불가능하도록 보장합니다.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Scaffold with bottom navigation bar for protected routes.
class ShellScaffold extends StatelessWidget {
final Widget child;
const ShellScaffold({
super.key,
required this.child,
});
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
// Determine active index based on route
selectedIndex: _calculateSelectedIndex(context),
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
/// Calculates navigation index based on current route.
int _calculateSelectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/profile')) return 1;
return 0;
}
/// Navigates to route corresponding to index.
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0:
context.goNamed('home');
case 1:
context.goNamed('profile');
}
}
}Material 3의 NavigationBar는 라이트 테마와 다크 테마에 자동으로 적응합니다.
화면과 폼
인터랙티브 화면 생성은 Widget, 상태 관리, 폼 유효성 검사를 결합하여 이루어집니다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../../../../shared/widgets/custom_button.dart';
/// Login screen with validated form.
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
// Global key for the form
final _formKey = GlobalKey<FormState>();
// Controllers for text fields
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
// Local state for password visibility
bool _obscurePassword = true;
void dispose() {
// Resource cleanup
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
/// Submits the login form.
Future<void> _submit() async {
// Validate all fields
if (_formKey.currentState!.validate()) {
await ref.read(authProvider.notifier).signIn(
_emailController.text.trim(),
_passwordController.text,
);
}
}
Widget build(BuildContext context) {
final theme = Theme.of(context);
final authState = ref.watch(authProvider);
final isLoading = authState is AuthLoading;
// Listen for errors to display snackbar
ref.listen<AuthState>(authProvider, (previous, next) {
if (next is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.message),
backgroundColor: theme.colorScheme.error,
),
);
}
});
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo or title
Icon(
Icons.flutter_dash,
size: 80,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Sign In',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Email field with validation
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'example@email.com',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
// Basic email format validation
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(value)) {
return 'Invalid email format';
}
return null;
},
),
const SizedBox(height: 16),
// Password field with visibility toggle
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Minimum 8 characters';
}
return null;
},
),
const SizedBox(height: 24),
// Sign in button
CustomButton(
label: 'Sign In',
isLoading: isLoading,
onPressed: _submit,
),
const SizedBox(height: 16),
// Link to registration
TextButton(
onPressed: () => context.goNamed('register'),
child: const Text('No account? Create one'),
),
],
),
),
),
),
),
);
}
}ConsumerStatefulWidget의 사용으로 로컬 상태(폼 컨트롤러)와 글로벌 상태(Riverpod)가 통합됩니다.
TextEditingController 인스턴스는 메모리 누수를 방지하기 위해 dispose() 메서드에서 해제해야 합니다. 이 규칙은 수동으로 생성된 모든 Controller와 Listener에 적용됩니다.
Dio를 활용한 HTTP 통신
Dio는 인터셉터, 에러 처리, 응답 자동 변환을 갖춘 강력한 HTTP API를 제공합니다.
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../constants/api_constants.dart';
/// Provider for Dio HTTP client.
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
/// HTTP client configured with interceptors.
class ApiClient {
late final Dio _dio;
ApiClient() {
_dio = Dio(
BaseOptions(
baseUrl: ApiConstants.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
// Add interceptors
_dio.interceptors.addAll([
_AuthInterceptor(),
_LoggingInterceptor(),
]);
}
/// Generic GET request.
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
T Function(dynamic)? fromJson,
}) async {
try {
final response = await _dio.get(
path,
queryParameters: queryParameters,
);
if (fromJson != null) {
return fromJson(response.data);
}
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic POST request.
Future<T> post<T>(
String path, {
dynamic data,
T Function(dynamic)? fromJson,
}) async {
try {
final response = await _dio.post(path, data: data);
if (fromJson != null) {
return fromJson(response.data);
}
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Sets the authentication token.
void setAuthToken(String token) {
_dio.options.headers['Authorization'] = 'Bearer $token';
}
/// Clears the authentication token.
void clearAuthToken() {
_dio.options.headers.remove('Authorization');
}
/// Converts Dio errors to readable exceptions.
Exception _handleError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return Exception('Connection timeout. Check your connection.');
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
final message = error.response?.data['message'] ?? 'Server error';
return Exception('Error $statusCode: $message');
case DioExceptionType.cancel:
return Exception('Request cancelled');
default:
return Exception('Network error: ${error.message}');
}
}
}
/// Interceptor to automatically add token.
class _AuthInterceptor extends Interceptor {
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
// Token is added via setAuthToken()
handler.next(options);
}
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
// Handle 401 for token refresh
if (err.response?.statusCode == 401) {
// TODO: Implement token refresh
}
handler.next(err);
}
}
/// Logging interceptor for development.
class _LoggingInterceptor extends Interceptor {
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
print('→ ${options.method} ${options.path}');
handler.next(options);
}
void onResponse(
Response response,
ResponseInterceptorHandler handler,
) {
print('← ${response.statusCode} ${response.requestOptions.path}');
handler.next(response);
}
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
print('✗ ${err.response?.statusCode} ${err.requestOptions.path}');
handler.next(err);
}
}인터셉터를 통해 각 요청을 오염시키지 않으면서 인증과 로깅 로직을 중앙 집중화합니다.
Material 3 커스텀 테마
일관된 테마는 통일된 사용자 경험을 보장합니다. Material 3(Material You)는 시스템 설정에 적응합니다.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Application theme configuration.
class AppTheme {
// Brand primary color
static const _primaryColor = Color(0xFF6750A4);
/// Material 3 light theme.
static ThemeData get lightTheme {
// Generate color scheme from primary color
final colorScheme = ColorScheme.fromSeed(
seedColor: _primaryColor,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
// Custom typography
textTheme: GoogleFonts.interTextTheme(),
// AppBar configuration
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
),
// Form field configuration
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
// Button configuration
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
// Card configuration
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
);
}
/// Material 3 dark theme.
static ThemeData get darkTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: _primaryColor,
brightness: Brightness.dark,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData.dark().textTheme,
),
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
);
}
}ColorScheme.fromSeed는 단일 색상으로부터 완전하고 접근성 있는 색상 팔레트를 자동으로 생성합니다.
애플리케이션 구성
애플리케이션의 진입점은 Riverpod를 초기화하고 테마를 구성합니다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
// Ensure Flutter bindings are initialized
WidgetsFlutterBinding.ensureInitialized();
// Launch application with Riverpod scope
runApp(
const ProviderScope(
child: App(),
),
);
}import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/app_theme.dart';
import 'routing/app_router.dart';
/// Root application widget.
class App extends ConsumerWidget {
const App({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// Get configured router
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'My Flutter App',
debugShowCheckedModeBanner: false,
// Theme configuration
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
// Router configuration
routerConfig: router,
);
}
}ThemeMode.system은 사용자 설정에 맞춰 테마를 자동으로 적응시킵니다.
결론
Flutter는 고성능 크로스 플랫폼 애플리케이션을 구축하기 위한 완전한 생태계를 제공합니다. 선언적 Widget, 상태 관리를 위한 Riverpod, 내비게이션을 위한 GoRouter의 조합으로 유지보수성과 확장성이 뛰어난 애플리케이션을 구축할 수 있습니다.
Flutter 시작을 위한 체크리스트
- ✅ Flutter SDK를 설치하고
flutter doctor로 환경을 설정합니다 - ✅ Feature 단위로 프로젝트를 구성하여 정리합니다
- ✅ 타입 안전한 상태 관리를 위해 Riverpod를 사용합니다
- ✅ 인증 리다이렉트가 포함된 GoRouter를 구성합니다
- ✅ 재사용 가능하고 합성 가능한 Widget을 생성합니다
- ✅ 일관된 Material 3 테마를 구현합니다
- ✅ Dio와 인터셉터로 HTTP 호출을 중앙 집중화합니다
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
Flutter의 선언적 접근 방식은 합성과 재사용을 촉진합니다. 각 Widget은 독립적으로 테스트할 수 있으며, Hot Reload는 개발 주기를 크게 가속화합니다. 이 견고한 기반을 통해 복잡한 애니메이션, 자동화된 테스트, 스토어 배포 등 고급 기능으로 애플리케이션을 확장할 수 있습니다.
태그
공유
관련 기사

모바일 개발자를 위한 Flutter 면접 질문 20선
Flutter 면접에서 가장 자주 출제되는 20가지 질문을 준비하십시오. Widget, 상태 관리, Dart, 아키텍처, 모범 사례를 상세한 코드 예제와 함께 설명합니다.

2026년 Flutter 상태 관리 완벽 가이드: Riverpod vs Bloc vs GetX 비교 분석
Riverpod 3.0, Bloc 9.0, GetX 세 가지 Flutter 상태 관리 솔루션을 코드 예제, 성능 분석, 테스트 전략 관점에서 비교 분석합니다.

React Native: 2026년 완성도 높은 모바일 앱 구축하기
React Native를 활용한 iOS, Android 모바일 앱 개발 실전 가이드입니다. 환경 설정부터 스토어 배포까지 핵심 기초를 모두 다룹니다.