BloomCard: 90개의 CustomPainter — 이모지를 전부 버린 이유
처음 버그 리포트를 받았을 때는 대수롭지 않게 넘겼다. 삼성 갤럭시 사용자가 "친구 폰이랑 꽃이 다르게 보여요"라고 했을 때, 폰트 캐시 문제겠거니 했다. 앱 재시작하면 된다고 답변하고 이슈를 닫았다.
일주일 뒤 같은 리포트가 또 왔다. 그리고 또. 그 다음에는 스크린샷이 오기 시작했다.
같은 플래시카드를 세 대의 폰으로 봤는데, 해바라기가 전부 다르게 생겼다. iOS에서는 입체적이고 디테일한 해바라기, 픽셀에서는 납작한 만화풍 해바라기, 삼성에서는 아예 다른 스타일의 해바라기. 정원 테마 플래시카드 앱을 만들었는데, 폰 브랜드에 따라 세 개의 다른 앱처럼 보이는 상황이었다.
그날 결심했다. 이모지를 전부 없앤다.
아무도 안 알려주는 이모지의 함정
크로스 플랫폼 앱에서 이모지를 시각적 아이덴티티로 쓰는 게 왜 문제인지, 핵심은 이거다: 이모지는 이미지가 아니라 폰트다. 각 운영체제가 자체 이모지 폰트를 탑재하고 있고, 유니코드 스펙을 해석하는 방식이 벤더마다 전부 다르다.
Apple 이모지는 디테일한 3D 일러스트다. Google의 Android 이모지는 한때 물방울 스타일이었다가 지금은 플랫한 원형으로 바뀌었다. Samsung은 독자 디자인을 밀고 있어서 Apple/Google 어느 쪽과도 닮지 않았다. 여기에 Xiaomi, Huawei, LG까지 더하면 같은 유니코드 코드포인트가 완전히 다른 비주얼로 렌더링되는 셈이다.
메모 앱이라면 이 정도는 사소한 미관 문제다. 하지만 BloomCard에서는 디자인이 깨지는 수준의 문제였다.
BloomCard는 정원 테마 플래시카드 앱이다. 카드를 복습하면 꽃이 자란다 — 씨앗에서 새싹, 꽃봉오리, 만개까지 네 단계를 거친다. Common, Uncommon, Rare, Legendary 네 등급에 걸쳐 21종의 꽃을 수집하고, 가상 펫을 키우고, 정원을 꾸미고, 진행 상황을 스크린샷으로 공유한다.
이 모든 시각 요소가 원래 이모지였다. 튤립, 해바라기, 고양이, 수정구슬, 눈사람 — 전부 이모지. 그리고 전부 폰마다 다르게 렌더링됐다.
단순히 보기 안 좋은 게 아니라 게임 메카닉이 깨졌다. Legendary 연꽃이 특정 기기에서 Common 데이지와 거의 비슷하게 보이면 레어도 체계가 의미를 잃는다. 펫 종은 알아볼 수 없게 되고, 어떤 폰에서 예쁘게 보이던 정원 장식은 다른 폰에서 추상화처럼 보였다.
선택지는 세 가지였다. 이미지 에셋 번들, 아이콘 라이브러리, 아니면 전부 직접 그리기.
이미지도 아이콘 폰트도 답이 아니었다
PNG나 SVG 번들이 첫 번째 후보였다. 하지만 계산을 해보면 문제가 보인다. 꽃 21종 x 성장 단계 4개 = 84개 에셋, 여기에 시들기 상태까지 고려하면 더 늘어난다. 화면 밀도별로 1x, 2x, 3x 해상도를 준비하면 에셋 번들이 걷잡을 수 없이 커진다. 펫, 코스메틱, 장식까지 더하면 수백 개의 이미지 파일이 된다.
Material Icons나 FontAwesome 같은 아이콘 폰트? "꽃봉오리 단계의 튤립"이나 "모자 쓴 여우"는 없다. BloomCard의 비주얼은 기성 아이콘 라이브러리로는 불가능할 정도로 구체적이다.
남은 건 CustomPainter였다. Flutter에서 Dart 코드로 캔버스에 직접 그리는 API. 외부 에셋 없이, 폰트 의존성 없이, 순수한 수학과 페인트 명령만으로 그리는 방식이다.
핵폭탄 같은 선택을 했다. 앱의 모든 이모지를 직접 코딩한 CustomPainter 클래스로 교체하기로.
범위: 90개 이상의 painter
최종적으로 만든 것들을 정리하면 이렇다.
FlowerPainter — 21종
- Common 8종: 데이지, 민들레, 클로버, 미나리아재비, 금잔화, 팬지, 페튜니아, 제비꽃
- Uncommon 5종: 튤립, 해바라기, 백합, 붓꽃, 수국
- Rare 4종: 난초, 장미, 벚꽃, 동백
- Legendary 3종: 연꽃, 파란 장미, 황금 백합
각 꽃은 4단계 성장 상태(씨앗/새싹/꽃봉오리/만개), 6가지 꽃잎 형태(원형/타원/뾰족/하트/별/레이어), 그리고 렌더링 크기에 따른 3단계 디테일 레벨을 갖는다.
PetSpeciesPainter — 5종
- 고양이, 토끼, 여우, 올빼미, 다람쥐
- 마스터 진화 단계용 스파클 오버레이 변형 포함
CosmeticPainter — 34종
- 모자, 스카프, 안경, 각종 액세서리 — 가상 펫이 착용할 수 있는 모든 아이템
Decoration painters — 15종
- 양초, 고양이 조각상, 액자, 물뿌리개, 풍경(風磬), 스노우볼, 수정, 허브 화분, 오르골, 용 조각상, 마법 거울, 사쿠라 등, 조개, 호박, 눈사람
- 정원의 6개 장식 구역에 각각 배치 가능
합계: 90개 이상의 CustomPainter 클래스, 전부 수작업.
Painter 내부 구조
실제 FlowerPainter의 핵심 구조를 보면 이렇다. 실제 코드는 단계별 렌더링을 위한 mixin(SeedRenderer, LeafRenderer, BudRenderer)을 사용하지만, 기본 골격은 다음과 같다:
class FlowerPainter extends CustomPainter
with SeedRenderer, LeafRenderer, BudRenderer {
final FlowerDef flower;
final FlowerGrowthStage stage;
final double animationProgress;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = min(size.width, size.height) / 2;
switch (stage) {
case FlowerGrowthStage.seed:
_paintSeed(canvas, center, radius, size);
case FlowerGrowthStage.sprout:
_paintSprout(canvas, center, radius, size);
case FlowerGrowthStage.bud:
_paintBud(canvas, center, radius, size);
case FlowerGrowthStage.bloom:
_paintBloom(canvas, center, radius, size);
}
}
}
FlowerDef라는 데이터 객체가 종별 파라미터를 전부 들고 있다 — primaryColor, centerColor, petalCount, petalWidth, petalShape. Painter는 이 파라미터를 읽어서 베지어 곡선, 그래디언트, 그림자를 계산한다.
만개 단계에서 꽃잎은 이차 베지어 곡선으로 그린다:
for (int i = 0; i < petalCount; i++) {
final angle = (2 * pi / petalCount) * i - pi / 2;
final tipX = center.dx + cos(angle) * petalRadius;
final tipY = center.dy + sin(angle) * petalRadius;
petalPath = Path()
..moveTo(center.dx, center.dy)
..quadraticBezierTo(ctrl1.dx, ctrl1.dy, tipX, tipY)
..quadraticBezierTo(ctrl2.dx, ctrl2.dy, center.dx, center.dy)
..close();
}
각 꽃잎에는 중심부에서 가장자리로 갈수록 밝은 색에서 원래 색으로 전환되는 방사형 그래디언트 채우기, 그 아래에 미묘한 드롭 섀도가 들어간다. 꽃 중심부에는 조명을 시뮬레이션하기 위해 오프셋된 화이트 하이라이트와 함께 별도의 방사형 그래디언트가 적용된다.
핵심 인사이트: 같은 painter 클래스가 21종의 꽃을 전부 렌더링한다는 점이다. 시각적 차이는 전적으로 FlowerDef 파라미터에서 나온다. 데이지는 13장의 둥근 꽃잎에 노란 중심부. 벚꽃은 5장의 하트형 꽃잎에 분홍 중심부. 해바라기는 21장의 뾰족한 꽃잎에 갈색 중심부. 같은 코드 경로, 완전히 다른 꽃.
크기에 따른 적응형 디테일
직접 만들면서 가장 마음에 드는 부분 중 하나가 적응형 디테일 시스템이다. 꽃을 36px 미만으로 렌더링할 때(촘촘한 정원 그리드 등)는 그림자와 그래디언트를 전부 생략하고 단색으로만 그린다. 36~64px에서는 그래디언트와 간단한 그림자를 추가한다. 64px 이상에서는 글로우 레이어와 하이라이트를 포함한 풀 이펙트를 렌더링한다.
enum FlowerDetailLevel {
compact, // < 36px — 그림자 없음, 그래디언트 없음
standard, // 36-64px — 그래디언트 + 간단한 그림자
detailed, // 64px+ — 풀 이펙트
}
단순히 최적화만을 위한 게 아니었다. compact 레벨은 사실 작은 크기에서 더 보기 좋다. 24px에서 그래디언트와 그림자를 넣으면 시각적 노이즈일 뿐이다. 과감하게 벗겨내면 어떤 크기에서든 깔끔하게 읽히는 아이콘이 된다.
장식 painter: 15개의 미니어처 작품
정원 장식 시스템이 만들면서 가장 재미있었다. 각 장식은 사실상 작은 그림이다 — 눈이 떨어지는 스노우볼, 바람에 흔들리는 풍경, 반짝이는 수정.
장식 painter는 라우터 함수를 통해 ID에서 painter로 매핑된다:
CustomPainter? getDecorationPainter(String decorationId, {double progress = 0}) {
switch (decorationId) {
case 'candle':
return _CandlePainter(progress: progress);
case 'snow_globe':
return _SnowGlobePainter(progress: progress);
case 'wind_chime':
return _WindChimePainter(progress: progress);
// ... 15개 전체
}
}
progress 파라미터가 애니메이션을 구동한다 — 양초의 불꽃 깜빡임, 풍경의 부드러운 흔들림, 스노우볼 안의 눈 내림. 스노우볼 painter가 개인적으로 가장 좋아하는 건데, 캔버스를 원형으로 클리핑하고 안에 작은 나무와 눈 바닥을 그린 다음 무한 루프로 떨어지는 눈송이 파티클을 뿌린다.
양초 painter는 간단한 사인파로 설득력 있는 불꽃 깜빡임을 만든다:
final flameFlicker = sin(progress * pi * 6) * 0.08;
final flameH = h * (0.18 + flameFlicker);
삼각함수 한 줄에 눈물방울 형태 베지어 경로, 외부 글로우 블러를 합치면 놀랍도록 그럴싸한 촛불 효과가 완성된다.
Isolate 문제: 삼각함수를 Taylor 급수로
여기서부터 좀 재미있어진다. BloomCard는 공유 가능한 정원 이미지를 생성한다. 1080x1080 PNG 카드인데, 사용자가 SNS에 올릴 수 있는 형태다. ShareCardGenerator 클래스는 Flutter 위젯 레이어 없이 raw dart:ui Canvas를 사용해서 이미지를 렌더링한다. 백그라운드 처리에 적합하도록.
공유 카드에는 워터마크 바에 작은 장식용 꽃 아이콘이 들어간다. 이 꽃을 그리려면 꽃잎 위치 계산에 cos()와 sin()이 필요하다. 간단한 문제 같지만 — 공유 카드 생성기는 isolate 안전해야 했다.
Dart에서 isolate는 접근 가능한 라이브러리에 제약이 있다. dart:math가 기술적으로는 isolate에서 사용 가능하긴 하지만, 백그라운드 처리 컨텍스트에서 문제가 생길 가능성을 완전히 차단하고 싶었다. 해결책은 황당할 정도로 단순했다: Taylor 급수 근사.
static double _cos(double radians) {
final x = radians % (2 * 3.14159265);
final x2 = x * x;
return 1 - x2 / 2 + x2 * x2 / 24 - x2 * x2 * x2 / 720;
}
static double _sin(double radians) {
final x = radians % (2 * 3.14159265);
final x2 = x * x;
return x - x2 * x / 6 + x2 * x2 * x / 120 - x2 * x2 * x2 * x / 5040;
}
Taylor 급수 전개 6개 항. 소수점 이하 여러 자리까지 정확한데, 꽃잎 5장 배치하는 데는 이 정도면 충분하고도 남는다. 배경 그래디언트, 사용자 이름, 꽃 그리드, 통계 행, 꽃 아이콘 포함 브랜드 워터마크 — 공유 카드 전체가 외부 의존성 제로의 순수 캔버스 연산으로 렌더링된다.
시들기 시스템: 색상 행렬로 탈채도
이모지로는 절대 불가능했을 기능이 꽃 시들기 시스템이다. 사용자가 플래시카드 복습을 방치하면 꽃이 시들기 시작한다. FlowerWidget이 단계적 시각 열화를 적용한다:
- 휘도 가중 탈채도 행렬로 wilt 레벨에 따라 색을 빼간다
- 갈색빛 틴트 오버레이로 죽어가는 식물을 시뮬레이션
- 살짝 회전시켜 꽃이 고개를 숙인 느낌
- 스케일을 줄여 쪼그라든 느낌
탈채도에는 Rec. 709 표준의 휘도 계수(0.2126, 0.7152, 0.0722)를 사용한 ColorFilter.matrix를 쓴다. wilt 레벨에 따라 채도 승수를 보간하면 꽃이 선명한 색에서 회갈색으로 부드럽게 전환된다.
이모지로 이걸 해보라고 하면? 불가능하다.
i18n에도 이모지 없음
엄격하게 지키는 규칙이 하나 있다: 국제화 문자열에 이모지를 넣지 않는다. BloomCard는 영어와 한국어를 지원하고 번역 키가 약 1,178개인데, 단 하나도 이모지 문자를 포함하지 않는다. 모든 시각 요소는 UI 레이어에서 painter, Material Icons, 또는 커스텀 위젯으로 렌더링한다.
단순히 일관성 때문만이 아니다. 관심사 분리라는 실용적 이유가 있다. 번역 파일에는 텍스트만 들어가고, UI 레이어에 비주얼이 들어간다. 이걸 섞으면 유지보수가 지옥이 되고, 비주얼을 업데이트할 때마다 모든 로컬라이제이션 파일을 건드려야 하는 상황이 된다.
대가
솔직하게 이 접근법의 비용을 말하자면.
90개 이상의 클래스 유지보수. 새로운 꽃 종이나 펫 코스메틱이나 정원 장식을 추가할 때마다 새 painter를 처음부터 작성해야 한다. 드래그 앤 드롭 에셋 파이프라인 같은 건 없다. 각 painter는 베지어 곡선, 그래디언트 정의, 그림자 파라미터가 담긴 수작업 코드고, 정확하고 성능 좋게 만들어야 한다.
개발 시간. 장식 painter 하나 그리는 데 30분에서 1시간이 걸린다. 스노우볼만 해도 세심하게 조율된 좌표로 80줄이다. 90개 이상을 곱하면 누적 작업량이 몇 주 단위가 된다.
디자이너 핸드오프 불가. 일반적인 에셋 파이프라인에서는 디자이너가 PNG나 SVG를 내보내고 개발자가 넣으면 된다. CustomPainter에서는 내가 곧 디자이너다. 모든 시각 요소가 머릿속 기하학에서 시작해 좌표로 변환되고 반복을 통해 다듬어진다. Figma에서 코드로 바로 가는 지름길은 없다.
테스트 복잡도. 유닛 테스트에서 painter 출력을 시각적으로 diff 할 수 없다. painter가 에러 없이 렌더링되고 예상된 시맨틱 레이블을 생성하는지 확인하는 위젯 테스트를 사용하지만, 시각적 리그레션을 잡으려면 결국 직접 눈으로 확인해야 한다.
예상치 못한 장점들
비용에도 불구하고, 여러 장점이 예상 밖이었다.
더 작은 바이너리. 번들된 이미지 에셋이 제로이므로 APK/IPA가 눈에 띄게 작다. 1x, 2x, 3x 밀도별 PNG 없음. SVG 파싱 라이브러리 없음. Painter는 그냥 Dart 코드니까 앱의 나머지와 같은 바이너리로 컴파일된다.
완벽한 스케일링. 전부 벡터 수학이므로 어떤 크기에서든 선명하다. 적응형 디테일 시스템 덕분에 어떤 크기에서든 성능도 좋다. 24px의 꽃이 200px의 꽃만큼 선명하면서, 각 크기에 적절한 디테일을 표시한다.
애니메이션 용이성. Painter가 파라미터를 받으므로 애니메이션이 간단하다. AnimatedFlowerWidget은 AnimationController 하나로 성장 단계 간 전환을 부드럽게 처리한다. 풍경이 흔들리고, 양초가 깜빡이고, 수정이 반짝인다. 전부 이미 어떤 상태에서든 그리는 법을 아는 painter에 progress 파라미터 하나를 넘기는 것으로 구동된다.
픽셀 퍼펙트 일관성. 이게 원래 목표였고, 완벽하게 달성됐다. BloomCard 정원은 Flutter가 돌아가는 모든 기기에서 동일하게 보인다. 삼성, iOS, 픽셀, 샤오미 — 같은 베지어 곡선, 같은 그래디언트, 같은 그림자. "제 꽃이 왜 다르게 보이나요" 버그 리포트는 더 이상 들어오지 않는다.
테마 제어. 색상이 코드로 정의되어 있으므로 다크 모드나 시즌 테마 구현이 간단하다. 모든 꽃의 색조를 바꾸고, 모든 그림자를 조정하고, 모든 그래디언트를 시프트할 수 있다 — 전부 테마 파라미터로. 이모지 색을 바꿔보라고 하면? 할 수 없다.
다시 한다면?
당연히 다시 한다. 다만 범위를 조금 다르게 잡을 것이다.
지금 BloomCard를 처음부터 만든다면, 핵심 아이덴티티 요소에는 여전히 CustomPainter를 올인할 거다. 꽃, 펫, 장식 — 이것들은 앱의 시각적 영혼이다. 일관적이어야 하고, 애니메이션이 되어야 하고, 표현력이 있어야 한다.
주변부 UI 요소에는 Material Icons이 있는 곳에서는 더 적극적으로 활용할 것이다. 모든 아이콘이 커스텀 페인팅일 필요는 없다. 핵심은 어떤 비주얼이 브랜드를 정의하고, 어떤 것이 단순한 기능적 어포던스인지를 구분하는 것이다.
90개 painter 접근법이 통하는 이유는 BloomCard의 정체성 자체가 비주얼이기 때문이다. 정원, 꽃, 펫 — 이것들은 플래시카드 앱 위에 얹힌 장식이 아니다. 앱의 인격 그 자체다. 그 인격이 기기마다 다르게 렌더링되면 앱이 영혼을 잃는다.
그래서 영혼을 직접 그렸다. 베지어 곡선 하나씩.
BloomCard는 Flutter로 만든 정원 테마 플래시카드 앱입니다. bloomcard.9-87.org에서 더 알아보세요.