들어가며
GitHub 프로필에 들어가면 보이는 초록색 잔디밭, 개발자라면 한 번쯤 "이 잔디 그래프를 내 앱에도 넣을 수 있으면 좋겠다"고 생각해본 적 있을 것이다. 습관 트래커, 공부 기록, 운동 로그 등 매일의 활동을 기록하는 앱에서 이런 시각화는 강력한 동기 부여가 된다.
그런데 막상 Jetpack Compose에서 이걸 구현하려고 찾아보면, 마땅한 라이브러리가 없었다. 있더라도 Material3에 의존하거나, 특정 앱의 모델에 강하게 결합되어 있거나, 커스터마이징이 제한적이었다.
그래서 직접 만들었다. compose-git-grass — Map<LocalDate, Int>만 넘기면 GitHub 스타일의 잔디 그래프를 그려주는 Jetpack Compose 라이브러리다.
왜 만들었나
기존 방법들의 한계
GitHub 잔디 그래프를 Compose로 구현하는 방법을 찾아보면 크게 세 가지 접근이 있었다:
- Canvas로 직접 그리기 — 자유도는 높지만, 월 라벨 정렬, 스크롤 처리, 셀 클릭 처리 등을 전부 직접 구현해야 한다. 유지보수가 힘들다.
- 기존 라이브러리 사용 — 대부분 Material3의
Text,Surface등에 의존한다. 내 앱이 Material3를 쓰지 않거나 버전이 다르면 충돌이 난다. - 앱 내부 코드로 구현 — 특정 앱의 데이터 모델에 묶여 있어 재사용이 불가능하다.
목표한 설계 원칙
이런 한계를 해결하기 위해 세 가지 원칙을 정했다:
- UI 의존성 제로 — Material3 없이, Compose Foundation만으로 구현한다.
BasicText,Box,Row만 사용하면 어떤 디자인 시스템과도 충돌하지 않는다. - 입력은 하나 —
Map<LocalDate, Int>하나면 끝. 커스텀 모델도, 어댑터도, 보일러플레이트도 없다. - 완전한 커스터마이징 — 색상, 라벨, 크기, 레벨 기준, 셀 클릭까지 모든 것을 파라미터로 설정할 수 있되, 기본값이 잘 잡혀 있어서 아무것도 설정하지 않아도 바로 쓸 수 있다.
라이브러리 구조
최종적으로 라이브러리는 5개의 파일로 구성된다:
library/src/main/java/com/inseong/gitgrass/
├── GitGrass.kt // Public API — 유일한 public 컴포저블
├── GitGrassColors.kt // 색상 데이터 클래스
├── GitGrassComponents.kt // internal UI 컴포넌트들
├── GitGrassDefaults.kt // 기본값 오브젝트
└── GridUtils.kt // 순수 함수 유틸리티
Public API는 딱 하나다. GitGrass 컴포저블 함수 하나와 GitGrassColors 데이터 클래스, GitGrassDefaults 오브젝트, GitGrassStreakInfo 데이터 클래스. 나머지는 전부 internal이다. 외부에 노출되는 surface area를 최소화해서 나중에 내부 구현을 자유롭게 바꿀 수 있도록 했다.
기술적 결정들
1. Material3 없이 구현하기
가장 큰 설계 결정이었다. Material3의 Text를 쓰면 편하지만, 그러면 라이브러리 사용자가 반드시 Material3 의존성을 가져야 한다. 앱에서 Material2를 쓰거나, 커스텀 디자인 시스템을 쓰거나, Material3 버전이 다르면 충돌이 날 수 있다.
그래서 Compose Foundation의 BasicText만 사용했다:
// Material3의 Text 대신
BasicText(
text = label,
style = TextStyle(fontSize = fontSize, color = textColor),
)
Surface, Card 같은 Material 컴포넌트도 전혀 쓰지 않고, Box에 .background()와 .clip()을 직접 적용했다:
@Composable
internal fun GrassCell(
color: Color,
size: Dp,
cornerRadius: Dp,
onClick: (() -> Unit)?,
) {
val shape = RoundedCornerShape(cornerRadius)
val baseModifier = Modifier
.size(size)
.clip(shape)
.background(color, shape)
Box(
modifier = if (onClick != null) {
baseModifier.clickable(onClick = onClick)
} else {
baseModifier
},
)
}
이렇게 하면 build.gradle.kts의 의존성이 깔끔하다:
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
}
Material3가 없다. 이 덕분에 라이브러리 사용자의 프로젝트에서 버전 충돌이 일어날 가능성을 원천 차단했다.
2. 유연한 색상 레벨 시스템
처음에는 GitHub처럼 4단계로 고정하려고 했다:
// 처음 생각했던 방식 (사용하지 않음)
data class GitGrassColors(
val empty: Color,
val level1: Color,
val level2: Color,
val level3: Color,
val level4: Color,
)
하지만 이렇게 하면 3단계나 5단계, 10단계로 커스터마이징하고 싶을 때 대응이 안 된다. 결국 List<Color>로 결정했다:
@Immutable
data class GitGrassColors(
val empty: Color,
val levels: List<Color>, // 개수 자유
val text: Color,
val border: Color,
)
레벨 매핑은 levelToColor 함수에서 안전하게 처리한다:
internal fun levelToColor(level: Int, colors: GitGrassColors): Color {
if (level <= 0 || colors.levels.isEmpty()) return colors.empty
return colors.levels[(level - 1).coerceIn(0, colors.levels.lastIndex)]
}
coerceIn으로 인덱스를 클램핑하기 때문에, 레벨이 리스트 크기를 초과해도 크래시 없이 가장 진한 색상을 반환한다. 사용자가 레벨 3개를 정의했는데 levelOf가 5를 반환해도 안전하다.
3. 가장 어려웠던 문제 — 월 라벨과 그리드의 정렬
개발 과정에서 가장 많은 시간을 쏟은 문제가 바로 이것이다. 월 라벨("Jan", "Feb", ...)이 그리드 위에 정확히 해당 주(week) 위치에 맞춰져야 한다. 그런데 그리드는 수평 스크롤이 된다.
처음 시도: 월 라벨과 그리드를 각각 독립적인 스크롤로 구현했다. 당연히 스크롤 위치가 어긋났다.
두 번째 시도: Modifier.offset()으로 위치를 계산해서 배치했다. 셀 크기와 간격이 바뀌면 계산이 깨졌다.
최종 해결: 하나의 ScrollState를 공유하되, 월 라벨 행은 스크롤을 비활성화하는 방식이다:
// GitGrass.kt에서 하나의 ScrollState를 생성
val scrollState: ScrollState = rememberScrollState()
// MonthRow는 같은 scrollState를 받되, 직접 스크롤은 비활성화
Row(
modifier = Modifier.horizontalScroll(scrollState, enabled = false),
horizontalArrangement = Arrangement.spacedBy(cellSpacing),
) { ... }
// GrassGridContent는 같은 scrollState로 실제 스크롤
Row(
modifier = Modifier.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(cellSpacing),
) { ... }
핵심은 horizontalScroll(scrollState, enabled = false)이다. 같은 ScrollState를 사용하므로 그리드가 스크롤되면 월 라벨도 똑같이 따라간다. 하지만 enabled = false이므로 월 라벨 행을 직접 터치해서 스크롤할 수는 없다. 그리드만 스크롤의 주도권을 가진다.
그리고 월 라벨의 각 슬롯은 그리드의 각 주 열과 동일한 너비(cellSize)와 동일한 간격(cellSpacing)을 사용한다. 같은 Arrangement.spacedBy(cellSpacing)을 적용했기 때문에 어떤 셀 크기에서도 정렬이 보장된다.
여기서 한 가지 더 트릭이 있다. "Sep"처럼 긴 텍스트가 cellSize(12.dp) 안에 들어갈 리 없다. 이걸 해결하기 위해 wrapContentSize(unbounded = true)를 사용했다:
Box(
modifier = Modifier
.width(cellSize)
.wrapContentSize(unbounded = true, align = Alignment.CenterStart)
) {
BasicText(text = label, softWrap = false, ...)
}
Box의 레이아웃 크기는 cellSize로 고정되지만, 내부의 BasicText는 unbounded = true 덕분에 실제 텍스트 크기로 렌더링된다. 레이아웃에 영향을 주지 않으면서 텍스트가 잘리지 않고 표시되는 것이다.
4. 순수 함수로 로직 분리
UI와 비즈니스 로직을 철저히 분리했다. GridUtils.kt의 모든 함수는 순수 함수(pure function)다:
// 날짜 목록 생성 — 입력만으로 결정, 부수효과 없음
internal fun generateDayList(start: LocalDate, end: LocalDate): List<LocalDate>
// 주 단위 그리드 구축 — 순수 변환
internal fun buildGrid(days: List<LocalDate>): List<List<LocalDate?>>
// 월 라벨 위치 계산
internal fun createMonthLabels(grid: List<List<LocalDate?>>): List<Pair<Int, Int>>
// 스트릭 계산
internal fun calculateStreak(
contributions: Map<LocalDate, Int>,
days: List<LocalDate>,
today: LocalDate = LocalDate.now(),
): GitGrassStreakInfo
순수 함수로 분리한 가장 큰 이유는 테스트 용이성이다. Android Instrumented Test가 아닌 일반 JVM Unit Test로 테스트할 수 있다. 에뮬레이터 없이 몇 초 만에 테스트가 끝난다:
@Test
fun `buildGrid places days in correct weekday index`() {
val monday = LocalDate.of(2025, 1, 6) // Monday
val days = generateDayList(monday, monday.plusDays(6))
val grid = buildGrid(days)
assertEquals(1, grid.size)
assertEquals(monday, grid[0][0]) // Monday = index 0
assertEquals(monday.plusDays(6), grid[0][6]) // Sunday = index 6
}
총 26개의 유닛 테스트가 그리드 생성, 스트릭 계산, 색상 매핑, 엣지 케이스를 검증한다. 특히 스트릭 계산에서 미래 날짜를 무시하는 로직, 빈 데이터 처리, 음수 기여도 처리 등 경계 조건을 꼼꼼히 테스트했다.
5. 입력 처리
라이브러리를 만들면서 가장 신경 쓴 부분 중 하나가 "잘못된 입력에도 크래시 없이 동작하기"다.
// 날짜 역전 — 자동 교체
val safeStart = if (startDate.isAfter(endDate)) endDate else startDate
val safeEnd = if (startDate.isAfter(endDate)) startDate else endDate
// 음수 기여도 — 0으로 클램핑
val safeContributions = remember(contributions) {
if (contributions.values.any { it < 0 }) {
contributions.mapValues { (_, v) -> v.coerceAtLeast(0) }
} else {
contributions
}
}
음수 처리에서 any { it < 0 } 체크를 먼저 하는 이유가 있다. 대부분의 경우 음수 값은 없기 때문에 불필요한 mapValues 호출(새로운 Map 생성)을 피할 수 있다. 최적화이면서 동시에 안전장치다.
짧은 라벨 리스트도 안전하게 처리한다:
val label = monthLabels.getOrElse(monthNumber) { "" }
val label = weekLabels.getOrElse(index) { "" }
getOrElse를 사용해서 인덱스가 범위를 벗어나면 빈 문자열을 반환한다. 사용자가 실수로 12개 미만의 월 라벨을 넘겨도 크래시가 나지 않는다.
6. 오늘 날짜로 자동 스크롤
잔디 그래프는 보통 1년치 데이터를 보여주는데, 사용자가 보고 싶은 건 대부분 최근(오른쪽 끝)이다. 그래서 첫 컴포지션 시 자동으로 오른쪽 끝으로 스크롤한다:
LaunchedEffect(grid) {
scrollState.scrollTo(scrollState.maxValue)
}
LaunchedEffect의 키를 grid로 설정한 이유는, 날짜 범위가 변경되어 그리드가 재생성되면 다시 스크롤해야 하기 때문이다. 단순히 Unit을 키로 쓰면 초기 1회만 동작하고, 데이터가 바뀌었을 때 반영되지 않는다.
그리드 생성
잔디 그래프의 핵심은 날짜를 주 단위 그리드로 변환하는 것이다. 이 과정을 단계별로 설명한다.
Step 1: 날짜 리스트 생성
internal fun generateDayList(start: LocalDate, end: LocalDate): List<LocalDate> {
if (start.isAfter(end)) return emptyList()
val days = mutableListOf<LocalDate>()
var current = start
while (!current.isAfter(end)) {
days.add(current)
current = current.plusDays(1)
}
return days
}
Step 2: 주 단위 그리드 구축
internal fun buildGrid(days: List<LocalDate>): List<List<LocalDate?>> {
if (days.isEmpty()) return emptyList()
val weeks = mutableListOf<MutableList<LocalDate?>>()
var currentWeek = MutableList<LocalDate?>(7) { null }
for (day in days) {
val dayIndex = day.dayOfWeek.value - 1 // Monday=0, Sunday=6
if (dayIndex == 0 && currentWeek.any { it != null }) {
weeks.add(currentWeek)
currentWeek = MutableList(7) { null }
}
currentWeek[dayIndex] = day
}
if (currentWeek.any { it != null }) {
weeks.add(currentWeek)
}
return weeks
}
시작일이 수요일이라면, 첫 주의 월/화 자리는 null이 된다. 마지막 주도 마찬가지. 이 null 자리에는 Spacer가 배치되어 그리드의 직사각형 모양이 유지된다.
Step 3: 월 라벨 위치 결정
internal fun createMonthLabels(grid: List<List<LocalDate?>>): List<Pair<Int, Int>> {
val result = mutableListOf<Pair<Int, Int>>()
var lastMonth = -1
for ((weekIndex, week) in grid.withIndex()) {
val firstDay = week.firstNotNullOfOrNull { it } ?: continue
val month = firstDay.monthValue
if (month != lastMonth) {
result.add(weekIndex to month)
lastMonth = month
}
}
return result
}
각 주에서 firstNotNullOfOrNull로 해당 주의 대표 날짜를 찾고, 월이 바뀌는 시점의 주 인덱스를 기록한다. 이 정보로 월 라벨을 정확한 위치에 배치한다.
Maven Central 배포
라이브러리를 Maven Central에 배포하는 과정도 하나의 여정이었다.
vanniktech/gradle-maven-publish-plugin 사용
Maven Central 배포에 com.vanniktech.maven.publish 플러그인을 사용했다. Sonatype Central Portal을 통한 배포 설정은 다음과 같다:
// library/build.gradle.kts
mavenPublishing {
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
signAllPublications()
coordinates("io.github.ois0886", "compose-git-grass", "0.1.1")
pom {
name.set("Compose Git Grass")
description.set("GitHub contribution graph (grass) UI component for Jetpack Compose")
inceptionYear.set("2026")
url.set("https://github.com/ois0886/compose-git-grass")
// ... licenses, developers, scm 설정
}
}
배포 시 필요한 것들
Maven Central 배포를 위해서는 몇 가지 준비가 필요하다:
- Sonatype 계정 및 네임스페이스 검증 (GitHub 기반이면
io.github.{username}) - GPG 키 생성 및 키서버 업로드 (서명에 필요)
~/.gradle/gradle.properties에 인증 정보 설정:mavenCentralUsername/mavenCentralPasswordsigning.keyId/signing.password/signing.secretKeyRingFile
.gitignore에 *.gpg와 secring*을 추가하여 인증 정보가 실수로 커밋되지 않도록 했다.
CI/CD
GitHub Actions로 CI를 구성했다. main 브랜치에 push 또는 PR이 올라오면 자동으로 테스트와 빌드가 실행된다:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :library:test
- run: ./gradlew :library:assembleDebug
- run: ./gradlew :app:assembleDebug
라이브러리의 유닛 테스트 → 라이브러리 빌드 → 샘플 앱 빌드 순서로 실행된다. 테스트가 실패하면 빌드가 중단되므로, 깨진 코드가 main에 들어가는 것을 방지한다.
배포는 CI에서 자동으로 하지 않고 로컬에서 수동으로 진행한다. GPG 키와 Maven Central 인증 정보를 CI 환경에 올리는 것보다 로컬에서 직접 하는 게 더 안전하다고 판단했다.
사용 예시
최소 사용
GitGrass(
contributions = mapOf(
LocalDate.now() to 5,
LocalDate.now().minusDays(1) to 3,
)
)
이게 끝이다. 나머지는 전부 기본값이 적용된다.
다크 모드 + 스트릭 + 클릭 이벤트
GitGrass(
contributions = data,
colors = GitGrassDefaults.darkColors(),
showStreak = true,
onCellClick = { date, count ->
println("$date: ${count}회 기여")
},
)
커스텀 색상 + 한국어
GitGrass(
contributions = data,
colors = GitGrassColors(
empty = Color(0xFFEEEEEE),
levels = listOf(
Color(0xFFBBDEFB),
Color(0xFF42A5F5),
Color(0xFF1565C0),
),
text = Color.Black,
border = Color.Gray,
),
weekLabels = listOf("월", "화", "수", "목", "금", "토", "일"),
monthLabels = listOf("", "1월", "2월", "3월", "4월", "5월", "6월",
"7월", "8월", "9월", "10월", "11월", "12월"),
showStreak = true,
streakMaxLabel = "최대 연속",
streakCurrentLabel = "현재 연속",
)
개발하면서 배운 점
1. API 디자인이 제일 어렵다
코드를 작성하는 것보다 "어떤 파라미터를 노출할지"를 결정하는 게 훨씬 어려웠다. 너무 많으면 사용하기 복잡하고, 너무 적으면 커스터마이징이 안 된다.
결국 "기본값으로 바로 쓸 수 있되, 필요하면 모든 것을 바꿀 수 있다"는 원칙으로 갔다. Kotlin의 기본 파라미터(default parameter) 덕분에 이게 자연스럽게 가능했다. 필수 파라미터는 contributions 하나뿐이고, 나머지 16개는 전부 기본값이 있다.
2. Compose Foundation만으로도 충분하다
Material3 없이 Foundation만으로 UI를 만들어본 건 좋은 경험이었다. BasicText, Box, Row, Column, Spacer — 이 다섯 가지 기본 컴포넌트만으로 꽤 복잡한 UI를 만들 수 있다.
오히려 Material3의 추상화 없이 직접 Modifier를 조합하다 보니 Compose의 레이아웃 시스템을 더 깊이 이해하게 됐다. wrapContentSize(unbounded = true)나 horizontalScroll(state, enabled = false) 같은 기법은 Material3만 사용할 때는 접할 일이 적다.
3. 순수 함수는 테스트를 쉽게 만든다
UI 로직을 순수 함수로 분리한 덕분에 Android Instrumented Test 없이 일반 JVM Unit Test만으로 핵심 로직을 검증할 수 있었다. 에뮬레이터를 띄우지 않아도 되니 테스트 실행 시간이 매우 짧다.
이 경험을 통해 "Compose UI에서도 로직은 최대한 순수 함수로 빼는 게 좋다"는 확신을 갖게 됐다.
4. 오픈소스 배포는 코드 그 이상이다
코드를 작성하는 건 전체 작업의 절반 정도였다. 나머지 절반은:
- README 작성 (사용법, API 레퍼런스, 예시 코드)
- Maven Central 배포 설정 (Sonatype 계정, GPG 키, POM 메타데이터)
- CI 구성
- 라이선스 설정
- 샘플 앱 작성
특히 Maven Central 배포는 처음이라 GPG 키 생성부터 Sonatype Central Portal 인증까지 시행착오가 있었다.
앞으로의 계획
현재 버전은 0.1.1이다. 기본적인 기능은 모두 동작하지만, 개선하고 싶은 것들이 있다:
- 툴팁(Tooltip) — 셀을 길게 누르면 날짜와 기여 횟수를 보여주는 팝업
- 애니메이션 — 첫 로딩 시 셀이 순차적으로 나타나는 효과
- Compose Multiplatform — iOS, Desktop에서도 사용할 수 있도록 확장
- 접근성(Accessibility) — TalkBack/VoiceOver 지원 강화
마치며
작은 라이브러리지만, 하나의 컴포넌트를 제대로 만들어서 배포하기까지의 과정은 생각보다 많은 것을 가르쳐줬다. API 설계, Compose 내부 동작, 테스트 전략, 오픈소스 배포 — 단순히 "잔디 그래프 그리기" 이상의 경험이었다.
GitHub: https://github.com/ois0886/compose-git-grass
GitHub - ois0886/compose-git-grass: GitHub contribution graph (grass) widget for Jetpack Compose
GitHub contribution graph (grass) widget for Jetpack Compose - ois0886/compose-git-grass
github.com
// build.gradle.kts
dependencies {
implementation("io.github.ois0886:compose-git-grass:0.1.1")
}'Android > Study' 카테고리의 다른 글
| [Android] Android LiveData에 대해서 Deep Dive 해보자. (0) | 2026.02.12 |
|---|---|
| [Android] Compose가 그려지는 과정에 대해서 알아보자. (0) | 2025.10.13 |
| [Android] Custom ComposeUI 간단 차트 개발 여정기 (0) | 2025.10.13 |
| [Android] launchedEffect은 무엇이고 다른건 뭐가 있을까? (0) | 2025.10.10 |
| [Android] aynck와 await로 여러개의 api 병렬 처리하기 feat. 최적화 여정기 (0) | 2025.10.10 |