프로젝트

뭐먹지 룰렛 — 오늘 점심 메뉴, 이제 고민하지 마세요

뭐먹지 룰렛 — 오늘 점심 메뉴, 이제 고민하지 마세요

솔직히 이 프로젝트는 거창한 기획서가 아니라 점심시간의 현실적인 고통에서 시작됐습니다. 팀원끼리 '오늘 뭐 먹지?'를 10분째 주고받다 결국 아무도 결정하지 못하고 어제 갔던 곳을 또 가는 상황. 누구나 한 번쯤 겪어봤을 겁니다. 그래서 하루 날을 잡아 만들었습니다. 이 글은 '뭐먹지 룰렛(yami.lumic.kr)'을 만들며 실제로 부딪힌 문제와, 그걸 어떻게 풀었는지에 대한 개발 기록입니다.

뭐먹지 룰렛 메인 화면. 카테고리를 고르고 가운데 룰렛을 돌리면 오늘의 메뉴가 정해진다.
뭐먹지 룰렛 메인 화면. 카테고리를 고르고 가운데 룰렛을 돌리면 오늘의 메뉴가 정해진다.

결정을 대신해 주는 게 아니라, 결정의 '책임'을 대신 진다

만들기 전에 스스로에게 물었습니다. 사람들이 메뉴를 못 정하는 게 정말 '선택지가 많아서'일까? 관찰해 보니 아니었습니다. 대부분은 이미 몇 개로 좁혀놨는데, 그중 하나를 '내가' 고르는 순간 '별로면 어떡하지'라는 책임이 따라오는 게 싫었던 겁니다. 그래서 룰렛의 핵심 가치를 '무작위 추천'이 아니라 '책임의 이전'으로 잡았습니다. 실제로 팀에서 써보니, 나온 결과가 마음에 안 들면 '에이 다시'라며 한 번 더 돌리는 행동이 자연스럽게 나왔습니다. 룰렛은 답을 주는 도구가 아니라, 마음을 정하게 도와주는 핑계였던 셈입니다.

가장 오래 붙잡은 문제: 룰렛이 '진짜처럼' 멈추게 하기

기능 목록만 보면 단순한데, 정작 개발 시간의 절반은 룰렛이 도는 '느낌'에 들어갔습니다. 처음엔 CSS 한 줄이면 될 줄 알았습니다. transform: rotate()에 transition을 걸면 되겠지, 하고요. 그런데 CSS transition은 '몇 초에 걸쳐 A에서 B로'만 표현할 뿐, 실제 룰렛처럼 처음엔 쌩 돌다가 끝에서 아슬아슬하게 한 칸씩 넘어가며 멈추는 물리적 감속을 만들지 못했습니다. 감속 곡선을 프레임 단위로 직접 그려야 했고, 그래서 requestAnimationFrame으로 매 프레임 각도를 계산하는 방식으로 바꿨습니다.

룰렛 회전 — requestAnimationFrame + easeOutCubic 감속
// 당첨 인덱스로 '멈춰야 할 각도'를 먼저 계산한 뒤,
// 매 프레임 easeOutCubic으로 감속하며 그 각도까지 회전시킨다.
function spin(targetIndex, itemCount) {
  const slice = 360 / itemCount;

  // 최소 5~6바퀴를 더 돈 뒤, 당첨 슬라이스의 '중앙'에서 멈추도록
  const extraTurns = 5 + Math.floor(Math.random() * 2);
  const target =
    extraTurns * 360 + (360 - targetIndex * slice - slice / 2);

  const duration = 4200;               // 총 4.2초
  const startAngle = angle % 360;
  const startTime = performance.now();

  function frame(now) {
    const t = Math.min((now - startTime) / duration, 1);
    const eased = 1 - Math.pow(1 - t, 3); // 처음 빠르게 → 끝에서 급감속
    setAngle(startAngle + (target - startAngle) * eased);

    if (t < 1) requestAnimationFrame(frame);
    else onResult(targetIndex);          // 멈춘 뒤 결과 처리
  }
  requestAnimationFrame(frame);
}

포인트는 두 가지였습니다. 첫째, '어디서 멈출지'를 돌리기 전에 미리 정해두는 것. 돌아가는 도중에 랜덤을 뽑으면 당첨 칸에 정확히 안 맞고 눈금이 어긋납니다. 그래서 당첨 인덱스를 먼저 뽑고, 그 슬라이스의 정중앙에 바늘이 오도록 목표 각도를 역산했습니다. 둘째, easeOutCubic(1 - (1-t)³) 곡선. 여러 감속 함수를 붙여봤는데, 이 곡선이 '초반 시원하게 돌다가 막판에 쫄깃하게 느려지는' 실제 원판 룰렛의 손맛에 가장 가까웠습니다.

왜 굳이 5~6바퀴를 더 돌렸나

목표 각도만 딱 맞춰 돌리면 결과는 같지만 재미가 없습니다. 사람은 '충분히 돌았다'는 감각이 있어야 결과를 납득하거든요. 그래서 실제 당첨 각도에 5~6바퀴(extraTurns × 360°)를 얹어, 눈으로 '한참 돌았다'를 느낀 뒤 멈추게 했습니다. 바퀴 수에 약간의 랜덤을 준 것도 매번 똑같이 도는 지루함을 없애기 위해서였습니다.

'어제 먹은 건 빼줘' — 제외 기능의 진짜 이유

초기 버전을 팀에 풀자마자 나온 첫 피드백이 '어제 먹은 거 또 나오면 짜증난다'였습니다. 그래서 후보에서 특정 메뉴를 빼는 제외 기능을 넣었는데, 여기서 작은 함정이 있었습니다. 선택한 메뉴, 제외한 메뉴, 실제로 룰렛에 올라갈 메뉴가 서로 다른 상태라는 점입니다. 이걸 매번 직접 계산하면 버그가 나기 쉬워서, 파생 상태로 한 번에 정리했습니다.

룰렛에 올릴 '진짜 후보'는 파생 상태로 계산
// 선택했지만 제외하지 않은 메뉴 = 실제로 룰렛에 올라갈 후보
const pool = useMemo(
  () =>
    menus.filter(
      (m) => selected.includes(m.id) && !excluded.includes(m.id)
    ),
  [menus, selected, excluded]
);

// 후보가 1개뿐이면 돌릴 이유가 없으니 버튼을 잠근다
const canSpin = pool.length >= 2;

덕분에 어디서든 pool 하나만 보면 '지금 룰렛에 뭐가 올라가 있는지'가 명확해졌고, 후보가 하나뿐일 때 룰렛이 무한히 도는 엣지 케이스도 canSpin 하나로 막았습니다. 사소해 보여도, 이런 상태 정리를 처음에 해두지 않으면 기능이 붙을수록 걷잡을 수 없어집니다.

메뉴만 정해주면 반쪽짜리였다 — 맛집 연결

'좋아 오늘은 파스타!'까지 정해줘도, 그다음 '그래서 어디 가지?'에서 다시 검색의 늪에 빠지면 결국 문제를 반만 푼 겁니다. 그래서 결과 화면에서 당첨된 카테고리를 그대로 지도 검색으로 넘겨, 주변 가게를 바로 볼 수 있게 이었습니다. 메뉴 결정 → 가게 선택까지가 한 흐름으로 끊기지 않는 것, 그게 이 서비스를 '재미있는 장난감'에서 '실제로 매일 쓰는 도구'로 바꾼 지점이었습니다.

무엇으로, 어떻게 만들었나

  • 프론트엔드: React. 룰렛 상태(각도·회전 여부·결과)를 컴포넌트 상태로 관리하고, 후보 목록은 위처럼 파생 상태(useMemo)로 계산.
  • 애니메이션: CSS transition 대신 requestAnimationFrame으로 프레임 단위 제어 → 감속 곡선을 원하는 대로 설계.
  • 반응형: 점심시간에 대부분 휴대폰으로 켠다는 전제로 모바일 세로 화면을 먼저 설계(Mobile First). 룰렛 크기와 버튼 위치를 엄지 기준으로 배치.
  • 배포: 본 사이트와 분리해 yami.lumic.kr 서브도메인으로 운영. 자체 서비스는 독립적으로 빠르게 실험하고 개선하기 위함.

직접 만들고, 매일 쓰며 배운 것

가장 크게 배운 건 '기능의 정답은 회의실이 아니라 사용 현장에 있다'는 점입니다. 제외 기능도, 맛집 연결도, 기획서에 없다가 실제로 써보면서 '아 이게 없으면 안 되겠다' 싶어 붙은 것들입니다. 카페·디저트 카테고리를 추가한 것도 마찬가지였습니다. 밥만 넣고 보니 오후 3시의 '커피 뭐 마시지'가 점심만큼이나 자주 오는 고민이더라고요.

작은 사이드 프로젝트지만, LUMIC이 클라이언트 프로젝트에서 일하는 방식을 그대로 압축한 작업이라고 생각합니다. 실제 사용자의 불편에서 출발하고, 눈에 안 보이는 디테일(룰렛이 멈추는 손맛 같은)에 시간을 쓰고, 만든 뒤에도 계속 고쳐 나가는 것. 뭐먹지 룰렛은 yami.lumic.kr에서 바로 써보실 수 있습니다. 써보시고 '이런 게 있으면 좋겠다' 싶은 게 떠오르면 편하게 알려주세요.

Turning dreams into reality

LUMIC

Contact Us

대구 중구 명륜로28길 27 3층

E-mail. lumic.dev@gmail.com

개인정보처리방침이용약관