GitHub 잔디밭에 픽셀 아트를 그리다 알게 된 두 가지

일요일 오후, 별일 없을 때 떠오르는 시답잖은 충동이 있다. 내 GitHub 잔디밭에 내 이름을 새겨볼 수 있을까? 매일 출석 도장 찍듯 한 칸씩 채워 넣은 단조로운 초록 패턴 말고, 글자에 음영이 들어가고 배경에는 그라데이션도 깔리는, 그럴듯한 그림으로.
잔디밭을 자세히 보니 53×7 격자, 정확히 371칸이다. 칸당 색은 5단계(빈 칸 + 초록 4단계). 디자인 캔버스 치고는 꽤 매력적인 제약이다. 어디 끼워 넣어도 부담 없을 만큼 작고, 글자를 또렷하게 그리기엔 부족하지 않으며, 5단계뿐이라 결국 색이 아니라 디더링을 고민하게 된다.
비슷한 도구는 이미 있다. gitfiti, github-spray, github_painter 같은 게 2014년부터 굴러다닌다. 다만 다들 "칠한다 / 비운다"의 이진 픽셀에서 멈춘다. 잔디밭을 후광이나 그라데이션, 사진 같은 음영이 살아 있는 회색조 이미지로 다루는 도구는 보이지 않았다.
뭐, 마침 나는 바이브 코더고 옆에는 Claude Code가 있다. 일요일 오후 한나절을 갈면 도구 하나쯤은 빠지는 시대다. 그래서 그냥 만들었다. 256단계 회색조에서 디자인하고, 출력 직전에 Floyd-Steinberg나 Atkinson 디더링으로 5단계까지 떨어뜨린 다음, 칸별 강도에 맞춰 가짜 날짜 커밋을 찍는 도구다. 이름은 github-commit-painter.
디더링 파이프라인 자체는 별로 흥미로운 부분이 아니다. 오히려 도구를 만드는 동안 GitHub 잔디밭 알고리즘에서 마주친 의외로 이상한 동작 두 가지가 더 재미있었다.
1. 다섯 단계 색은 절댓값이 아니라 사분위로 정해진다
처음에는 잔디밭 아래 범례(Less ▢▢▢▢▢ More)가 고정된 커밋 수 임계값에 매핑된다고 짐작했다. 1~3개면 연두, 10개 이상이면 진초록, 이런 식으로. 그런데 그게 아니었다.
GitHub는 사용자별, 연도별로 음영을 다음 순서로 계산한다.
- 그 기간 동안 활동한 날(커밋이 1개 이상 있는 날)을 모은다.
- 커밋 수로 정렬한다.
- 상위 25%는 L4(가장 진한 초록), 그다음 25%는 L3, 같은 식으로 내려간다.
즉 L4는 절대 기준이 아니라, 그 사람의 활동일 중 상위 사분위라는 상대 기준이다. 평소 최대 활동일이 153 커밋인 사람이 한 칸당 156 커밋씩 일정하게 칠해버리면, 그 칸들은 L4 경계선에 죄다 들러붙는다. 결과물은 진초록 한 가지가 아니라 L2와 L3가 뒤섞인 탁한 그림이 된다.
내 첫 시도가 칸당 수천 커밋을 박고도 흐릿했던 이유가 여기 있었다. 해법은 단순했다. GraphQL의 viewer.contributionsCollection.contributionCalendar로 그 사용자의 최근 365일 최대 활동일을 먼저 조회한 다음, 4 × m > 사용자_최대값 + 안전 마진을 만족하는 배수 m을 고른다. 내 계정은 39배가 나왔다. 디자인의 L4 한 칸을 가장 진한 초록으로 띄우려면 실제 커밋 156개가 들어가야 한다는 뜻이다.
후광과 그라데이션 배경을 두른 "HULRYUNG"은 한 해 기준 35,022개 커밋이 들어갔다. 본문 없는 빈 커밋에 GIT_AUTHOR_DATE만 백데이트하는 거라 생성에는 1분 남짓 걸렸다.
2. gh auth token에는 user:email 스코프가 없다
painter는 커밋을 만들기 전에 이메일 검증을 한 번 돈다. git config user.email에 들어 있는 주소가 GitHub에 등록·인증된 이메일과 맞지 않으면, 그렇게 박은 커밋은 잔디밭에 카운트되지 않는다. 한참 그려놓고 그 사실을 알게 되면 정말 맥이 빠진다.
인증 이메일을 가져오는 자연스러운 경로는 REST 엔드포인트 /user/emails다. 기본 gh auth token으로 호출하면 404가 떨어진다. 그럼 GraphQL viewer.email로 가보자. 이번엔 403이다.
INSUFFICIENT_SCOPES: The 'email' field requires one of the following
scopes: ['user:email', 'read:user'], but your token has only been
granted the: ['gist', 'read:org', 'repo', 'workflow'] scopes.
gh가 기본으로 받아 오는 스코프 묶음으로는 사용자의 인증 이메일 목록을 정상 경로로 가져올 방법이 없다. gh auth refresh -s user:email을 돌려달라고 안내할 수도 있지만, 도구를 처음 돌려보려는 그 순간에 마찰을 한 번 더 끼얹는 셈이다.
내가 택한 우회는 users.noreply.github.com 별칭 두 개를 직접 만들어내는 방식이다. viewer.login과 viewer.databaseId는 기본 스코프에서도 잘 돌아가니까.
data = self._post("query { viewer { login databaseId } }")
login = data["viewer"]["login"]
db_id = data["viewer"]["databaseId"]
return [
VerifiedEmail(f"{login}@users.noreply.github.com", primary=False),
VerifiedEmail(f"{db_id}+{login}@users.noreply.github.com", primary=False),
]사용자의 git config user.email이 이 두 형식 중 하나라면(프라이버시를 신경 쓰는 개발자라면 대개 이미 쓰고 있는 형식이다) 스코프를 더 받지 않고도 검증을 통과한다. 다른 인증 이메일을 쓰는 경우에도 "3만 5천 개를 박았는데 잔디밭이 그대로다" 같은 침묵 대신, 무엇을 어떻게 고치라는 메시지를 명확히 띄울 수 있다.
결과물
최종 도구는 256단계 회색조 위에서 배경 → 후광 → 텍스트 순서로 레이어를 합성한 다음, 출력 직전에 디더링을 건다. 작은 글자도 또렷하게 찍히도록 5×7 래스터 폰트를 내장했고, 장식 글꼴은 pyfiglet 어댑터로 받는다. Python 약 1,800줄, 테스트는 125개 정도.
내 2018년 잔디밭에 새긴 "HULRYUNG"은 여기서 볼 수 있다. 전체 코드는 github.com/hulryung/github-commit-painter에 올려뒀다.
돌이켜보면 이번 프로젝트의 진짜 재미는 "잔디밭에 그림을 그렸다"가 아니었다. 53×7짜리 작은 UI 한 조각 뒤에도 사용자별 사분위 정규화가 돌아가고, 그 옆에는 OAuth 스코프 권력 다툼이 숨어 있다는 사실을 들춰본 쪽이 훨씬 흥미로웠다. 일요일 오후의 시답잖은 충동 하나를 Claude Code 옆에 끼고 한나절 굴린 끝에 그런 속살을 한 꺼풀 벗겨봤다는 것만으로도, 그날의 3만 5천 커밋은 값을 했다.