ClockHub 아이콘의 설계 철학: 16픽셀에서 거꾸로 그린 문자판
오래된 괘종시계의 문자판. CC0, Wikimedia Commons
![]()
이 사진에 있는 것 중 아이콘에 남은 것은 하나도 없다.
무늬도, 로마 숫자도, 뚫린 장식도 없다. 최종적으로 내보낸 것은 두 가지 배색, 각각 14개 파일이고, 그중 가장 작은 것은 16×16 정사각형 안에 선 12개와 바늘 2개, 빨간 점 하나다.
이건 "미니멀한 취향"의 문제가 아니다. 순전히 미적인 선택이라면 더 정교하게 만들 수도 있었다. 이 도형을 결정한 것은 훨씬 단단한 제약이었다. 16픽셀 안에서 알아볼 수 있어야 한다.
그래서 이 글은 영감이 아니라 역산에 관한 이야기다. 16픽셀에서 거꾸로 올라가며 각 단계가 어떻게 강제되었는지를 적는다.
I. 정사각형 하나에 눈금이 몇 개 들어가는가
디자인 캔버스는 512×512, 설계 반지름은 R=200. 16픽셀로 줄이면 비율은 1:32.
그러니 모든 값을 32로 나눈다.
- 문자판의 설계 지름 400은 16픽셀에서 12.5픽셀
- 눈금 바깥 반지름 190은 5.94픽셀
- 문자판이 보이는 폭은 12픽셀도 안 된다
이게 전부다. 여유도, 더 짜낼 여백도 없다.
그러면 눈금이 몇 개 들어갈까. 원둘레를 구하면 된다. 반지름 5.94픽셀, 원둘레 37.3픽셀.
| 눈금 수 | 이웃 간격 |
|---|---|
| 60 | 0.62 px |
| 24 | 1.55 px |
| 12 | 3.11 px |
![]()
셋 다 1.1픽셀까지 굵힌 상태. 변수는 눈금 수 하나뿐
취리히 중앙역의 유명한 스위스 철도 시계는 문자판에 눈금이 60개다. 시 단위의 굵은 12개와 분 단위의 가는 48개. 이 디자인 계보의 정점이지만, 그 60개를 비율대로 16픽셀에 밀어 넣으면 틈이 메워져 하나의 고리가 된다. 시계인지조차 알 수 없다.
여기서 이 글에서 가장 중요한 문장이 나온다.
선 굵기는 굵힐 수 있다. 간격은 굵힐 수 없다.
폭 0.6픽셀의 선은 1.1로도, 2로도 굵힐 수 있다. 굵혀도 여전히 그 선이다. 하지만 두 선 사이 0.62픽셀의 틈은 어떤 방법으로도 넓힐 수 없다. 그 사이에 있는 것을 없애는 것 말고는.
눈금이 60에서 12로 줄어든 것은 12가 더 좋아서가 아니다. 16픽셀에는 12개만 들어가기 때문이다.
II. 뺄셈 목록
형태는 한 번에 정해지지 않았다. 아이콘 조정 도구에는 3세대 파라미터가 남아 있고, 바뀐 값은 모두 scripts/icon/tuner.html에 기록되어 있다.
| 버전 | 눈금 폭 | 바늘 폭 | 중심 원 | 시침 각도 | 분침 각도 |
|---|---|---|---|---|---|
| 참고 이미지 | 15 | 16.5 | 없음 | 10° | 125° |
| 얇은 판(이전 버전) | 4.0 | 4.8 | r = 5 | 0° | 120° |
| 최종판 | 10 | 12 | r = 12 | 0° | 120° |
(길이와 폭은 반지름 R에 대한 백분율이다. 10이면 선폭이 200의 10%, 즉 설계상 20픽셀.)
![]()
3세대 파라미터와 각각의 16픽셀 모습
첫 판은 참고 이미지를 그대로 따랐다. 굵은 눈금, 굵은 바늘, 중심 원 없음. 큰 크기에서는 무게감이 있었지만 가까이 보면 둔탁했다. 그래서 얇은 판을 만들었다. 눈금 4.0, 바늘 4.8, 선은 깔끔하고 중심 원은 r=5까지 줄였다. 셋 중 가장 아름다운 것은 얇은 판이다.
그리고 그것은 16픽셀에서 사라졌다.
"덜 예뻐서"가 아니라 아예 그릴 수가 없다. 얇은 판의 눈금 폭 4.0은 16픽셀에서 0.25픽셀이다. 굵히는 계산은 4.4배를 뽑아내지만 상한이 3.2배에서 멈춘다. 최대로 굵혀도 0.8픽셀이라 하한 1.1픽셀에 닿지 못한다.
최종판은 한 걸음 물러난 형태다. 눈금 10, 바늘 12, 중심 원 r=12. 큰 크기에서는 얇은 판만큼 우아하지 않지만, 16픽셀에서 "눈금 12개와 바늘 2개"로 읽히는 유일한 판이다.
교훈은 이렇다. 예쁨은 특정 크기 하나에서의 국소 최적해일 뿐이다. 결정은 가장 작은 크기에서 내리고, 가장 큰 크기에서의 결과는 받아들여야 한다.
III. 시각을 알리지 않는 문자판
최종판의 시침은 0°, 분침은 120°를 가리킨다. 이 문자판이 보여주는 시각은 12:20.
그리고 12:20은 실재하지 않는 시각이다. 진짜 시계가 20분을 가리킬 때 시침은 이미 12에서 삼분의 일만큼 떨어진 10°에 있다. 참고 이미지판이 10°였고, 그쪽이 "맞는" 값이다.
시침을 0°로 되돌린 것은 12시의 빨간 눈금과 같은 반지름 위에 올리기 위해서다. 중심에서 위로, 흰 바늘의 아래쪽 절반과 빨간 눈금이 하나의 선이 된다. 멀리서 보면 끝만 빨갛게 물든 바늘처럼 보인다.
분침을 125°에서 120°로 옮긴 것도 같은 이유다. 0도 120도 30의 배수라서 두 바늘 끝이 정확히 눈금 위에 올라간다. 시침은 12시의 빨간 눈금에, 분침은 4시 눈금에. 참고 이미지판은 둘 다 눈금 사이에 멈춰 있었다. 10°는 12시와 1시 사이, 125°는 4시와 5시 사이.
이 두 결정은 사실 16픽셀과 관계가 없다. 그 크기에서 5°는 분침 끝을 0.35픽셀, 10°는 시침 끝을 0.49픽셀 움직일 뿐이다. 보이지 않는다. 이것은 220픽셀 이상을 위한 구도 결정이다.
하지만 방향은 같다. 격자에 맞춘다. 진실에는 맞추지 않는다. 눈금 12개가 이 도형의 유일한 좌표계이고, 바늘 끝은 눈금 위에 오르거나 눈금 사이에 떨어지거나 둘 중 하나다. 전자는 "설계된" 것으로, 후자는 "아무렇게나 놓인" 것으로 읽힌다.
덧붙여, 시계 광고에서 유명한 10:10. 시침이 305°, 분침이 60°라서 각도는 실제로 **115°**다. 이것도 눈금 위에 있지 않다. 우리는 그 각도를 따라 하지 않았다.
대가는 이 문자판이 더 이상 실재하는 시각을 보여주지 않는다는 것이다.
아이콘은 시각을 알리지 않는다. 전달하는 것은 "이것은 시계다"이지 "지금 20분 지났다"가 아니다. 둘이 충돌할 때 가독성이 이기고 정확성이 진다.
IV. 선 하나의 최소 폭
0.625픽셀 이야기로 돌아가자.
폭 0.625픽셀의 선은 화면에서 가는 선이 아니라 농도가 다른 픽셀의 줄이다. 렌더러는 그것을 이웃한 두 픽셀에 절반씩 나눠 담는다. 먹색이어야 할 눈금이 옅은 회색 안개가 된다.
scripts/icon/generate.mjs의 처방은 최소 선폭을 그대로 숫자로 적어두는 것이었다.
const MIN_PX = 1.1 // 출력 픽셀에서 이보다 가늘게 두지 않는다
const MAX_BOOST = 3.2 // 다만 3.2배까지만. 더 키우면 덩어리가 된다
function boostFor(size) {
const tickPx = ((SHAPE.tick.width / 100) * SHAPE.R * size) / CANVAS
const k = MIN_PX / tickPx
return Math.min(MAX_BOOST, Math.max(1, k))
}
논리가 거꾸로다. "이 크기에서 몇 배로 키울까"가 아니라 "선폭을 1.1픽셀로 만들려면 몇 배가 필요한가"를 묻는다. 나온 배수를 다시 선폭과 선 길이에 곱한다.
출력 크기 열 개를 모두 통과시키면 이렇게 된다.
![]()
손대는 것은 16과 24 둘뿐. 32픽셀부터는 전혀 건드리지 않는다
바뀌는 것은 16과 24뿐이다. 32픽셀에서는 선폭이 이미 1.25픽셀이라 하한을 넘으므로 배율이 정확히 1.00이다. 코드는 아무 일도 하지 않는다. 이것이 이 장치의 가장 좋은 성질이다. 이것은 취향이 아니라 안전장치다.
놓치기 쉬운 세부가 하나 더 있다. 굵힐 때 길이도 함께 늘어난다.
const grow = 1 + (k - 1) * 0.35 // 굵히면서 동그란 점이 되는 것을 막으려고 길이도 늘린다
const hubGrow = 1 + (k - 1) * 0.4 // 중심 원은 더 보수적으로
선의 끝이 모두 둥글기 때문이다(stroke-linecap: round). 길이 48, 폭 20인 눈금을 길이는 그대로 두고 폭만 35로 굵히면 더 이상 눈금이 아니라 캡슐이다. 조금 더 굵히면 원이 된다. 그래서 폭을 1.76배 할 때 길이는 1.27배. 절반이 조금 넘는 비율로 "가늘고 긴 막대"라는 형태를 겨우 유지한다.
중심 원은 더 보수적이다(0.35가 아니라 0.4). 이미 거의 정원이라 더 키우면 두 바늘의 뿌리를 먹어버린다.
이 방식은 우리가 발명한 것이 아니다. Apple의 휴먼 인터페이스 가이드라인에 분명히 적혀 있다. "극단적으로 가는 선과 날카로운 모서리를 피하라. 작은 아이콘 크기와 낮은 해상도에서 디테일과 선명도를 잃기 쉽다." Google은 더 나아가 이것을 폰트의 축으로 만들었다. Material Symbols의 opsz(optical size)는 20dp에서 48dp까지 움직이며, "서로 다른 크기에서 같게 보이도록 선 굵기가 크기에 따라 변한다".
차이는 하나뿐이다. 그들은 여러 벌을 손으로 그린다. 우리는 코드가 계산하게 한다.
V. 두 가지 바탕
아이콘에는 외곽 원이 없다.
취향이 아니라 공간 셈이다. 고리를 그리면 최소 2픽셀이 든다. 16픽셀 문자판에서는 지름 12.5픽셀의 16%다. 빼면 눈금을 바깥으로 밀 수 있고 도형 전체가 한 바퀴 커진다.
하지만 외곽 원을 빼면 대가가 있다. 도형이 자기 경계를 잃는다.
고리가 있는 아이콘은 어떤 배경에서도 "원"이다. 고리가 없으면 공중에 뜬 선 12개가 알려진 바탕에 기대야 한다. 판을 깔거나, 반드시 흰색 위에 온다는 것을 보장하거나.
그래서 두 가지 배색이 나오고 역할은 분명히 갈린다.
![]()
같은 두 개의 탭 바에 각각의 아이콘을 올린 것
- dark(검은 판에 흰 바늘):
public/icons/dark/, 브라우저 탭용. 탭 바는 브라우저의 UI이고, 아이콘은 자기가 밝은 테마에 놓일지 어두운 테마에 놓일지 알 방법이 없다. 둘 다에서 통하는 한 가지를 골라야 한다. 검은 판은 밝은 탭 바에서 분명한 검은 사각형이 되고, 어두운 탭 바에서도 흰 바늘이 떠오른다. - light(흰 판에 검은 바늘):
public/icons/light/, 사이트 헤더, OG 이미지, iOS 홈 화면용. 이 바탕은 우리가 통제한다.
그 판단은 src/lang/seo.ts의 한 줄에 있다.
icons: { icon: '/icons/dark/favicon.ico', apple: '/icons/light/icon.svg' }
favicon은 dark, apple-touch-icon은 light. 전자는 낯선 배경에 홀로 맞서고, 후자는 iOS 홈 화면의 옅은 배경화면 위에 놓인다.
사이트 헤더는 세 번째 형태를 쓴다. clock_mark.tsx의 plate는 기본값이 false라 판을 그리지 않고, 먹색 선을 흰 헤더에 그대로 놓는다. 같은 기하, 세 가지 얹는 방식. 어두운 판, 밝은 판, 판 없음.
iOS판에는 하나 더 있다. 투명하면 안 된다. iOS는 아이콘 안의 투명 영역을 검게 채우기 때문에, 배경이 투명한 아이콘은 홈 화면에서 검은 사각형이 된다. 180픽셀 apple-touch-icon은 판까지 함께 그린다. 공중에 뜬 선의 모음이 아니다.
VI. 왜 스크린샷으로 끝내지 않는가
아이콘 스크립트는 Chrome을 부르지 않는다. rsvg-convert도 부르지 않는다. npm 패키지도 설치하지 않는다.
스스로 두 가지를 구현했다. 래스터라이저와 PNG 인코더다.
이유는 스크립트 첫머리 주석에 있다.
아이콘은 픽셀 단위로 정확하고 매번 완전히 같게 생성되어야 한다. 외부 도구의 확대·축소 정책, 배경색, DPR은 모두 결과에 영향을 준다. 여기서는 4~8배 슈퍼샘플링으로 직접 래스터화하고 Node 내장 zlib에만 의존한다. 다른 기계에서 돌려도 PNG는 1바이트도 다르지 않다.
방식은 이렇다. 먼저 4~8배 해상도로 샘플링한다(16픽셀 아이콘이면 128×128). 각 서브샘플 점이 어느 도형 안에 있는지 판정해 색을 정하고, 8×8 블록으로 박스 평균해 16×16으로 되돌린다. 안티에일리어싱은 계산된 것이지 렌더러의 휴리스틱이 아니다.
const ss = Math.max(2, Math.min(8, Math.ceil(2048 / size)))
작은 그림은 많이(16픽셀에서 8배), 큰 그림은 적게(512픽셀에서 4배). 절대 샘플 밀도를 2048 근처로 유지하기 위해서다.
인코더도 직접 짰다. PNG의 IHDR / IDAT / IEND와 CRC32, ICO의 ICONDIR과 내장 PNG. 거창하게 들리지만 100줄이 안 된다. 보상은 크기다. 512×512가 7KB, 16픽셀이 319바이트.
진짜 의미는 의존성을 줄이는 것이 아니다. 아이콘을 빌드 산출물로 만드는 것이다.
코드가 만드는 아이콘은 git에 들어가고, diff가 되고, 몇 번이든 다시 돌릴 수 있다. 숫자 하나를 바꾸면 두 배색 28개 파일이 한꺼번에 다시 만들어진다. 디자이너에게 PNG를 한 번 더 부탁할 필요도, 내보낼 때 체크를 빼먹지 않았나 걱정할 필요도 없다. 아이콘은 "이미지 한 장"이기를 그치고 "실행할 수 있는 서술"이 된다.
VII. 하나의 JSON, 두 명의 소비자
형태의 출처는 하나뿐이다. src/constants/clock_icon.json. 13줄, 숫자 15개, 불리언 1개, 색 3개.
{
"canvas": 512,
"R": 200,
"plateRadius": 0.16,
"tick": { "len": 24, "width": 10 },
"redTick": { "len": 35, "width": 12 },
"hour": { "len": 45, "width": 12, "angle": 0 },
"minute": { "len": 65, "width": 10, "angle": 120 },
"hub": { "r": 12, "width": 3, "fill": true },
"ink": "#111827",
"paper": "#ffffff",
"red": "#ef4444"
}
이것을 읽는 곳이 둘이다.
scripts/icon/generate.mjs— 28개의 정적 파일을 만든다src/app/[lang]/components/clock_mark.tsx— 헤더와 푸터의 움직이는 로고
이것이 이 체계에서 가장 눈에 안 띄지만 가장 중요한 규칙이다. 정적 아이콘과 페이지의 로고는 어긋날 수 없다. 같은 숫자로 계산하기 때문이다.
헤더 판은 한 가지를 더 한다. 돈다.
포인터를 올리면 두 바늘이 회전하기 시작한다. 분침은 초당 840도(약 2.3바퀴), 시침은 1:12 기어비로 정확히 따라간다. 떼도 급정지하지 않고 "정지 각까지 몇 도를 더 돌아야 하는지"를 계산해 3차 이징으로 0.7초에 걸쳐 12:20에 착지한다.
여기에는 피할 수 없는 타협이 있다. 진짜 시계의 기어비는 1:12라서 분침이 360° 도는 동안 시침은 30°만 움직인다. 즉 정확한 비율로는 두 바늘이 동시에 제자리로 돌아올 수 없다. 분침이 120°로 돌아온 시점에 시침은 0°에 있지 않다. 그래서 돌아오는 0.7초 동안만 기어를 떼어내고 각자 최단 경로로 간다. 눈에 띄지 않고, 둘 다 정확히 원래 각도에 멈춘다.
하나 더. 시스템이 "동작 줄이기"(prefers-reduced-motion: reduce)를 요청하면 바늘은 아예 돌지 않는다.
VIII. 가장 작은 인터페이스
16픽셀 아이콘은 만들 수 있는 인터페이스 중 가장 작은 것이다.
툴팁도, 호버 상태도, 로딩 실패 시 재시도 버튼도, 빈 상태도 없다. 기회는 한 번, 알아보는 그 한눈뿐이다. 아무도 들여다보지 않고, 외우지도 않으며, 정교하게 만들었다고 해서 반 초 더 머물지도 않는다. 그저 열 개 남짓한 탭 중에서 맞는 것을 찾고 있을 뿐이다.
이 제약이 장식적 선택지를 모두 지운다. 남은 것은 스스로 서야 한다. 눈금 12개가 "문자판"을, 바늘 2개가 "움직인다"를, 빨간 점 하나가 "옆 아이콘과 다른 것"을 말한다.
그 괘종시계를 만든 사람도 아마 같은 생각이었을 것이다. 그가 마주한 것은 16픽셀이 아니라 지름 50센티미터의 놋쇠판이었으니, 꽃을 새기고 숫자를 새기고 바늘을 뚫을 여유가 있었다. 우리에게는 16픽셀밖에 없었으므로 선 12개를 남겼다.
같은 물건, 다른 예산.
이 선들이 제 크기에서 어떻게 보이는지는 아날로그 시계가 같은 기하로 움직이는 문자판을 그려 보여준다. 시각을 온전히 숫자에 넘기면 어떻게 되는지는 플립 시계가 반대 방향의 답을 내놓는다.