매일 하나, A냐 B냐 — Versus 밸런스 게임 서비스 소개와 개발 이야기
매일 하나, A냐 B냐 — Versus 밸런스 게임 개발 이야기
"짜장면 vs 짬뽕" 질문 앞에서 진지하게 고민해본 사람이라면, Versus가 어떤 서비스인지 이미 절반쯤 이해한 셈입니다.
Versus는 매일 하나의 A vs B 밸런스 게임을 던집니다. 질문에 투표하면 다른 사람들의 선택이 실시간으로 펼쳐집니다. 단순하지만 멈추기 어렵습니다. 내가 고른 선택이 다수인지 소수인지, 그 갭이 얼마나 큰지 확인하는 순간 다음 질문도 클릭하게 됩니다.
이 글은 Versus라는 서비스를 소개하는 동시에, 어떻게 만들었는지 이야기하려 합니다. 기획 배경, 핵심 기능 설계, 기술 선택, 콘텐츠 자동화까지 — 개발기를 함께 담았습니다.
👉 지금 바로 참여하기: https://weggle-plus.co.kr/versus
왜 밸런스 게임인가
밸런스 게임은 독특한 포맷입니다. 정답이 없는 질문, 그러나 반드시 하나를 골라야 하는 구조. 이 긴장감이 사람들을 대화로 끌어당깁니다.
카카오톡 단톡방에 "아이폰 vs 갤럭시" 밸런스 게임 하나 올리면 반응이 옵니다. 다들 자기 선택을 설명하려 하고, 상대방의 선택을 이해하려 합니다. 취향이 드러나고, 그 취향이 대화를 만듭니다.
weggle+ 팀이 Versus를 만든 이유는 여기에 있습니다. 취향을 드러내는 행위 자체가 콘텐츠가 된다는 것. 사용자가 콘텐츠를 소비하는 게 아니라, 참여하는 순간 콘텐츠를 함께 만들어가는 구조입니다.
데일리 포맷으로 고정한 이유
초기 기획에서 가장 먼저 결정한 것이 "매일 하나"라는 리듬이었습니다. 한 번에 수십 개의 게임을 쏟아내는 방식 대신, 하루에 딱 하나를 공개합니다.
이유는 단순합니다. 매일 같은 시각에 새 게임이 열리면, 사용자가 다시 찾아올 이유가 생깁니다. "오늘 versus는 뭐지?"라는 기대감이 서비스 방문의 습관이 됩니다. 구독이나 알림 없이도 자연스러운 리텐션 루프를 만드는 방법입니다.
Versus의 핵심 기능
서비스는 단순하게 설계했습니다. 진입장벽을 없애고, 참여하는 순간의 경험을 극대화하는 방향으로 만들었습니다.
TODAY — 오늘의 게임
메인 화면에 들어서면 오늘의 밸런스 게임이 전면에 등장합니다. 게임 제목, 설명, 현재 참여 인원이 한 눈에 들어옵니다. 클릭하면 게임 상세 페이지로 이동합니다.
게임 하나에는 여러 질문이 포함됩니다. 각 질문마다 A와 B 중 하나를 선택합니다. 투표 버튼은 의도적으로 크게 만들었습니다. 선택지 각각이 화면의 절반을 차지합니다. "A를 누른다"는 행동 자체가 명확한 의사표현처럼 느껴지도록 설계했습니다.
투표 결과 — Territory Bar
투표를 마치면 결과가 애니메이션으로 펼쳐집니다. A 진영과 B 진영의 점유율이 바 형태로 채워지고, 퍼센트 숫자가 천천히 올라갑니다. 내 선택은 강조되고, 상대 선택은 살짝 흐릿하게 처리됩니다.
이 애니메이션에 Framer Motion(motion/react)을 사용했습니다. useMotionValue와 useTransform으로 숫자가 부드럽게 올라가는 효과를 구현했고, prefers-reduced-motion 접근성 설정을 존중해 모션을 줄일 수 있는 처리도 했습니다. 결과를 확인하는 순간의 만족감이 서비스를 계속 쓰고 싶게 만드는 중요한 포인트입니다.
ARCHIVE — 지난 게임 보관함
오늘의 게임 아래에는 지난 게임들이 카드 형태로 쌓입니다. 놓친 날의 게임을 언제든 다시 즐길 수 있습니다. 주제별로 아카이브를 탐색하다 보면 생각보다 많은 시간이 흘러 있습니다.
결과 카드 공유
투표 후 내 결과를 이미지로 저장해 공유할 수 있습니다. html-to-image 라이브러리로 결과 카드 DOM을 1080×1080 PNG로 캡처합니다. 내가 어떤 선택을 했고, 전체 중 몇 %인지 — 이 정보를 카드 형태로 인스타그램이나 카카오톡에 올릴 수 있습니다.
공유 흐름 자체가 바이럴 경로가 됩니다. 카드를 본 친구가 "나는 뭐야?"라며 링크를 클릭하면, 그게 새 유저 유입입니다.
댓글 시스템
투표 후 댓글을 남길 수 있습니다. 밸런스 게임의 진짜 재미는 선택 이유를 설명하는 순간 시작됩니다. "B를 골랐는데 이런 이유에서..." — 이 댓글들이 질문 하나를 콘텐츠 하나로 만들어줍니다.
기술 스택과 아키텍처
Versus는 weggle+ 플랫폼 모노레포 안에서 운영됩니다.
프론트엔드는 Next.js(App Router) + TypeScript + Tailwind CSS + React Query 조합입니다. 게임 목록은 SSR로 초기 데이터를 빠르게 내려받고, 투표와 댓글은 클라이언트 사이드에서 React Query로 처리합니다.
백엔드는 Kotlin + Spring Boot + JPA + PostgreSQL입니다. 밸런스 게임, 질문, 선택지, 투표 결과를 정규화된 테이블로 관리합니다. Admin API 하나로 게임 전체 데이터(제목, 설명, 질문, 선택지)를 생성할 수 있어, 콘텐츠 파이프라인과의 연동이 매끄럽습니다.
다크 테마 UI를 선택했습니다. A(빨강 계열)와 B(파랑 계열)의 대비가 어두운 배경 위에서 훨씬 강렬하게 살아납니다. 선택의 긴장감을 UI 레벨에서 강화하기 위한 선택이었습니다.
개발 이야기 1 — openedAt 스케줄링 시스템
가장 까다로웠던 기술 과제 중 하나는 하루 1개 게임 예약 공개 시스템이었습니다.
초기 구현에서는 openDate: LocalDate (날짜만)로 게임 공개일을 관리했습니다. 단순하지만 문제가 있었습니다. 날짜만 있으면 "몇 시에 공개되는지"를 제어할 수 없고, 같은 날 여러 게임이 동시에 올라갈 수도 있습니다.
LocalDate에서 OffsetDateTime으로
openDate: LocalDate → openedAt: OffsetDateTime으로 전환했습니다.
// Before
@Column(name = "open_date", nullable = false)
var openDate: LocalDate
// After
@Column(name = "opened_at", nullable = false)
var openedAt: OffsetDateTime
이 변경으로 "2025-07-20T00:00:00+09:00"처럼 날짜와 시각, 타임존을 함께 저장할 수 있게 됐습니다. 매일 KST 자정에 게임이 열리고, 그 이전에 접근하면 해당 게임이 목록에 나타나지 않습니다.
DB 마이그레이션
기존 데이터는 Flyway 스크립트로 변환했습니다.
ALTER TABLE balance_games ADD COLUMN opened_at TIMESTAMPTZ;
UPDATE balance_games
SET opened_at = (open_date::TIMESTAMP) AT TIME ZONE 'UTC';
ALTER TABLE balance_games ALTER COLUMN opened_at SET NOT NULL;
ALTER TABLE balance_games DROP COLUMN open_date;
CREATE UNIQUE INDEX uq_balance_games_opened_at_date
ON balance_games (DATE(opened_at AT TIME ZONE 'UTC'))
WHERE deleted_at IS NULL;
Unique Index가 핵심입니다. 같은 날짜(UTC 기준)에 두 개 이상의 게임이 등록되는 것을 DB 레벨에서 막습니다. 애플리케이션 로직에서도 409 Conflict를 반환하지만, DB 인덱스가 마지막 방어선 역할을 합니다.
UTC 날짜 범위 비교
날짜 중복 체크에서 한 가지 함정이 있었습니다. CAST(openedAt AS date)로 비교하면 DB 세션의 타임존 설정에 따라 결과가 달라질 수 있습니다. KST 자정(00:00+09:00)은 UTC로는 전날 15:00입니다.
이를 타임존 독립적으로 처리하기 위해 UTC 날짜 범위 비교 방식을 선택했습니다.
private fun utcDayRange(dt: OffsetDateTime): Pair<OffsetDateTime, OffsetDateTime> {
val utcDate = dt.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate()
val startOfDay = utcDate.atStartOfDay().atOffset(ZoneOffset.UTC)
val endOfDay = utcDate.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC)
return startOfDay to endOfDay
}
openedAt을 UTC로 변환한 뒤, 그 UTC 날짜의 자정~다음날 자정 범위를 구합니다. 이 범위 안에 다른 게임이 있으면 409를 반환합니다. DB 세션 타임존이 무엇이든 동일한 결과를 보장합니다.
개발 이야기 2 — AI 콘텐츠 자동화 파이프라인
Versus의 가장 큰 운영 과제는 매일 새로운 게임을 만드는 것입니다. 사람이 직접 매일 질문을 기획하고 입력하면 금방 지칩니다.
weggle+ 팀이 선택한 답은 AI 콘텐츠 자동화입니다.
파이프라인 흐름
터미널에서 주제 하나를 입력하면 7단계가 자동으로 진행됩니다.
주제 입력
↓
프롬프트 구성 (품질 기준 주입)
↓
Gemini + Codex 병렬 생성
↓
CPO 평가 → 최종 선택
↓
openedAt 자동 계산 (마지막 게임 + 1일)
↓
Admin API 저장
↓
CEO 보고
두 AI를 동시에 쓰는 이유
"직장인 공감 밸런스 게임"을 만들어달라고 하면, Gemini와 Codex가 동시에 작동합니다. 하나의 Bash 프로세스에서 &로 병렬 실행하고 wait로 대기합니다.
같은 주제라도 두 AI의 결과물은 다릅니다. Gemini는 감성적이고 공감 중심의 질문을 만드는 경향이 있고, Codex는 상황 기반의 구체적인 질문을 만드는 경향이 있습니다. CPO 역할의 AI가 5개 기준으로 두 결과를 비교하고 더 나은 쪽을 선택합니다.
| 평가 기준 | 검증 내용 |
|---|---|
| 공감도 | 질문 상황이 실제로 공감 가능한가? |
| 선택 갈등 | A vs B가 진짜 고민되는 구도인가? |
| 바이럴성 | 공유하고 싶을 만큼 흥미로운가? |
| 데이터 완성도 | JSON 스키마가 정확한가? 선택지가 2개인가? |
| 한국어 자연스러움 | 번역체 없이 자연스러운가? |
openedAt 자동 계산
게임을 저장할 때 날짜를 수동으로 입력하지 않습니다. 파이프라인이 현재 등록된 가장 마지막 게임의 openedAt을 조회하고, 다음날 KST 자정을 자동으로 계산합니다.
kst = timezone(timedelta(hours=9))
latest_iso = data['content'][0]['openedAt']
dt = datetime.fromisoformat(latest_iso.replace('Z', '+00:00'))
next_day = (dt.astimezone(kst) + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
주제만 입력하면 나머지는 모두 자동입니다. "이번 달 치 게임을 한 번에 쌓아두기"도 가능합니다.
개발 이야기 3 — 프론트엔드 설계의 고민들
타임존 처리
openedAt이 ISO 8601 형식(2025-07-20T00:00:00+09:00)으로 내려오면서, 프론트엔드에서 "오늘의 게임"을 찾는 로직도 함께 바뀌었습니다.
// Before (UTC 기준이라 KST에서 오동작 가능)
const todayStr = new Date().toISOString().split("T")[0];
const todayGame = games.find((g) => g.openDate === todayStr);
// After (로컬 타임존 기준)
const todayLocale = toKSTDateString(new Date());
const todayGame = games.find((g) => toKSTDateString(g.openedAt) === todayLocale) ?? games[0];
UTC 기준으로 비교하면 KST에서 자정이 넘은 직후 "오늘 게임"이 다음 날 것을 가리키는 문제가 생깁니다. KST 기준으로 날짜를 비교하도록 toKSTDateString 유틸리티를 만들었습니다.
투표 UX
투표 카드는 최소 높이를 140px로 고정하고, 큰 화면에서는 최대 260px까지 늘어납니다. 터치 영역을 충분히 확보해, 모바일에서 "잘못 눌렀다"는 느낌이 없도록 했습니다.
투표 후 선택한 카드는 살짝 커지고(scale-[1.03]), 선택하지 않은 카드는 투명도가 낮아집니다(opacity-50). 내 선택이 무엇인지 한눈에 보이면서, 결과 바에 집중하게 유도합니다.
Territory Bar 애니메이션 — 숫자가 올라가는 경험
결과 바의 퍼센트 숫자가 단순히 "42%"로 뚝 나타나는 게 아니라, 0에서 42까지 천천히 올라갑니다. 이 작은 차이가 사용자 경험에서 큰 차이를 만듭니다. 숫자가 올라가는 동안 "내 선택이 다수인가 소수인가"를 기대하게 됩니다.
구현은 motion/react의 useMotionValue와 animate를 조합했습니다.
const motionValue = useMotionValue(0);
const rounded = useTransform(motionValue, (v) => Math.round(v));
useEffect(() => {
const controls = animate(motionValue, target, {
duration: 0.7,
ease: "easeOut",
delay: 0.3,
});
return () => controls.stop();
}, [motionValue, target]);
useReducedMotion() 훅으로 OS 설정을 확인해, 모션 감소를 선호하는 사용자에게는 즉시 최종값을 보여줍니다. 애니메이션을 끄는 게 아니라 존중하는 방식입니다.
React Query 캐싱 전략
게임 목록은 자주 바뀌지 않습니다. 하루에 딱 하나의 게임이 추가됩니다. 그래서 게임 목록 쿼리는 staleTime을 넉넉히 설정했습니다. 같은 페이지 안에서 탭을 이동해도 불필요한 재요청이 발생하지 않습니다.
반면 투표 결과(voteCount, percent)는 실시간성이 중요합니다. 내가 투표한 직후 결과가 바로 반영돼야 합니다. 투표 후 queryClient.invalidateQueries로 해당 게임의 캐시를 즉시 무효화해, 새 데이터를 바로 받아옵니다. "투표했는데 숫자가 안 바뀐다"는 경험은 없어야 하기 때문입니다.
지금 Versus는
Versus는 매일 새로운 게임을 공개하고 있습니다. AI 파이프라인 덕분에 콘텐츠 고갈 걱정 없이 주제를 다양하게 이어갈 수 있습니다.
직장인의 일상 공감, 음식 취향, 여행 스타일, 연애관, 가벼운 인생 선택지까지 — 밸런스 게임은 어떤 주제든 잘 담아냅니다. 그 다양성이 Versus를 매일 찾게 만드는 힘입니다.
weggle+ 팀의 목표는 "매일 1분의 참여"입니다. 부담 없이 오늘의 질문에 답하고, 다른 사람들의 선택과 비교하고, 짧은 댓글을 남기는 경험. 그 1분이 쌓이면 서비스가 됩니다.
다음 목표는 주간 결과 리포트입니다. "이번 주 가장 갈렸던 질문", "가장 많이 참여한 게임 TOP 3"를 매주 정리해 보여주는 기능입니다. 개별 게임이 아닌 흐름을 보여줄 때, 사용자가 Versus라는 커뮤니티의 일부라는 느낌을 받을 수 있다고 생각합니다. Versus가 어디까지 발전할지, 함께 지켜봐주세요.
👉 오늘의 밸런스 게임 참여하기: https://weggle-plus.co.kr/versus
비슷한 고민을 하고 계시거나 같이 실험해보고 싶은 분은 커피챗 하고 싶습니다 ☕️
함께 읽으면 좋은 글
댓글
0의견을 남겨보세요. 로그인하면 닉네임이 자동으로 입력됩니다.
댓글을 불러오는 중...