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를 대안으로 사용할 수 있습니다.

구분SharedPreferencesPreferences DataStore
데이터 형태Key-ValueKey-Value
데이터 읽기동기 API 중심Flow 기반
데이터 쓰기apply(), commit()suspend edit()
변경 감지ListenerFlow
트랜잭션제한적원자적 업데이트 지원
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
    }
}

이번 글에서는 두 가지 설정 화면 구현 방식을 살펴봅니다.

  1. PreferenceFragmentCompat를 이용한 설정 목록

  2. 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설정 상태 모델
SettingsRepositoryDataStore 읽기·쓰기
SettingsViewModelUI 상태 관리
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" />

그리고 필요한 시점에 사용자에게 권한을 요청해야 합니다.

앱 내부의 알림 설정은 다음 두 가지 조건을 구분해야 합니다.

  1. 사용자가 앱 내부에서 알림을 사용하도록 설정했는가?

  2. 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 = false

RecyclerView가 있는 화면에서는 이 설정을 관찰할 수 있습니다.

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_saver

DataStore에서는 SharedPreferencesMigration을 사용할 수 있습니다.

val Context.settingsDataStore by preferencesDataStore(

    name = "app_settings",

    produceMigrations = { context ->

        listOf(
            SharedPreferencesMigration(
                context,
                "legacy_settings"
            )
        )
    }
)

이렇게 하면 DataStore가 초기화될 때 기존 SharedPreferences 데이터를 이전할 수 있습니다.

단, 기존 키의 이름과 타입이 새 DataStore 키와 호환되는지 확인해야 합니다.


37. 마이그레이션 시 주의사항

SharedPreferencesMigration을 적용할 때는 다음 사항을 확인해야 합니다.

  1. 기존 SharedPreferences 파일 이름

  2. 기존 키 이름

  3. 기존 값의 타입

  4. 새 DataStore 키 이름

  5. 마이그레이션 완료 후 기존 저장소 사용 여부

  6. 앱 업데이트 중 데이터 손실 가능성

예를 들어 기존 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.2

Android에서는 PackageManager를 통해 앱 버전 정보를 확인할 수 있습니다.

val packageInfo =
    requireContext().packageManager
        .getPackageInfo(
            requireContext().packageName,
            0
        )

val versionName =
    packageInfo.versionName

Android 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 기반 앱에서는 다음 원칙을 기억하는 것이 좋습니다.

  1. 설정값은 Repository를 통해 관리합니다.

  2. Preferences DataStore를 이용해 Coroutine과 Flow 기반으로 설정을 읽고 저장합니다.

  3. 설정 키와 기본값을 한곳에서 정의합니다.

  4. Fragment에서는 ViewModel의 상태를 관찰합니다.

  5. PreferenceFragmentCompat의 기본 저장소와 Jetpack DataStore를 혼동하지 않습니다.

  6. 다크 모드 변경 시 Activity 재생성을 고려합니다.

  7. 앱 내부 알림 설정과 Android 시스템 권한을 구분합니다.

  8. 데이터 절약 설정은 실제 네트워크 작업에 반영해야 합니다.

  9. 기존 SharedPreferences 데이터를 안전하게 이전합니다.

  10. 설정 변경과 저장 실패, 앱 재실행 상황을 테스트합니다.

여러 Android 앱을 운영한다면 설정 Repository와 공통 설정 화면 구조를 재사용할 수 있도록 설계하는 것도 좋은 방법입니다.

특히 다크 모드, 개인정보처리방침, 앱 버전, 알림 설정 등은 대부분의 앱에서 반복적으로 사용하는 기능입니다.

설정 화면의 핵심은 사용자의 선택을 저장하는 것뿐 아니라, 그 선택이 앱 전체에서 일관되게 적용되도록 만드는 것입니다.


참고 자료

태그: Android, Kotlin, Android 설정 화면, PreferenceFragmentCompat, Jetpack DataStore, SharedPreferences, MVVM, StateFlow, 다크 모드, AppCompatDelegate, 알림 권한, 데이터 절약 모드, 설정 초기화, Android XML, Android 앱 개발