모바일 다크 모드 & 동적 테마: iOS/Android 완전 구현 가이드

모바일

다크 모드동적 테마iOS 다크 모드Android 다크 모드모바일 UI

이 글은 누구를 위한 것인가

  • 앱에 다크 모드를 처음 적용하려는 iOS/Android 개발자
  • 색상이 깨지거나 이상하게 보이는 다크 모드 버그를 고치는 팀
  • 사용자가 직접 다크/라이트를 선택하는 기능을 구현해야 하는 엔지니어

들어가며

다크 모드를 "배경만 검게 바꾸는 것"으로 생각하면 반드시 문제가 생긴다. 배경이 검은데 텍스트도 어둡게 남아있거나, 이미지 배경이 흰색이어서 튀거나, 특정 컴포넌트에서만 다크 모드가 적용 안 되는 문제가 발생한다.

올바른 다크 모드 구현은 색상 시스템을 시맨틱하게 정의하는 것에서 시작한다. "파란색"이 아니라 "기본 버튼 색상"처럼 의미로 색상을 관리해야 한다.

이 글은 bluefoxdev.kr의 모바일 디자인 시스템 가이드 를 참고하고, 다크 모드 실전 구현 관점에서 확장하여 작성했습니다.


1. 시맨틱 색상 시스템

[잘못된 접근]
Text(color = Color.Black)  // 라이트 모드에서만 보임

[올바른 접근]
Text(color = Color.onBackground)  // 배경에 따라 자동 결정

시맨틱 색상 레이어:
기본값(Hex) → 시맨틱 토큰 → 컴포넌트 색상

예시:
#1A1A1A → color.text.primary
#F5F5F5 → color.background.primary

라이트 모드:   color.text.primary = #1A1A1A
다크 모드:     color.text.primary = #F5F5F5

2. iOS 다크 모드 구현

2.1 시스템 색상 사용 (권장)

// UIKit: 시스템 시맨틱 색상
let textColor = UIColor.label           // 자동 라이트/다크 전환
let bgColor = UIColor.systemBackground  // 배경
let secondaryBg = UIColor.secondarySystemBackground
let separatorColor = UIColor.separator

// 직접 적응형 색상 생성
let adaptiveColor = UIColor { traitCollection in
    switch traitCollection.userInterfaceStyle {
    case .dark:
        return UIColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1)
    default:
        return UIColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1)
    }
}

2.2 Asset Catalog 색상 정의

Xcode Assets.xcassets에서:
Color Set 추가 → Appearances: Any, Dark 선택

Light: #1A1A1A
Dark:  #F5F5F5

자동으로 모드에 맞는 색상 적용
// SwiftUI에서 Color Asset 사용
extension Color {
    static let textPrimary = Color("TextPrimary")    // Assets의 Color Set
    static let bgPrimary = Color("BackgroundPrimary")
    static let accentColor = Color("AccentColor")
}

struct ThemedView: View {
    var body: some View {
        VStack {
            Text("안녕하세요")
                .foregroundColor(.textPrimary)
            
            Button("확인") { }
                .tint(.accentColor)
        }
        .background(.bgPrimary)
    }
}

2.3 사용자 다크 모드 선택 구현

// 앱 내 테마 설정 저장
enum AppTheme: String, CaseIterable {
    case system = "system"
    case light = "light"
    case dark = "dark"
    
    var userInterfaceStyle: UIUserInterfaceStyle {
        switch self {
        case .system: return .unspecified
        case .light:  return .light
        case .dark:   return .dark
        }
    }
}

class ThemeManager: ObservableObject {
    @Published var theme: AppTheme {
        didSet {
            UserDefaults.standard.set(theme.rawValue, forKey: "appTheme")
            apply()
        }
    }
    
    init() {
        let stored = UserDefaults.standard.string(forKey: "appTheme") ?? "system"
        theme = AppTheme(rawValue: stored) ?? .system
    }
    
    func apply() {
        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
        scene.windows.forEach { window in
            window.overrideUserInterfaceStyle = theme.userInterfaceStyle
        }
    }
}

// SwiftUI 앱 레벨 설정
@main
struct MyApp: App {
    @StateObject private var themeManager = ThemeManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(themeManager)
                .preferredColorScheme(themeManager.theme == .system ? nil :
                    themeManager.theme == .dark ? .dark : .light)
        }
    }
}

// 설정 화면
struct ThemeSettingsView: View {
    @EnvironmentObject var themeManager: ThemeManager
    
    var body: some View {
        Picker("테마", selection: $themeManager.theme) {
            Text("시스템 설정 따름").tag(AppTheme.system)
            Text("라이트").tag(AppTheme.light)
            Text("다크").tag(AppTheme.dark)
        }
        .pickerStyle(.segmented)
    }
}

3. Android 다크 모드 구현

3.1 Material 3 테마 설정

<!-- res/values/themes.xml (라이트) -->
<style name="Theme.MyApp" parent="Theme.Material3.DayNight">
    <item name="colorPrimary">@color/md_theme_primary</item>
    <item name="colorOnPrimary">@color/md_theme_onPrimary</item>
    <item name="colorBackground">@color/md_theme_background</item>
    <item name="colorOnBackground">@color/md_theme_onBackground</item>
</style>

<!-- res/values-night/themes.xml (다크) -->
<style name="Theme.MyApp" parent="Theme.Material3.DayNight">
    <item name="colorPrimary">@color/md_theme_dark_primary</item>
    <item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
    <item name="colorBackground">@color/md_theme_dark_background</item>
    <item name="colorOnBackground">@color/md_theme_dark_onBackground</item>
</style>

3.2 Jetpack Compose Material 3 다크 테마

// ui/theme/Color.kt
object AppColors {
    // 라이트 팔레트
    val light = lightColorScheme(
        primary = Color(0xFF1976D2),
        onPrimary = Color(0xFFFFFFFF),
        background = Color(0xFFFAFAFA),
        onBackground = Color(0xFF1A1A1A),
        surface = Color(0xFFFFFFFF),
        onSurface = Color(0xFF1A1A1A),
    )
    
    // 다크 팔레트
    val dark = darkColorScheme(
        primary = Color(0xFF90CAF9),
        onPrimary = Color(0xFF0D47A1),
        background = Color(0xFF121212),
        onBackground = Color(0xFFF5F5F5),
        surface = Color(0xFF1E1E1E),
        onSurface = Color(0xFFE0E0E0),
    )
}

// ui/theme/Theme.kt
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,  // Android 12+ Material You
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Material You: 배경화면 색상 추출 (Android 12+)
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> AppColors.dark
        else -> AppColors.light
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content
    )
}

3.3 사용자 테마 설정

// DataStore로 설정 저장
class ThemePreferences(private val context: Context) {
    private val dataStore = context.createDataStore("theme_prefs")
    
    companion object {
        val THEME_KEY = stringPreferencesKey("app_theme")
    }
    
    val themeFlow: Flow<String> = dataStore.data
        .map { prefs -> prefs[THEME_KEY] ?: "system" }
    
    suspend fun setTheme(theme: String) {
        dataStore.edit { prefs ->
            prefs[THEME_KEY] = theme
        }
    }
}

// ViewModel
class ThemeViewModel(private val prefs: ThemePreferences) : ViewModel() {
    val theme = prefs.themeFlow.stateIn(
        viewModelScope, 
        SharingStarted.Eagerly, 
        "system"
    )
    
    fun setTheme(theme: String) {
        viewModelScope.launch { prefs.setTheme(theme) }
    }
}

// Activity에서 적용
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        val themeVm: ThemeViewModel by viewModels()
        
        setContent {
            val theme by themeVm.theme.collectAsStateWithLifecycle()
            
            val darkTheme = when (theme) {
                "dark" -> true
                "light" -> false
                else -> isSystemInDarkTheme()
            }
            
            AppTheme(darkTheme = darkTheme) {
                MainScreen(themeVm)
            }
        }
    }
}

4. 자주 발생하는 다크 모드 버그

[버그 1] 하드코딩된 색상
증상: 특정 텍스트가 다크 모드에서 보이지 않음
원인: color = Color.Black 같은 하드코딩
수정: color = MaterialTheme.colorScheme.onBackground

[버그 2] 이미지 배경색
증상: 투명 PNG가 흰 배경으로 표시됨
원인: 이미지 배경이 흰색으로 가정
수정: 이미지에 tint 또는 다크 모드용 별도 이미지

[버그 3] WebView 내 다크 모드
증상: 앱은 다크인데 WebView 내부는 밝음
수정:
// Android
webView.settings.forceDark = WebSettings.FORCE_DARK_AUTO

// iOS
webView.underPageBackgroundColor = .systemBackground

[버그 4] 스플래시 화면 색상
증상: 앱 로딩 시 스플래시가 항상 밝음
수정:
// iOS: LaunchScreen.storyboard에 Dynamic Color 사용
// Android: themes.xml의 windowBackground에 dynamic color

5. 다크 모드 QA 체크리스트

[ ] 시스템 설정에서 다크/라이트 전환 시 즉시 반영되는가?
[ ] 앱 내 테마 설정이 앱 재시작 후에도 유지되는가?
[ ] 모든 텍스트가 배경과 충분한 대비를 갖는가? (WCAG AA: 4.5:1)
[ ] 아이콘과 이미지가 두 모드에서 모두 잘 보이는가?
[ ] 외부 링크(WebView, 브라우저)에서도 다크 모드가 적용되는가?
[ ] 키보드, 얼럿, 시트 등 시스템 UI가 테마와 맞는가?
[ ] 스크린샷/공유 시 현재 테마가 반영되는가?

마무리

다크 모드의 핵심은 색상을 값이 아닌 의미로 정의하는 것이다. #1A1A1A가 아니라 onBackground로 정의하면 시스템이 모드에 맞게 자동으로 전환해준다.

처음부터 시맨틱 색상 시스템을 갖추면 다크 모드 추가가 간단하지만, 나중에 적용하면 하드코딩된 색상을 모두 찾아서 바꿔야 하는 고된 작업이 된다. 새 프로젝트라면 지금 바로 시맨틱 색상 시스템을 도입하라.