DESIGN SYSTEM DEVELOPMENT

꽥꽥 디자인 시스템 2.0.0 개발기 — Aide

올바른 디자인 컴포넌트 사용을 위한 Lint 구현하기

Ji Sungbin
Duckie Tech
Published in
21 min readMar 23, 2023

--

Photo by blackieshoot on Unsplash

안녕하세요, 덕키에서 꽥꽥 디자인 시스템을 개발하는 성빈입니다. 지난번 꽥꽥의 2.0.0 개발 계획 공유에서 aide라는 시스템을 소개하였습니다. 간단하게 다시 소개하자면 aide는 디자인 컴포넌트의 디자인 스펙을 어길 수 있는 Modifier 사용이 감지됐을 때 경고를 발생시키는 안드로이드 린트 시스템입니다.

예를 들어 다음과 같은 코드가 있습니다.

Button(
type = Secondary,
modifier = Modifier
.leadingIcon(Close)
.trailingIcon(Heart)
.highlight { text -> // error!
Highlight(text, "짱", SemiBold)
},
text = "나 좀 짱인듯? (짱 아님.. 짱되고 싶다",
onClick = ::`am_I_awesome?`,
)

Modifier.highlightText의 데코레이터이지만 위 코드를 보면 Button에 사용되고 있습니다. 이러한 경우를 예방하기 위해 해당 컴포넌트에서 사용할 수 있는 데코레이터 외에 다른 데코레이터의 사용이 감지되면 린트 에러를 표시합니다. (이번 글에서는 이 내용만 다룹니다.)

또한 컴포저블의 디자인을 변경시키는 Modifier 사용이 감지됐을 때도 린트 에러를 표시합니다.

  • Modifier.background
  • Modifier.size
  • Modifier.graphicsLayer
  • Modifier.border
  • Modifier.alpha
  • Modifier.clip
  • Modifier.drawBehind
  • … 기타 등등

aide는 3가지의 모듈로 구현됩니다.

  • core-aide: aide의 핵심 린트 구현
  • core-aide-annotation: core-aide-processor를 위한 어노테이션 제공
  • core-aide-processor: core에서 사용되는 데코레이션 Modifier의 전체 집합 자동 생성

core-aide-annotation부터 알아보겠습니다.

core-aide-annotation

core-aide-annotation 모듈은 하나의 어노테이션만을 제공합니다.

@DecorateModifier는 해당 컴포넌트 도메인에서 데코레이터로 사용 가능한 Modifier임을 나타냅니다. 예를 들어 text.kt 파일에 다음과 같은 코드가 있습니다.

// file: text.kt

@Composable
fun Text(
modifier: Modifier = Modifier,
text: String,
) {
BasicText(modifier = modifier, text = text)
}

@DecorateModifier
fun Modifier.highlight(text: String, color: Color): Modifier {
// ... awesome code
}

위와 같은 경우에 Text 컴포넌트는 데코레이션 ModifierModifier.highlight만 허용됩니다.

core-aide-processor

core-aide-processor 모듈은 @DecorateModifier가 달린 Modifier의 모음을 도메인별로 그룹하여 자동으로 생성해 줍니다. 예를 들어 text 도메인에 Modifier.span, Modifier.highlight 데코레이터가 있다 하면 아래와 같은 코드가 자동 생성됩니다.

internal val aideModifiers: Map<String, List<String>> = run {
val aide = mutableMapOf<String, List<String>>()

// key는 컴포넌트의 도메인을 나타내고,
// value는 해당 도메인에서 사용 가능한 데코레이션 Modifier의 이름들을 나타냅니다.
aide["text"] = listOf("span", "highlight")

// 감지된 Modifier가 데코레이션 Modifier인지 확인할 때 `O(1)`만에 진행하기 위해 생성됩니다.
// 만약 `aideModifiers["_$modifierName"]` 값이 null이 아니라면
// 유효한 데코레이션 Modifier로 간주할 수 있습니다.
aide["_span"] = emptyList()
aide["_highlight"] = emptyList()

aide
}

aideModifiers 외에 quackComponents 코드도 같이 생성됩니다.

internal val quackComponents: Map<String, String> = run {
val aide = mutableMapOf<String, String>()

aide["QuackAnimatedContent"] = "QuackAnimatedContent"

aide["QuackAnimatedVisibility"] = "QuackAnimatedVisibility"

aide["QuackLarge1"] = "text"
aide["QuackHeadLine1"] = "text"
aide["QuackHeadLine2"] = "text"
aide["QuackTitle1"] = "text"
aide["QuackTitle2"] = "text"
aide["QuackSubtitle"] = "text"
aide["QuackSubtitle2"] = "text"
aide["QuackBody1"] = "text"
aide["QuackBody2"] = "text"
aide["QuackBody3"] = "text"
aide["QuackText"] = "text"

aide["QuackTheme"] = "theme"

aide
}

quackComponents는 꽥꽥 컴포넌트와 해당 컴포넌트의 도메인을 매핑합니다. 이는 core-aide에서 제공되는 린트가 오직 꽥꽥 컴포넌트에만 실행되도록 지정하기 위함과 현재 린팅이 진행되고 있는 꽥꽥 컴포넌트의 도메인을 조회하기 위해 사용됩니다.

이러한 코드 자동 생성은 모두 KSPKotlinPoet으로 진행됩니다. KSP의 코드는 의외로 간단합니다.

차례대로 살펴보겠습니다. 먼저 quackComponents 조회를 시작합니다.

  1. @Composable 어노테이션이 달려 있고,
  2. 선언된 함수를 의미하는 KSFunctionDeclaration으로 캐스팅이 가능하고,
  3. public 함수이고,
  4. 함수명이 “Quack”으로 시작하며,
  5. 반환 타입이 Unit인 symbol을 가져옵니다.

다음으로 aideModifiers 조회를 시작하는데 이것도 조건은 비슷합니다.

  1. @DecorateModifier 어노테이션이 달려 있고,
  2. 선언된 함수를 의미하는 KSFunctionDeclaration으로 캐스팅이 가능하고,
  3. public 함수이고,
  4. Modifier의 확장 함수이며,
  5. 반환 타입이 Modifier인 symbol을 가져옵니다.

조회된 symbol이 비어있지 않다면 위에서 보았던 코드 생성 규칙에 맞게 FileSpec을 만드는 단계가 진행됩니다. quackComponentsFileSpec을 만드는 generateQuackComponents 함수부터 보겠습니다.

인자로 제공된 symbol을 순회하며 컴포넌트의 도메인(컴포넌트가 선언된 파일명을 도메인으로 간주)으로 그룹한 후, Pair<DomainName, ComponentSimpleName>과 같이 Pair로 만들어 줍니다.

// Symbol -> SimpleName
Symbol("team.duckie.quackquack.Text") -> "Text"
Symbol("team.duckie.quackquack.Button") -> "Button"
Symbol("team.duckie.quackquack.TextField") -> "TextField"

이후 createQuackComponentsFileSpec을 진행합니다.

중복된 요소를 만들지 않도록 컴포넌트명의 모음은 Set으로 받고 있고, createQuackComponentsFileSpec에서는 다음과 같은 작업을 진행합니다.

  1. quackComponents 이름의 프로퍼티를 만들고,
  2. 타입을 Map<String, String>으로 지정하고,
  3. 접근제한자로 internal을 사용하고,
  4. 초기화 코드를 지정한다.

quackComponents는 변수이기에 4번의 초기화 코드는 run {으로 시작됩니다.

beginControlFlow("run")

이어서 데이터를 담을 Map<String, String> 을 만들어 주고,

addStatement(
"val aide = mutableMapOf<%T, %T>()",
String::class,
String::class,
)

인자로 받은 groupedComponents를 순회하며 aide[GroupName] = ComponentSimpleName 형태로 값을 대입합니다.

groupedComponents.forEach { (domain, components) ->
addStatement("") // append newline
components.forEach { component ->
addStatement("aide[%S] = %S", component, domain)
}
}

마지막으로 aide를 반환하면서 run을 닫습니다.

addStatement("")// append newline
addStatement("aide")
endControlFlow()

이렇게 만들어지는 FileSpecgenerateFile 함수에 넘기면서 quackComponents 코드를 생성하는 generateQuackComponents 함수가 끝납니다.

다음으로 aideModifiers 코드를 생성하는 generateAideModifiers 함수를 봅시다.

aideModifiersquackComponents와 동일하게 Pair<DomainName, ModifierSimpleName>과 같이 Pair로 만들어 줍니다.

이어서 generateQuackComponents와 동일하게 aideModifiersFileSpec을 만들고, generateFile 함수를 호출하여 실제 파일로 생성해 줍니다.

createAideModifiersFileSpeccreateQuackComponentsFileSpec과 비슷한 흐름으로 진행됩니다. 단, createQuackComponentsFileSpec에는 초기화 과정에서 toLiteralListString라는 새로운 확장 함수가 사용됩니다.

Collection<*>.toLiteralListString()는 주어진 컬랙션을 list literal 형식으로 나타내는 확장 함수입니다.

여기까지의 모든 과정을 완료하면 core-aide-processor 작업이 끝납니다.

core-aide

이제 core-aide가 작동될 준비를 마쳤습니다. core-aide는 린트 모듈이므로 임세현님이 작성하신 “덕키팀에서 Custom Lint를 만드는 여정” 글을 읽어 보시면 내용 이해에 도움이 됩니다.

바로 Detector를 구현해 봅시다.

메서드를 사용할 때 Modifier 인자를 검사해야 하므로 SourceCodeScannervisitMethodCall을 사용합니다. 린트 작업을 최적화하기 위해 꽥꽥 컴포넌트만 방문하도록 getApplicableMethodNames를 지정해 줍니다. core-aide-processor에서 생성되는 quackComponentns의 key로 지정하면 됩니다.

visitMethodCall에서의 첫 번째 작업은 컴포넌트의 도메인 조회와, 해당 도메인에서 사용 가능한 데코레이션 Modifier 조회입니다. core-aide-processor로 생성되는 aideModifiers 덕분에 쉽게 진행할 수 있습니다. 방문한 컴포넌트의 도메인을 quackComponents[method.name]으로 가져오고, aideModifiers[DomainName]으로 사용 가능한 데코레이션 Modifier를 불러옵니다.

다음으로 해야 할 일은 방문한 메서드에서 Modifier을 받는 인자를 가져와야 합니다. 예를 들면 다음과 같은 코드가 있습니다.

fun main() {
QuackText(
modifier = Modifier.fillMaxSize().background(color = Color.DuckieOrange),
text = "Hi, Hello.",
singleLine = true,
typography = QuackTypography.HeadLine1,
)
}

저는 위와 같은 코드에서 Modifier.fillMaxSize().background(color = Color.DuckieOrange)를 가져오길 원합니다. 원하는 방향으로 구현하는 데 도움을 받을 수 있도록 위 코드의 PSI Tree를 확인해 보겠습니다.

UFile (package = )
UMethod (name = main)
UIdentifier (Identifier (main))
UDeclarationsExpression
UBlockExpression
UCallExpression (kind = UastCallKind(name='method_call'), argCount = 4))
USimpleNameReferenceExpression (identifier = QuackText)
UIdentifier (Identifier (QuackText))
USimpleNameReferenceExpression (identifier = modifier)
UIdentifier (Identifier (modifier))
UQualifiedReferenceExpression
UQualifiedReferenceExpression
USimpleNameReferenceExpression (identifier = Modifier)
UIdentifier (Identifier (Modifier))
UCallExpression (kind = UastCallKind(name='method_call'), argCount = 0))
USimpleNameReferenceExpression (identifier = fillMaxSize)
UIdentifier (Identifier (fillMaxSize))
UCallExpression (kind = UastCallKind(name='method_call'), argCount = 1))
USimpleNameReferenceExpression (identifier = background)
UIdentifier (Identifier (background))
USimpleNameReferenceExpression (identifier = color)
UIdentifier (Identifier (color))
UQualifiedReferenceExpression
USimpleNameReferenceExpression (identifier = Color)
UIdentifier (Identifier (Color))
USimpleNameReferenceExpression (identifier = DuckieOrange)
UIdentifier (Identifier (DuckieOrange))
USimpleNameReferenceExpression (identifier = text)
UIdentifier (Identifier (text))
ULiteralExpression (value = "Hi, Hello.")
ULiteralExpression (value = "Hi, Hello.")
USimpleNameReferenceExpression (identifier = singleLine)
UIdentifier (Identifier (singleLine))
ULiteralExpression (value = true)
USimpleNameReferenceExpression (identifier = typography)
UIdentifier (Identifier (typography))
UQualifiedReferenceExpression
USimpleNameReferenceExpression (identifier = QuackTypography)
UIdentifier (Identifier (QuackTypography))
USimpleNameReferenceExpression (identifier = HeadLine1)
UIdentifier (Identifier (HeadLine1))

modifier 인자의 PSI Tree만 나타내 보면 이렇습니다.

조금만 자세히 분석해 보면 UQualifiedReferenceExpression으로 modifier chain이 나누어지는 걸 확인할 수 있습니다. 다행히도 린트에는 이러한 UQualifiedReferenceExpression chain을 가져올 수 있는 UExpression#getQualifiedChain 함수가 존재합니다.

현재 방문 중인 메서드의 인자를 순회하며 우리가 찾고자 하는 modifier chain에 해당하는 인자를 가져와 보겠습니다.

순회 중인 인자에 getQualifiedChain()을 사용하여 UExpression 목록을 가져오고, 첫 번째 요소 타입의 fully-qualified name이 Modifier를 나타낸다면 해당 인자의 modifier chain을 반환합니다. 단, 첫 번째 요소에 해당하는 Modifier 혹은 modifier 체인은 제거합니다.

getQualifiedChain(): chain("Modifier"), chain("fillMaxSize"), chain("background")

drop(1) -> chain("fillMaxSize"), chain("background") 만 가져옴

이렇게 구한 Modifier chain에서 데코레이터 Modifier에 해당하는 Modifier만 가져와 줍니다.

val decorateModifiers = modifiers.filter { modifier ->
val identifier = modifier.asCall()?.methodIdentifier ?: return@filter false
aideModifiers["_${identifier.name}"] != null
}

이제 decorateModifiers를 순회하며 acceptableModifiers에 없는 Modifier일 시 린트 에러를 발생시키면 됩니다.

decorateModifiers.forEach { modifier ->
context.reportWrongModifierIfNeeded(acceptableModifiers, modifier)
}

JavaContext#reportWrongModifierIfNeeded 함수를 보겠습니다.

린트 에러를 발생시킴과 동시에 QuickFix를 제공하도록 구성해 보았습니다. 하지만 위 방식은 한 가지 단점이 존재합니다. Incident의 위치로 modifier.sourcePsi를 사용하고 있습니다. 따라서 QuickFix의 replace 영역에 leading sibling이 포함되지 않습니다.

Modifier.fillMaxSize().background(color = Color.DuckieOrange)에서 fillMaxSize 체인을 돌고 있을 때, 해당 체인의 sourcePsi 영역은 fillMaxSize() 입니다. 즉, 위와 같이 sourcePsi에 QuickFix를 사용한다면 결과는 Modifier..background(color = Color.DuckieOrange)가 됩니다.

이처럼 진행한다면 QuickFix를 정상 제공할 수 없습니다. QuickFix가 있어야 개발자 경험을 좋은 수준으로 유지할 수 있겠다고 생각했고, 이 문제를 해결하기 위해 PSI를 직접 파싱하여 leading sibling을 포함하는 Location을 직접 만들기로 합니다.

Modifier.fillMaxSize().background(color = Color.DuckieOrange)에서 문제가 되는 영역인 fillMaxSize()의 PSI element 구성을 봐봅시다. fillMaxSize()는 다음과 같은 PSI 구조로 구성돼 있습니다.

CALL_EXPRESSION // current chain
REFERENCE_EXPRESSION
PsiElement (IDENTIFIER) // "fillMaxSize"
VALUE_ARGUMENT_LIST // last child
PsiElement (LPAR) // "("
PsiElement (RPAR) // ")", inner last child

fillMaxSize()는 두 번째 체인이므로 이전 체인과 연결되기 위해선 DOT PSI가 필요합니다. 좀 더 넓게 Modifier.fillMaxSize()의 PSI element 구성을 보면 다음과 같은 구조로 되어 있습니다.

PsiElement (IDENTIFIER) // "Modifier"
PsiElement (DOT) // ".", previous sibling
CALL_EXPRESSION // current chain
REFERENCE_EXPRESSION
PsiElement (IDENTIFIER) // "fillMaxSize"
VALUE_ARGUMENT_LIST // last child
PsiElement (LPAR) // "("
PsiElement (RPAR) // ")", inner last child

Modifier.fillMaxSize()의 PSI element를 분석하니 우리가 원하는 Location의 오프셋을 제공해 주기 위한 startPsiendPsi를 가져올 수 있어 보입니다.

val startPsi = modifier.sourcePsi?.prevSibling // PsiElement (DOT) "."
val endPsi = modifier.sourcePsi?.lastChild?.lastChild // PsiElement (RPAR) ")"

이제 위 2가지 변수를 기반으로 새로운 Location 인스턴스를 생성하고, Incident의 location을 업데이트하는 작업을 진행합니다.

이렇게 해서 core-aide의 린트와 QuickFix가 모두 완성됩니다.

core-aide 덕분에 디자인 시스템을 안전하게 사용할 수 있게 됐습니다. core-aide 모듈의 전체 코드는 아래 깃허브에서 확인하실 수 있습니다. (테스트 코드 포함)

끝까지 읽어주셔서 감사합니다.

--

--

Experience Engineers for us. I love development that creates references.