안드로이드 위클리 공부 노트

#749

디자인 시스템은 계약이다 — Konsist와 Android Lint로 어기지 못하게 하기

원문 Your Design System Is a Contract. Enforce It with Konsist and Android Lint, Ravi Rawal

한 줄 요약

디자인 시스템(앱 전체가 함께 쓰는 색·글자·간격·버튼 같은 정해진 값과 부품)은 문서로 남겨 두고 리뷰에서 지켜 달라고 부탁하는 것만으로는 몇 달 안에 무너진다. 이 글의 주장은 디자인 시스템을 "개발 계약"으로 보고, 계약을 어기는 코드가 PR 검사에서 자동으로 빨간불이 되게 만들라는 것이다. 규칙의 종류에 따라 맞는 도구가 다르다. 구조(무엇이 무엇을 import 하나)는 Konsist, 코드에 금지된 모양이 있는지(기능 코드의 Color(0xFF…) 등)는 직접 만든 Android Lint 규칙, 실제로 그려진 화면의 동작은 Compose UI 테스트가 맡는다. 저자는 하드코딩된 색을 잡는 Lint 규칙 하나를 처음부터 끝까지(검사기·등록·모듈 설정·테스트) 만들어 보인다.

원문은 Medium(ProAndroidDev)에 실렸고 별도 라이선스 표시가 없으며, 코드를 담은 공개 저장소도 없다. 그래서 이 글의 코드는 원문 코드를 옮긴 것이 아니라, 원문이 설명하는 구조를 따라 예시 이름과 값을 바꿔 직접 쓴 스케치다. 예시 앱은 원문의 쇼핑 앱(com.acme) 대신 메모 앱(com.example.memo)으로 바꿨다. 원문 그림 두 장 중 표지 그림은 한국어로 다시 그렸고, 규칙과 도구를 짝지은 표 그림은 본문의 표로 옮겼다. 원문 주소의 이름(konsist-design-system-validator-…)과 지금 제목이 다른데, 저자가 글을 올린 뒤 제목을 바꾼 것으로 보인다.

먼저 알아 둘 말

서론 — 디자인 시스템이 조금씩 무너지는 이유

왼쪽은 색·간격·글자·모양 토큰과 공용 버튼으로 된 계약, 가운데는 Konsist·직접 만든 Lint 규칙·Compose UI 테스트의 세 겹 검사, 오른쪽은 세 검사를 통과한 기능 화면들과 규칙을 어겨 PR이 빨간불이 되는 경우
디자인 시스템이라는 계약과 그것을 지키게 하는 세 겹의 검사. 원문 표지 그림을 바탕으로 다시 그림 (원문)

1. 계약이 무엇인지부터 정한다

검사 코드를 쓰기 전에 "모든 기능이 지켜야 할 규칙"을 먼저 목록으로 만든다. 그리고 규칙마다 어떤 도구로 지킬지 고른다.

디자인 시스템 규칙 알맞은 검사 도구
기능 모듈은 디자인 시스템의 내부 패키지에 의존하면 안 된다. Konsist로 검사한다.
기능 코드는 숫자로 색을 직접 정의하면 안 된다. 직접 만든 Android Lint 규칙으로 검사한다.
기능 화면은 정해진 글자 토큰을 써야 한다. 직접 만든 Android Lint 규칙으로 검사한다.
컴포넌트는 정해진 간격과 크기를 써야 한다. 직접 만든 Android Lint 규칙으로 검사한다.
기능은 정해진 버튼과 카드 컴포넌트를 써야 한다. 직접 만든 Android Lint 규칙으로 검사하고, 필요하면 구조 검사를 더한다.
디자인 시스템의 내부 구현은 기능에서 닿을 수 없어야 한다. 모듈 공개 범위(Kotlin internal 등), Gradle 의존 설정, Konsist로 막는다.
글씨를 크게 키워도 글자가 읽혀야 한다. Compose UI 테스트로 확인한다.
컴포넌트는 접근성 정보(화면 읽기 프로그램이 읽을 설명)를 내보내야 한다. Compose UI 테스트로 확인한다.
컴포넌트는 로딩·오류·비활성 상태에서 올바르게 동작해야 한다. Compose UI 테스트로 확인한다.

2. 토큰으로 디자인 시스템의 기준을 세운다

지키게 하려면 먼저 "무엇이 정답인지" 한 곳에 있어야 한다.

// 직접 쓴 스케치 — 화면 코드가 디자인 결정을 스스로 내리고 있다
@Composable
fun NoteDetailScreen() {
    Column(modifier = Modifier.padding(15.dp)) {
        Text(text = "메모 상세", fontSize = 21.sp)
        Spacer(modifier = Modifier.height(11.dp))
        Button(onClick = {}, modifier = Modifier.height(45.dp)) {
            Text("저장")
        }
    }
}

① 간격과 크기

// 직접 쓴 스케치 — com.example.memo.designsystem.tokens
object Spacing {
    val xs = 4.dp
    val sm = 8.dp
    val md = 16.dp
    val lg = 24.dp
    val xl = 32.dp
}

object Sizes {
    val button = 48.dp
    val iconS = 16.dp
    val iconM = 24.dp
    val iconL = 32.dp
}

// 기능 코드는 이제 숫자 대신 뜻을 말한다
Column(modifier = Modifier.padding(Spacing.md)) { /* 화면 내용 */ }

② 글자

// 직접 쓴 스케치 — 글자 역할에 값을 채운다
val MemoTypography = Typography(
    titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp),
    bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp),
)

// 기능 코드는 역할 이름으로 쓴다
Text(text = "메모 상세", style = MaterialTheme.typography.titleLarge)

③ 색과 모양

// 직접 쓴 스케치
Text(text = "동기화됨", color = MaterialTheme.colorScheme.primary)

3. Konsist로 구조의 경계를 지킨다

Konsist는 패키지 구조·의존·이름 규칙 같은 "소스 수준의 아키텍처 규칙"을 테스트로 쓰게 해 준다. 디자인 시스템을 공개 API와 내부 구현으로 나눴을 때 특히 쓸모 있다.

com.example.memo
├── feature
│   ├── home
│   └── note
└── designsystem
    ├── components     ← 기능이 써도 되는 공개 컴포넌트
    ├── tokens         ← 기능이 써도 되는 공개 토큰
    └── internal
        └── palette    ← 디자인 시스템 안에서만 쓰는 원재료 색
// 직접 쓴 스케치 — 원문과 같은 Konsist 아키텍처 API를 쓰되 이름을 바꿨다
class DesignSystemBoundaryTest {

    @Test
    fun `기능 코드는 디자인 시스템 내부를 쓰지 않는다`() {
        Konsist.scopeFromProject()
            .assertArchitecture {
                val features = Layer("기능", "com.example.memo.feature..")
                val dsInternal = Layer("디자인 시스템 내부", "com.example.memo.designsystem.internal..")
                features.doesNotDependOn(dsInternal)
            }
    }
}

4. Konsist로 더 지킬 수 있는 것, 그리고 한계

5. 직접 만든 Lint 규칙으로 디자인 시스템 위반을 잡는다

Android Lint는 소스를 분석해 문제를 보고하고, 팀이 검사기를 직접 만들어 넣을 수 있는 API를 준다. 디자인 시스템에서는 팀 규칙에 어긋나는 코드 모양을 찾게 할 수 있다.

6. 기능 코드에서 숫자로 색 만들기를 막는다

val accent = Color(0xFF…) 한 줄은 지금 브랜드 색과 같은 값일 수도 있다. 그래도 문제다. 디자인 시스템이 가져야 할 결정을 기능이 갖게 되기 때문이다.

// 직접 쓴 스케치 — 원문 검사기의 구조를 따랐다
class RawColorDetector : Detector(), SourceCodeScanner {

    // ① Compose Color 를 만드는 호출만 보겠다고 Lint 에게 알린다
    override fun getApplicableConstructorTypes() = listOf("androidx.compose.ui.graphics.Color")

    override fun visitConstructor(context: JavaContext, node: UCallExpression, constructor: PsiMethod) {
        // ② 색을 정의해도 되는 패키지라면 넘어간다
        val pkg = context.uastFile?.packageName.orEmpty()
        if (ALLOWED.any { pkg == it || pkg.startsWith("$it.") }) return

        // ③ 인자 중에 숫자 글자가 있을 때만 보고한다
        val hasNumber = node.valueArguments.any { it is ULiteralExpression && it.value is Number }
        if (!hasNumber) return

        context.report(
            ISSUE, node, context.getLocation(node),
            "디자인 시스템 색이나 MaterialTheme.colorScheme 의 역할 색을 쓰세요. 숫자로 Color 를 만들지 마세요.",
        )
    }

    companion object {
        private val ALLOWED = setOf(
            "com.example.memo.designsystem.tokens",
            "com.example.memo.designsystem.internal.palette",
        )

        @JvmField
        val ISSUE = Issue.create(
            id = "RawDesignColor",
            briefDescription = "숫자로 만든 디자인 색",
            explanation = "기능 코드는 디자인 시스템 색이나 MaterialTheme 의 역할 색을 써야 한다. " +
                "숫자 색 정의는 디자인 시스템 패키지에만 둔다.",
            category = Category.CORRECTNESS,
            priority = 8,
            severity = Severity.ERROR,
            implementation = Implementation(RawColorDetector::class.java, Scope.JAVA_FILE_SCOPE),
        )
    }
}

7. 문제 정의를 등록한다

Lint가 직접 만든 Issue를 찾으려면 등록표(IssueRegistry)가 필요하다.

// 직접 쓴 스케치
class MemoIssueRegistry : IssueRegistry() {
    override val issues = listOf(RawColorDetector.ISSUE)
    override val api: Int = CURRENT_API   // 지금 컴파일에 쓴 Lint API 버전
    override val minApi: Int = 8          // 이 규칙이 돌 수 있는 가장 낮은 Lint API 버전
    override val vendor = Vendor(
        vendorName = "Memo 앱 팀",
        feedbackUrl = "https://example.com/memo/issues",
        contact = "android@example.com",
    )
}
memo-lint/
└── src/main/resources/
    └── META-INF/services/
        └── com.android.tools.lint.client.api.IssueRegistry

8. 규칙 모듈을 설정한다

흔한 형태는 JVM(Android가 아닌 순수 Kotlin/Java) 모듈 하나에 Lint 규칙을 모으는 것이다. 컴파일에는 Lint API가, 단위 테스트에는 Lint 테스트 도구가 필요하다.

// 직접 쓴 스케치 — memo-lint/build.gradle.kts
plugins { kotlin("jvm") }

dependencies {
    compileOnly("com.android.tools.lint:lint-api:$lintVersion")
    testImplementation("com.android.tools.lint:lint-tests:$lintVersion")
    testImplementation("junit:junit:4.13.2")
}
// 직접 쓴 스케치 — app/build.gradle.kts
dependencies {
    lintChecks(project(":memo-lint"))
}

9. 강제하기 전에 규칙부터 테스트한다

테스트 없는 검사기는 "어느 날 조용히 일을 안 하게 되는 코드"가 하나 더 생긴 것뿐이다.

// 직접 쓴 스케치 — 원문 테스트의 구조를 따랐다
class RawColorDetectorTest : LintDetectorTest() {

    override fun getDetector() = RawColorDetector()
    override fun getIssues() = listOf(RawColorDetector.ISSUE)

    // 테스트 환경에는 Compose 가 없으니, 아주 작은 가짜 Color 를 넣어 준다
    private val colorStub = kotlin(
        """
        package androidx.compose.ui.graphics
        class Color(val value: Long) { constructor(value: Int) : this(value.toLong()) }
        """,
    ).indented()

    @Test
    fun `기능 코드의 숫자 색은 오류`() {
        lint().files(
            colorStub,
            kotlin(
                """
                package com.example.memo.feature.home
                import androidx.compose.ui.graphics.Color
                val tag = Color(0xFF2B7A4BL)
                """,
            ).indented(),
        ).run()
            .expectErrorCount(1)
            .expectContains("[RawDesignColor]")
    }

    @Test
    fun `토큰 패키지의 숫자 색은 허용`() {
        lint().files(
            colorStub,
            kotlin(
                """
                package com.example.memo.designsystem.tokens
                import androidx.compose.ui.graphics.Color
                val brand = Color(0xFF2B7A4BL)
                """,
            ).indented(),
        ).run()
            .expectClean()
    }
}

10. 검사기의 한계를 안다

이 첫 검사기는 색이 디자인 시스템을 우회하는 모든 길을 막지 못한다. 원문이 드는 빈틈:

11. 같은 방법을 글자·간격·모양·이름으로 넓힌다

첫 규칙이 잘 돌면 다른 규칙도 더할 수 있다. 다만 모든 규칙을 거대한 검사기 하나에 넣지 말고, 규칙마다 목적·범위·테스트를 따로 둔다.

① 글자

② 간격과 크기

③ 모양

④ 이름 규칙

12. 토큰만이 아니라 컴포넌트 재사용도 계약에 넣는다

토큰을 가운데서 관리하면 값 하나하나가 흩어지는 것은 막는다. 하지만 컴포넌트의 동작까지 같게 만들지는 못한다.

// 직접 쓴 스케치 — 디자인 시스템이 제공하는 공용 버튼
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, enabled: Boolean = true) {
    Button(onClick = onClick, enabled = enabled, modifier = Modifier.height(Sizes.button)) {
        Text(text = text, style = MaterialTheme.typography.labelLarge)
    }
}

13. 실행해야 알 수 있는 것은 Compose UI 테스트로

정적 분석은 소스 모양을 찾을 수 있지만, 그려진 화면의 모든 성질을 증명하지는 못한다.

14. 위반을 CI에서 보이게 한다

규칙은 늘 같은 방식으로 돌아야 효과가 있다. 구조 테스트와 Lint 검사를 평소 검증 단계에 넣는다.

15. 개발을 멈추지 않고 규칙을 들여온다

몇 년 치 들쭉날쭉한 UI 코드가 있는 프로젝트에서 첫날부터 모든 규칙을 막는 오류로 켜면, 실패가 쏟아진다.

16. 저자의 정리 — 디자인 시스템이 개발 흐름의 일부가 된다

원문을 읽을 때 주의할 점

코드로 보기 — Aide 구조로 옮긴 스케치

원문 코드가 아니다. Aide에는 이미 Konsist 테스트를 모아 둔 coding-convention 모듈(Konsist 0.18.1, JUnit 5)이 있고, CI가 PR마다 ./gradlew assembleDebug test lint 를 돌린다. 원문의 "숫자로 색 만들기 금지"를 새 Lint 모듈 없이 이 Konsist 모듈에 먼저 넣으면 어떤 모양일지 직접 쓴 스케치다. hasTextMatching 과 assertFalse(additionalMessage = …) 는 Konsist 0.18.1에 있는 것을 확인했지만, 이 테스트 자체를 돌려 보지는 않았다.

// coding-convention/src/test/kotlin/com/giwon/aide/konsist/DesignTokenTest.kt

class DesignTokenTest {

    // ① 기능 화면 코드만 본다. 테스트 소스와 디자인 시스템은 뺀다.
    private val featureMainFiles = Konsist.scopeFromProject()
        .files
        .filter { it.packagee?.name?.startsWith("com.giwon.aide.feature.") == true }
        .filter { it.sourceSetName == "main" }

    @Test
    fun `feature 는 숫자로 Compose 색을 만들지 않는다`() {
        featureMainFiles.assertFalse(
            // ② 원문의 "세 질문"에 답하는 문구 — 무엇을 어겼나 · 왜 · 대신 무엇을
            additionalMessage = "기능 코드에서 Color(0x…) 로 색을 만들었습니다. 색은 core:designsystem 이 정합니다. " +
                "MaterialTheme.colorScheme 의 역할 색이나 AideColors 의 이름을 쓰세요.",
        ) { file -> file.hasTextMatching(Regex("""\bColor\(\s*0x""")) }
    }

    @Test
    fun `feature 는 글자 크기를 직접 정하지 않는다`() {
        featureMainFiles.assertFalse(
            additionalMessage = "fontSize = …sp 대신 MaterialTheme.typography 의 역할을 쓰세요.",
        ) { file -> file.hasTextMatching(Regex("""fontSize\s*=\s*\d""")) }
    }
}

배운 점

Aide에 적용하면

Aide는 아직 화면 코드가 거의 없고, core:designsystem 모듈은 빈 껍데기이며, coding-convention 모듈에 Konsist 규칙 5개가 이미 있다. 위반이 0인 지금이 규칙을 켜기 가장 싼 때다.

  1. 토큰을 core:designsystem 한 곳에 모은다 — 가져온다. 시점은 Task 4와 Task 6이다.
    • Task 4에서 AideTheme 를 만들 때 색은 MaterialTheme.colorScheme 역할에, 글자는 Typography 역할에 채운다. 간격은 AideSpacing 같은 이름 하나에 모은다.
    • Task 6에서 Note-Android의 Gray·Orange·White·Font 를 core:designsystem 으로 옮길 때, 계획대로 실제 쓰는 것만 가져오고 이름은 그대로 둔다(Task 6 원칙 "UI 코드는 고치지 않는다").
  2. 디자인 시스템 내부 숨기기 — 바꿔서 가져온다. Konsist 테스트가 필요 없다.
    • 원문은 같은 모듈 안의 designsystem.internal 패키지를 Konsist로 막는다. Aide의 디자인 시스템은 따로 떨어진 Gradle 모듈(core:designsystem)이다.
    • 그래서 숨길 것에 Kotlin의 internal 을 붙이면 다른 모듈에서는 컴파일부터 안 된다. 원문의 표에서도 이 규칙의 첫 도구는 "모듈 공개 범위"였다. Konsist 테스트보다 더 일찍, 더 확실하게 막힌다.
  3. 기능 코드의 Color(0x…)·fontSize = 숫자 금지 — 바꿔서 가져온다. Lint 모듈 대신 Konsist 글자 검사로 시작한다.
    • 위 스케치처럼 이미 있는 coding-convention 에 테스트 두 개를 더한다. 새 모듈도, CI 수정도 필요 없다. CI의 test 가 이미 :coding-convention:test 를 포함한다.
    • 시점은 첫 Compose 화면 코드가 생기는 Task 8(첫 화면 대시보드) 직전이다. 계획서의 TDD 방식대로 "일부러 Color(0xFF…) 를 넣어 빨간불 확인 → 지워서 초록불"로 확인한다.
    • 충돌하는 결정이 있다. Task 6은 Note-Android 캘린더 UI를 feature:calendar 로 고치지 않고 옮긴다고 정했다. 그 코드에 색이나 글자 크기가 직접 적혀 있으면 이 규칙에 걸린다. 두 가지 중 하나를 정해야 한다.
      • 이식한 캘린더 패키지만 규칙에서 빼고, 빼는 이유를 테스트 코드에 주석으로 남긴다.
      • 이식하면서 그 값들만 core:designsystem 으로 옮긴다. 다만 이는 "UI 코드는 고치지 않는다"를 조금 넓히는 것이다.
      • Task 6 이식이 끝난 뒤 실제 위반 개수를 보고 정하기를 추천한다.
    • 이식한 캘린더는 XML 레이아웃과 커스텀 View를 쓰므로, res/values/colors.xml 이나 android.graphics.Color 는 이 검사에 걸리지 않는다. 이 규칙은 새로 쓰는 Compose 코드를 지키는 것이다.
  4. 직접 만든 Android Lint 모듈 — 지금은 안 한다.
    • 할 일이 많다. 새 JVM 모듈을 만들어야 하고, lint-api·lint-tests 버전을 AGP에 맞춰야 한다(Aide의 AGP 9.2.1이면 Lint는 32.2.1이다. Google 저장소에 있는 것을 확인했다). 또 Lint 테스트는 JUnit 4라서, JUnit 5로 맞춘 aide.jvm.library 에 vintage 엔진을 더해야 한다.
    • 3번의 Konsist 글자 검사가 오탐(주석·문자열)이나 우회로 실제로 불편해질 때 다시 꺼낸다. 그때는 위 "주의할 점"대로 생성자가 아니라 ColorKt.Color 함수 호출을 보게 만든다.
    • 만들게 되면 CI는 고칠 필요가 없다. 앱 모듈에 lintChecks(project(":aide-lint")) 만 넣으면 지금 CI의 lint 가 함께 돌린다.
  5. dp 간격 숫자 금지 — 지금은 안 한다.
    • 원문도 말하듯 모든 dp 가 간격은 아니다. 아이콘·테두리·터치 영역까지 잡는 오탐이 많을 것이다. 간격 토큰을 먼저 써 보고, 같은 숫자가 여러 화면에서 반복되는 게 보일 때 padding( 주변으로 좁힌 규칙을 생각한다.
  6. 공용 컴포넌트를 디자인 시스템에 둔다 — 가져온다. 이미 계획에 있다.
    • 1-a 계획의 Task 15는 "권한이 필요해요 [설정 열기]"를 core:designsystem 의 공용 컴포넌트(PermissionNeeded)로 만든다고 적어 두었다. 원문 12장과 같은 생각이다.
    • 같은 버튼·카드가 두 기능에서 처음 반복될 때, 기능 안에 복사하지 말고 core:designsystem 으로 올린다.
  7. Compose UI 테스트 — 1-a에서는 안 한다. 큰 글씨 확인만 실기기에서 한다.
    • 1-a 계획은 화면을 실기기 확인으로 검증한다. Task 18(1주 실사용) 확인 목록에 "폰 글씨 크기를 가장 크게 하고 첫 화면·캘린더·설정을 본다" 한 줄을 더하는 것을 추천한다. 원문 13장의 "큰 글씨 배율"을 손으로 대신하는 셈이다.
  8. 오류 문구에 "무엇을 · 왜 · 대신 무엇을" — 가져온다.
    • 지금 Konsist 테스트 5개는 테스트 이름이 한국어 문장이라 "무엇을 어겼나"는 이미 보인다. 새 규칙부터 additionalMessage 로 "왜 · 대신 무엇을"을 붙인다. 기존 5개에도 붙일지는 사용자가 정한다.

출처