← 목록으로

🥁 3천 년 역사의 심장에 담은 베트남 맘껌: 평범함 속 비범함을 담은 랜딩 페이지

2026. 8. 7.

🥁 3천 년 역사의 심장에 담은 베트남 맘껌: 평범함 속 비범함을 담은 랜딩 페이지

얼마 전 프런트엔드 챌린지 - 컴포트 푸드 에디션, 완벽한 랜딩에 참여하며 흥미로운 프로젝트를 진행했습니다.

무엇을 만들었을까요?

'컴포트 푸드(Comfort Food)'라는 키워드를 마주했을 때, 저는 단순히 특정 음식을 떠올리지 않았습니다. 대신, 하나의 형태에 주목했죠. 베트남어에는 '앙트레(entrée)'라는 단어가 없습니다. 오직 '껌(cơm)', 즉 '밥'만이 존재하고, 그 밥을 둘러싼 모든 반찬이 있죠. 매일 저녁, 수많은 베트남 가정에서는 6~7가지 작은 요리들이 둥근 쟁반 위에 놓이고, 가족 구성원 모두가 원하는 순서대로 한 번에 즐깁니다. 우리는 이것을 맘껌(mâm cơm), 즉 '가족 밥상'이라고 부릅니다. 이것은 코스 요리의 순서가 아니라, 따뜻한 대화 그 자체입니다.

처음에 '컴포트 푸드'라는 키워드를 접했을 때, 저 역시 단순히 특정 요리보다는 그 이상의 무언가를 구현하고 싶다는 막연한 생각이 들었던 기억이 납니다. 이런 문화적 배경이 디자인 영감으로 이어지는 경우가 많죠.

이 맘껌을 생각하다가 문득 떠올라 머릿속을 떠나지 않던 것이 있습니다. 바로 베트남 최고(最古)의 유물인 동선(Đông Sơn) 청동 북이죠. 기원전 1000년경에 주조된 이 북의 표면은 동심원으로 이루어진 완벽한 원형입니다. 중앙에는 14개의 햇살이 뿜어져 나오는 태양 문양이, 그 주변으로는 락(Lạc) 왜가리, 긴 배, 깃털 머리 장식을 한 춤꾼들이 띠처럼 둘러져 있습니다. 그런데 우리의 가장 평범한 가정용품인 대나무 쟁반이 바로 이 청동 북과 정확히 같은 형태를 가지고 있다는 사실에 놀랐습니다. 그래서 저는 맘껌 쟁반을 이 북 위에 구현하기로 했습니다. 3천 년의 시공간을 넘어, 같은 원형을 공유하는 셈이죠.

image 1

핵심 요소: 회전하는 맘껌 쟁반

이 맘껌 쟁반은 실제로 돌릴 수 있는 '레이지 수잔(lazy susan)' 형태로 만들었습니다. 7가지 요리가 청동 북 면을 중심으로 공전하고, 특정 요리를 선택하면 해당 요리가 12시 방향에 올 때까지 북 전체가 회전합니다. 이때 각 요리는 반대 방향으로 회전하여 음식이 절대 거꾸로 뒤집히지 않도록 했죠. 중앙 메달리온은 현재 선택된 요리의 이름을 표시하고, 그 옆 패널은 해당 요리의 전체 너비 클로즈업 이미지를 보여줍니다.

드래그, 스와이프, 요리 클릭, 방향키 누르기, 또는 두 개의 회전 버튼을 사용해 맘껌 쟁반을 조작할 수 있습니다. 원래 레이지 수잔은 손으로 밀어서 움직이는 물건이니까요.

Image 2 gif

내부적으로 보면, 이것은 원형으로 구부러진 **ARIA 탭리스트(tablist)**에 불과합니다. 각 요리는 role="tab"이고, 각 카드 내용은 role="tabpanel"을 가지며, 로빙(roving) tabindex를 적용했습니다. 평범한 탭 메뉴와 동일한 위젯 패턴을 사용했지만, 그저 배경이 청동 북일 뿐인 셈이죠.

페이지의 나머지 부분

페이지의 다른 부분에는 맘껌 전체를 담은 풀 블리드(full-bleed) 사진이 배치되어 있습니다. 또한, 용과 요정의 자녀이자 백 명의 아들 중 쉰 명은 산으로, 쉰 명은 바다로 떠났다는 '꼰 롱 짜우 띠엔(Con Rồng Cháu Tiên)' 설화도 소개하고, 조절 가능한 서빙 스케일러가 적용된 '팃 코 쯩(thịt kho trứng)' 레시피, 그리고 베트남 밥상의 불문율 등 다양한 콘텐츠를 담았습니다.

Image 3

페이지의 마지막은 베트남에서 가장 의미 있는 인사말인 '안 껌 쯔어(Ăn cơm chưa)?' - '밥 먹었니?'로 마무리됩니다. 이 말은 단순한 인사뿐만 아니라, '널 생각하고 있어', '괜찮니?', '아직 솥에 밥이 좀 남아있어' 같은 깊은 마음을 담고 있습니다. 사랑이라는 단어 없이는 온전히 번역하기 어려운 표현이죠.

이 인사말 아래에는 맘껌 쟁반을 둘러싼 세대가 함께 있는 그림이 있고, 그 뒤로 청동 북 문양이 비쳐 보입니다. 둘 다 검은색 배경에 금색으로 그려진 아트워크인데, screen 블렌드 모드를 적용하니 3천 년 된 유물이 오늘 밤 저녁을 먹는 가족 뒤에서 후광처럼 빛나는 효과가 연출되었습니다. 사실 의도한 것은 아니었지만, 이 컴포지팅(compositing) 효과가 페이지 전체의 메시지를 함축적으로 표현하게 되었죠.

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 %}

{% codepen https://codepen.io/longphanquangminh/pen/myRrpyw %}

키보드로 조작해 보세요. '움직임 감소(reduced motion)' 설정을 켜고 사용해 보시거나, JavaScript를 비활성화한 상태로도 테스트해 보세요. JS가 꺼지면 쟁반은 옆으로 치워지고 7가지 요리 카드가 모두 완전히 렌더링 됩니다. 견고한 폴백(fallback)을 느낄 수 있을 겁니다.

개발 여정

디자인

'베트남스러우면서도 관광 포스터처럼 보이지 않는' 페이지를 만들고 싶어서, 원본 자료들을 참고했습니다. 칠기에서 영감을 받은 짙은 붉은색 (#8B0C1C), 잎사귀 금색, 비취색, 그리고 표백하지 않은 쌀 종이 크림색을 주 팔레트로 삼았죠. 또한, 리 왕조의 용 문양과 응옥루(Ngọc Lũ) 북 문양을 단순히 장식이 아닌, 페이지의 '장식적 요소(ornament)'로 활용했습니다.

두 가지 장식 요소는 순수한 검은색 배경에 금색 선으로 그려진 아트워크인데, mix-blend-mode: screen을 적용해 컴포지팅했습니다. 이렇게 하면 검은색은 완전히 사라지고 금색만 남아서, 어떤 배경색 위에 놓아도 그 위에 금색 문양만 비치게 되죠. 하나의 애셋으로 투명도나 배경색에 구애받지 않고 재활용할 수 있게 됩니다.

하지만 이 기법에는 까다로운 부분이 있었습니다. 애셋들이 12색 팔레트로 양자화(quantised)되어 있었는데, 거의 검은색에 가까운 팔레트 색상들을 완전한 검은색으로 맞춰야만 했습니다. 왜냐하면 screen(x, near-black)은 여전히 배경을 밝게 만들어서, 애셋의 바운딩 박스(bounding box)가 단색 배경 위에 희미한 직사각형으로 보이게 되거든요. 실제로 어떤 장식 요소는 프레임의 92%가 rgb(5,5,4)라는 '검은색'으로 되어 있어서 페이지에 희미한 회색 상자를 그리기도 했습니다. 이런 미묘한 색상 값 하나가 시각적 완성도를 해칠 수 있다는 걸 다시 한번 체감했죠.

사진 사용에 대한 고민

'순수 CSS, 이미지 없음' 같은 제목으로 진행되는 챌린지가 절반 이상을 차지할 정도로, CSS만으로 음식을 그리는 것은 분명 매력적이고 개발 실력을 뽐내기 좋은 방법입니다. 저 역시 그 매력을 이해하죠. 하지만 이 챌린지가 '컴포트 푸드'라는 주제를 가지고 있다는 사실에 계속 집중했습니다. 컴포트 푸드는 '오감'을 자극하는 경험이 핵심이라고 생각했거든요. 그래서 저는 의도적으로 정반대의 길을 택했습니다. 전체 맘껌 쟁반의 풀 블리드 사진과 각 요리의 커다란 클로즈업 사진을 아낌없이 사용했죠. 이 페이지를 스크롤하면서도 식욕이 돋지 않는다면, 아무리 마크업이 깔끔해도 실패한 페이지라고 판단했습니다. 기능적인 완벽함과 직관적인 사용자 경험 사이에서 어떤 균형을 찾아야 할지 고민하는 건 언제나 흥미로운 과제라고 생각합니다. 특히 웹에서 '감성'을 전달할 때는 시각적 요소의 힘을 무시할 수 없죠.

이러한 결정은 흥미로운 문제들의 대부분을 만들어냈습니다.

수학적 접근

순수 CSS로 7가지 요리를 원형으로 배치하는 것은 정말 즐거운 작업이었습니다. 각 요리는 고유한 --a 각도를 가지며, transform 속성들을 정해진 순서대로 적용합니다. translate → rotate → transform 순이죠. 코드는 다음과 같습니다:

.dish {
  translate: -50% -50%;              /* 중앙 정렬 */
  rotate: var(--a);                  /* 각도에 맞춰 배치 */
  transform: translateY(-189.5%);    /* 해당 각도를 따라 바깥으로 이동 */
}

요리의 궤도 위치가 쟁반의 백분율이고, 요리 자체도 쟁반 크기의 백분율로 지정되었기 때문에, 단 하나의 미디어 쿼리 없이 전체 위젯이 320px에서 560px까지 완벽하게 스케일링됩니다. JavaScript는 --ring-rot이라는 하나의 CSS 커스텀 속성만 설정하고, 나머지는 CSS가 전부 처리합니다. 그리고 두 단계의 역회전(counter-rotation)을 통해 모든 그릇이 항상 똑바로 서 있도록 유지했습니다.

그림자에도 동일한 기법이 적용되어 효과를 톡톡히 봤습니다. 각 요리는 이미 --a 각도에 맞춰 회전된 프레임 안에 있기 때문에, 단순한 0 -2px 오프셋만으로도 모든 요리에서 중앙을 벗어나는 방향으로 그림자가 생성됩니다. 마치 테이블 중앙에 하나의 램프가 걸려 있는 것처럼, 단 하나의 선언으로 7개의 방사형 그림자를 얻을 수 있었죠. 요리별로 복잡한 수학 계산을 할 필요가 없어서 매우 효율적이었습니다.

axe가 잡아내지 못하는 대비 문제

처음에는 만찬 헤드라인을 사진 위에 배치하고 그 뒤에 그라디언트 스크림(scrim)을 넣었습니다. 모든 자동화된 접근성 검사 도구가 통과했는데, 그 이유는 axe-core가 이미지 위에 있는 텍스트를 평가할 수 없었기 때문입니다. 글꼴 뒤에 어떤 픽셀이 있는지 전혀 알 수 없는 거죠. 제가 실무에서 이 부분을 테스트해 봤을 때, 자동화된 검사 도구가 잡아내지 못하는 미묘한 UI 버그나 접근성 이슈를 수동으로 찾아내는 것이 얼마나 중요한지 다시 한번 깨달았어요. 결국, 기계는 픽셀의 의미를 완벽히 이해하지 못하니까요.

그래서 저는 Playwright 스크립트를 작성하여 텍스트를 숨긴 다음, 컴포지팅된 배경을 스크린샷 찍고, 각 텍스트 블록의 바운딩 박스 내 모든 픽셀을 샘플링했습니다. 그리고 실제 텍스트 색상과의 최악의 WCAG 대비율을 계산했죠:

kicker over photo    최악 1.77:1  (필요 4.5)  실패
headline over photo  최악 2.49:1  (필요 3.0)  실패

금색 텍스트와 쌀색 배경의 대비율은 1.77:1에 불과했습니다. 도구에는 보이지 않지만, 일단 측정하고 나니 명확하게 드러나는 문제였죠. 스크림을 어둡게 해봤지만 오히려 상황은 더 나빠졌습니다. 텍스트 블록이 589px로 너무 길어서 어떤 세련된 그라디언트로도 사진 위를 전부 덮을 수 없었기 때문입니다.

결국 정직한 해결책은 시각적인 보정이 아닌 구조적인 변화였습니다. 사진 위에 텍스트를 배치하는 것을 멈춘 것이죠. 이제 이미지는 전체 프레임을 차지하고, 텍스트는 그 아래 견고한 단색 밴드 위에 놓이게 했습니다. 이는 대비를 보장할 뿐만 아니라 더 많은 음식을 보여주는 효과도 가져왔죠. 그 결과, 이전의 네 가지 측정값은 이제 9.95:1과 14.89:1로 개선되었습니다.

오탐이었지만 어쨌든 수정한 버그

나중에 axe가 플라크(plaque) 버튼의 대비율이 3.24:1이라고 경고했습니다. 하지만 제 픽셀 샘플링 결과는 6.63:1이었고, 제 샘플링이 정확했습니다. 제가 금색 채우기(gold fill)를 레이블의 absolute 포지셔닝된 형제 요소에 적용했기 때문에, axe가 DOM 트리를 거슬러 올라가면서 실제 텍스트가 아닌 청동 규칙을 찾아내어 존재하지 않는 오류를 보고한 것이었죠.

그래도 저는 구조를 다시 짰습니다. 배경을 실제 텍스트를 포함하는 요소로 옮겼죠. axe를 실행하는 심사위원이 빨간 줄을 봤을 때, 그게 잘못된 보고인지 알 방법이 없었을 테니까요. 이런 부분은 실무에서 의도치 않게 접근성 검사 도구의 '오탐(false positive)'으로 오해받을 수 있어 늘 조심해야 합니다.

눈으로는 괜찮아 보였지만 수치적으로 틀렸던 네 가지 버그

  • 정사각형을 회전시키면 바운딩 박스가 최대 √2배까지 커져, 모바일에서 49px의 가로 스크롤이 소리 없이 추가되는 문제가 있었습니다. 육안으로는 아무것도 오버플로우 되지 않았지만, 페이지가 화면보다 넓었던 거죠.
  • 히어로 영역에 min-block-size: 92svh를 적용했을 때, 고정된(sticky) 헤더의 높이를 고려하지 않아 히어로가 항상 헤더 높이만큼 아래로 튀어나오는 현상이 발생했습니다. calc(100svh - 4.5rem)로 해결했습니다.
  • aspect-ratio와 min-block-size를 함께 사용했을 때, 상자가 높이 대신 너비를 기준으로 크기를 계산하면서 374px만큼 예상치 못하게 커져버리는 버그가 있었습니다.
  • offsetHeight는 정수 픽셀로 반올림하기 때문에, 804.42px인 패널을 804px로 보고하여 미세한 반 픽셀 점프가 발생하는 문제가 있었습니다. getBoundingClientRect().height와 Math.ceil을 함께 사용하여 이 문제를 0.00px로 해결할 수 있었습니다. 이런 미세한 픽셀 단위의 오차도 사용자 경험에 영향을 줄 수 있다는 것을 보여주는 좋은 예시죠.

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

모바일 내비게이션에서 마지막 레이블이 'Nếp Nh'로 잘리는 현상이 있었습니다. 하지만 제 어설션(assertion)은 document.scrollWidth만 테스트하고 있었고, 내비게이션 자체의 overflow-x: auto 속성이 이 문제를 숨기고 있었죠. 스크롤 가능한 컨테이너는 문서 레벨의 검사에서 잘린 레이아웃을 숨길 수 있습니다. 이제 테스트 스위트는 nav.scrollWidth <= nav.clientWidth도 함께 어설션하도록 개선했습니다.

테스트 스위트를 제대로 작성하자, 이전에는 '오래된 폰에서나 그렇겠지' 하고 무시했던 320px 너비에서의 실제 리플로우(reflow) 오류도 발견했습니다. 요리 스트립이 두 줄로 나뉘어 폴드(fold) 아래로 떨어지는 문제였죠. WCAG 리플로우 기준 너비가 320px이기 때문에, 이 부분도 수정했습니다.

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

각 요리마다 콘텐츠 길이가 달라서, 요리를 전환할 때마다 패널 크기가 변하고 align-items: center 속성 때문에 북 전체가 위아래로 움직이는 현상이 있었습니다.

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

이 차이는 뷰포트 너비에 따라 달라지기 때문에, 하드코딩된 min-height 값은 단 하나의 사이즈에서만 올바르고 다른 모든 사이즈에서는 틀린 값이 되어버립니다. 해결책은 런타임에 가장 높은 패널의 높이를 측정하여 모든 패널에 고정하는 것이었습니다. 웹 폰트는 초기 렌더링 후 로드되어 모든 텍스트 메트릭을 변경하므로, 리사이즈 이벤트와 document.fonts.ready 이벤트 발생 시 다시 측정하도록 구현했습니다.

맘껌 쟁반에 물리적인 느낌 부여하기

드래그해서 돌리는 기능을 추가하는 데는 약 40줄의 코드밖에 들지 않았지만, 예상치 못했던 두 가지를 망가뜨렸습니다.

pointerdown 시 setPointerCapture 사용은 클릭 이벤트를 무력화합니다. capture는 최종적인 click 이벤트를 캡처하는 요소로 리타겟팅하기 때문에, 요리를 탭해도 해당 요리의 버튼에 도달하지 못했습니다. capture는 포인터가 '데드 존(dead zone)'을 실제로 통과할 때까지 기다려야 했습니다.

브라우저가 자체 제스처를 먼저 실행합니다. 요리 사진을 누르면 네이티브 이미지 드래그가 시작되고, 쟁반을 가로질러 쓸어넘기면 텍스트 선택이 시작됩니다. 이 둘 중 하나라도 포인터 스트림을 가로채게 되죠. 이 때문에 첫 번째 회전은 괜찮았지만, 두 번째 이후의 회전은 왠지 모르게 뻑뻑하게 느껴졌던 것입니다. 해결책은 draggable="false"와 user-select: none 속성을 적용하는 것이었는데, 이는 합성(synthetic) 테스트 입력에는 보이지 않기 때문에, 동작이 아닌 상태를 검사하여 문제를 찾아낼 수 있었습니다. 이런 사용자 경험 저해 요소는 실제 디바이스에서 테스트하지 않으면 놓치기 쉽죠.

결국 출시하지 않은 기능

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

하지만 결국 이 기능을 버렸습니다. 3D transform이 적용되자 각 요리의 원형 overflow: hidden 클리핑이 작동하지 않았고, 금색 선택 링도 사라졌으며, 모든 사진이 평면으로 래스터화된 후 재투영되면서 흐릿해졌기 때문입니다. 충분한 노력을 기울이면 이 세 가지 모두 해결할 수 있었겠지만, 맘껌은 위에서 내려다보는 것이고, 북 표면은 평평한 원반입니다. 기울기를 적용하는 순간, '3천 년을 넘어선 같은 원형'이라는 개념이 평범한 저녁 식탁으로 변질되어 버렸죠. 더 나은 아이디어로 구현된 것이 아니라, 오히려 더 나쁜 아이디어를 더 잘 구현한 꼴이었습니다. 기능적인 완벽함과 직관적인 사용자 경험 사이에서 어떤 균형을 찾아야 할지 고민하는 건 언제나 흥미로운 과제라고 생각합니다. 결국 본질을 잃지 않는 것이 중요하죠.

딥 링크 처리

URL의 / #manners로 딥 링크(deep link)했을 때 제대로 작동하지 않았습니다. 여기에는 두 가지 이유가 겹쳐 있었습니다. 첫째, scroll-behavior: smooth 때문에 프래그먼트(fragment) 점프가 9,500px에 달하는 페이지를 가로지르는 긴 애니메이션으로 바뀌었고, 독자가 스크롤 휠에 손을 대는 순간 취소되어 버렸죠. 둘째, 지연 로딩(lazy-loaded) 이미지와 측정된 패널 높이 때문에 브라우저가 이미 스크롤한 후에도 타겟 위로 계속 픽셀이 추가되는 문제가 있었습니다.

그리고 진짜 범인은 따로 있었습니다. F5 키를 눌러 새로 고침 할 때, history.scrollRestoration이 프래그먼트 점프 이후에 이전 스크롤 위치를 복원하면서 우선권을 가져갔던 겁니다. 아이러니하게도 제 페이지의 '독자에게 페이지를 갑자기 휙 움직이지 않도록' 하는 보호 로직이 위치 변경을 감지하고 정중하게 포기해 버린 셈이죠. URL에 해시(hash)가 포함되어 있다면, 그 해시가 사용자의 의도이므로 스크롤 복원 기능을 껐습니다. 이처럼 브라우저의 기본 동작을 이해하고 제어하는 것이 얼마나 중요한지 다시 한번 느꼈습니다.

접근성(Accessibility)

접근성은 개발 후반에 '통과 여부'를 결정하는 요소가 아니라, 개발 초기부터 최우선 제약 조건이었습니다.

  • 완전한 키보드 tablist 지원, 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줄의 바닐라 JavaScript로 구성되어 있습니다. 별도의 프레임워크나 빌드 단계 없이, 단일 파일과 이미지로 이루어져 있죠. 유일한 외부 요청은 Google Fonts뿐입니다. 폴드(fold) 아래의 모든 이미지는 지연 로딩(lazy-loaded) 처리했고, 금색 장식 요소들은 12색 PNG 파일입니다. 전체 용량은 4.2MB이며, 거의 대부분은 사진 파일이 차지하고 있습니다.


긴 글 읽어주셔서 감사합니다. Ă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-07 01:59:30