ClockHub
블로그문의하기
전체 글
제품10 분 소요

ClockHub 아이콘의 설계 철학: 16픽셀에서 거꾸로 그린 문자판

ClockHub

오래된 괘종시계의 문자판. 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픽셀.

눈금 수이웃 간격
600.62 px
241.55 px
123.11 px

실제로 16×16픽셀로 렌더링한 뒤 14배 확대한 문자판 세 개. 왼쪽은 눈금 60개가 꽉 찬 고리로 뭉개지고, 가운데는 24개가 굵은 점의 연속이 되고, 오른쪽은 12개가 또렷하게 분리된다. 각 그림 아래에 0.62, 1.55, 3.11픽셀의 간격을 표시

셋 다 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에 기록되어 있다.

버전눈금 폭바늘 폭중심 원시침 각도분침 각도
참고 이미지1516.5없음10°125°
얇은 판(이전 버전)4.04.8r = 50°120°
최종판1012r = 120°120°

(길이와 폭은 반지름 R에 대한 백분율이다. 10이면 선폭이 200의 10%, 즉 설계상 20픽셀.)

3세대 문자판 나란히 비교. 왼쪽은 참고 이미지판으로 눈금과 바늘이 가장 굵고 중심 원이 없다. 가운데는 얇은 판으로 선이 아주 가늘고 중심 원도 작다. 오른쪽은 최종판으로 굵기가 중간이고 중심 원이 뚜렷하다. 각 그림 아래에 16픽셀 축소 렌더링 첨부

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~512픽셀(로그), 세로축은 배율. 16픽셀에서 1.76배, 24픽셀에서 1.17배, 32픽셀부터는 정확히 1.00으로 수평을 유지한다

손대는 것은 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"
}

이것을 읽는 곳이 둘이다.

  1. scripts/icon/generate.mjs — 28개의 정적 파일을 만든다
  2. 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개를 남겼다.

같은 물건, 다른 예산.

이 선들이 제 크기에서 어떻게 보이는지는 아날로그 시계가 같은 기하로 움직이는 문자판을 그려 보여준다. 시각을 온전히 숫자에 넘기면 어떻게 되는지는 플립 시계가 반대 방향의 답을 내놓는다.

ClockHub