CSS 아트 챌린지: 픽셀로 쌓아 올린 푸틴, 그 '카오스' 속의 아름다움
2026. 8. 1.
CSS 아트 챌린지: 픽셀로 쌓아 올린 푸틴, 그 '카오스' 속의 아름다움
이 포스팅은 Frontend Challenge: Comfort Food Edition의 CSS 아트 부문에 제출했던 결과물입니다. '컴포트 푸드'를 CSS 아트로 표현하는 도전이었죠.
제가 만든 것은요
종이 트레이에 담긴 푸틴 한 접시입니다. 감자튀김 50개, 치즈 커드 13개, 그레이비 소스 조각 11개가 올라가 있고, 소스가 한 줄기 계속 흘러내리며 김이 모락모락 피어오르는 모습까지 담았습니다. 이미지도, SVG도, 캔버스도 전혀 사용하지 않았어요. 화면에 보이는 먹거리 객체들은 모두 CSS로 모양을 잡은 <i> 또는 <b> 태그들입니다.
푸틴을 고른 건 제가 생각할 수 있는 음식 중 가장 '제멋대로인(worst-behaved)' 음식이었기 때문이에요. 그리고 역설적으로 이 점이 가장 흥미로운 부분이었습니다. 크루아상은 실루엣 문제에 가깝습니다. 외곽선만 잘 잡으면 거의 완성된 거죠. 하지만 그레이비 소스 아래 쌓인 감자튀김은 깊이감(depth) 문제입니다. 재료들이 서로 겹치고, 소스는 틈새로 스며들죠. 이 요리의 진정한 매력은 각 재료의 경계를 정확히 알 수 없다는 데 있습니다.
이는 하나의 기발한 형태로 대충 흉내 낼 수 없다는 의미이기도 합니다. 말 그대로 실제 더미를 쌓아 올리듯 만들어야 했어요.
데모 살펴보기
{% codepen https://codepen.io/xbill9/pen/WbRLbKq %}
전체 화면으로 보고 싶다면 xbill9.github.io/devto-poutine/css-art/에서도 확인 가능합니다.
버튼은 딱 하나 있습니다. "Add more gravy" (그레이비 소스 더 추가). 이 작품에 사용된 유일한 자바스크립트로, 20줄 정도의 코드가 애니메이션을 다시 재생하고 치즈 커드를 더미 위에 하나 더 떨어뜨립니다. 그 외의 모든 것은 CSS로 구현했습니다.
개발 여정
감자튀김 하나당 하나의 요소
각 감자튀김은 고유한 좌표를 가진 단일 요소입니다.
<i class="fry" style="--x:44%; --y:49%; --r:-32deg; --l:26; --w:3.2; --s:.35"></i>
--x/--y— 더미 내에서의 위치--r— 회전 각도 (0deg는 감자튀김이 똑바로 서 있는 상태)--l/--w— 길이와 너비--s— 익은 정도 (굽기 정도)
마지막 --s가 제가 가장 마음에 드는 부분입니다. 이 변수는 color-mix() 함수에 들어가 각 감자튀김이 독립적으로 갈색을 띠게 합니다. 덕분에 50개의 똑같은 직사각형이 똑같아 보이지 않게 되는 마법을 부리죠.
background: linear-gradient(to bottom,
color-mix(in oklab, var(--fry-crisp), var(--fry-deep) calc((1 - var(--s)) * 100%)) 0%,
var(--fry-mid) 11%,
var(--fry-light) 38%,
var(--fry-mid) 76%,
color-mix(in oklab, var(--fry-deep), var(--fry-crisp) calc(var(--s) * 100%)) 100%);
여기서 oklab 색 공간에서 보간하는 것이 중요합니다. sRGB에서 갈색을 섞으면 흙탕물 같은 회색빛으로 변질되는 경향이 있는데, 튀긴 감자에서 회색은 절대로 피해야 할 색이니까요. 제가 실무에서 제품 페이지의 색상을 다룰 때도 sRGB의 한계 때문에 고심했던 적이 많습니다. oklab 같은 진보된 색 공간을 활용하면 훨씬 더 풍부하고 자연스러운 색상 표현이 가능하죠.
치즈 커드와 그레이비 조각들도 같은 패턴을 따릅니다. --br 변수에 비대칭적인 border-radius 값을 넣어 두 덩이의 치즈가 같은 모양을 갖지 않도록 했습니다.
이제 제가 실수했던 세 가지를 이야기해볼까 합니다. 개인적으로 이런 뒷이야기가 더 읽고 싶은 내용이기에, 이 부분에 좀 더 많은 지면을 할애해 볼게요.
실수 1: 모든 크기를 vmin으로 잡았다
반응형 아트워크에 vmin은 당연한 단위처럼 느껴졌습니다. 하지만 아니었습니다. 그 이유는 제가 결과물을 렌더링하고 한참을 들여다본 후에야 깨달을 만큼 미묘했죠.
장면의 크기는 620px로 제한되어 있었습니다. 하지만 vmin은 뷰포트를 기준으로 하지, 아트워크 자체를 기준으로 하지 않습니다. 그래서 넓은 화면에서 캔버스의 크기가 고정된 후에도 모든 요소는 계속 커지는 문제가 발생했습니다. 각 그레이비 덩어리를 26vmin으로 지정했는데, 900px 뷰포트에서는 234px, 즉 **620px 캔버스의 37%**를 차지하게 된 겁니다. 이런 덩어리가 6개쯤 되니 접시는 몇 개의 감자튀김만 튀어나온 거대한 갈색 돔처럼 보였습니다. 마치 초콜릿 언덕 같았죠.
해결책은 아트워크 자체가 고유한 단위를 정의하도록 하는 것이었습니다.
.scene{
--u: min(86vw, 78vh, 620px); /* 아트워크 자체의 변 길이 */
--px: calc(var(--u) / 100); /* 그 길이의 1/100 */
}
이제 내부의 모든 요소는 --px 단위로 표현됩니다. 이는 장면 크기의 100분의 1을 의미합니다. --l:26인 감자튀김은 모든 화면에서 장면 높이의 26%를 차지하게 되는 거죠. --u 값을 변경하면 푸틴 접시 전체가 하나의 객체처럼 크기가 조절됩니다.
이 글에서 딱 한 가지만 얻어가신다면, 바로 이겁니다: 여러 부품으로 구성된 아트는 창이 아닌 아트워크 자체에 상대적인 단위를 필요로 합니다. vmin은 모든 요소를 주변 요소가 아닌 브라우저 크롬에 묶어 버립니다. 제가 실무에서 반응형 웹사이트의 특정 컴포넌트를 만들 때, vmin이 미묘하게 예상치 못한 레이아웃 깨짐을 유발해서 고생했던 기억이 생생하네요. 아트워크든 UI 컴포넌트든 '자신만의' 기준 단위를 갖는 게 정말 중요합니다.
실수 2: 그레이비를 하나의 덩어리로 만들었다
처음 만든 그레이비는 감자튀김 더미 위에 올려진 커다란 하나의 형태였습니다. 그런데 이게 소스가 아니라 뚜껑처럼 느껴지더군요. 소스는 음식 위에 얹히기보다는 음식 사이에 고여야 합니다. 틈새는 어둡게, 높은 부분은 얇게 덮고, 감자튀김이 소스 표면을 뚫고 튀어나와야 비로소 소스답게 보이죠.
결과적으로 효과적이었던 방법은 세 개의 개별 레이어를 사용하는 것이었습니다.
- 더미 바닥에 부드럽게 흐릿한 어두운 소스 웅덩이를 만들고, 감자튀김 뒤에 칠해서 틈새를 채웁니다.
- 중간 감자튀김 위에 불규칙한 소스 조각 6개를 얹습니다.
- 맨 앞줄 감자튀김 뒤에 5개를 더 추가하여, 시청자에게 가장 가까운 것들이 소스에 visibly 코팅된 것처럼 보이게 합니다.
그리고 각 소스 조각의 가장자리를 부드럽게 처리하여 스티커처럼 보이지 않게 했습니다.
mask-image: radial-gradient(closest-side, #000 58%, rgba(0,0,0,.55) 84%, transparent 100%);
이 mask가 정말 핵심이었습니다. 하지만 이 과정에서 하나 해결하기 어려운 문제가 발생했죠. 각 소스 조각의 "물방울"을 하단 가장자리에 매달린 ::after 가상 요소로 만들었는데, 이것들이 모두 소리 없이 사라지는 겁니다. 마스크는 가상 요소도 클리핑합니다. 물방울이 closest-side 반경 바깥에 매달려 있어서 마스크에 의해 가려진 것이었죠. 결국 물방울들은 자체적인 별도 요소로 분리해야 했습니다. 이 사실을 제가 또 잊어버릴까 봐 지금 스타일시트에 주석으로 남겨두었습니다. 이런 디테일 하나가 전체 결과물의 퀄리티를 확 끌어올리는데, 마스크 속성 때문에 가상 요소가 사라지는 건 저도 몇 번 겪어본 함정이죠. 개발자라면 누구나 한 번쯤은 만나게 되는 예상치 못한 버그의 전형입니다.
실수 3: 회전이 위치를 따라가게 했다
이것 때문에 정말 아름다운 성게 모양이 만들어졌습니다.
처음 만든 더미는 수평에 가까운 감자튀김을 바깥쪽에, 세워진 감자튀김을 중앙에 배치했습니다. 생각해보면 별똥별 모양을 그리는 방식과 정확히 일치하죠. 모든 감자튀김이 중앙에서 바깥으로 뻗어 나가는 형태였습니다. 방사형 대칭이었고, 완전히 잘못된 것이었습니다.
진짜 감자튀김은 뒤죽박죽 섞여 있습니다. 해결책은 직관적이지 않았습니다. 바로 가파른 회전은 바깥쪽 가장자리에, 거의 수평인 회전은 내부에 두는 것이었습니다. 트레이 가장자리에 똑바로 서 있는 감자튀김은 벽에 기대어 있는 것처럼 보이지만, 가장자리에 납작하게 누워 있는 감자튀김은 바퀴살처럼 보입니다. 이를 뒤집자마자 대칭이 깨지며 자연스러워졌죠.
또한 --r이 --x와 전혀 상관관계가 없도록 만들어야 했습니다. 만약 오른쪽으로 갈수록 회전이 매끄럽게 증가한다면, 50개의 요소로 이루어져 있어도 눈은 그 패턴을 찾아내 버립니다.
조용히 만족스러웠던 몇 가지 꼼수들
더미는 감자튀김의 --x 값이 허용하는 것보다 더 넓어야 했지만, 컨테이너 크기를 조절하면 감자튀김이 찌그러질 수 있었습니다. 감자튀김 크기는 --px(장면 기준)이고, 감자튀김 위치는 더미 박스의 백분율이었기 때문에, 다른 요소에 영향을 주지 않고 하나만 늘릴 수 있었습니다.
/* 더미 박스는 의도적으로 장면보다 넓습니다: 이는 --x 값을
바깥쪽으로 분산시켜 더미가 트레이를 채우도록 하면서도,
감자튀김 자체를 왜곡하지 않습니다 (감자튀김은 --px 단위로,
더미 %가 아닌 장면 % 기준으로 크기가 정해집니다). */
.pile{ left:-4%; width:108%; }
또 다른 꼼수는 이겁니다. 트레이의 가까운 가장자리는 음식 위에 그려진 완전한 타원형으로, 윗부분은 잘라내어 음식이 트레이 안에 실제로 담겨 있는 것처럼 보이게 한 것입니다.
/* 시청자에게 가장 가까운 아랫부분만 남깁니다. */
clip-path: polygon(-2% 50%, 102% 50%, 102% 104%, -2% 104%);
접근성 (Accessibility) 고려하기
CSS 아트는 장식적이지만, 사용자에게 적대적이어서는 안 됩니다.
전체 장면은 접시에 대한 설명을 담은 단일 role="img"로 지정하고, 내부에 있는 90개의 모든 장식 요소들은 aria-hidden 처리했습니다. 스크린 리더는 90개의 익명 리스트 아이템 대신 푸틴 한 접시에 대한 한 문장의 설명을 듣게 됩니다.
증기, 물방울, 소스가 부어지는 애니메이션 등 모든 애니메이션은 prefers-reduced-motion 설정에 따라 비활성화됩니다. 그리고 가장 중요한 점은, 요소들이 단순히 opacity:0으로 사라지는 것이 아니라, 합리적인 정지 상태를 유지하도록 했습니다.
@media (prefers-reduced-motion:reduce){
.steam i,.sauce,.runnel,.pour,.pour::after,.droplet{ animation:none !important; }
.steam i{ opacity:.45; transform:translate(-50%,-40%) scale(1.1); }
.droplet{ opacity:1; transform:translate(-50%,300%); }
}
움직임을 끄면 증기가 있던 자리가 텅 비는 것이 아니라, 마치 정지된 사진처럼 보여야 합니다. 웹 접근성은 단순히 규제 준수를 넘어, 모든 사용자에게 더 나은 경험을 제공하는 핵심 요소라는 점을 다시 한번 깨닫게 되죠.
시작하는 저에게 해주고 싶은 말
만든 것을 초반부터 자주 렌더링하고 보세요. 이 세 가지 실수 모두 코드상에서는 보이지 않았지만, 화면에 나타나자마자 명확해졌습니다. 특히 vmin 버그는 CSS 코드로는 완전히 합리적으로 보였지만, 그림으로는 완전히 터무니없어 보였죠.
코드 확인: github.com/xbill9/devto-poutine · MIT 라이선스.
이 아트워크는 'Perfect Landing' 카테고리의 풀 랜딩 페이지 히어로 섹션으로 재사용되기도 했습니다. 슬라이더를 통해 그레이비를 마음껏 부어볼 수 있도록 만들었었죠. CSS로만 이렇게 복잡하고 동적인 장면을 구현하는 과정은 분명 쉽지 않았지만, 그만큼 얻는 것이 많았던 즐거운 도전이었습니다. 단순한 기술을 넘어 예술의 영역까지 넘보는 CSS의 잠재력을 다시 한번 확인하는 계기가 되었네요.
원문: https://dev.to/xbill/poutine-the-true-food-of-the-gods-5fep 수집일: 2026-08-01 01:27:27