Android 앱 설정 화면 완벽 가이드 — PreferenceFragmentCompat, DataStore, 다크 모드, 알림 설정과 설정값 마이그레이션
Android 앱을 개발하다 보면 처음에는 설정 화면이 단순하게 시작됩니다.
예를 들어 다음과 같은 기능입니다.
다크 모드 사용
알림 활성화
이미지 다운로드 품질
모바일 데이터 사용 제한
검색 결과 표시 방식
학명 표시 여부
앱 버전 확인
개인정보처리방침 열기
처음에는 SharedPreferences에 값을 저장하는 방식으로도 충분합니다.
하지만 설정 항목이 늘어나고 여러 화면에서 설정값을 사용하기 시작하면 관리가 복잡해질 수 있습니다.
특히 다음과 같은 문제가 발생할 수 있습니다.
Activity와 Fragment마다 설정값을 직접 읽음
동일한 설정 키를 여러 파일에 중복 선언
설정 변경 후 다른 화면에 즉시 반영되지 않음
비동기 작업과 설정 저장 코드가 혼재
다크 모드 변경 시 화면 재생성 문제
알림 설정과 Android 시스템 권한의 불일치
기존 SharedPreferences 데이터 이전 문제
이러한 문제를 해결하기 위해 Android에서는 Jetpack DataStore를 활용할 수 있습니다.
이번 글에서는 PreferenceFragmentCompat와 DataStore의 역할을 구분하고, MVVM 구조로 설정 화면을 구현하는 방법을 살펴보겠습니다.
또한 SharedPreferences 마이그레이션, 다크 모드, 알림 설정, 네트워크 사용 옵션, 테스트 방법까지 정리하겠습니다.
작성 기준: 2026년 10월. 예제는 Kotlin, XML, AndroidX Preference, Preferences DataStore, AppCompat, ViewModel을 기준으로 합니다. 라이브러리 버전은 프로젝트의 Kotlin·AGP·AndroidX 호환성을 확인한 뒤 선택해야 합니다.
1. Android 설정 화면의 역할
설정 화면은 사용자가 앱의 동작 방식을 직접 조절하는 곳입니다.
예를 들어 조류 도감 앱이라면 다음과 같은 설정을 제공할 수 있습니다.
설정
────────────────────────────
화면
테마
시스템 설정 / 라이트 / 다크
조류 학명 표시
[ON]
────────────────────────────
데이터 사용
고화질 이미지
[OFF]
데이터 절약 모드
[ON]
────────────────────────────
알림
알림 사용
[ON]
────────────────────────────
정보
개인정보처리방침
앱 버전
설정 초기화이 설정들은 단순히 화면에 표시되는 값이 아닙니다.
앱의 여러 기능에 영향을 줍니다.
예를 들어 학명 표시 설정을 변경하면 RecyclerView와 상세 화면에서 학명을 표시하거나 숨겨야 합니다.
데이터 절약 모드를 변경하면 이미지 다운로드 정책이 달라질 수 있습니다.
따라서 설정 화면과 실제 앱 기능을 연결하는 구조가 필요합니다.
2. SharedPreferences와 DataStore 비교
Android에서는 오랫동안 SharedPreferences를 사용해 간단한 설정값을 저장했습니다.
최근 앱에서는 Jetpack DataStore를 대안으로 사용할 수 있습니다.
| 구분 | SharedPreferences | Preferences DataStore |
|---|---|---|
| 데이터 형태 | Key-Value | Key-Value |
| 데이터 읽기 | 동기 API 중심 | Flow 기반 |
| 데이터 쓰기 | apply(), commit() | suspend edit() |
| 변경 감지 | Listener | Flow |
| 트랜잭션 | 제한적 | 원자적 업데이트 지원 |
| Coroutine 연동 | 직접 구성 | 기본 지원 |
| 기존 데이터 이전 | 필요 없음 | Migration 제공 |
SharedPreferences의 apply()는 메모리 값을 먼저 갱신하고 디스크 쓰기를 비동기로 예약합니다.
하지만 특정 생명주기 전환 등에서 미완료 디스크 쓰기를 기다리며 메인 스레드 지연이 발생할 가능성이 있습니다.
commit()은 호출 스레드에서 디스크 저장 완료를 기다리므로 메인 스레드에서 사용하지 않는 것이 좋습니다.
DataStore는 이러한 설정 저장 작업을 Coroutine과 Flow 기반으로 다룰 수 있도록 설계되었습니다.
3. Preferences DataStore와 Proto DataStore
DataStore는 크게 두 가지 방식으로 사용할 수 있습니다.
Preferences DataStore
키와 값을 저장합니다.
theme_mode = DARK
show_scientific_name = true
image_quality = 2간단한 앱 설정에 적합합니다.
Proto DataStore
Protocol Buffers로 정의한 데이터 구조를 저장합니다.
UserSettings
├── theme_mode
├── show_scientific_name
└── image_quality타입이 명확한 구조화 데이터에 적합합니다.
이번 글에서는 설정 항목이 비교적 단순한 Android 앱을 가정하고 Preferences DataStore를 사용하겠습니다.
참고로 DataStore는 SQLite를 대체하는 데이터베이스가 아닙니다.
사용자 메모나 검색 기록처럼 다수의 레코드를 조회하고 관계를 처리해야 한다면 SQLite가 더 적합합니다.
4. 프로젝트 의존성 추가
build.gradle.kts에 필요한 라이브러리를 추가합니다.
dependencies {
implementation(
"androidx.datastore:datastore-preferences:<version>"
)
implementation(
"androidx.preference:preference-ktx:<version>"
)
implementation(
"androidx.lifecycle:lifecycle-viewmodel-ktx:<version>"
)
implementation(
"androidx.lifecycle:lifecycle-runtime-ktx:<version>"
)
implementation(
"androidx.appcompat:appcompat:<version>"
)
}<version>은 실제 프로젝트에 맞는 안정 버전으로 교체해야 합니다.
또한 예제에서 ViewBinding을 사용한다면 다음 설정이 필요합니다.
android {
buildFeatures {
viewBinding = true
}
}이번 글에서는 두 가지 설정 화면 구현 방식을 살펴봅니다.
PreferenceFragmentCompat를 이용한 설정 목록XML + ViewBinding + ViewModel을 이용한 사용자 정의 설정 화면
두 방식은 UI 구성 방법이 다릅니다.
데이터 저장소는 DataStore로 통일할 수 있습니다.
5. 추천 프로젝트 구조
설정 기능을 별도 패키지로 관리하면 유지보수가 쉬워집니다.
com.example.birds
│
├── data
│ └── settings
│ ├── SettingsKeys.kt
│ ├── UserSettings.kt
│ └── SettingsRepository.kt
│
├── ui
│ └── settings
│ ├── SettingsFragment.kt
│ ├── SettingsViewModel.kt
│ └── SettingsViewModelFactory.kt
│
└── MainActivity.kt핵심 역할은 다음과 같습니다.
| 클래스 | 역할 |
|---|---|
| SettingsKeys | 설정 키 정의 |
| UserSettings | 설정 상태 모델 |
| SettingsRepository | DataStore 읽기·쓰기 |
| SettingsViewModel | UI 상태 관리 |
| SettingsFragment | 설정 화면 표시 |
| MainActivity | 앱 전체 테마 등 적용 |
이렇게 분리하면 다른 Fragment에서도 동일한 설정 Repository를 사용할 수 있습니다.
6. DataStore 인스턴스 생성
먼저 Context 확장 프로퍼티를 정의합니다.
package com.example.birds.data.settings
import android.content.Context
import androidx.datastore.preferences.preferencesDataStore
val Context.settingsDataStore by preferencesDataStore(
name = "app_settings"
)이 코드는 하나의 Kotlin 파일에 최상위 프로퍼티로 선언하는 것이 좋습니다.
같은 DataStore 파일에 대해 여러 인스턴스를 중복 생성하지 않도록 주의해야 합니다.
DataStore는 앱에서 공유하는 저장소로 관리하는 것이 적합합니다.
7. 설정 키 정의하기
설정 키는 한곳에서 관리합니다.
object SettingsKeys {
val THEME_MODE =
stringPreferencesKey("theme_mode")
val SHOW_SCIENTIFIC_NAME =
booleanPreferencesKey(
"show_scientific_name"
)
val HIGH_QUALITY_IMAGES =
booleanPreferencesKey(
"high_quality_images"
)
val DATA_SAVER =
booleanPreferencesKey(
"data_saver"
)
val NOTIFICATIONS_ENABLED =
booleanPreferencesKey(
"notifications_enabled"
)
}필요한 import는 다음과 같습니다.
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey설정 키를 문자열로 여러 곳에 직접 작성하는 것보다 이렇게 상수로 관리하는 것이 안전합니다.
8. 테마 모드 정의하기
다크 모드는 일반적으로 세 가지 선택지를 제공합니다.
시스템 설정 사용
라이트 모드
다크 모드
Enum으로 정의할 수 있습니다.
enum class ThemeMode {
SYSTEM,
LIGHT,
DARK
}DataStore에는 Enum 이름을 문자열로 저장할 수 있습니다.
theme_mode = SYSTEM문자열을 Enum으로 변환할 때는 잘못된 값에 대한 기본값을 제공해야 합니다.
fun parseThemeMode(
value: String?
): ThemeMode {
return ThemeMode.entries.firstOrNull {
it.name == value
} ?: ThemeMode.SYSTEM
}Kotlin 버전에 따라 entries 대신 values()를 사용할 수도 있습니다.
9. 설정 데이터 모델 정의하기
여러 설정값을 하나의 데이터 클래스로 묶습니다.
data class UserSettings(
val themeMode: ThemeMode =
ThemeMode.SYSTEM,
val showScientificName: Boolean = true,
val highQualityImages: Boolean = false,
val dataSaver: Boolean = false,
val notificationsEnabled: Boolean = true
)이렇게 하면 ViewModel과 Fragment에서 설정값을 하나의 객체로 관리할 수 있습니다.
기본값은 앱의 정책에 맞게 결정해야 합니다.
특히 알림 사용 여부는 앱 내부 선호 설정일 뿐, Android 시스템의 알림 권한을 대신하지 않습니다.
10. SettingsRepository 구현
DataStore에 접근하는 Repository를 작성합니다.
class SettingsRepository(
context: Context
) {
private val dataStore =
context.applicationContext.settingsDataStore
val settingsFlow: Flow<UserSettings> =
dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
UserSettings(
themeMode = parseThemeMode(
preferences[
SettingsKeys.THEME_MODE
]
),
showScientificName =
preferences[
SettingsKeys.SHOW_SCIENTIFIC_NAME
] ?: true,
highQualityImages =
preferences[
SettingsKeys.HIGH_QUALITY_IMAGES
] ?: false,
dataSaver =
preferences[
SettingsKeys.DATA_SAVER
] ?: false,
notificationsEnabled =
preferences[
SettingsKeys.NOTIFICATIONS_ENABLED
] ?: true
)
}
}필요한 주요 import는 다음과 같습니다.
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException여기서 중요한 부분은 catch()입니다.
DataStore 파일을 읽는 중 IOException이 발생하면 기본 설정을 제공하도록 구성했습니다.
하지만 모든 예외를 무조건 무시해서는 안 됩니다.
예상하지 못한 오류는 다시 전달해야 원인을 파악할 수 있습니다.
11. 설정값 저장 함수 구현
DataStore의 edit()를 사용하면 설정값을 변경할 수 있습니다.
suspend fun setThemeMode(
mode: ThemeMode
) {
dataStore.edit { preferences ->
preferences[
SettingsKeys.THEME_MODE
] = mode.name
}
}학명 표시 설정도 같은 방식입니다.
suspend fun setShowScientificName(
enabled: Boolean
) {
dataStore.edit { preferences ->
preferences[
SettingsKeys.SHOW_SCIENTIFIC_NAME
] = enabled
}
}이미지 품질과 데이터 절약 설정도 추가합니다.
suspend fun setHighQualityImages(
enabled: Boolean
) {
dataStore.edit { preferences ->
preferences[
SettingsKeys.HIGH_QUALITY_IMAGES
] = enabled
}
}
suspend fun setDataSaver(
enabled: Boolean
) {
dataStore.edit { preferences ->
preferences[
SettingsKeys.DATA_SAVER
] = enabled
}
}위 함수들은 SettingsRepository 클래스 내부에 작성합니다.
DataStore의 edit()는 하나의 업데이트 작업을 원자적으로 처리합니다.
따라서 여러 Coroutine에서 같은 설정 파일을 수정하더라도 각각의 업데이트가 순차적으로 처리되도록 설계되어 있습니다.
12. SettingsViewModel 구현
Repository에서 전달하는 Flow를 ViewModel에서 UI 상태로 변환합니다.
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel() {
val settings = repository.settingsFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserSettings()
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch {
repository.setThemeMode(mode)
}
}
fun setShowScientificName(
enabled: Boolean
) {
viewModelScope.launch {
repository.setShowScientificName(enabled)
}
}
fun setHighQualityImages(
enabled: Boolean
) {
viewModelScope.launch {
repository.setHighQualityImages(enabled)
}
}
fun setDataSaver(
enabled: Boolean
) {
viewModelScope.launch {
repository.setDataSaver(enabled)
}
}
}필요한 주요 import는 다음과 같습니다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch이 구조의 장점은 Fragment가 DataStore에 직접 접근하지 않아도 된다는 것입니다.
Fragment는 ViewModel의 상태를 관찰하고 사용자 입력을 전달하는 역할에 집중할 수 있습니다.
13. ViewModelFactory 구성
생성자에 Repository가 필요한 ViewModel은 Factory를 통해 생성할 수 있습니다.
class SettingsViewModelFactory(
private val repository: SettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(
modelClass: Class<T>
): T {
if (
modelClass.isAssignableFrom(
SettingsViewModel::class.java
)
) {
@Suppress("UNCHECKED_CAST")
return SettingsViewModel(repository) as T
}
throw IllegalArgumentException(
"Unknown ViewModel class"
)
}
}앱 규모가 커지면 Hilt 같은 의존성 주입 도구를 도입할 수 있습니다.
하지만 소규모 앱에서는 Factory 방식만으로도 충분할 수 있습니다.
14. XML 설정 화면 만들기
사용자 정의 설정 화면에서는 SwitchCompat, RadioGroup, TextView 등을 조합할 수 있습니다.
예를 들어 fragment_settings.xml을 다음과 같이 구성합니다.
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="화면 설정"
android:textStyle="bold"
android:textSize="18sp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchScientificName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="조류 학명 표시"
android:minHeight="48dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchHighQuality"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="고화질 이미지"
android:minHeight="48dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchDataSaver"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="데이터 절약 모드"
android:minHeight="48dp" />
</LinearLayout>
</ScrollView>실제 프로젝트에서는 표시 문자열을 strings.xml로 분리하는 것이 좋습니다.
또한 다국어와 큰 글자 크기를 고려해 고정 높이보다 minHeight와 wrap_content를 활용하는 것이 유리합니다.
15. Fragment에서 설정값 관찰하기
Fragment에서는 ViewModel의 StateFlow를 수집합니다.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(
Lifecycle.State.STARTED
) {
viewModel.settings.collect { settings ->
binding.switchScientificName.isChecked =
settings.showScientificName
binding.switchHighQuality.isChecked =
settings.highQualityImages
binding.switchDataSaver.isChecked =
settings.dataSaver
}
}
}repeatOnLifecycle()을 사용하면 화면이 STARTED 상태일 때 Flow를 수집하고, 화면이 중지되면 수집을 일시 중단할 수 있습니다.
Fragment에서는 반드시 View의 생명주기인 viewLifecycleOwner를 기준으로 수집하는 것이 좋습니다.
16. Switch 변경 이벤트 처리
사용자가 스위치를 변경하면 ViewModel에 값을 전달합니다.
binding.switchScientificName
.setOnCheckedChangeListener { _, checked ->
viewModel.setShowScientificName(checked)
}다만 이 코드에는 주의할 점이 있습니다.
Flow에서 설정값을 받아 isChecked를 변경할 때도 Listener가 호출될 수 있기 때문입니다.
따라서 UI 갱신과 사용자 입력을 구분하는 것이 좋습니다.
예를 들어 다음과 같이 처리할 수 있습니다.
fun updateSwitch(
switch: SwitchCompat,
checked: Boolean
) {
if (switch.isChecked != checked) {
switch.isChecked = checked
}
}그리고 사용자 입력 Listener에서는 필요한 경우 buttonView.isPressed 등의 상태를 활용할 수 있습니다.
하지만 접근성 서비스나 키보드 조작은 일반 터치와 다르게 동작할 수 있으므로 isPressed만으로 사용자 입력 여부를 판단하는 방식은 권장하지 않습니다.
보다 안정적인 방법은 UI 상태 갱신 중 Listener를 일시적으로 제거하거나, 중복 저장이 발생하지 않도록 상태를 비교하는 것입니다.
17. PreferenceFragmentCompat란?
AndroidX Preference 라이브러리에는 설정 화면을 쉽게 구성할 수 있는 PreferenceFragmentCompat가 있습니다.
설정 항목을 XML로 정의하고 목록 형태로 표시할 수 있습니다.
예를 들어 다음과 같은 설정입니다.
화면 설정
테마
시스템 설정
학명 표시
[ON]
데이터 사용
고화질 이미지
[OFF]일반적인 설정 화면이라면 사용자 정의 XML 레이아웃보다 구현이 간단할 수 있습니다.
하지만 주의해야 할 점이 있습니다.
PreferenceFragmentCompat의 기본 저장 방식은 DataStore가 아닙니다.
일반적으로 SharedPreferences 기반 저장소를 사용합니다.
따라서 DataStore와 연동하려면 별도의 연결 계층이 필요합니다.
18. Preference XML 작성하기
res/xml/preferences.xml을 생성합니다.
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:title="화면 설정">
<ListPreference
app:key="theme_mode"
app:title="테마"
app:entries="@array/theme_entries"
app:entryValues="@array/theme_values"
app:defaultValue="SYSTEM" />
<SwitchPreferenceCompat
app:key="show_scientific_name"
app:title="학명 표시"
app:defaultValue="true" />
</PreferenceCategory>
<PreferenceCategory
app:title="데이터 사용">
<SwitchPreferenceCompat
app:key="high_quality_images"
app:title="고화질 이미지"
app:defaultValue="false" />
<SwitchPreferenceCompat
app:key="data_saver"
app:title="데이터 절약 모드"
app:defaultValue="false" />
</PreferenceCategory>
</PreferenceScreen>실제 프로젝트에서는 제목과 설명을 문자열 리소스로 분리하는 것이 좋습니다.
19. 테마 선택 배열 정의
res/values/arrays.xml에 다음과 같이 작성합니다.
<resources>
<string-array name="theme_entries">
<item>시스템 설정</item>
<item>라이트 모드</item>
<item>다크 모드</item>
</string-array>
<string-array name="theme_values">
<item>SYSTEM</item>
<item>LIGHT</item>
<item>DARK</item>
</string-array>
</resources>entries는 사용자에게 표시되는 이름입니다.
entryValues는 실제 저장되는 값입니다.
이렇게 구분하면 표시 문구를 변경하거나 다국어를 지원하더라도 저장되는 값은 일정하게 유지할 수 있습니다.
20. PreferenceFragmentCompat 구현
기본적인 설정 Fragment는 다음과 같이 작성할 수 있습니다.
class SettingsPreferenceFragment :
PreferenceFragmentCompat() {
override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?
) {
setPreferencesFromResource(
R.xml.preferences,
rootKey
)
}
}이 방식은 매우 간단합니다.
하지만 기본 구현을 그대로 사용하면 SharedPreferences에 설정값이 저장될 수 있습니다.
따라서 DataStore를 이미 사용하고 있다면 저장소가 두 개로 나뉘지 않도록 주의해야 합니다.
21. PreferenceFragmentCompat와 DataStore 연동
AndroidX Preference에서는 PreferenceDataStore 인터페이스를 통해 기본 저장 방식을 교체할 수 있습니다.
다만 여기서 주의해야 합니다.
PreferenceDataStore는 Jetpack DataStore와 이름이 비슷하지만 서로 다른 API입니다.
PreferenceDataStore는 Preference 라이브러리의 저장소 인터페이스입니다.
반면 Jetpack DataStore는 Coroutine과 Flow 기반의 데이터 저장 라이브러리입니다.
두 API를 연결하려면 어댑터가 필요합니다.
특히 PreferenceDataStore의 putBoolean(), putString() 등은 동기 메서드이므로 Jetpack DataStore의 suspend edit()와 직접 연결할 수 없습니다.
따라서 단순히 runBlocking으로 저장을 강제하는 방식은 피하는 것이 좋습니다.
22. PreferenceFragmentCompat에서 추천하는 DataStore 연동 방식
기존 DataStore를 사용하는 앱에서는 다음 구조를 추천합니다.
PreferenceFragmentCompat
│
▼
Preference 변경 이벤트
│
▼
SettingsViewModel
│
▼
SettingsRepository
│
▼
Jetpack DataStore이 방식에서는 Preference의 기본 자동 저장 기능을 사용하지 않고, 변경 이벤트를 ViewModel에 전달합니다.
먼저 PreferenceManager의 기본 저장 기능을 비활성화합니다.
override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?
) {
preferenceManager.preferenceDataStore =
object : PreferenceDataStore() {}
setPreferencesFromResource(
R.xml.preferences,
rootKey
)
}이 코드는 저장소 인터페이스를 비워 둔 예제이며, 실제 운영 코드에서는 읽기·쓰기 동작을 명확하게 관리해야 합니다.
보다 단순한 접근으로는 각 Preference에 isPersistent = false를 설정하고, UI 상태를 ViewModel에서 관리하는 방법이 있습니다.
23. Preference 변경 이벤트 처리
예를 들어 학명 표시 설정을 다음과 같이 처리할 수 있습니다.
val scientificPreference =
findPreference<SwitchPreferenceCompat>(
"show_scientific_name"
)
scientificPreference?.isPersistent = false
scientificPreference?.setOnPreferenceChangeListener {
_, newValue ->
viewModel.setShowScientificName(
newValue as Boolean
)
true
}이렇게 하면 사용자가 설정을 변경할 때 ViewModel을 통해 DataStore에 저장할 수 있습니다.
단, Preference의 화면 상태와 실제 DataStore 상태가 일치하도록 Flow를 관찰해 갱신해야 합니다.
또한 저장 실패가 발생할 수 있으므로, 중요한 설정이라면 실패 시 이전 상태로 되돌리거나 오류를 안내하는 구조가 필요합니다.
24. DataStore 값을 Preference UI에 반영하기
DataStore의 설정값을 수집한 뒤 Preference 상태를 갱신합니다.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(
Lifecycle.State.STARTED
) {
viewModel.settings.collect { settings ->
scientificPreference?.isChecked =
settings.showScientificName
}
}
}다만 UI 상태를 변경하는 과정에서 Preference 변경 Listener가 중복 호출되지 않는지 확인해야 합니다.
특히 사용자 입력과 DataStore 상태 갱신이 반복적으로 발생하지 않도록 설계해야 합니다.
PreferenceFragmentCompat를 사용할 때는 이러한 연결 코드가 필요하므로, 설정 항목이 적고 UI 디자인을 자유롭게 구성하고 싶다면 일반 Fragment + XML 방식이 더 단순할 수 있습니다.
25. 다크 모드 적용하기
Android 앱에서는 AppCompatDelegate를 통해 다크 모드를 제어할 수 있습니다.
대표적인 모드는 다음과 같습니다.
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
AppCompatDelegate.MODE_NIGHT_NO
AppCompatDelegate.MODE_NIGHT_YES각 모드의 의미는 다음과 같습니다.
| 모드 | 설명 |
|---|---|
| FOLLOW_SYSTEM | 시스템 설정 따름 |
| NO | 라이트 모드 |
| YES | 다크 모드 |
테마 설정을 적용하는 함수를 작성합니다.
fun applyTheme(mode: ThemeMode) {
val nightMode = when (mode) {
ThemeMode.SYSTEM ->
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
ThemeMode.LIGHT ->
AppCompatDelegate.MODE_NIGHT_NO
ThemeMode.DARK ->
AppCompatDelegate.MODE_NIGHT_YES
}
if (
AppCompatDelegate.getDefaultNightMode()
!= nightMode
) {
AppCompatDelegate.setDefaultNightMode(
nightMode
)
}
}이 함수는 Activity나 Application에서 호출할 수 있습니다.
단, setDefaultNightMode() 호출로 Activity가 재생성될 수 있으므로 화면 상태 보존을 고려해야 합니다.
26. 다크 모드 테마 설정
앱 테마는 DayNight 계열을 사용하는 것이 좋습니다.
예를 들어 themes.xml에서 다음과 같이 설정할 수 있습니다.
<style
name="Theme.Birds"
parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">
@color/purple_500
</item>
</style>Material Components를 사용하지 않는 앱이라면 AppCompat DayNight 테마를 사용할 수도 있습니다.
<style
name="Theme.Birds"
parent="Theme.AppCompat.DayNight.NoActionBar" />프로젝트에서 사용하는 UI 라이브러리와 일치하는 테마를 선택해야 합니다.
27. 다크 모드 변경 시 화면 깜빡임
DataStore는 비동기적으로 설정값을 읽습니다.
따라서 앱 시작 시 기본 테마로 화면이 표시된 뒤 저장된 다크 모드가 적용되면 화면이 잠깐 변경되는 현상이 발생할 수 있습니다.
예를 들어 다음과 같은 상황입니다.
앱 실행
↓
기본 라이트 테마 표시
↓
DataStore 설정 읽기
↓
다크 모드 적용
↓
Activity 재생성이 문제를 줄이려면 테마 적용 시점을 신중하게 설계해야 합니다.
하지만 DataStore를 읽기 위해 메인 스레드에서 runBlocking을 사용하는 것은 권장하지 않습니다.
앱 시작 지연이나 ANR 위험이 있기 때문입니다.
실무에서는 다음과 같은 방식을 검토할 수 있습니다.
SplashScreen 동안 필요한 초기 설정 로드
앱 시작 시 테마 변경 최소화
Activity 생성 전에 적용 가능한 설정 구조 설계
AppCompat의 기본 테마 관리 활용
어떤 방식이 적합한지는 앱의 시작 구조와 최소 지원 Android 버전에 따라 달라집니다.
28. 알림 설정과 시스템 권한은 다르다
설정 화면에서 알림 스위치를 ON으로 변경했다고 해서 Android 시스템의 알림 권한이 자동으로 허용되는 것은 아닙니다.
Android 13(API 33) 이상에서는 일반 알림에 POST_NOTIFICATIONS 런타임 권한이 필요할 수 있습니다.
Manifest에는 다음과 같이 선언합니다.
<uses-permission
android:name="android.permission.POST_NOTIFICATIONS" />그리고 필요한 시점에 사용자에게 권한을 요청해야 합니다.
앱 내부의 알림 설정은 다음 두 가지 조건을 구분해야 합니다.
사용자가 앱 내부에서 알림을 사용하도록 설정했는가?
Android 시스템에서 앱의 알림이 허용되어 있는가?
두 조건이 모두 충족되어야 의도한 알림을 표시할 수 있는 경우가 많습니다.
29. Android 13 알림 권한 요청
Activity Result API를 이용해 권한을 요청할 수 있습니다.
private val notificationPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
// 알림 사용 가능 여부 다시 확인
} else {
// 권한 거부 상태 안내
}
}권한 요청은 다음과 같이 수행합니다.
if (
Build.VERSION.SDK_INT >=
Build.VERSION_CODES.TIRAMISU
) {
notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS
)
}다만 권한 요청은 사용자에게 알림 기능의 필요성을 설명한 뒤 적절한 시점에 수행하는 것이 좋습니다.
앱 실행 직후 무조건 권한을 요청하는 방식은 사용자 경험 측면에서 불리할 수 있습니다.
30. 실제 알림 허용 상태 확인
AndroidX Core의 NotificationManagerCompat를 사용할 수 있습니다.
val enabled =
NotificationManagerCompat
.from(requireContext())
.areNotificationsEnabled()이 값은 앱 수준에서 알림이 허용되어 있는지 확인하는 데 도움이 됩니다.
다만 개별 알림 채널이 비활성화된 경우까지 모두 동일하게 판단할 수 있는 것은 아닙니다.
따라서 특정 채널을 사용하는 앱에서는 채널별 설정도 고려해야 합니다.
앱 내부 스위치가 ON이더라도 시스템 알림이 차단되어 있다면 사용자에게 시스템 설정을 확인하도록 안내할 수 있습니다.
31. 데이터 절약 모드 구현
이미지를 사용하는 앱에서는 모바일 데이터 사용량이 중요할 수 있습니다.
예를 들어 다음과 같은 설정을 제공할 수 있습니다.
데이터 절약 모드
ON
모바일 데이터에서 고화질 이미지 다운로드 제한
OFF
네트워크 환경에 따라 이미지 다운로드하지만 설정값을 저장하는 것만으로 실제 다운로드가 제한되는 것은 아닙니다.
이미지 Repository나 다운로드 작업에서 설정값을 확인해야 합니다.
예를 들어 다음과 같은 구조입니다.
SettingsRepository
│
▼
dataSaver = true
│
▼
ImageRepository
│
▼
네트워크 상태 확인
│
▼
다운로드 정책 적용32. WorkManager와 데이터 절약 설정
백그라운드 이미지 다운로드에 WorkManager를 사용한다면 네트워크 제약 조건을 설정할 수 있습니다.
예를 들어 다음과 같습니다.
val constraints = Constraints.Builder()
.setRequiredNetworkType(
NetworkType.UNMETERED
)
.build()UNMETERED는 일반적으로 요금이 부과되지 않는 네트워크를 의미합니다.
하지만 이것이 항상 Wi-Fi만을 의미하는 것은 아닙니다.
일부 네트워크는 Wi-Fi라도 요금제 기반으로 측정될 수 있고, 반대로 다른 네트워크가 비과금으로 판단될 수도 있습니다.
따라서 사용자에게는 'Wi-Fi 전용'보다 '데이터 절약 모드' 또는 '비과금 네트워크에서만 다운로드'처럼 실제 동작에 맞는 설명을 제공하는 것이 정확합니다.
33. 설정 변경을 다른 화면에 반영하기
예를 들어 사용자가 학명 표시 설정을 OFF로 변경했다고 가정하겠습니다.
설정 화면에서는 DataStore에 다음 값이 저장됩니다.
show_scientific_name = falseRecyclerView가 있는 화면에서는 이 설정을 관찰할 수 있습니다.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(
Lifecycle.State.STARTED
) {
settingsRepository.settingsFlow.collect {
settings ->
adapter.setShowScientificName(
settings.showScientificName
)
}
}
}Adapter에서는 설정값에 따라 학명 TextView의 표시 여부를 변경할 수 있습니다.
binding.textScientificName.isVisible =
showScientificName다만 Adapter가 설정 변경 시 기존 ViewHolder를 다시 바인딩하도록 해야 합니다.
예를 들어 ListAdapter를 사용한다면 설정값을 포함한 UI 모델을 생성하거나 적절한 갱신 정책을 적용할 수 있습니다.
단순히 Adapter의 Boolean 값만 변경하면 이미 표시된 아이템이 자동으로 갱신되지는 않습니다.
34. 설정 초기화 기능 구현
사용자가 모든 설정을 기본값으로 되돌리고 싶어 할 수 있습니다.
DataStore에서는 다음과 같이 설정값을 초기화할 수 있습니다.
suspend fun resetSettings() {
dataStore.edit { preferences ->
preferences.clear()
}
}이 코드는 해당 Preferences DataStore 파일의 모든 설정 키를 제거합니다.
이후 Flow에서는 기본값이 적용됩니다.
하지만 앱에서 유지해야 하는 별도 키가 있다면 clear() 대신 특정 키만 제거하는 것이 좋습니다.
또한 설정 초기화는 SQLite에 저장된 사용자 메모나 좋아요를 삭제하는 기능과 구분해야 합니다.
35. 설정 초기화 확인 Dialog
실수로 설정을 초기화하지 않도록 확인 Dialog를 제공할 수 있습니다.
MaterialAlertDialogBuilder(requireContext())
.setTitle("설정 초기화")
.setMessage(
"모든 앱 설정을 기본값으로 되돌리시겠습니까?"
)
.setPositiveButton("초기화") { _, _ ->
viewModel.resetSettings()
}
.setNegativeButton("취소", null)
.show()ViewModel에는 다음 함수를 추가합니다.
fun resetSettings() {
viewModelScope.launch {
repository.resetSettings()
}
}설정 초기화 후 테마가 변경되거나 Activity가 재생성될 수 있으므로 화면 상태를 확인해야 합니다.
36. SharedPreferences에서 DataStore로 마이그레이션
기존 앱에서 SharedPreferences를 사용하고 있다면 사용자 설정을 DataStore로 이전해야 합니다.
예를 들어 기존 설정 파일이 다음과 같다고 가정하겠습니다.
SharedPreferences 이름
legacy_settings
설정 키
theme_mode
show_scientific_name
data_saverDataStore에서는 SharedPreferencesMigration을 사용할 수 있습니다.
val Context.settingsDataStore by preferencesDataStore(
name = "app_settings",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context,
"legacy_settings"
)
)
}
)이렇게 하면 DataStore가 초기화될 때 기존 SharedPreferences 데이터를 이전할 수 있습니다.
단, 기존 키의 이름과 타입이 새 DataStore 키와 호환되는지 확인해야 합니다.
37. 마이그레이션 시 주의사항
SharedPreferencesMigration을 적용할 때는 다음 사항을 확인해야 합니다.
기존 SharedPreferences 파일 이름
기존 키 이름
기존 값의 타입
새 DataStore 키 이름
마이그레이션 완료 후 기존 저장소 사용 여부
앱 업데이트 중 데이터 손실 가능성
예를 들어 기존 SharedPreferences에서 다음과 같이 저장했다고 가정하겠습니다.
preferences.edit()
.putBoolean("dark_mode", true)
.apply()새 DataStore에서는 다음 키를 사용합니다.
theme_mode = DARK이 경우 키 이름과 타입이 다릅니다.
따라서 기본 SharedPreferencesMigration만으로 원하는 형태로 자동 변환된다고 가정해서는 안 됩니다.
별도의 변환 로직이 필요합니다.
38. 기존 설정값 변환 전략
기존 Boolean 다크 모드 설정을 새로운 ThemeMode로 변환해야 한다면 다음과 같은 흐름을 고려할 수 있습니다.
기존 SharedPreferences
dark_mode = true
│
▼
마이그레이션
│
▼
새 DataStore
theme_mode = DARK이러한 변환은 DataStore의 마이그레이션 API를 이용해 구현할 수 있습니다.
다만 마이그레이션은 데이터 읽기 과정에서 실행될 수 있으므로, 예외 처리와 재실행 가능성을 고려해야 합니다.
또한 마이그레이션 완료 후에는 기존 SharedPreferences와 DataStore에 동시에 값을 기록하지 않도록 해야 합니다.
두 저장소에 서로 다른 값이 남으면 설정 불일치 문제가 발생할 수 있습니다.
39. 설정 화면에 앱 버전 표시하기
설정 화면에서는 앱 버전을 표시하는 경우가 많습니다.
예를 들어 다음과 같습니다.
앱 버전
1.4.2Android에서는 PackageManager를 통해 앱 버전 정보를 확인할 수 있습니다.
val packageInfo =
requireContext().packageManager
.getPackageInfo(
requireContext().packageName,
0
)
val versionName =
packageInfo.versionNameAndroid 13 이상에서는 일부 PackageManager API에 새로운 플래그 타입이 도입되었으므로 대상 API에 맞게 구현할 수 있습니다.
앱 버전은 사용자가 문의하거나 오류를 제보할 때 유용한 정보입니다.
40. 개인정보처리방침 링크 연결하기
Google Play에 배포하는 앱에서는 개인정보처리방침 안내가 중요한 항목입니다.
설정 화면에 개인정보처리방침 메뉴를 제공할 수 있습니다.
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://example.com/privacy"
)
)
startActivity(intent)실제 앱에서는 운영 중인 개인정보처리방침 URL을 사용해야 합니다.
또한 외부 브라우저에서 열리는지, 링크가 정상적으로 작동하는지 확인해야 합니다.
앱에서 AdMob이나 Firebase 같은 SDK를 사용한다면 실제 데이터 처리 방식과 개인정보처리방침의 내용이 일치해야 합니다.
41. 설정 화면 접근성 개선
설정 화면에는 Switch, ListPreference, Button 등 여러 입력 요소가 포함됩니다.
따라서 접근성을 함께 고려해야 합니다.
예를 들어 다음 항목을 확인합니다.
설정 항목 이름이 명확한가?
Switch 상태가 TalkBack에서 전달되는가?
설정 설명이 충분한가?
큰 글자 크기에서 텍스트가 잘리지 않는가?
터치 영역이 충분한가?
다크 모드에서 명암 대비가 적절한가?
설정 초기화 버튼이 실수로 눌리지 않도록 구성되어 있는가?
표준 Preference 컴포넌트는 기본적인 접근성 지원을 제공하지만, 사용자 정의 UI에서는 직접 확인해야 합니다.
42. 설정 저장 오류 처리
DataStore의 edit()는 suspend 함수이며 저장 과정에서 예외가 발생할 수 있습니다.
따라서 실제 서비스에서는 오류를 처리해야 합니다.
예를 들어 ViewModel에서 다음과 같이 구성할 수 있습니다.
fun setDataSaver(
enabled: Boolean
) {
viewModelScope.launch {
try {
repository.setDataSaver(enabled)
} catch (exception: IOException) {
// UI에 오류 상태 전달
}
}
}실제 프로젝트에서는 SharedFlow나 별도의 UI 이벤트 상태를 통해 사용자에게 오류 메시지를 표시할 수 있습니다.
중요한 것은 설정 저장 실패를 조용히 무시하지 않는 것입니다.
사용자가 스위치를 ON으로 변경했는데 실제로 저장되지 않았다면 다음 실행 시 설정이 원래대로 돌아갈 수 있습니다.
43. 설정 기능 테스트 시나리오
설정 화면은 단순해 보이지만 다양한 상황을 테스트해야 합니다.
| 테스트 | 확인할 내용 |
|---|---|
| 앱 최초 실행 | 기본 설정 적용 |
| 다크 모드 변경 | 즉시 테마 반영 |
| 시스템 테마 변경 | FOLLOW_SYSTEM 동작 |
| Activity 재생성 | 설정 유지 |
| 앱 종료 후 재실행 | 설정 유지 |
| 학명 표시 변경 | 목록·상세 화면 반영 |
| 데이터 절약 모드 | 실제 다운로드 정책 적용 |
| 알림 설정 ON | 시스템 권한과 구분 |
| 알림 권한 거부 | 안내 표시 |
| 설정 초기화 | 기본값 복원 |
| SharedPreferences 이전 | 기존 값 유지 |
| 잘못된 설정값 | 안전한 기본값 적용 |
| 빠른 스위치 연속 조작 | 최종 값 일치 |
| 큰 글자 크기 | 화면 잘림 없음 |
| TalkBack | 설정 이름·상태 안내 |
특히 앱을 강제 종료한 뒤 다시 실행해 설정값이 유지되는지 확인하는 것이 중요합니다.
44. 개인 개발자에게 추천하는 설정 화면 구조
개인 개발자가 여러 Android 앱을 운영한다면 설정 기능을 공통 구조로 만들어 두는 것이 좋습니다.
예를 들어 다음과 같은 공통 기능을 구성할 수 있습니다.
공통 설정 모듈
│
├── 테마 설정
├── 알림 설정
├── 데이터 절약 모드
├── 앱 버전
├── 개인정보처리방침
└── 설정 초기화그리고 앱별 기능을 추가합니다.
조류 도감 앱
└── 학명 표시
어류 도감 앱
└── 학명 표시
이미지 중심 앱
└── 고화질 이미지
달력 앱
└── 기념일 알림이렇게 구성하면 여러 앱에서 설정 코드를 반복해서 작성하는 일을 줄일 수 있습니다.
다만 앱마다 실제로 필요한 설정만 제공하는 것이 좋습니다.
사용하지 않는 설정을 공통 모듈이라는 이유로 모두 표시하면 오히려 설정 화면이 복잡해질 수 있습니다.
45. 설정 화면 구현 체크리스트
설정 항목 목록 정의
기본값 정책 결정
SharedPreferences와 DataStore 역할 구분
Preferences DataStore 생성
DataStore 인스턴스 중복 생성 방지
설정 키 한곳에서 관리
UserSettings 데이터 모델 정의
SettingsRepository 구현
Flow 기반 설정 읽기
DataStore edit() 기반 저장
IOException 처리
ViewModel 상태 관리
lifecycle-aware Flow 수집
Switch 변경 이벤트 중복 처리 방지
PreferenceFragmentCompat 사용 여부 결정
Preference 기본 저장소와 DataStore 중복 방지
다크 모드 적용
Activity 재생성 테스트
Android 13 이상 알림 권한 처리
알림 채널 상태 검토
데이터 절약 설정을 실제 다운로드에 반영
설정 초기화 구현
SharedPreferences 마이그레이션
앱 버전 표시
개인정보처리방침 링크 연결
TalkBack 접근성 테스트
앱 재실행 후 설정 유지 확인
46. 마무리
Android 앱의 설정 화면은 단순히 Switch와 버튼을 배치하는 기능이 아닙니다.
앱의 여러 기능에서 공통으로 사용하는 사용자 환경설정을 안전하게 관리하는 구조가 필요합니다.
특히 Kotlin + XML + MVVM 기반 앱에서는 다음 원칙을 기억하는 것이 좋습니다.
설정값은 Repository를 통해 관리합니다.
Preferences DataStore를 이용해 Coroutine과 Flow 기반으로 설정을 읽고 저장합니다.
설정 키와 기본값을 한곳에서 정의합니다.
Fragment에서는 ViewModel의 상태를 관찰합니다.
PreferenceFragmentCompat의 기본 저장소와 Jetpack DataStore를 혼동하지 않습니다.
다크 모드 변경 시 Activity 재생성을 고려합니다.
앱 내부 알림 설정과 Android 시스템 권한을 구분합니다.
데이터 절약 설정은 실제 네트워크 작업에 반영해야 합니다.
기존 SharedPreferences 데이터를 안전하게 이전합니다.
설정 변경과 저장 실패, 앱 재실행 상황을 테스트합니다.
여러 Android 앱을 운영한다면 설정 Repository와 공통 설정 화면 구조를 재사용할 수 있도록 설계하는 것도 좋은 방법입니다.
특히 다크 모드, 개인정보처리방침, 앱 버전, 알림 설정 등은 대부분의 앱에서 반복적으로 사용하는 기능입니다.
설정 화면의 핵심은 사용자의 선택을 저장하는 것뿐 아니라, 그 선택이 앱 전체에서 일관되게 적용되도록 만드는 것입니다.
참고 자료
Android Developers — DataStore
https://developer.android.com/topic/libraries/architecture/datastoreAndroid Developers — Preferences DataStore
https://developer.android.com/codelabs/android-preferences-datastoreAndroid Developers — Settings with Preference Library
https://developer.android.com/develop/ui/views/components/settingsAndroid Developers — PreferenceFragmentCompat
https://developer.android.com/reference/androidx/preference/PreferenceFragmentCompatAndroid Developers — PreferenceDataStore
https://developer.android.com/reference/androidx/preference/PreferenceDataStoreAndroid Developers — Dark Theme
https://developer.android.com/develop/ui/views/theming/darkthemeAndroid Developers — Notification Runtime Permission
https://developer.android.com/develop/ui/views/notifications/notification-permissionAndroid Developers — WorkManager Constraints
https://developer.android.com/develop/background-work/background-tasks/persistent/how-to/define-workAndroid Developers — Lifecycle-aware Flow Collection
https://developer.android.com/topic/libraries/architecture/coroutines
태그: Android, Kotlin, Android 설정 화면, PreferenceFragmentCompat, Jetpack DataStore, SharedPreferences, MVVM, StateFlow, 다크 모드, AppCompatDelegate, 알림 권한, 데이터 절약 모드, 설정 초기화, Android XML, Android 앱 개발