Jetpack Compose 초심자 가이드 8: 테마와 스타일링으로 앱 디자인 시스템 구성하기 🚀

윤성현·2025년 7월 20일
post-thumbnail

📌 개요

이전 글에서는 Jetpack Compose Navigation을 활용해 여러 화면을 연결하고, 화면 간 데이터 전달과 백스택 관리를 통해 완성도 있는 앱의 흐름을 구성해보았습니다.

이제 여러 화면이 있는 앱을 만들 수 있게 되었으니, 앱 전체에 일관된 디자인과 스타일을 적용하는 방법을 알아볼 차례입니다. 사용자에게 통일감 있고 세련된 경험을 제공하기 위해서는 색상, 글꼴, 모양 등의 디자인 요소를 체계적으로 관리해야 합니다.

이번 글에서는 Jetpack Compose의 Material Theme을 중심으로 앱의 디자인 시스템을 구축하고, 다크 모드 대응, 커스텀 색상 정의, 타이포그래피(글꼴) 설정 등을 실습해보겠습니다.

1. Material Theme이란?

Material Theme은 Google의 Material Design 가이드라인을 Jetpack Compose 환경에 맞게 구현한 디자인 시스템입니다. 앱 전반에 걸쳐 일관된 색상, 글꼴, 모양을 적용할 수 있도록 도와주는 핵심 도구입니다.

1-1. Material Design 3

Jetpack Compose는 Material Design 3를 기본으로 지원합니다. 이를 통해 색상, 글꼴, 모양, 컴포넌트 등 앱 전체에 걸친 디자인 시스템을 쉽게 구현할 수 있습니다.

Material Theme의 핵심 구성 요소

구성 요소설명주요 역할
ColorScheme의미를 기반으로 색상 체계 정의primary, secondary, background, surface 등
Typography글꼴과 텍스트 스타일 정의제목, 본문, 캡션 등의 텍스트 크기와 스타일
ShapesUI 요소의 모양 정의버튼, 카드, 입력 필드의 모서리 둥근 정도

1-2. 기본 Material Theme 구조

@Composable
fun MyApp() {
    MaterialTheme(
        colorScheme = lightColorScheme(), // 색상 설정
        typography = Typography,        // 글꼴 설정
        shapes = Shapes,                // 모양 설정
    ) {
        // 앱의 모든 컴포저블이 이 테마를 상속받음
        MainContent()
    }
}

MaterialTheme이 제공하는 장점

  • 일관성: 앱 전반에 걸쳐 통일된 디자인 원칙을 쉽게 적용할 수 있습니다.
  • 다크 모드 대응: 시스템 설정에 따라 라이트/다크 테마 전환이 자동으로 이루어집니다.
  • 접근성 향상: 색상 대비, 글자 크기 등 접근성 가이드라인을 준수하기 쉬워집니다.

2. 색상 시스템 이해하기

Material Design 3에서는 색상을 역할과 목적에 따라 체계적으로 분류합니다. 단순히 "빨간색", "파란색"이 아니라 "Primary", "Secondary", "Background" 같은 의미적 이름을 사용합니다.

2-1. 주요 색상 역할

@Composable
fun ColorSystemExample() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // Primary: 앱의 주요 브랜드 색상
        Card(
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.primaryContainer
            )
        ) {
            Text(
                text = "Primary Container",
                color = MaterialTheme.colorScheme.onPrimaryContainer,
                modifier = Modifier.padding(16.dp)
            )
        }

        // Secondary: 보조 색상
        Card(
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.secondaryContainer
            )
        ) {
            Text(
                text = "Secondary Container",
                color = MaterialTheme.colorScheme.onSecondaryContainer,
                modifier = Modifier.padding(16.dp)
            )
        }

        // Surface: 카드, 시트 등 표면 색상
        Card(
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.surface
            )
        ) {
            Text(
                text = "Surface",
                color = MaterialTheme.colorScheme.onSurface,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

2-2. 색상 이름 규칙

Material Theme의 색상은 색상 + 역할 조합으로 이름이 정해집니다:

색상 그룹기본 색상컨테이너 색상텍스트 색상
PrimaryprimaryprimaryContaineronPrimary, onPrimaryContainer
SecondarysecondarysecondaryContaineronSecondary, onSecondaryContainer
Backgroundbackground-onBackground
SurfacesurfacesurfaceContaineronSurface

"on" 접두사의 의미:

  • onPrimary: Primary 색상 위에 올라가는 텍스트/아이콘 색상
  • onBackground: Background 색상 위에 올라가는 콘텐츠 색상
  • 자동으로 적절한 대비를 제공해 가독성을 보장

3. 테마 구성과 적용

Material Theme을 직접 정의하면 앱의 브랜드 스타일을 반영할 수 있으며, 라이트/다크 테마 전환도 시스템 설정에 따라 자동으로 또는 사용자 설정에 따라 유연하게 지원할 수 있습니다.

3-1. 커스텀 색상 정의하기

먼저 ui/theme/Color.kt 파일에서 사용할 색상을 정의합니다.

// ui/theme/Color.kt
import androidx.compose.ui.graphics.Color

// 브랜드 기본 색상 정의
val BrandPrimary = Color(0xFF6750A4)
val BrandPrimaryLight = Color(0xFF9A82DB)
val BrandPrimaryDark = Color(0xFF381E72)

val BrandSecondary = Color(0xFF625B71)
val BrandSecondaryLight = Color(0xFF8B8499)
val BrandSecondaryDark = Color(0xFF3D3848)

// 시스템 색상
val BrandBackground = Color(0xFFFFFBFE)
val BrandSurface = Color(0xFFFFFBFE)
val BrandError = Color(0xFFBA1A1A)

// 다크 테마용 색상
val BrandBackgroundDark = Color(0xFF1C1B1F)
val BrandSurfaceDark = Color(0xFF1C1B1F)
val BrandErrorDark = Color(0xFFFFB4AB)

3-2. 라이트/다크 테마 구성하기

이제 위에서 정의한 색상을 기반으로 ColorScheme을 생성합니다.

// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

private val LightColorScheme = lightColorScheme(
    primary = BrandPrimary,
    onPrimary = Color.White,
    primaryContainer = BrandPrimaryLight,
    onPrimaryContainer = BrandPrimaryDark,

    secondary = BrandSecondary,
    onSecondary = Color.White,
    secondaryContainer = BrandSecondaryLight,
    onSecondaryContainer = BrandSecondaryDark,

    background = BrandBackground,
    onBackground = Color(0xFF1C1B1F),
    surface = BrandSurface,
    onSurface = Color(0xFF1C1B1F),

    error = BrandError,
    onError = Color.White
)

private val DarkColorScheme = darkColorScheme(
    primary = BrandPrimaryLight,
    onPrimary = BrandPrimaryDark,
    primaryContainer = BrandPrimaryDark,
    onPrimaryContainer = BrandPrimaryLight,

    secondary = BrandSecondaryLight,
    onSecondary = BrandSecondaryDark,
    secondaryContainer = BrandSecondaryDark,
    onSecondaryContainer = BrandSecondaryLight,

    background = BrandBackgroundDark,
    onBackground = Color(0xFFE6E1E5),
    surface = BrandSurfaceDark,
    onSurface = Color(0xFFE6E1E5),

    error = BrandErrorDark,
    onError = Color(0xFF690005)
)

3-3. 테마 적용 함수 만들기

테마를 실제 앱에 적용할 수 있도록 MyAppTheme 컴포저블을 정의합니다. 이 테마 함수(MyAppTheme)는 앱의 전체 UI에 색상, 글꼴, 모양 등 일관된 디자인 시스템을 적용하는 역할을 합니다.

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(), // 시스템 다크모드 자동 감지
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        shapes = Shapes,
        content = content
    )
}

3-4. 실제 앱에 테마 적용하기

MainActivity에서 MyAppTheme로 앱의 컴포저블 트리를 감싸면 전체 UI에 테마가 적용됩니다.

// MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                MyAppContent()
            }
        }
    }
}

3-5. 수동으로 다크 모드 토글하기

사용자가 직접 라이트/다크 모드를 전환할 수 있는 예제도 구현할 수 있습니다.

@file:OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DarkModeToggleExample() {
    var isDarkMode by remember { mutableStateOf(false) }

    MyAppTheme(darkTheme = isDarkMode) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("다크 모드 예제") },
                    actions = {
                        IconButton(onClick = { isDarkMode = !isDarkMode }) {
                            Icon(
                                imageVector = if (isDarkMode)
                                    Icons.Filled.Star // 다크 모드 ON 상태
                                else
                                    Icons.Filled.Settings, // 라이트 모드 ON 상태
                                contentDescription = "테마 전환"
                            )
                        }
                    }
                )
            },
            containerColor = MaterialTheme.colorScheme.background
        ) { innerPadding ->
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(innerPadding)
                    .background(MaterialTheme.colorScheme.background),
                contentAlignment = Alignment.Center
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(
                        text = if (isDarkMode) "🌙 다크 모드 활성화됨" else "☀️ 라이트 모드 활성화됨",
                        color = MaterialTheme.colorScheme.onBackground,
                        style = MaterialTheme.typography.headlineSmall
                    )
                    Spacer(modifier = Modifier.height(24.dp))
                    Button(onClick = { isDarkMode = !isDarkMode }) {
                        Text("테마 전환")
                    }
                }
            }
        }
    }
}

3-6. 요약

항목설명
색상 정의브랜드 색상 및 시스템 색상 직접 정의
라이트/다크 구성lightColorScheme, darkColorScheme로 두 가지 테마 설정
테마 적용MaterialTheme를 통해 전체 UI에 스타일 적용
수동 전환사용자가 버튼으로 테마 전환 가능

4. 타이포그래피 (Typography) 설정

타이포그래피는 앱의 모든 텍스트에 일관된 스타일을 제공합니다. Material Design 3에서는 다양한 텍스트 역할을 위한 미리 정의된 스타일을 제공합니다.

4-1. 기본 텍스트 스타일

@Composable
fun TypographyExample() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(
            text = "Display Large",
            style = MaterialTheme.typography.displayLarge
        )
        Text(
            text = "Headline Large",
            style = MaterialTheme.typography.headlineLarge
        )
        Text(
            text = "Title Large",
            style = MaterialTheme.typography.titleLarge
        )
        Text(
            text = "Body Large - 일반적인 본문 텍스트에 사용됩니다.",
            style = MaterialTheme.typography.bodyLarge
        )
        Text(
            text = "Body Medium - 조금 더 작은 본문 텍스트입니다.",
            style = MaterialTheme.typography.bodyMedium
        )
        Text(
            text = "Label Small - 버튼이나 작은 라벨에 사용",
            style = MaterialTheme.typography.labelSmall
        )
    }
}

4-2. 커스텀 폰트 적용

앱에 독특한 개성을 부여하려면 커스텀 폰트를 사용할 수 있습니다.

1단계: 폰트 파일 추가

  • app/src/main/res/font/ 폴더에 .ttf 또는 .otf 파일 추가
  • 예: noto_sans_kr_bold.ttf, noto_sans_kr_regular.ttf

2단계: FontFamily 정의

// ui/theme/Type.kt
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight

val NotoSansKr = FontFamily(
    Font(R.font.noto_sans_kr_regular, FontWeight.Normal),
    Font(R.font.noto_sans_kr_bold, FontWeight.Bold)
)

3단계: Typography 정의

val Typography = Typography(
    displayLarge = TextStyle(
        fontFamily = NotoSansKr,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp,
        letterSpacing = (-0.25).sp,
    ),
    headlineLarge = TextStyle(
        fontFamily = NotoSansKr,
        fontWeight = FontWeight.Bold,
        fontSize = 32.sp,
        lineHeight = 40.sp,
        letterSpacing = 0.sp,
    ),
    bodyLarge = TextStyle(
        fontFamily = NotoSansKr,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp,
    ),
    // 필요한 다른 스타일들도 정의...
)

4단계: 테마에 적용

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography, // 커스텀 타이포그래피 적용
        content = content
    )
}

5. 모양 (Shapes) 커스터마이징

Shapes는 버튼, 카드, 입력 필드 등 UI 요소의 모서리 둥근 정도를 정의합니다.

5-1. 기본 Shapes 이해

@Composable
fun ShapesExample() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        // Small Shape (버튼 등에 사용)
        Card(
            shape = MaterialTheme.shapes.small,
            modifier = Modifier
                .fillMaxWidth()
                .height(60.dp)
        ) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.fillMaxSize()
            ) {
                Text("Small Shape (4dp)")
            }
        }

        // Medium Shape (카드 등에 사용)
        Card(
            shape = MaterialTheme.shapes.medium,
            modifier = Modifier
                .fillMaxWidth()
                .height(60.dp)
        ) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.fillMaxSize()
            ) {
                Text("Medium Shape (8dp)")
            }
        }

        // Large Shape (시트, 다이얼로그 등에 사용)
        Card(
            shape = MaterialTheme.shapes.large,
            modifier = Modifier
                .fillMaxWidth()
                .height(60.dp)
        ) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.fillMaxSize()
            ) {
                Text("Large Shape (16dp)")
            }
        }
    }
}

5-2. 커스텀 Shapes 정의

// ui/theme/Shape.kt
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp

val Shapes = Shapes(
    extraSmall = RoundedCornerShape(2.dp),   // 칩, 작은 요소 (거의 직각에 가까운 형태)
    small = RoundedCornerShape(8.dp),        // 버튼
    medium = RoundedCornerShape(12.dp),      // 카드, 다이얼로그
    large = RoundedCornerShape(20.dp),       // 큰 카드, 시트
    extraLarge = RoundedCornerShape(28.dp)   // 전체 화면 요소
)

6. 예시로 보는 테마 활용 팁

6-1. 색상 사용 가이드라인

// ✅ 좋은 예: 의미적 색상 사용
Text(
    text = "에러 메시지",
    color = MaterialTheme.colorScheme.error
)

Card(
    colors = CardDefaults.cardColors(
        containerColor = MaterialTheme.colorScheme.primaryContainer
    )
) { /* 내용 */ }

// ❌ 피해야 할 예: 하드코딩된 색상
Text(
    text = "에러 메시지",
    color = Color.Red // 다크 모드에서 문제 발생 가능
)

6-2. 접근성 고려사항

// 색상 대비 자동 보장
Text(
    text = "콘텐츠",
    color = MaterialTheme.colorScheme.onPrimary, // Primary 배경에 적절한 텍스트 색상
    modifier = Modifier
        .background(MaterialTheme.colorScheme.primary)
        .padding(16.dp)
)

6-3. 일관성 유지

// 같은 종류의 요소는 같은 스타일 사용
@Composable
fun ConsistentButtons() {
    Column {
        Button(
            onClick = { },
            shape = MaterialTheme.shapes.small // 모든 버튼에 동일한 shape
        ) {
            Text("버튼 1")
        }

        Button(
            onClick = { },
            shape = MaterialTheme.shapes.small // 일관성 유지
        ) {
            Text("버튼 2")
        }
    }
}

7. 정리

이번 글에서는 Jetpack Compose의 MaterialTheme을 중심으로, 앱 전반에 통일된 디자인 시스템을 적용하는 방법을 단계별로 알아보았습니다.

Compose 앱에서 색상, 글꼴, 모양 등 다양한 스타일 요소를 직접 정의하고, 라이트/다크 모드 대응까지 커스터마이징하는 과정을 실습해보며, 앱의 개성과 일관성을 함께 갖춘 UI를 만드는 방법을 익혔습니다.

📌 이번 글에서 다룬 주요 내용

  • MaterialTheme의 구성 요소: ColorScheme, Typography, Shapes
  • 의미 기반 색상 시스템과 onPrimary, onSurface 등의 역할
  • 라이트/다크 모드 대응을 위한 색상 테마 구성
  • 커스텀 폰트 적용과 타이포그래피 설정
  • UI 요소의 둥근 모양을 설정하는 Shapes 커스터마이징
  • 테마를 전역에 적용하는 방법과 다크 모드 토글 구현
  • 접근성과 일관성을 고려한 디자인 팁 정리

Jetpack Compose의 테마 시스템을 잘 활용하면, 디자인과 개발의 경계를 허물고 사용자 경험과 유지보수성을 동시에 향상시킬 수 있습니다.

🎯 다음 글 예고: Jetpack Compose에서 애니메이션 적용하기

앱이 기능적으로 완성되었다고 해서, 사용자 경험이 곧바로 뛰어난 것은 아닙니다. 화면 전환이 너무 갑작스럽거나, 버튼을 눌러도 변화가 뚝 끊기는 느낌이라면 사용자는 쉽게 이탈하게 되죠.

다음 글에서는 Jetpack Compose에서 애니메이션을 활용하는 방법을 소개할 예정입니다. 단순히 예쁘게 만드는 것을 넘어서, 앱의 흐름을 부드럽게 연결하고, 사용자의 행동에 자연스럽게 반응하는 UI를 어떻게 구현할 수 있는지 함께 알아보겠습니다.

  • 요소가 나타나고 사라지는 전환 효과
  • 상태 변화에 따른 부드러운 화면 변화
  • 사용자 입력에 유연하게 반응하는 인터랙션

이런 요소들을 통해 앱은 훨씬 더 자연스럽고 몰입감 있게 다가갈 수 있습니다. 다음 글에서는 이러한 애니메이션을 실제 예제와 함께 체험해보며, 사용자 경험을 향상시키는 방법을 익혀보겠습니다. 기대해주세요! 🚀

0개의 댓글