Flutter 튜토리얼 22편: 테마와 다크 모드

요약#

핵심 요지#

  • 문제 정의: 앱 전체에서 일관된 색상과 스타일을 유지하려면 각 위젯에 개별적으로 스타일을 적용하는 것은 비효율적이다.
  • 핵심 주장: ThemeData1ColorScheme2을 사용하면 앱 전체의 시각적 일관성을 쉽게 유지하고 다크 모드도 구현할 수 있다.
  • 주요 근거: MaterialAppthemedarkTheme 속성으로 라이트/다크 테마를 정의하고, themeMode3로 전환한다.
  • 실무 기준: Material 3가 Flutter 3.16부터 기본값이므로 ColorScheme.fromSeed()로 테마를 생성하는 것을 권장한다.
  • 한계: 복잡한 커스텀 디자인 시스템은 기본 테마만으로 구현하기 어려울 수 있다.

문서가 설명하는 범위#

  • ThemeData와 ColorScheme의 구조와 사용법
  • 라이트/다크 테마 정의와 전환
  • Theme.of(context)로 테마 값 접근
  • 위젯별 테마 오버라이드

읽는 시간: 12분 | 난이도: 중급


참고 자료#


문제 상황#

앱을 만들 때 모든 화면에서 일관된 색상, 폰트, 스타일을 유지해야 합니다. 개별 위젯마다 스타일을 지정하면 여러 문제가 발생합니다.

스타일 관리의 어려움#

문제 1: 같은 색상을 여러 곳에서 하드코딩 → 변경 시 모든 곳을 수정해야 함
문제 2: 다크 모드 지원 → 조건문이 곳곳에 퍼짐
문제 3: 디자인 변경 → 전체 코드 수정 필요
문제 4: 일관성 유지 어려움 → 실수로 다른 색상 사용

해결 방법#

Flutter의 테마 시스템은 앱 전체에서 공유하는 색상과 스타일을 중앙에서 관리합니다. ThemeData로 테마를 정의하고 Theme.of(context)로 어디서든 접근할 수 있습니다.

챕터 1: ThemeData 기본 구조 이해하기#

Why#

NOTE

테마를 사용하려면 먼저 ThemeData의 구조를 이해해야 합니다.
어떤 속성이 있고, 각 속성이 어떤 위젯에 영향을 미치는지 알아야 합니다.

ThemeData → 앱 전체 스타일 정의
ColorScheme → 색상 팔레트
TextTheme → 텍스트 스타일

What#

NOTE

ThemeData는 Flutter Material 앱의 전체적인 시각적 테마를 정의합니다.
색상, 타이포그래피, 위젯별 테마 등을 포함합니다.

How#

TIP

기본 ThemeData 구조

MaterialApp(
theme: ThemeData(
// Material 3 사용 (Flutter 3.16+ 기본값)
useMaterial3: true,
// 색상 팔레트
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
// 텍스트 스타일
textTheme: const TextTheme(
displayLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
bodyMedium: TextStyle(fontSize: 14),
),
// 위젯별 테마
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
)

주요 ThemeData 속성

속성설명
colorScheme앱의 색상 팔레트
textTheme텍스트 스타일 모음
appBarThemeAppBar 스타일
cardThemeCard 스타일
elevatedButtonThemeElevatedButton 스타일
inputDecorationThemeTextField 스타일
scaffoldBackgroundColorScaffold 배경색

Watch out#

WARNING

Flutter 3.16부터 Material 3가 기본값입니다.
이전 Material 2 디자인을 사용하려면 명시적으로 설정해야 합니다.

// Material 2 사용 (레거시)
ThemeData(
useMaterial3: false,
)
// Material 3 사용 (기본값, 권장)
ThemeData(
useMaterial3: true,
)

Material 2 지원은 향후 제거될 예정이므로 새 프로젝트는 Material 3를 사용하세요.

결론: ThemeData로 앱 전체의 시각적 테마를 중앙에서 정의합니다.


챕터 2: ColorScheme으로 색상 팔레트 정의#

Why#

NOTE

앱에서 사용하는 모든 색상을 일관되게 관리해야 합니다.
ColorScheme은 Material Design의 색상 시스템을 구현하며, 접근성을 고려한 대비색도 자동으로 제공합니다.

graph LR A[seedColor] --> B[ColorScheme 생성] B --> C[primary, secondary, surface 등] B --> D[onPrimary, onSecondary 등]

What#

NOTE

ColorScheme은 앱의 색상 팔레트를 정의합니다.
기본 색상(primary, secondary)과 그 위에 표시되는 요소의 색상(onPrimary, onSecondary)을 포함합니다.

How#

TIP

시드 색상으로 ColorScheme 생성

ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
)

Material 3의 색상 알고리즘이 시드 색상에서 조화로운 팔레트를 자동 생성합니다.

ColorScheme 주요 색상

// 테마 접근
final colorScheme = Theme.of(context).colorScheme;
// 주요 색상
colorScheme.primary // 주요 브랜드 색상
colorScheme.onPrimary // primary 위의 텍스트/아이콘 색상
colorScheme.primaryContainer // primary의 컨테이너 색상
// 보조 색상
colorScheme.secondary // 보조 색상
colorScheme.onSecondary // secondary 위의 색상
// 표면 색상
colorScheme.surface // Card, Dialog 등의 배경
colorScheme.onSurface // surface 위의 텍스트
// 배경색
colorScheme.background // 전체 배경 (deprecated in M3)
colorScheme.surfaceContainerLow // M3 배경 대체
// 에러 색상
colorScheme.error // 에러 표시 색상
colorScheme.onError // error 위의 색상

수동으로 ColorScheme 정의

ColorScheme(
brightness: Brightness.light,
primary: const Color(0xFF6750A4),
onPrimary: Colors.white,
primaryContainer: const Color(0xFFEADDFF),
onPrimaryContainer: const Color(0xFF21005D),
secondary: const Color(0xFF625B71),
onSecondary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
error: const Color(0xFFB3261E),
onError: Colors.white,
)

Watch out#

WARNING

onPrimary, onSurface 같은 “on” 색상을 사용해야 접근성이 보장됩니다.
대비를 직접 계산하지 마세요.

// 잘못된 방법 - 대비가 보장되지 않음
Container(
color: Theme.of(context).colorScheme.primary,
child: const Text('텍스트', style: TextStyle(color: Colors.white)),
)
// 올바른 방법 - 대비가 보장됨
Container(
color: Theme.of(context).colorScheme.primary,
child: Text(
'텍스트',
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),
),
)

결론: ColorScheme.fromSeed()로 시드 색상에서 조화로운 색상 팔레트를 자동 생성합니다.


챕터 3: 다크 모드 테마 구현#

Why#

NOTE

많은 사용자가 다크 모드를 선호합니다.
눈의 피로를 줄이고 배터리를 절약할 수 있습니다.
MaterialApp에서 라이트/다크 테마를 함께 정의할 수 있습니다.

시스템 설정 → themeMode: system
라이트 강제 → themeMode: light
다크 강제 → themeMode: dark

What#

NOTE

MaterialApptheme 속성에 라이트 테마를, darkTheme 속성에 다크 테마를 정의합니다.
themeMode로 어떤 테마를 사용할지 제어합니다.

How#

TIP

라이트/다크 테마 정의

MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: ThemeMode.system, // 시스템 설정 따라감
)

사용자가 테마 선택

class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.system;
void setThemeMode(ThemeMode mode) {
setState(() => _themeMode = mode);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
themeMode: _themeMode,
home: ThemeSettingsPage(
currentMode: _themeMode,
onThemeChanged: setThemeMode,
),
);
}
}
class ThemeSettingsPage extends StatelessWidget {
final ThemeMode currentMode;
final ValueChanged<ThemeMode> onThemeChanged;
const ThemeSettingsPage({
super.key,
required this.currentMode,
required this.onThemeChanged,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('테마 설정')),
body: ListView(
children: [
RadioListTile<ThemeMode>(
title: const Text('시스템 설정'),
value: ThemeMode.system,
groupValue: currentMode,
onChanged: (mode) => onThemeChanged(mode!),
),
RadioListTile<ThemeMode>(
title: const Text('라이트 모드'),
value: ThemeMode.light,
groupValue: currentMode,
onChanged: (mode) => onThemeChanged(mode!),
),
RadioListTile<ThemeMode>(
title: const Text('다크 모드'),
value: ThemeMode.dark,
groupValue: currentMode,
onChanged: (mode) => onThemeChanged(mode!),
),
],
),
);
}
}

Watch out#

WARNING

다크 테마에서 단순히 배경만 어둡게 하면 안 됩니다.
텍스트 대비, 그림자, 표면 색상 등을 모두 고려해야 합니다.

// ColorScheme.fromSeed는 다크 모드에 최적화된 색상을 자동 생성
ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark, // 다크 모드용 색상 생성
)

수동으로 ColorScheme을 정의할 때는 WCAG 대비 기준(4.5<1> 이상)을 확인하세요.

결론: theme과 darkTheme을 함께 정의하고 themeMode로 전환하면 다크 모드를 쉽게 구현할 수 있습니다.


챕터 4: Theme.of(context)로 테마 접근#

Why#

NOTE

정의한 테마를 위젯에서 사용하려면 Theme.of(context)로 접근합니다.
이 방식으로 어디서든 일관된 스타일을 적용할 수 있습니다.

Theme.of(context) → ThemeData
.colorScheme → 색상
.textTheme → 텍스트 스타일

What#

NOTE

Theme.of(context)는 가장 가까운 상위 테마를 반환합니다.
MaterialApp에서 정의한 테마 또는 Theme 위젯으로 오버라이드된 테마일 수 있습니다.

How#

TIP

색상 접근

Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
color: colorScheme.primaryContainer,
child: Text(
'제목',
style: TextStyle(color: colorScheme.onPrimaryContainer),
),
);
}

텍스트 스타일 접근

Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Column(
children: [
Text('큰 제목', style: textTheme.displayLarge),
Text('부제목', style: textTheme.titleMedium),
Text('본문', style: textTheme.bodyMedium),
],
);
}

스타일 확장 (copyWith)

Text(
'커스텀 스타일',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.red,
fontWeight: FontWeight.bold,
),
)

현재 테마 밝기 확인

Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Icon(
isDarkMode ? Icons.dark_mode : Icons.light_mode,
);
}

Watch out#

WARNING

Theme.of(context)는 BuildContext가 필요합니다.
initState()에서는 사용할 수 없습니다.

// 잘못된 사용 - initState에서 context 사용 불가
@override
void initState() {
super.initState();
final theme = Theme.of(context); // 에러!
}
// 올바른 사용 - didChangeDependencies에서 사용
@override
void didChangeDependencies() {
super.didChangeDependencies();
final theme = Theme.of(context); // OK
}
// 또는 build에서 사용 (권장)
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// ...
}

결론: Theme.of(context)로 테마에 접근하고 copyWith로 필요시 스타일을 확장합니다.


챕터 5: 위젯별 테마 오버라이드#

Why#

NOTE

특정 위젯이나 화면에서만 다른 테마를 적용해야 할 때가 있습니다.
Theme 위젯으로 하위 트리의 테마를 오버라이드할 수 있습니다.

상위 테마 → Theme 위젯 → 하위 위젯에 새 테마 적용

What#

NOTE

Theme 위젯은 하위 트리에 새로운 ThemeData를 제공합니다.
copyWith()를 사용하면 기존 테마를 유지하면서 일부만 변경할 수 있습니다.

How#

TIP

완전히 새로운 테마 적용

Theme(
data: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
),
child: Builder(
builder: (context) {
return FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
);
},
),
)

기존 테마 확장 (권장)

Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Colors.pink,
),
),
child: ElevatedButton(
onPressed: () {},
child: const Text('핑크 버튼'),
),
)

특정 위젯 테마만 오버라이드

Theme(
data: Theme.of(context).copyWith(
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
),
),
child: ElevatedButton(
onPressed: () {},
child: const Text('커스텀 버튼'),
),
)

입력 필드 테마 오버라이드

Theme(
data: Theme.of(context).copyWith(
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
filled: true,
fillColor: Colors.grey[100],
),
),
child: TextField(
decoration: const InputDecoration(
labelText: '둥근 입력 필드',
),
),
)

Watch out#

WARNING

Theme 위젯 바로 아래에서 Theme.of(context)를 호출하면 새 테마가 아닌 상위 테마를 참조합니다.
Builder나 별도 위젯을 사용해야 합니다.

// 잘못된 방법 - 상위 테마 참조
Theme(
data: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink)),
child: Text(
'텍스트',
// 이 context는 Theme 위젯 위의 context
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
)
// 올바른 방법 - Builder 사용
Theme(
data: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink)),
child: Builder(
builder: (context) {
// 이 context는 Theme 위젯 아래의 context
return Text(
'텍스트',
style: TextStyle(color: Theme.of(context).colorScheme.primary),
);
},
),
)

결론: Theme 위젯과 copyWith()로 특정 영역의 테마를 유연하게 오버라이드할 수 있습니다.


챕터 6: 테마 설정 저장하기#

Why#

NOTE

사용자가 선택한 테마 모드를 저장해야 앱을 다시 열어도 설정이 유지됩니다.
SharedPreferences4로 간단하게 저장할 수 있습니다.

사용자 선택 → SharedPreferences 저장 → 앱 재시작 → 설정 복원

What#

NOTE

SharedPreferences는 키-값 형태로 간단한 데이터를 저장하는 패키지입니다.
테마 모드 같은 설정값을 저장하기에 적합합니다.

How#

TIP

패키지 설치

Terminal window
flutter pub add shared_preferences

테마 설정 저장 및 복원

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeNotifier extends ChangeNotifier {
static const _key = 'theme_mode';
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
ThemeNotifier() {
_loadThemeMode();
}
Future<void> _loadThemeMode() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_key);
_themeMode = switch (value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
await prefs.setString(_key, value);
}
}

Provider와 함께 사용

import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => ThemeNotifier(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final themeNotifier = context.watch<ThemeNotifier>();
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
themeMode: themeNotifier.themeMode,
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final themeNotifier = context.read<ThemeNotifier>();
return Scaffold(
appBar: AppBar(title: const Text('테마 설정')),
body: ListView(
children: ThemeMode.values.map((mode) {
return RadioListTile<ThemeMode>(
title: Text(_themeModeLabel(mode)),
value: mode,
groupValue: themeNotifier.themeMode,
onChanged: (value) => themeNotifier.setThemeMode(value!),
);
}).toList(),
),
);
}
String _themeModeLabel(ThemeMode mode) {
return switch (mode) {
ThemeMode.system => '시스템 설정',
ThemeMode.light => '라이트 모드',
ThemeMode.dark => '다크 모드',
};
}
}

Watch out#

WARNING

SharedPreferences는 앱이 완전히 종료되어도 데이터가 유지됩니다.
하지만 앱을 삭제하면 데이터도 함께 삭제됩니다.

// 비동기 로딩 중 깜빡임 방지
// FutureBuilder로 로딩 완료까지 스플래시 화면 표시
FutureBuilder(
future: themeNotifier.initialized,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SplashScreen();
}
return const MyApp();
},
)

결론: SharedPreferences로 테마 설정을 저장하면 앱을 다시 열어도 사용자 선택이 유지됩니다.


한계#

Flutter의 테마 시스템은 강력하지만 몇 가지 제약이 있습니다.

  • 복잡한 디자인 시스템: Material Design을 벗어난 복잡한 커스텀 디자인은 추가 작업이 필요합니다.
  • 동적 테마: 런타임에 테마를 완전히 교체하면 전체 위젯 트리가 리빌드됩니다.
  • 플랫폼 일관성: iOS의 Cupertino 스타일과 Material 스타일을 완전히 통합하기 어렵습니다.
  • 테마 상속: 중첩된 Theme 위젯에서 특정 속성만 상속하기가 번거로울 수 있습니다.

Footnotes#

  1. ThemeData: Flutter Material 앱의 전체적인 시각적 테마를 정의하는 클래스다.

  2. ColorScheme: Material Design의 색상 시스템을 구현하는 클래스로, 앱의 색상 팔레트를 정의한다.

  3. ThemeMode: 라이트, 다크, 시스템 설정 중 어떤 테마를 사용할지 지정하는 열거형이다.

  4. SharedPreferences: 간단한 키-값 데이터를 기기에 영구 저장하는 패키지다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 22편: 테마와 다크 모드
https://moodturnpost.net/posts/flutter/flutter-themes-dark-mode/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차