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

#750

모서리 크기가 아니라 쓰임새로 이름 짓기 — KMP 네이티브 UI의 디자인 토큰

원문 Front Wing, Not Small Wing: Design Tokens for Native UI in KMP, KMP Bits

한 줄 요약

Android는 Compose, iOS는 SwiftUI로 화면을 따로 그리는 Kotlin Multiplatform 앱에서, 두 앱의 모서리 둥글기가 어느새 제각각이 된 것을 고친 이야기다. 원인은 값이 틀려서가 아니라 토큰 이름이 small·medium·large 처럼 크기를 말할 뿐 무엇에 쓰는지를 말하지 않았기 때문이다. 저자는 모서리 모양을 card·sheet·field 처럼 쓰임새(역할)로 이름 붙여 XML 파일 하나에 적고, Gradle 작업이 그 파일로 Kotlin 클래스와 Swift 구조체를 둘 다 만들게 했다. 마지막으로 Detekt와 SwiftLint 규칙으로 모양을 손으로 만드는 코드를 빌드 실패로 막는다. 공유한 것은 화면 코드가 아니라 결정 하나다.

원문(저자 사이트 kmpbits.com에 먼저 실림)은 "All Rights Reserved"(모든 권리 보유)로 표시되어 있고, 같은 코드를 담은 공개 라이선스 저장소도 찾지 못했다(저자의 GitHub 저장소 가운데 이 템플릿 프로젝트는 공개되어 있지 않다). 그래서 이 글의 코드는 원문 코드를 옮긴 것이 아니라, 원문이 설명하는 구조를 따라 이름과 예시를 바꿔 직접 쓴 스케치다. 예시는 "할 일 앱"의 역할(taskCard·chip 등)로 바꿨다. 원문 표지 그림은 원문 서버 주소를 걸어 보여 주고, 본문 구조는 그림으로 새로 그렸다.

먼저 알아 둘 말

서론 — 규정집에 "작은 날개"는 없다

어두운 작업대 위에 탄소섬유로 만든 F1 앞날개 끝판이 놓여 있고, 옆 종이에 FRONT WING과 ENDPLATE 설계도와 RADIUS 표시가 그려져 있다. 뒤 모니터 두 대에 모서리가 둥근 사각형이 보이고, 아래에 Front Wing, Not Small Wing 제목이 있다
원문 표지 그림. 탄소섬유 앞날개 끝판(endplate)의 둥근 모서리와, 같은 모서리에 "RADIUS"(반지름)를 표시한 설계도가 놓여 있다. 뒤 모니터에는 화면 속 둥근 카드가 보인다. 원문 이미지, 원문 서버에서 불러옴 (원문)

1. 크기가 아니라 부품에 이름을 붙인다

아래는 같은 생각을 할 일 앱의 역할로 바꿔 직접 쓴 스케치다.

<!-- core/ui/src/commonMain/design-tokens/shapes.xml (직접 쓴 스케치) -->
<shapes>
    <!-- 한 줄 = 역할 하나. 이름은 "어디에 쓰나"이고, 숫자는 그 역할의 현재 값일 뿐이다 -->
    <shape name="taskCard"  radius="16"/>
    <shape name="chip"      radius="999"/>          <!-- 양끝이 반원인 알약 모양 -->
    <shape name="input"     radius="12"/>
    <shape name="dialog"    radius="28"/>
    <shape name="sheet"     radius="24" edges="top"/> <!-- 윗모서리만 둥글다 -->
    <shape name="toast"     radius="8"/>
    <shape name="placeholder" radius="12"/>
</shapes>

2. 파일 하나, 언어 둘

// build.gradle.kts (루트) — 직접 쓴 스케치
tasks.register("generateShapeTokens") {
    val source = file("core/ui/src/commonMain/design-tokens/shapes.xml")
    val kotlinOut = layout.buildDirectory.dir("generated/shapeTokens/kotlin")
    val swiftOut = file("iosApp/iosApp/Theme/ShapeTokens.swift")

    inputs.file(source)      // 이 파일이 바뀌었을 때만 다시 돈다
    outputs.dir(kotlinOut)
    outputs.file(swiftOut)

    doLast {
        val line = Regex("""<shape name="(\w+)"\s+radius="([\d.]+)"(\s+edges="top")?/>""")
        // 이름 · 반지름 · 윗모서리만 여부. 여기서 data class 를 새로 만들지 않는다(4장 참고)
        val tokens = line.findAll(source.readText())
            .map { Triple(it.groupValues[1], it.groupValues[2], it.groupValues[3].isNotBlank()) }
            .toList()

        // tokens 로 Kotlin 글자와 Swift 글자를 각각 이어 붙여 두 파일에 쓴다 (생략)
    }
}
// ShapeTokens.kt — 생성되는 파일의 모양 (직접 쓴 스케치, 저장소에 올리지 않는다)
class ShapeTokens internal constructor(
    val taskCard: RoundedCornerShape,
    val chip: RoundedCornerShape,
    val input: RoundedCornerShape,
    val sheet: RoundedCornerShape,
    // … 역할마다 한 줄
)

internal val DefaultShapeTokens = ShapeTokens(
    taskCard = RoundedCornerShape(16.dp),
    chip = RoundedCornerShape(999.dp),
    input = RoundedCornerShape(12.dp),
    sheet = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
)
// ShapeTokens.swift — 생성되는 파일의 모양 (직접 쓴 스케치, 저장소에 올리지 않는다)
struct ShapeTokens {
    let taskCard = RoundedRectangle(cornerRadius: 16)
    let chip = RoundedRectangle(cornerRadius: 999)
    let input = RoundedRectangle(cornerRadius: 12)
    let sheet = UnevenRoundedRectangle(
        topLeadingRadius: 24, bottomLeadingRadius: 0,
        bottomTrailingRadius: 0, topTrailingRadius: 24
    )
    // … 역할마다 한 줄
}
// MaterialShapes.kt — 손으로 쓰는 유일한 파일 (직접 쓴 스케치)
internal fun ShapeTokens.toMaterialShapes() = Shapes(
    extraSmall = toast,      // Material 부품이 "가장 작은 칸"을 읽으면 토스트 모양을 받는다
    small = input,
    medium = taskCard,
    large = sheet,
    extraLarge = dialog,
)
역할 이름으로 적은 XML 파일 하나를 Gradle 작업이 빌드마다 읽어 Android용 Kotlin 클래스와 iOS용 Swift 구조체를 만든다. 화면 코드는 양쪽 모두 역할 이름만 부른다. Android는 Detekt 금지 import 규칙이, iOS는 SwiftLint 정규식 규칙이 모양을 직접 만드는 코드를 막는다. 두 플랫폼이 같은 화면에 같은 역할을 골랐는지는 어느 검사도 보지 못한다
XML 하나에서 두 플랫폼 코드와 두 검사 규칙까지. 원문 본문을 바탕으로 새로 그림 (원문)

3. 부품 쪽은 어떻게 보이는가

// TaskCard.kt (직접 쓴 스케치)
Column(
    modifier = modifier
        .clip(AppTheme.shapes.taskCard)                       // 모서리 모양: 역할 이름만
        .background(AppTheme.colors.surface)
        .padding(AppTheme.spacing.card)
) { content() }
// TaskCard.swift (직접 쓴 스케치)
VStack(alignment: .leading) { content() }
    .padding(AppTheme.spacing.card)
    .background(AppTheme.colors.surface)
    .clipShape(AppTheme.shapes.taskCard)                      // 같은 역할 이름

4. 발목을 잡은 세 가지

# detekt.yml — Detekt 1.23.x 형식 (직접 쓴 스케치)
style:
  ForbiddenImport:
    active: true
    imports:                       # 맵이 아니라 글자 목록
      - 'androidx.compose.material3.MaterialTheme'
      - 'androidx.compose.foundation.shape.RoundedCornerShape'
      - 'androidx.compose.foundation.shape.CutCornerShape'
    excludes:                      # 토큰을 만드는 테마 파일만 예외
      - '**/ui/theme/AppTheme.kt'
      - '**/ui/theme/MaterialShapes.kt'

5. 지키게 만들기

# .swiftlint.yml (직접 쓴 스케치)
custom_rules:
  shape_from_tokens_only:
    name: "Shape tokens only"
    regex: '(RoundedRectangle\(cornerRadius|UnevenRoundedRectangle\(|\.cornerRadius\()'
    excluded: 'iosApp/Theme/ShapeTokens\.swift'   # 생성 파일만 예외
    message: "모서리는 AppTheme.shapes.<역할> 로 고르세요."
    severity: error

6. 저자가 지키는 선

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

Aide는 Android 앱 하나뿐이라 XML 생성기와 Swift 쪽은 필요 없다. 원문에서 가져올 것은 역할 이름으로 된 모양 묶음과 그것을 지키게 하는 검사다. 아래는 모두 직접 쓴 스케치이고, Aide 코드는 고치지 않았다.

① core:designsystem 의 역할 이름 모양 묶음

// core/designsystem/.../theme/Shape.kt (직접 쓴 스케치)
@Immutable
class AideShapes internal constructor(
    val card: Shape,          // 할 일·일정 카드
    val chip: Shape,          // AI가 꺼낸 답 버튼(예/아니요/나중에)
    val input: Shape,         // 입력칸
    val sheet: Shape,         // 아래에서 올라오는 시트 — 윗모서리만
    val dialog: Shape,        // 대화상자
)

internal val DefaultAideShapes = AideShapes(
    card = RoundedCornerShape(16.dp),
    chip = RoundedCornerShape(percent = 50),
    input = RoundedCornerShape(12.dp),
    sheet = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
    dialog = RoundedCornerShape(28.dp),
)

// Material 부품이 읽는 칸에 역할을 잇는다. 크기 이름은 여기에만 남는다
internal fun AideShapes.toMaterialShapes() = Shapes(
    small = input,
    medium = card,
    large = sheet,
    extraLarge = dialog,
)

internal val LocalAideShapes = staticCompositionLocalOf { DefaultAideShapes }

object AideTheme {
    val shapes: AideShapes
        @Composable @ReadOnlyComposable
        get() = LocalAideShapes.current
}

지금 AideAndroidTheme 은 MaterialTheme(colorScheme, typography, content) 만 부른다. 여기에 모양 묶음을 더하면 이렇다.

// core/designsystem/.../theme/Theme.kt 의 끝부분 (직접 쓴 스케치)
CompositionLocalProvider(LocalAideShapes provides DefaultAideShapes) {
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        shapes = DefaultAideShapes.toMaterialShapes(),   // Material 부품도 같은 역할을 읽는다
        content = content,
    )
}

② coding-convention 의 Konsist 검사 — Detekt 대신

// coding-convention/src/test/kotlin/com/giwon/aide/konsist/ShapeTokenTest.kt (직접 쓴 스케치)
class ShapeTokenTest {

    @Test
    fun `디자인 시스템 밖에서는 RoundedCornerShape 로 모서리를 직접 만들지 않는다`() {
        Konsist.scopeFromProject()
            .files
            .filterNot { it.path.contains("/core/designsystem/") }
            .assertFalse(
                additionalMessage = "모서리는 AideTheme.shapes.<역할> 로 고른다. " +
                    "맞는 역할이 없으면 core:designsystem 의 AideShapes 에 역할을 하나 더한다.",
            ) { file ->
                file.imports.any { it.name == "androidx.compose.foundation.shape.RoundedCornerShape" }
            }
    }
}

배운 점

Aide에 적용하면

Aide는 Android 앱 하나뿐이고, core:designsystem 의 테마는 아직 Android Studio가 만들어 준 그대로다(Purple80 같은 색과 bodyLarge 하나짜리 글자). 모양 토큰도, 모서리 코드도 아직 없다. 대신 1-a 계획의 Task 6이 Note-Android의 캘린더와 그것이 쓰는 디자인 시스템 일부를 가져온다. 그 Note-Android designsystem 모듈에는 원문이 말한 드리프트가 이미 있다. RoundedCornerShape 를 쓰는 파일이 22개이고, 반지름은 0·4·5·8·12·16·20·24dp 여덟 가지에 cornerSize·radiusCornersSize·cornerRadius 같은 이름 다른 변수까지 섞여 있다(읽기만 해서 확인). Aide는 이 가운데 실제 쓰는 것만 가져오므로, 실제로 들어올 파일 수는 Task 6을 해 봐야 안다.

  1. 모양을 역할 이름으로 core:designsystem 에 모은다 — 바꿔서 가져온다. 시점은 Task 8(첫 화면) 직전이다.
    • 원문의 XML + Gradle 생성기 대신, 위 스케치 ①처럼 Kotlin 클래스 AideShapes 를 손으로 쓴다. 생성기는 두 플랫폼에 같은 결정을 나눠 주려고 있는 것인데 Aide에는 받을 쪽이 하나뿐이다.
    • 역할은 처음 쓰는 화면이 생길 때 하나씩 더한다(2026-10-14 결정 "쓰지 않는 것을 미리 넣지 않는다"와 같은 원칙). 첫 화면 대시보드가 쓰는 카드·답 버튼부터 시작하면 된다.
    • AideAndroidTheme 에 shapes = …toMaterialShapes() 를 넘겨, Material 부품도 같은 역할을 읽게 한다.
  2. 기능 코드의 RoundedCornerShape 를 막는다 — 바꿔서 가져온다. Detekt 대신 이미 있는 Konsist로.
    • Aide는 Detekt를 쓰지 않고, coding-convention 의 Konsist 테스트가 CI(./gradlew assembleDebug test lint)에서 이미 돈다. 위 스케치 ②를 더하면 새 도구도 CI 수정도 필요 없다.
    • #749 디자인 시스템 계약 글의 적용안 3번("기능 코드의 Color(0x…) 금지")과 같은 때, 같은 방식으로 넣는 것을 추천한다. 둘 다 "디자인 시스템 밖에서 값을 직접 만들지 않는다"는 한 규칙의 두 얼굴이다.
    • CircleShape 는 원문처럼 막지 않는다. 아바타·상태 점은 원이 맞다.
    • 확인은 계획서의 TDD 방식대로 "위반 심기 → 빨간불 → 지우기 → 초록불"로 한다. 이때 원문 ②번 교훈을 그대로 지킨다 — 위반 파일은 손으로(Android Studio에서) 만들고, 빨간불을 보기 전에 git status 로 정말 들어갔는지 확인한다. 위반 커밋은 2026-10-20 결정대로 따로 PR을 열고 머지 없이 닫는다.
  3. Task 6에서 가져오는 Note-Android 코드와 부딪힌다 — 결정이 필요하다.
    • 계획서 Task 6은 "캘린더 UI 코드는 고치지 않는다"고 정했고, 디자인 시스템도 "실제 쓰는 것만, 이름 그대로" 가져온다. 그런데 가져오는 Compose 코드에 RoundedCornerShape(20.dp) 같은 줄이 있으면 2번 검사에 걸린다.
    • 이식한 캘린더 본체는 XML 레이아웃과 커스텀 View라서 Compose import 가 거의 없을 것이다. 걸리는 것은 함께 가져오는 Compose 디자인 시스템 부품 쪽이다. 그것이 core:designsystem 안으로 들어오면 검사 예외 구역 안이라 걸리지 않는다.
    • 그래서 Task 6 이식이 끝난 뒤 실제로 걸리는 파일 수를 보고 "그 패키지만 예외로 두고 이유를 주석으로 남긴다" 또는 "그 숫자만 역할로 바꾼다" 중에 고르기를 추천한다. #749 글의 3번과 같은 결론이다.
  4. 색·글자도 역할 이름으로 — 새로 만드는 것부터 가져온다.
    • 지금 Color.kt 의 Purple80·Pink40 은 템플릿 이름이고, 실제로는 colorScheme 의 primary·secondary 같은 역할 칸을 채우는 데만 쓰인다. 템플릿을 Aide 색으로 바꿀 때 화면 코드는 MaterialTheme.colorScheme.primary 처럼 역할로만 부르게 한다.
    • Task 6에서 오는 Gray·Orange·White 는 계획대로 이름을 그대로 둔다. 이름을 바꾸면 "UI 코드는 고치지 않는다"를 넘는다. 새로 만드는 토큰부터 역할 이름을 쓴다.
  5. XML 원본 + Gradle 생성기 — 지금은 안 한다.
    • 이유: 값을 나눠 받을 두 번째 플랫폼이 없다. 생성기는 Gradle 스크립트 유지보수(원문 ①번 같은 컴파일러 문제 포함)만 늘린다.
    • 다시 꺼낼 때: Aide에 iOS나 위젯·Wear처럼 같은 결정을 다른 언어·다른 도구로 읽는 쪽이 생기면, 그때 손으로 쓴 AideShapes 를 XML로 옮긴다.
  6. "두 화면이 같은 역할을 골랐는가" 검사 — 해당 없음.
    • 원문 저자가 못 막은 빈틈은 플랫폼이 둘일 때 생긴다. Aide는 화면이 한 벌이라 이 빈틈이 없다. 다만 같은 생각을 줄여 적용하면, "같은 종류의 카드인데 화면마다 다른 역할을 골랐는가"는 여전히 리뷰가 볼 일이다.

출처