← 목록으로

3천 년 청동 북 위, 따뜻한 밥상 '맘껌'을 차리다: 프론트엔드 챌린지 개발 생존기

2026. 8. 8.

3천 년 청동 북 위, 따뜻한 밥상 '맘껌'을 차리다: 프론트엔드 챌린지 개발 생존기

프론트엔드 챌린지 '컴포트 푸드 에디션, 완벽한 랜딩 페이지'에 제가 참여하며 만들었던 프로젝트를 소개합니다.

무엇을 만들었을까?

챌린지 주제가 '컴포트 푸드(comfort food)'라고 했을 때, 저는 특정 음식을 떠올리지 않았습니다. 대신 **'형태'**에 집중했죠.

베트남어에는 '앙트레(entrée)'라는 단어가 없습니다. 오직 cơm — 쌀밥 — 이 있을 뿐이고, 그 주위에 모든 반찬이 놓입니다. 매일 저녁, 수억 가구의 식탁에는 여섯일곱 가지의 작은 요리들이 둥근 쟁반 위에 놓이고, 모두 함께 어떤 순서로든 자유롭게 먹습니다. 우리는 이것을 맘껌(mâm cơm), 즉 '가족 밥상'이라고 부릅니다. 코스 요리가 아니에요. 마치 하나의 대화와 같죠.

그리고 제가 이 사실을 깨달은 순간, 머릿속에서 떠나지 않던 것이 있습니다. 베트남에서 가장 오래된 유물은 기원전 1000년경에 주조된 **동선 청동 북(Đông Sơn bronze drum)**이라는 점이에요. 이 북의 표면은 완벽한 원형으로, 동심원을 이루는 생명력으로 가득합니다. 중앙에는 열네 줄기의 태양이, 그 주위에는 락(Lạc) 왜가리, 긴 배, 깃털 머리 장식을 한 춤꾼들이 띠를 이루고 있죠. 그런데 우리네 가장 평범한 가정용품인 대나무 쟁반이 정확히 같은 형태를 가지고 있다는 겁니다.

그래서 저는 이 밥상을 북 위에 만들었습니다. 3천 년의 시간을 넘어, 똑같은 원형 위에서 말이죠.

image 1

핵심 요소: 회전하는 밥상

이 밥상은 실제로 회전하는 '게으른 수잔(lazy susan)'입니다. 일곱 가지 요리가 북면을 중심으로 돌고 있죠. 요리 하나를 선택하면 북 전체가 돌아 해당 요리가 12시 방향으로 오게 됩니다. 이때, 각 요리는 반대 방향으로 회전해서 음식이 절대 뒤집히지 않도록 합니다. 중앙의 메달리온은 현재 선택된 요리의 이름을 보여주고, 옆 패널은 전체 너비의 클로즈업 이미지를 띄웁니다.

사용자는 드래그, 스와이프, 요리 클릭, 방향키, 혹은 두 개의 회전 버튼으로 밥상을 조작할 수 있습니다. 원래 게으른 수잔은 밀어서 사용하는 물건이니까요.

Image 2 gif

내부적으로는 원형으로 휘어진 단순한 **ARIA 탭리스트(tablist)**일 뿐입니다. 각 요리는 role="tab"을, 각 요리 정보 카드는 role="tabpanel"을 가지며, 로빙(roving) tabindex를 사용합니다. 따분한 탭 목록과 동일한 위젯 패턴이지만, 그저 청동 북 위에 놓여 있을 뿐인 거죠. 제가 실무에서 ARIA tablist를 활용할 때마다 느끼는 점인데, 이렇게 기본 위젯 패턴을 변형해서 UI/UX를 혁신할 수 있다는 사실이 정말 매력적입니다.

나머지 페이지 구성

페이지 하단에는 풍성하게 차려진 맘껌의 전체 사진이 펼쳐집니다. 베트남의 건국 신화인 '꼰 롱 짜우 띠엔(Con Rồng Cháu Tiên)' — 용의 후손이자 요정의 자녀, 백 명의 아들 중 쉰은 산으로, 쉰은 바다로 — 이 담겨있죠. 또한, 서빙 인원 조절 기능이 작동하는 팃 코 쯩(thịt kho trứng) 레시피도 있습니다. 베트남 식탁의 불문율도 찾아볼 수 있고요.

Image 3

페이지는 가장 중요한 인사말인 "Ăn cơm chưa?" — 밥 먹었니? — 로 마무리됩니다. 우리에게는 "안녕하세요"와 같은 말이죠. 동시에 "널 생각하고 있었어," "괜찮아?" 그리고 "아직 솥에 밥이 좀 남아있어"라는 의미를 담고 있습니다. 이 말을 '사랑'이라고 표현하지 않고는 제대로 번역할 방법이 없어요.

그 인사말 아래에는 맘껌 주위에 모인 삼대(三代)의 가족 그림이 있습니다. 그 뒤로는 청동 북이 비쳐 보이죠. 둘 다 검정색 배경 위에 금색으로 그려져 화면에 블렌딩되어 있습니다. 덕분에 3천 년 된 유물이 오늘 밤 저녁 식사를 하는 가족 뒤에서 후광처럼 빛나는 모습이 연출되었죠. 의도한 바는 아니었어요. 컴포지팅 과정에서 자연스럽게 그렇게 되었는데, 이 페이지 전체의 메시지가 되어버린 듯합니다.

Image 5

데모

🔗 라이브: https://the-family-rice-tray.vercel.app 💻 소스: https://github.com/longphanquangminh/the-family-rice-tray

{% embed https://www.youtube.com/watch?v=2z9cRo0Ya5M %}

{% embed https://the-family-rice-tray.netlify.app %}

키보드로 조작해보세요. '동작 줄이기(reduced motion)' 설정을 켠 상태에서도 테스트해보시고요. 자바스크립트를 끈 상태로 접근하면 맘껌 쟁반은 사라지고 일곱 가지 요리 카드가 전체 페이지에 깔끔하게 렌더링됩니다. 놀라운 경험을 선사할 거예요.

개발 여정

디자인 철학

관광 포스터처럼 보이지 않으면서도 베트남적인 페이지를 만들고 싶었습니다. 그래서 옻칠의 붉은색(#8B0C1C), 금박, 비취, 그리고 표백하지 않은 쌀 종이 크림색 등 1차 자료에서 영감을 얻었죠. 장식보다는 장신구처럼 사용된 리 왕조의 용 문양과 응옥루 북(Ngọc Lũ drum)도 빼놓을 수 없습니다.

두 장식 모두 순수한 검정색 위에 금색 선화로 그려져 있으며, mix-blend-mode: screen으로 합성했습니다. 검정색은 완전히 사라지고 금색만 남아서, 뒤에 어떤 붉은색을 놓아도 그 위에 금색이 빛나게 됩니다. 단일 애셋으로 어떤 배경에서도, 어떤 투명도에서도 재활용할 수 있는 셈이죠.

하지만 이 기법에는 까다로운 단점이 있습니다. 애셋이 12색 팔레트로 양자화되어 있어서, 거의 검정색에 가까운 팔레트 항목들을 '순수한' 검정색으로 강제해야 했어요. screen(x, near-black)는 여전히 배경을 밝게 만들기 때문에, 애셋의 바운딩 박스가 평평한 색상 위에 눈에 보이는 사각형으로 그려질 수 있거든요. 실제로 한 장식은 프레임의 92%가 rgb(5,5,4)인 "검정색"으로 돌아와 페이지에 희미한 회색 상자를 그려버리기도 했습니다.

사진 사용에 대한 단상

이번 챌린지 참가작 중 절반은 제목에 "이미지 없음" 또는 "순수 CSS, 사진 사용 안 함" 같은 문구를 달고 있었고, 그 매력을 저도 충분히 이해합니다. CSS로 음식을 그리는 것은 정말 대단한 기술이니까요. 하지만 저는 계속해서 이번 주제가 '컴포트 푸드'라는 사실로 돌아왔습니다. 컴포트 푸드는 감각적인 경험이 중요하죠. 그래서 저는 의도적으로 다른 길을 택했습니다. 전체 밥상을 가득 채우는 사진과 각 요리의 커다란 클로즈업 사진을 사용했죠. 이 페이지를 스크롤하면서도 배가 고프지 않다면, 아무리 마크업이 깔끔하더라도 이 페이지는 실패한 것입니다.

이런 확고한 신념이 대부분의 흥미로운 문제들을 야기했습니다.

수학적 접근

순수 CSS로 일곱 가지 요리를 원형에 배치하는 것은 정말 즐거운 작업이었습니다. 각 요리는 고유한 --a 각도를 가지고 개별 변환 속성을 사용하는데, 이는 translate → rotate → transform 순서로 적용됩니다.

.dish {
  translate: -50% -50%;              /* 중앙 정렬 */
  rotate: var(--a);                  /* 해당 광선 방향으로 회전 */
  transform: translateY(-189.5%);    /* 광선을 따라 바깥으로 이동 */
}

요리의 궤도가 요리의 퍼센티지이고, 요리가 쟁반의 퍼센티지이기 때문에, 전체 위젯은 미디어 쿼리 없이 320px에서 560px까지 완벽하게 스케일링됩니다. JS는 --ring-rot라는 하나의 CSS 커스텀 속성만 설정하고, 나머지는 CSS가 처리합니다. 두 단계의 역회전 덕분에 모든 그릇은 항상 똑바로 서 있죠.

그림자에도 같은 트릭이 적용되었습니다. 각 요리는 이미 --a만큼 회전된 프레임 안에 있으므로, 단순한 0 -2px 오프셋은 모든 요리에 대해 이미 중심에서 바깥으로 향하는 효과를 줍니다. 단 한 줄의 선언으로 마치 식탁 중앙에 하나의 램프가 걸려 있는 듯한 일곱 개의 방사형 그림자를 얻을 수 있었죠. 요리별로 복잡한 계산을 할 필요가 없었습니다.

Axe로는 잡을 수 없었던 명암비 버그

첫 번째 작업에서 저는 음식 사진 위에 헤드라인을 배치하고, 그 뒤에 그라데이션 스크림을 넣었습니다. 모든 자동화 검사기는 통과했어요. 왜냐하면 axe-core는 이미지 위에 있는 텍스트를 평가할 수 없기 때문입니다. 글리프 뒤에 어떤 픽셀이 있는지 전혀 알지 못하죠.

그래서 저는 Playwright 스크립트를 직접 작성했습니다. 텍스트를 숨기고, 합성된 배경을 스크린샷 찍은 다음, 각 텍스트 블록의 바운딩 박스 내 모든 픽셀을 샘플링하여 실제 텍스트 색상에 대한 최악의 WCAG 비율을 계산하도록 만들었죠.

kicker over photo    worst 1.77:1  (needs 4.5)  FAIL
headline over photo  worst 2.49:1  (needs 3.0)  FAIL

금색 텍스트와 밥알 색상 배경의 명암비는 1.77:1. 도구로는 보이지 않았지만, 측정해보니 확연한 실패였습니다. 스크림을 어둡게 했더니 오히려 더 나빠졌어요. 텍스트 블록이 589px나 되는 높이였고, 아무리 세련된 그라데이션으로도 커버할 수 없는 사진 상단까지 닿아버렸기 때문이죠. 제가 실무에서 이런 부분을 간과했다가 나중에 큰 수정 작업을 했던 경험이 여러 번 있습니다. 자동화 도구만으로는 잡히지 않는 디테일이 때로는 사용자 경험을 결정하죠.

결국 솔직한 해결책은 미적인 것이 아니라 구조적인 것이었습니다. 사진 위에 텍스트를 배치하는 것을 그만두는 것. 이제 이미지는 전체 프레임을 차지하고, 텍스트는 그 아래 단색 띠에 배치됩니다. 이렇게 하면 명암비를 보장할 수 있을 뿐 아니라, 더 많은 음식 사진을 보여줄 수도 있게 되었습니다. 그 결과, 이전의 네 가지 측정값은 각각 9.95:1과 14.89:1로 개선되었습니다.

어쨌든 고쳐버린 오탐(false positive)

나중에 axe는 명판 버튼의 명암비를 3.24:1로 표시했습니다. 하지만 제 픽셀 샘플링은 6.63:1을 보여주었고, 제 샘플링이 옳았죠. 금색 채우기를 라벨의 절대 위치에 있는 형제(sibling) 요소에 넣었기 때문에, axe는 DOM을 따라 올라가면서 대신 청동 규칙을 찾아 잘못된 실패를 보고했던 것입니다.

어쨌든 저는 구조를 다시 짰습니다. 배경을 실제로 텍스트를 포함하는 요소로 옮겼죠. axe를 실행하는 심사위원은 그 빨간 줄을 보고도 그것이 잘못된 정보임을 알 수 없었을 테니까요.

완벽해 보였지만 측정해보니 틀렸던 네 가지 버그

  • 정사각형을 회전시키면 **바운딩 박스(bounding box)**가 최대 √2배까지 늘어나면서 모바일에서 수평 스크롤이 49px나 조용히 추가되었습니다. 눈에 띄게 넘치는 부분은 없었지만, 페이지가 화면보다 넓어진 것이었죠.
  • 히어로 섹션에 min-block-size: 92svh를 적용하면 고정된 헤더를 고려하지 않아 히어로 섹션이 항상 헤더 높이만큼 접히는 부분(fold)을 넘어섰습니다. calc(100svh - 4.5rem)로 해결했죠.
  • aspect-ratio와 min-block-size를 함께 사용하면 상자가 높이 대신 너비를 기준으로 계산하여 374px나 과도하게 확장되는 문제가 발생했습니다.
  • offsetHeight는 정수 픽셀로 반올림되어 804.42px 패널을 804로 보고했습니다. 그 결과 반 픽셀 정도의 점프가 남아있었죠. getBoundingClientRect().height와 Math.ceil을 사용하여 0.00px로 해결했습니다.

UI는 망가졌는데 테스트는 통과했던 사례

모바일 내비게이션은 마지막 라벨을 "Nếp Nh"로 잘라버렸지만, 제 어설션(assertion)은 document.scrollWidth만 테스트했습니다. 내비게이션 자체의 overflow-x: auto가 이 문제를 숨겨버린 거죠. 어떤 스크롤 컨테이너든 문서 수준의 검사에서 잘린 레이아웃을 가릴 수 있다는 사실을 깨달았습니다. 이제 테스트 스위트는 nav.scrollWidth <= nav.clientWidth도 검증합니다.

제대로 된 테스트 스위트를 작성한 덕분에, 예전에 "오래된 폰 문제"라며 넘겼던 320px에서의 실제 리플로우(reflow) 실패도 발견했습니다. 요리 스트립이 두 줄로 감싸져 화면 아래로 내려가는 문제였죠. WCAG 리플로우가 320px에서 명시되어 있으니, 이 또한 수정되었습니다.

절대 흔들리지 않는 레이아웃

각 요리는 담고 있는 텍스트 양이 달라서 요리를 전환할 때마다 패널 크기가 변하고, align-items: center 때문에 맘껌 쟁반이 함께 위아래로 움직였습니다.

1440px:  패널 713→738px  (25px 차이)  →  북이 13px 움직임
1100px:  패널 723→768px  (45px 차이)  →  북이 23px 움직임

이 차이는 뷰포트 너비에 따라 달라지기 때문에, 하드코딩된 min-height는 특정 한 사이즈에서만 정확했습니다. 해결책은 런타임에 가장 높은 패널의 높이를 측정하여 모든 패널에 고정하고, 창 크기 변경 및 document.fonts.ready 시점에 다시 측정하는 것이었습니다. 웹 폰트는 초기 렌더링 후 로드되어 모든 텍스트 측정값을 변경할 수 있기 때문이죠.

맘껌 쟁반에 물리적인 느낌 주기

드래그하여 회전하는 기능을 추가하는 데는 약 40줄의 코드밖에 들지 않았지만, 예상치 못한 두 가지 문제가 발생했습니다.

pointerdown 시 setPointerCapture는 클릭 이벤트를 죽입니다. 포인터 캡처는 최종 click 이벤트를 캡처하는 요소로 리타겟팅하기 때문에, 요리를 탭해도 해당 요리의 버튼에 클릭 이벤트가 도달하지 않았습니다. 캡처는 포인터가 일정 거리 이상 이동한 후에만 활성화되도록 해야 했습니다.

브라우저가 자체 제스처를 먼저 실행합니다. 요리 사진을 누르면 기본 이미지 드래그가 시작되고, 쟁반 위를 스와이프하면 텍스트 선택이 시작됩니다. 둘 중 하나가 포인터 스트림을 가로채는 거죠. 이것이 첫 번째 회전은 괜찮았는데, 두 번째부터는 왠지 뻑뻑하게 느껴졌던 이유입니다. 해결책은 draggable="false"와 user-select: none를 적용하는 것이었고, 이는 합성(synthetic) 테스트 입력에는 감지되지 않기 때문에 동작이 아닌 상태를 검사하여 찾아내야 했습니다.

최종 배포하지 않은 아이디어

CSS 3D로 쟁반을 40° 기울여 테이블 위의 실제 오브젝트처럼 보이도록 프로토타이핑했습니다. 수학적으로는 작동했어요. 북은 1.78 종횡비로 원근 단축되었고, 요리들은 1.0 비율을 유지한 채 올바르게 빌보드 처리되었습니다.

하지만 이 아이디어는 버렸습니다. 3D 변환을 적용하자 각 요리의 원형 overflow: hidden 클리핑이 더 이상 작동하지 않았고, 금색 선택 링도 사라졌습니다. 모든 사진은 평면으로 래스터화된 후 다시 투영되면서 흐릿해졌죠. 이 세 가지 문제는 충분한 작업으로 해결할 수 있었겠지만, 맘껌은 원래 위에서 내려다보는 것이고, 북면은 원래 평평한 원반입니다. 기울기를 적용하자 "3천 년을 넘어선 같은 원"이라는 핵심 메시지가 평범한 식탁으로 변질되어 버렸습니다. 더 나쁘게 구현된 더 나쁜 아이디어였던 거죠.

딥 링크(Deep Link) 문제

/#manners로 직접 접속했을 때 작동하지 않는 문제가 있었는데, 여기엔 두 가지 이유가 겹쳐 있었습니다. scroll-behavior: smooth는 프래그먼트 점프를 9,500px 페이지를 가로지르는 긴 애니메이션으로 바꿔버렸고, 독자는 휠을 건드리는 순간 이 애니메이션을 취소해버렸습니다. 그리고 지연 로딩 이미지와 측정된 패널 높이 때문에 브라우저가 이미 스크롤한 후에도 대상 위에 계속 픽셀이 추가되었죠.

진짜 원인은 따로 있었습니다. F5를 눌러 새로고침 할 때, history.scrollRestoration이 프래그먼트 점프 후에 이전 위치를 복원하면서 승리했던 겁니다. 아이러니하게도 "독자로부터 페이지를 갑자기 잡아당기지 마라"는 제 자신의 보호 장치가 위치 변경을 감지하고 공손히 포기해버린 셈이죠. URL에 해시(#)가 있다면, 해시는 사용자의 의도를 나타내므로 복원 기능은 꺼져야 합니다.

웹 접근성 (Accessibility)

웹 접근성은 프로젝트의 끝에 추가되는 패스가 아니라, 처음부터 고려해야 하는 1등급 제약 사항이었습니다.

  • 완벽한 키보드 탭리스트, 44x44 크기의 회전 컨트롤, 그리고 입력 필드나 다른 컨트롤 내부, 또는 쟁반이 화면 밖에 있을 때는 작동을 멈추는 페이지 수준의 방향키를 구현했습니다.
  • :focus-visible 링은 사각형 히트 영역이 아닌 원형 모양을 따라가도록 만들었습니다. 명판 버튼에 두 개의 요소가 필요한 이유도 여기에 있습니다. clip-path는 아웃라인을 자르지 못하거든요.
  • prefers-reduced-motion은 모든 회전, 전환, 관성 글라이드를 비활성화하고, prefers-contrast: more는 장식적인 블렌드 레이어를 제거합니다.
  • 베트남어 문구는 lang="vi"로 마크업하여 스크린 리더가 영어처럼 읽지 않고 정확하게 발음하도록 했습니다.
  • <noscript> 스타일시트를 통한 점진적 향상(progressive enhancement)을 적용했습니다.
  • 1440px 및 390px 뷰포트에서 axe-core 위반 0건 (WCAG 2.1 A/AA + 베스트 프랙티스 준수).

이 모든 것은 레포지토리의 tools/verify.py 스크립트에서 실행됩니다. axe 검사, 픽셀 샘플링 명암비 확인, 레이아웃 안정성 어설션, 모든 회전 기능, 이미지 로딩을 제한한 딥 링크 테스트, 그리고 14가지 너비에 걸친 반응형 무결성 검사까지 모두 포함되어 있습니다.

스택

손으로 직접 작성한 HTML, CSS, 그리고 약 260줄의 바닐라 자바스크립트로 구성되었습니다. 프레임워크나 빌드 스텝 없이, 이미지 파일 외에는 단 하나의 파일로 이루어져 있습니다. 외부 요청은 Google Fonts가 유일합니다. 화면 아래 모든 이미지는 지연 로딩되며, 금색 장식은 12색 PNG입니다. 전체 용량은 4.2 MB이며, 거의 대부분이 사진 데이터입니다.


AI 활용에 대한 짧은 참고: 이 글을 작성하는 데 AI의 도움을 받았습니다. 발행 전 충분히 검토하고 편집했습니다.


읽어주셔서 감사합니다. Ăn cơm chưa? 🍚


원문: https://dev.to/minhlong2605/mam-com-landing-page-i-built-a-vietnamese-dinner-tray-on-a-3000-year-old-bronze-drum-3e6h 수집일: 2026-08-08 00:41:28